Skip to content

Commit

Permalink
feat: parse raw message, support commands handlers
Browse files Browse the repository at this point in the history
  • Loading branch information
bondiano committed Mar 24, 2024
1 parent 297c5e3 commit b843b2a
Show file tree
Hide file tree
Showing 8 changed files with 668 additions and 76 deletions.
22 changes: 13 additions & 9 deletions examples/echo-bot/src/bot.gleam
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import gleam/erlang/process
import gleam/result.{try}
import gleam/option.{Some}
import gleam/option.{None, Some}
import gleam/erlang/os
import gleam/bool
import dotenv_gleam
import mist
import wisp.{type Request, type Response}
import telega.{type Bot, type Context, HandleAll}
import telega/telega_wisp
import telega.{type Bot, HandleAll}
import telega/adapters/wisp as telega_wisp

fn middleware(
req: Request,
Expand All @@ -32,10 +32,6 @@ fn handle_request(bot: Bot, req: Request) -> Response {
}
}

fn echo_handler(ctx: Context) -> Result(Nil, Nil) {
telega.reply(ctx, ctx.message.text)
}

fn build_bot() -> Result(Bot, Nil) {
use bot_token <- try(os.get_env("BOT_TOKEN"))
use webhook_path <- try(os.get_env("WEBHOOK_PATH"))
Expand All @@ -48,7 +44,15 @@ fn build_bot() -> Result(Bot, Nil) {
webhook_path: webhook_path,
secret_token: Some(secret_token),
)
|> telega.add_handler(HandleAll(echo_handler))
|> telega.add_handler(
HandleAll(fn(ctx) {
case ctx.message.raw.text {
Some(text) -> telega.reply(ctx, text)
None -> Error(Nil)
}
|> result.map(fn(_) { Nil })
}),
)
|> Ok
}

Expand All @@ -68,7 +72,7 @@ pub fn main() {

case telega.set_webhook(bot) {
Ok(_) -> wisp.log_info("Webhook set successfully")
Error(e) -> wisp.log_error("Failed to set webhook:" <> e)
Error(e) -> wisp.log_error("Failed to set webhook: " <> e)
}

process.sleep_forever()
Expand Down
8 changes: 2 additions & 6 deletions gleam.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
name = "telega"
version = "0.0.2"
version = "0.0.3"
description = "Gleam Telegram Bot Library"
licences = ["Apache-2.0"]
# repository = { type = "github", user = "username", repo = "project" }
# links = [{ title = "Website", href = "https://gleam.run" }]
#
# For a full reference of all the available options, you can have a look at
# https://gleam.run/writing-gleam/gleam-toml/.
repository = { type = "github", user = "bondiano", repo = "telega" }

[dependencies]
gleam_stdlib = "~> 0.34 or ~> 1.0"
Expand Down
142 changes: 91 additions & 51 deletions src/telega.gleam
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
import gleam/result.{try}
import gleam/dynamic.{type Dynamic}
import gleam/option.{type Option, None, Some}
import gleam/list
import gleam/string
import telega/message.{type Message, CommandMessage, TextMessage}
import telega/api
import logging
import telega/log

const telegram_url = "https://api.telegram.org/bot"

type MessageUpdate {
MessageUpdate(message: Message)
}

pub type Chat {
Chat(id: Int)
}

pub opaque type Config {
Config(
token: String,
server_url: String,
webhook_path: String,
telegram_url: String,
/// An optional string to compare to X-Telegram-Bot-Api-Secret-Token
secret_token: Option(String),
telegram_url: String,
)
}

Expand All @@ -29,19 +24,41 @@ pub type Bot {
}

pub type Handler {
/// Handle all messages.
HandleAll(handler: fn(Context) -> Result(Nil, Nil))
/// Handle a specific command.
HandleCommand(
handler: fn(CommandContext) -> Result(Nil, Nil),
command: String,
)
/// Handle multiple commands.
HandleCommands(
handler: fn(CommandContext) -> Result(Nil, Nil),
commands: List(String),
)
/// Handle text messages.
HandleText(handler: fn(Context) -> Result(Nil, Nil))
}

/// Messages represent the data that the bot receives from the Telegram API.
pub type Message {
TextMessage(text: String, chat: Chat)
}

/// Handlers context.
pub type Context {
Context(message: Message, bot: Bot)
}

/// Create a new bot instance.
pub type Command {
Command(
text: String,
command: String,
/// The command arguments, if any.
payload: Option(String),
)
}

pub type CommandContext {
CommandContext(message: Message, bot: Bot, command: Command)
}

/// Creates a new Bot with the given options.
pub fn new(
token token: String,
url server_url: String,
Expand All @@ -60,7 +77,7 @@ pub fn new(
)
}

/// Set the webhook URL for the bot.
/// Set the webhook URL using [setWebhook](https://core.telegram.org/bots/api#setwebhook) API.
pub fn set_webhook(bot: Bot) -> Result(Bool, String) {
let webhook_url = bot.config.server_url <> "/" <> bot.config.webhook_path
use response <- try(api.set_webhook(
Expand Down Expand Up @@ -89,15 +106,17 @@ pub fn is_secret_token_valid(bot: Bot, token: String) -> Bool {
}
}

/// Replies to user with a text message.
pub fn reply(ctx: Context, text: String) -> Result(Nil, Nil) {
api.send_text(
/// Use this method to send text messages.
pub fn reply(ctx: Context, text: String) -> Result(Message, Nil) {
let chat_id = ctx.message.raw.chat.id

api.send_message(
token: ctx.bot.config.token,
telegram_url: ctx.bot.config.telegram_url,
chat_id: ctx.message.chat.id,
chat_id: chat_id,
text: text,
)
|> result.map(fn(_) { Nil })
|> result.map(fn(_) { ctx.message })
|> result.nil_error
}

Expand All @@ -111,41 +130,62 @@ pub fn handle_update(bot: Bot, message: Message) -> Nil {
do_handle_update(bot, message, bot.handlers)
}

/// Decode a message from the Telegram API.
pub fn decode_message(json: Dynamic) -> Result(Message, dynamic.DecodeErrors) {
let decode = build_message_decoder()
use message_update <- try(decode(json))
Ok(message_update.message)
}

fn new_context(bot: Bot, message: Message) -> Context {
Context(message: message, bot: bot)
}

fn build_message_decoder() {
dynamic.decode1(
MessageUpdate,
dynamic.field(
"message",
dynamic.decode2(
TextMessage,
dynamic.field("text", dynamic.string),
dynamic.field(
"chat",
dynamic.decode1(Chat, dynamic.field("id", dynamic.int)),
),
),
),
)
fn extract_command(message: Message) -> Command {
case message.raw.text {
None -> Command(text: "", command: "", payload: None)
Some(text) -> {
case string.split(text, " ") {
[command, ..payload] -> {
Command(text: text, command: command, payload: case payload {
[] -> None
[payload, ..] -> Some(payload)
})
}
_ -> Command(text: text, command: "", payload: None)
}
}
}
}

fn do_handle_update(bot: Bot, message: Message, handlers: List(Handler)) -> Nil {
case handlers {
[handler, ..rest] -> {
case handler.handler(new_context(bot, message)) {
let handle_result = case handler, message.kind {
HandleAll(handle), _ -> handle(Context(bot: bot, message: message))
HandleText(handle), TextMessage ->
handle(Context(bot: bot, message: message))

HandleCommand(handle, command), CommandMessage -> {
let message_command = extract_command(message)
case message_command.command == command {
True ->
handle(CommandContext(
bot: bot,
message: message,
command: message_command,
))
False -> Ok(Nil)
}
}
HandleCommands(handle, commands), CommandMessage -> {
let message_command = extract_command(message)
case list.contains(commands, message_command.command) {
True ->
handle(CommandContext(
bot: bot,
message: message,
command: message_command,
))
False -> Ok(Nil)
}
}
_, _ -> Ok(Nil)
}

case handle_result {
Ok(_) -> do_handle_update(bot, message, rest)
Error(_) -> {
logging.log(logging.Error, "Failed to handle message")
log.error("Failed to handle message")
Nil
}
}
Expand Down
30 changes: 22 additions & 8 deletions src/telega/telega_wisp.gleam → src/telega/adapters/wisp.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import wisp.{
import gleam/http/response.{Response as HttpResponse}
import gleam/result
import gleam/http/request
import telega.{type Bot}
import gleam/string
import gleam/bool
import logging
import gleam/dynamic
import gleam/list
import telega.{type Bot}
import telega/log
import telega/message

const secret_header = "x-relegram-bot-api-secret-token"

Expand All @@ -19,15 +23,21 @@ fn is_secret_token_valid(bot: Bot, req: WispRequest) -> Bool {
telega.is_secret_token_valid(bot, secret_header_value)
}

/// Format decode error to error message string.
pub fn decode_to_string(error: dynamic.DecodeError) -> String {
let dynamic.DecodeError(expected, found, path) = error
let path_string = string.join(path, ".")
"Expected " <> expected <> ", found " <> found <> " at " <> path_string
}

/// Handle incoming requests from the Telegram API.
/// Add this function as a handler to your wisp server.
///
/// ```gleam
/// import telega.{type Bot}
/// import telega/telega_wisp
/// import telega/adapters/wisp as telega_wisp
///
/// fn handle_request(bot: Bot, req: Request) -> Response {
/// use req <- middleware(req)
/// use <- bool.lazy_guard(telega_wisp.is_bot_request(bot, req), fn() {
/// telega_wisp.bot_handler(bot, req)
/// })
Expand All @@ -41,19 +51,23 @@ fn is_secret_token_valid(bot: Bot, req: WispRequest) -> Bool {
pub fn bot_handler(bot: Bot, req: WispRequest) -> WispResponse {
use json <- wisp.require_json(req)

case telega.decode_message(json) {
case message.decode(json) {
Ok(message) -> {
use <- bool.lazy_guard(is_secret_token_valid(bot, req), fn() {
HttpResponse(401, [], WispEmptyBody)
})

logging.log(logging.Info, "Received message: " <> message.text)
log.info("Received message " <> string.inspect(message))
telega.handle_update(bot, message)

wisp.ok()
}
Error(_) -> {
logging.log(logging.Error, "Failed to decode message")
Error(errors) -> {
let error_message =
errors
|> list.map(decode_to_string)
|> string.join("\n")
log.error("Failed to decode message:\n" <> error_message)

wisp.ok()
}
Expand Down
4 changes: 3 additions & 1 deletion src/telega/api.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ fn fetch(api_request: Result(Request(String), String)) {
})
}

/// **Official reference:** https://core.telegram.org/bots/api#setwebhook
pub fn set_webhook(
token token: String,
webhook_url webhook_url: String,
Expand All @@ -106,7 +107,8 @@ pub fn set_webhook(
|> fetch
}

pub fn send_text(
/// **Official reference:** https://core.telegram.org/bots/api#sendmessage
pub fn send_message(
chat_id chat_id: Int,
text text: String,
token token: String,
Expand Down
23 changes: 23 additions & 0 deletions src/telega/log.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import gleam/dynamic.{type Dynamic}
import gleam/string
import logging.{Debug, Error, Info}

pub fn error(message: String) -> Nil {
logging.log(Error, message)
}

pub fn debug(message: String) -> Nil {
logging.log(Debug, message)
}

pub fn debug_d(message: Dynamic) -> Nil {
logging.log(Debug, string.inspect(message))
}

pub fn info(message: String) -> Nil {
logging.log(Info, message)
}

pub fn info_d(message: Dynamic) -> Nil {
logging.log(Info, string.inspect(message))
}
Loading

0 comments on commit b843b2a

Please sign in to comment.