Add sigil-telegram package for Telegram Bot API clients
New package providing a layered Telegram Bot API client:
- Types: tg-update, tg-message, tg-chat, tg-user structs with dict->record converters for JSON API responses - Client: tg-api-call for arbitrary API methods, convenience wrappers for sendMessage, sendPhoto, sendDocument, editMessage, deleteMessage, answerCallbackQuery, and getUpdates with long-polling - File uploads: multipart/form-data encoding via direct TLS connection with tg-upload-photo and tg-upload-document for local file sending - Bot framework: make-tg-bot with tg-on-command, tg-on-message, tg-on-callback handler registration, tg-bot-run polling loop with configurable error recovery, and chat/user authorization lists - Formatting: tg-escape-markdown and helpers (tg-bold, tg-italic, tg-underline, tg-strike, tg-code, tg-code-block, tg-link, tg-spoiler) for composing MarkdownV2 messages - Inline keyboards: tg-inline-keyboard and tg-button builders
Dependencies: sigil-stdlib, sigil-json, sigil-http, sigil-tls, sigil-crypto
docs/telegram.md | 446 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
package.sgl | 20 +++++++
src/sigil/telegram.sgl | 105 ++++++++++++++++++++++++++++++++++
src/sigil/telegram/bot.sgl | 362 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/telegram/client.sgl | 498 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/telegram/format.sgl | 130 +++++++++++++++++++++++++++++++++++++++++++
src/sigil/telegram/types.sgl | 128 ++++++++++++++++++++++++++++++++++++++++++
test/test-telegram.sgl | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 1971 insertions(+)docs/telegram.mdadded
# Telegram> Telegram Bot API client with long-polling, command handlers, MarkdownV2 formatting, and file uploads.```scheme(import (sigil telegram))```## Two ApproachesThere are two ways to use the package depending on your use case:**Fetch-and-respond** — Best for scripts, cron jobs, and AI agents that wake up, check for messages, respond, and exit. Use `tg-fetch-updates` with a `tg-client` directly:```scheme(import (sigil telegram))(define client (tg-client token: "123456:ABC-DEF..."));; Fetch all pending messages (non-blocking, confirms receipt)(for-each (lambda (update) (let ((msg (tg-update-message update))) (when (and msg (tg-message-text msg)) (tg-send-message client (tg-chat-id (tg-message-chat msg)) (string-append "You said: " (tg-escape-markdown (tg-message-text msg))) parse-mode: "MarkdownV2")))) (tg-fetch-updates client))```**Long-running bot** — Best for interactive bots that stay online and respond in real time. Use `make-tg-bot` with handler registration and `tg-bot-run`:```scheme(import (sigil telegram))(define bot (make-tg-bot token: "123456:ABC-DEF..."))(tg-on-command bot "/start" (lambda (bot msg) (tg-reply bot msg "Hello! I'm a Sigil bot.")))(tg-bot-run bot)```Both approaches share the same client, types, formatting, and media APIs.## Sending Messages```scheme;; Plain text(tg-send-message client chat-id "Hello!");; With MarkdownV2 formatting(tg-send-message client chat-id (string-append (tg-bold "Status") ": " (tg-escape-markdown "All systems go!")) parse-mode: "MarkdownV2");; With reply markup (inline keyboard)(tg-send-message client chat-id "Choose one:" reply-markup: (tg-inline-keyboard (list (list (tg-button "Yes" callback: "yes") (tg-button "No" callback: "no")))))```## Message Formatting (MarkdownV2)Telegram's MarkdownV2 requires escaping 18 special characters outside of formatting entities. Use `tg-escape-markdown` on all dynamic text before composing messages.### Escaping```scheme;; These characters must be escaped: _ * [ ] ( ) ~ ` > # + - = | { } . !(tg-escape-markdown "Hello! Price: $5.00 (USD)"); => "Hello\\! Price: $5\\.00 \\(USD\\)"```### Formatting HelpersAll helpers wrap text with the appropriate MarkdownV2 syntax. They do NOT auto-escape their input, so escape dynamic content first.```scheme(tg-bold "important") ; => "*important*"(tg-italic "emphasis") ; => "_emphasis_"(tg-underline "noted") ; => "__noted__"(tg-strike "removed") ; => "~removed~"(tg-code "variable") ; => "`variable`"(tg-spoiler "hidden") ; => "||hidden||"(tg-link "Click here" "https://example.com"); => "[Click here](https://example.com)"(tg-code-block "(+ 1 2)"); => "```\n(+ 1 2)```"(tg-code-block "(+ 1 2)" language: "scheme"); => "```scheme\n(+ 1 2)```"```### Composing Formatted MessagesEscape dynamic text with `tg-escape-markdown`, then wrap with formatting helpers:```scheme(define (format-status-report name status duration) (string-append (tg-bold "Build Report") "\n\n" (tg-bold "Package: ") (tg-escape-markdown name) "\n" (tg-bold "Status: ") (tg-italic (tg-escape-markdown status)) "\n" (tg-bold "Duration: ") (tg-code (tg-escape-markdown duration))))(tg-send-message client chat-id (format-status-report "sigil-http" "passed" "42s") parse-mode: "MarkdownV2")```## Sending Photos and DocumentsThree ways to send media:### By URLTelegram downloads the file from the URL:```scheme(tg-send-photo client chat-id "https://example.com/photo.jpg" caption: "A nice photo")(tg-send-document client chat-id "https://example.com/report.pdf" caption: "Monthly report")```### By file_idReference a file already on Telegram's servers (e.g., from a received message):```scheme(let ((photo-id (dict-ref (array-ref (tg-message-photo msg) 0) file_id:))) (tg-send-photo client other-chat-id photo-id))```### By Local File UploadUpload a file from the local filesystem. MIME type is auto-detected:```scheme(tg-upload-photo client chat-id "/tmp/screenshot.png" caption: "Build output")(tg-upload-document client chat-id "/tmp/report.pdf" caption: "Generated report" parse-mode: "MarkdownV2")```### Photo with Formatted Caption```scheme(tg-upload-photo client chat-id "/tmp/chart.png" caption: (string-append (tg-bold "Daily Metrics") "\n" (tg-escape-markdown "Generated at 2024-01-15 09:00")) parse-mode: "MarkdownV2")```## Inline KeyboardsBuild interactive buttons that trigger callback queries:```scheme;; Create keyboard with callback buttons(tg-send-message client chat-id "Rate this:" reply-markup: (tg-inline-keyboard (list (list (tg-button "1" callback: "rate_1") (tg-button "2" callback: "rate_2") (tg-button "3" callback: "rate_3")) (list (tg-button "Visit docs" url: "https://example.com")))));; Handle button presses(tg-on-callback bot (lambda (bot query) (tg-answer-callback (tg-bot-client bot) (dict-ref query id:) text: (string-append "You pressed: " (dict-ref query data:)))))```## Bot Framework### Creating a Bot```scheme(define bot (make-tg-bot token: "123456:ABC-DEF..." allowed-chats: '(12345 67890) ; Optional: restrict to these chat IDs allowed-users: '(111 222))) ; Optional: restrict to these user IDs```Both `allowed-chats:` and `allowed-users:` default to `#f` (allow all). When set, both must pass (AND logic). Unauthorized messages are silently ignored.### Registering HandlersAll handlers receive `(bot msg)` as arguments:```scheme;; Command handler - matches /command messages(tg-on-command bot "/start" (lambda (bot msg) (tg-reply bot msg "Welcome!")));; Message handler - called for every authorized message(tg-on-message bot (lambda (bot msg) (when (tg-message-text msg) (display (tg-message-text msg)) (newline))));; Callback query handler - inline keyboard presses(tg-on-callback bot (lambda (bot query) (tg-answer-callback (tg-bot-client bot) (dict-ref query id:))));; Error handler - called on exceptions during update processing;; Return 'stop to halt, 'continue to skip backoff, anything else for default 5s backoff(tg-on-error bot (lambda (exn) (display "Error: " (current-error-port)) (display exn (current-error-port)) (newline (current-error-port))))```### Running the Bot```scheme;; Blocking polling loop (default 30s long-poll timeout)(tg-bot-run bot)(tg-bot-run bot timeout: 10);; Stop the bot (from a handler or another thread)(tg-bot-stop bot);; Non-blocking single poll (for custom event loops)(tg-bot-tick bot)```## Reply Helpers```scheme;; Text reply(tg-reply bot msg "Got it!");; Formatted reply(tg-reply bot msg (tg-bold "Done") parse-mode: "MarkdownV2");; Quote the original message(tg-reply bot msg "Replying to you" quote?: #t);; Reply with photo (URL/file_id)(tg-reply-photo bot msg "https://example.com/photo.jpg" caption: "Here you go!");; Reply with uploaded local photo(tg-reply-photo/upload bot msg "/tmp/result.png" caption: "Processing complete")```## Low-Level ClientFor direct API access without the bot framework:```scheme(define client (tg-client token: "123456:ABC-DEF..."));; Call any Telegram Bot API method(tg-api-call client "getMe"); => #{ id: 123 is_bot: #t first_name: "MyBot" ... };; With parameters(tg-api-call client "sendMessage" params: (dict chat_id: 12345 text: "Hello from low-level API"))```## Message and Update Types### tg-message```scheme(tg-message-message-id msg) ; => 42(tg-message-chat msg) ; => tg-chat record(tg-message-from msg) ; => tg-user record or #f(tg-message-date msg) ; => unix timestamp(tg-message-text msg) ; => "Hello" or #f(tg-message-photo msg) ; => array of photo sizes or #f(tg-message-document msg) ; => dict or #f(tg-message-reply-to msg) ; => tg-message or #f(tg-message-raw msg) ; => full API dict (access any field)```### tg-chat```scheme(tg-chat-id chat) ; => 12345(tg-chat-type chat) ; => "private", "group", "supergroup", "channel"(tg-chat-title chat) ; => "My Group" or #f(tg-chat-username chat) ; => "alice" or #f```### tg-user```scheme(tg-user-id user) ; => 12345(tg-user-is-bot user) ; => #t or #f(tg-user-first-name user) ; => "Alice"(tg-user-last-name user) ; => "Smith" or #f(tg-user-username user) ; => "alice" or #f```### Accessing Raw API FieldsAny Telegram API field not modeled as a struct field is accessible via `tg-message-raw`:```scheme;; Access sticker data from a message(dict-ref (tg-message-raw msg) sticker: #f);; Access location data(dict-ref (tg-message-raw msg) location: #f)```## Common Patterns### Echo Bot```scheme(import (sigil telegram))(define bot (make-tg-bot token: "123456:ABC-DEF..."))(tg-on-message bot (lambda (bot msg) (when (tg-message-text msg) (tg-reply bot msg (tg-message-text msg)))))(tg-bot-run bot)```### Command Bot```scheme(import (sigil telegram))(define bot (make-tg-bot token: "123456:ABC-DEF..."))(tg-on-command bot "/start" (lambda (bot msg) (tg-reply bot msg (string-append (tg-bold "Welcome\\!") "\n\n" "Available commands:\n" "/help \\- Show help\n" "/status \\- Check status") parse-mode: "MarkdownV2")))(tg-on-command bot "/help" (lambda (bot msg) (tg-reply bot msg "Send me a message and I'll echo it back.")))(tg-on-command bot "/status" (lambda (bot msg) (tg-reply bot msg (string-append (tg-bold "Status: ") (tg-code "online")) parse-mode: "MarkdownV2")))(tg-bot-run bot)```### Fetch-and-Respond ScriptProcess pending messages without a long-running loop. Ideal for cron jobs, AI agents, or any script that runs periodically:```scheme(import (sigil telegram))(define client (tg-client token: "123456:ABC-DEF..."))(for-each (lambda (update) (let ((msg (tg-update-message update))) (when msg (let ((text (tg-message-text msg)) (chat-id (tg-chat-id (tg-message-chat msg)))) (cond ((and text (string=? text "/status")) (tg-send-message client chat-id (string-append (tg-bold "Status: ") (tg-code "all systems go")) parse-mode: "MarkdownV2")) (text (tg-send-message client chat-id (string-append "You said: " (tg-escape-markdown text)) parse-mode: "MarkdownV2"))))))) (tg-fetch-updates client))```### Notification SenderSend one-shot messages from scripts or AI agents without checking for updates:```scheme(import (sigil telegram))(define client (tg-client token: "123456:ABC-DEF..."))(define chat-id 12345);; Send a formatted status report(tg-send-message client chat-id (string-append (tg-bold "Build Complete") "\n\n" "Package: " (tg-escape-markdown "sigil-http") "\n" "Status: " (tg-italic "passed") "\n" "Duration: " (tg-code "42s")) parse-mode: "MarkdownV2");; Upload a screenshot(tg-upload-photo client chat-id "/tmp/screenshot.png" caption: "Build output")```### Photo Bot```scheme(import (sigil telegram))(define bot (make-tg-bot token: "123456:ABC-DEF..."))(tg-on-command bot "/photo" (lambda (bot msg) (tg-reply-photo bot msg "https://picsum.photos/400/300" caption: "Random photo")))(tg-on-command bot "/upload" (lambda (bot msg) (tg-reply-photo/upload bot msg "/tmp/generated-chart.png" caption: (string-append (tg-bold "Chart") "\nGenerated just now") parse-mode: "MarkdownV2")))(tg-bot-run bot)```package.sgladded
;;; sigil-telegram - Telegram Bot API client library;;;;;; Provides a Telegram Bot API client with long-polling updates,;;; command/message handlers, chat authorization, MarkdownV2 formatting,;;; and media sending with file upload support.(package name: "sigil-telegram" version: "0.7.0" description: "Telegram Bot API client library" url: "https://codeberg.org/sigil/sigil" license: "BSD-3-Clause" authors: (list "David Wilson <[email protected]>") dependencies: (list (from-workspace name: "sigil-stdlib") (from-workspace name: "sigil-json") (from-workspace name: "sigil-http") (from-workspace name: "sigil-tls") (from-workspace name: "sigil-crypto")))src/sigil/telegram.sgladded
;;; (sigil telegram) - Telegram Bot API Client Library;;;;;; Provides a complete Telegram Bot API client with long-polling updates,;;; command/message handlers, chat authorization, MarkdownV2 formatting,;;; and media sending with file upload support.;;;;;; Quick start:;;; ```scheme;;; (import (sigil telegram));;;;;; (define bot (make-tg-bot token: "123456:ABC-DEF..."));;;;;; (tg-on-command bot "/start";;; (lambda (bot msg);;; (tg-reply bot msg "Hello!")));;;;;; (tg-bot-run bot);;; ```(define-library (sigil telegram) (import (sigil telegram types) (sigil telegram client) (sigil telegram bot) (sigil telegram format)) (export ;; Types - User tg-user tg-user? tg-user-id tg-user-is-bot tg-user-first-name tg-user-last-name tg-user-username dict->tg-user ;; Types - Chat tg-chat tg-chat? tg-chat-id tg-chat-type tg-chat-title tg-chat-username dict->tg-chat ;; Types - Message tg-message tg-message? tg-message-message-id tg-message-chat tg-message-from tg-message-date tg-message-text tg-message-photo tg-message-document tg-message-reply-to tg-message-raw dict->tg-message ;; Types - Update tg-update tg-update? tg-update-update-id tg-update-message tg-update-callback-query tg-update-raw dict->tg-update ;; Client tg-client tg-client? tg-client-token tg-client-last-update-id set-tg-client-last-update-id! tg-api-call ;; Messages tg-get-me tg-send-message tg-edit-message tg-delete-message ;; Media (URL/file_id) tg-send-photo tg-send-document ;; Media (file upload) tg-upload-photo tg-upload-document ;; Callbacks tg-answer-callback ;; Updates tg-get-updates tg-fetch-updates ;; Inline keyboards tg-inline-keyboard tg-button ;; Bot framework tg-bot make-tg-bot tg-bot? tg-bot-client tg-bot-allowed-chats set-tg-bot-allowed-chats! tg-bot-allowed-users set-tg-bot-allowed-users! tg-bot-running? tg-on-command tg-on-message tg-on-callback tg-on-error tg-bot-run tg-bot-stop tg-bot-tick tg-reply tg-reply-photo tg-reply-photo/upload ;; Formatting tg-escape-markdown tg-bold tg-italic tg-underline tg-strike tg-code tg-code-block tg-link tg-spoiler))src/sigil/telegram/bot.sgladded
;;; (sigil telegram bot) - Telegram Bot Framework;;;;;; High-level bot framework with command/message handlers,;;; long-polling event loop, and chat authorization.(define-library (sigil telegram bot) (import (sigil core) (sigil string) (sigil struct) (sigil time) (sigil telegram types) (sigil telegram client)) (export ;; Bot record tg-bot make-tg-bot tg-bot? tg-bot-client tg-bot-allowed-chats set-tg-bot-allowed-chats! tg-bot-allowed-users set-tg-bot-allowed-users! tg-bot-running? ;; Handler registration tg-on-command tg-on-message tg-on-callback tg-on-error ;; Event loop tg-bot-run tg-bot-stop tg-bot-tick ;; Convenience tg-reply tg-reply-photo tg-reply-photo/upload ;; Internal (exported for testing) extract-command authorized?) (begin ;; ============================================================ ;; Bot Record ;; ============================================================ (define-struct tg-bot (client) (allowed-chats default: #f mutable: #t) (allowed-users default: #f mutable: #t) (command-handlers default: #{} mutable: #t) (message-handlers default: '() mutable: #t) (callback-handlers default: '() mutable: #t) (error-handler default: #f mutable: #t) (running? default: #f mutable: #t)) ;; ============================================================ ;; Constructor ;; ============================================================ ;;; Create a Telegram bot. ;;; ;;; ```scheme ;;; (define bot (make-tg-bot ;;; token: "123456:ABC-DEF..." ;;; allowed-chats: '(12345 67890))) ;;; ``` (define (make-tg-bot (keys: (token #f) (allowed-chats #f) (allowed-users #f))) (: (token: string?) (allowed-chats: (maybe list?)) (allowed-users: (maybe list?)) -> tg-bot?) (unless token (error "make-tg-bot: token: is required")) (tg-bot client: (tg-client token: token) allowed-chats: allowed-chats allowed-users: allowed-users)) ;; ============================================================ ;; Handler Registration ;; ============================================================ ;;; Register a handler for a specific bot command. ;;; ;;; The handler receives `(bot msg)`. The command string should ;;; include the leading slash (e.g., "/start"). ;;; ;;; ```scheme ;;; (tg-on-command bot "/start" ;;; (lambda (bot msg) ;;; (tg-reply bot msg "Hello!"))) ;;; ``` (define (tg-on-command bot command handler) (: tg-bot? string? procedure? -> void?) (let* ((handlers (tg-bot-command-handlers bot)) (existing (dict-ref handlers (string->keyword command) '()))) (set-tg-bot-command-handlers! bot (dict-set handlers (string->keyword command) (append existing (list handler)))))) ;;; Register a handler for all text messages. ;;; ;;; Called for every message that passes authorization. ;;; ;;; ```scheme ;;; (tg-on-message bot ;;; (lambda (bot msg) ;;; (display (tg-message-text msg)))) ;;; ``` (define (tg-on-message bot handler) (: tg-bot? procedure? -> void?) (set-tg-bot-message-handlers! bot (append (tg-bot-message-handlers bot) (list handler)))) ;;; Register a handler for callback queries (inline keyboard presses). ;;; ;;; The handler receives `(bot query)` where `query` is a raw dict. ;;; ;;; ```scheme ;;; (tg-on-callback bot ;;; (lambda (bot query) ;;; (tg-answer-callback (tg-bot-client bot) ;;; (dict-ref query id:) text: "Received!"))) ;;; ``` (define (tg-on-callback bot handler) (: tg-bot? procedure? -> void?) (set-tg-bot-callback-handlers! bot (append (tg-bot-callback-handlers bot) (list handler)))) ;;; Set an error handler for exceptions during update processing. ;;; ;;; The handler receives the exception and should return: ;;; - `'stop` to halt the bot ;;; - `'continue` to retry immediately (skip backoff) ;;; - Any other value to apply the default 5-second backoff ;;; ;;; ```scheme ;;; (tg-on-error bot ;;; (lambda (exn) ;;; (display "Error: ") ;;; (display exn) ;;; (newline))) ;;; ``` (define (tg-on-error bot handler) (: tg-bot? procedure? -> void?) (set-tg-bot-error-handler! bot handler)) ;; ============================================================ ;; Authorization ;; ============================================================ ;;; Check if an update is authorized based on allowed-chats and ;;; allowed-users lists. (define (authorized? bot update) (: tg-bot? tg-update? -> boolean?) (let ((msg (tg-update-message update))) (if (not msg) #t ; Non-message updates pass through (let ((chat-id (tg-chat-id (tg-message-chat msg))) (user-id (and (tg-message-from msg) (tg-user-id (tg-message-from msg))))) (and (or (not (tg-bot-allowed-chats bot)) (member chat-id (tg-bot-allowed-chats bot))) (or (not (tg-bot-allowed-users bot)) (and user-id (member user-id (tg-bot-allowed-users bot))))))))) ;; ============================================================ ;; Command Extraction ;; ============================================================ ;;; Extract the command name from a message text. ;;; ;;; Strips @botname mentions and arguments. ;;; "/start" => "/start" ;;; "/help@MyBot" => "/help" ;;; "/set value 42" => "/set" (define (extract-command text) (: string? -> string?) (let* ((space-pos (string-index text (lambda (c) (char=? c #\space)))) (cmd-part (if space-pos (substring text 0 space-pos) text)) (at-pos (string-index cmd-part (lambda (c) (char=? c #\@))))) (if at-pos (substring cmd-part 0 at-pos) cmd-part))) ;; ============================================================ ;; Update Dispatch ;; ============================================================ ;; Dispatch a single update to registered handlers (define (dispatch-update bot update) (when (authorized? bot update) ;; Message handling (let ((msg (tg-update-message update))) (when msg ;; Check for command (let ((text (tg-message-text msg))) (when (and text (> (string-length text) 0) (char=? (string-ref text 0) #\/)) (let* ((cmd (extract-command text)) (cmd-key (string->keyword cmd)) (handlers (dict-ref (tg-bot-command-handlers bot) cmd-key '()))) (for-each (lambda (h) (h bot msg)) handlers)))) ;; General message handlers (for-each (lambda (h) (h bot msg)) (tg-bot-message-handlers bot)))) ;; Callback query handling (let ((cb (tg-update-callback-query update))) (when cb (for-each (lambda (h) (h bot cb)) (tg-bot-callback-handlers bot)))))) ;; ============================================================ ;; Event Loop ;; ============================================================ ;;; Run the bot polling loop. ;;; ;;; Blocks and polls for updates using long-polling. Each update ;;; is dispatched to registered handlers. Stops when `tg-bot-stop` ;;; is called. ;;; ;;; ```scheme ;;; (tg-on-command bot "/ping" ;;; (lambda (bot msg) ;;; (tg-reply bot msg "pong!"))) ;;; ;;; (tg-bot-run bot) ;;; ``` (define (tg-bot-run bot (keys: (timeout 30))) (: tg-bot? (timeout: integer?) -> void?) (set-tg-bot-running?! bot #t) (let loop () (when (tg-bot-running? bot) (let ((error-result (guard (exn (else (if (tg-bot-error-handler bot) ((tg-bot-error-handler bot) exn) (begin (display "tg-bot-run: error: " (current-error-port)) (display exn (current-error-port)) (newline (current-error-port)) ;; Default: apply backoff #t)))) (let ((updates (tg-get-updates (tg-bot-client bot) timeout: timeout))) (when updates (for-each (lambda (raw-update) (let ((update (dict->tg-update raw-update))) (set-tg-client-last-update-id! (tg-bot-client bot) (+ (tg-update-update-id update) 1)) (dispatch-update bot update))) (if (array? updates) (array->list updates) '()))) ;; No error #f)))) ;; Handle error recovery (cond ((eq? error-result 'stop) (set-tg-bot-running?! bot #f)) ((eq? error-result 'continue) ;; Skip backoff, retry immediately #t) (error-result ;; Default backoff (sleep 5)))) (loop)))) ;;; Stop the bot polling loop. (define (tg-bot-stop bot) (: tg-bot? -> void?) (set-tg-bot-running?! bot #f)) ;;; Process one batch of updates (non-blocking). ;;; ;;; Uses timeout: 0 for immediate return. Useful for integrating ;;; with other event loops. (define (tg-bot-tick bot) (: tg-bot? -> void?) (let ((updates (tg-get-updates (tg-bot-client bot) timeout: 0))) (when updates (for-each (lambda (raw-update) (let ((update (dict->tg-update raw-update))) (set-tg-client-last-update-id! (tg-bot-client bot) (+ (tg-update-update-id update) 1)) (dispatch-update bot update))) (if (array? updates) (array->list updates) '()))))) ;; ============================================================ ;; Convenience ;; ============================================================ ;;; Reply to a message with text. ;;; ;;; Sends a reply in the same chat. Set `quote?:` to #t to quote ;;; the original message. ;;; ;;; ```scheme ;;; (tg-reply bot msg "Got it!" parse-mode: "MarkdownV2") ;;; ``` (define (tg-reply bot msg text (keys: (parse-mode #f) (quote? #f) (reply-markup #f))) (: tg-bot? tg-message? string? (parse-mode: (maybe string?)) (quote?: boolean?) (reply-markup: (maybe dict?)) -> tg-message?) (tg-send-message (tg-bot-client bot) (tg-chat-id (tg-message-chat msg)) text parse-mode: parse-mode reply-to: (if quote? (tg-message-message-id msg) #f) reply-markup: reply-markup)) ;;; Reply to a message with a photo (URL or file_id). ;;; ;;; ```scheme ;;; (tg-reply-photo bot msg "https://example.com/photo.jpg" ;;; caption: "Here you go!") ;;; ``` (define (tg-reply-photo bot msg photo (keys: (caption #f) (parse-mode #f))) (: tg-bot? tg-message? string? (caption: (maybe string?)) (parse-mode: (maybe string?)) -> tg-message?) (tg-send-photo (tg-bot-client bot) (tg-chat-id (tg-message-chat msg)) photo caption: caption parse-mode: parse-mode)) ;;; Reply to a message with an uploaded local photo. ;;; ;;; ```scheme ;;; (tg-reply-photo/upload bot msg "/tmp/result.png" ;;; caption: "Processing complete") ;;; ``` (define (tg-reply-photo/upload bot msg path (keys: (caption #f) (parse-mode #f))) (: tg-bot? tg-message? string? (caption: (maybe string?)) (parse-mode: (maybe string?)) -> tg-message?) (tg-upload-photo (tg-bot-client bot) (tg-chat-id (tg-message-chat msg)) path caption: caption parse-mode: parse-mode)) ))src/sigil/telegram/client.sgladded
;;; (sigil telegram client) - Telegram Bot API Client;;;;;; Provides low-level HTTP transport for the Telegram Bot API,;;; including JSON-based API calls and multipart file uploads.(define-library (sigil telegram client) (import (sigil core) (sigil string) (sigil struct) (sigil io) (sigil fs) (sigil json) (sigil http client) (sigil http mime) (sigil tls) (sigil crypto) (sigil path) (sigil telegram types)) (export ;; Client tg-client tg-client? tg-client-token tg-client-last-update-id set-tg-client-last-update-id! ;; Core API tg-api-call ;; Messages tg-get-me tg-send-message tg-edit-message tg-delete-message ;; Media (URL/file_id) tg-send-photo tg-send-document ;; Media (file upload) tg-upload-photo tg-upload-document ;; Callbacks tg-answer-callback ;; Updates tg-get-updates tg-fetch-updates ;; Inline keyboards tg-inline-keyboard tg-button ;; Multipart (exported for testing) build-multipart-body encode-text-part encode-file-part generate-boundary) (begin ;; ============================================================ ;; Client Record ;; ============================================================ (define-struct tg-client (token) (api-url default: "https://api.telegram.org") (last-update-id default: 0 mutable: #t)) ;; ============================================================ ;; Core API Call ;; ============================================================ ;; Build the full API URL for a method (define (api-url client method) (string-append (tg-client-api-url client) "/bot" (tg-client-token client) "/" method)) ;;; Make a Telegram Bot API call. ;;; ;;; Calls the given method with the provided parameters dict. ;;; Returns the `result` field from the API response on success, ;;; or raises an error if `ok` is false. ;;; ;;; ```scheme ;;; (tg-api-call client "getMe" #{}) ;;; ; => #{ id: 123456 is_bot: #t first_name: "MyBot" ... } ;;; ``` (define (tg-api-call client method (keys: (params #{}))) (: tg-client? string? (params: dict?) -> any?) (let ((response (http-post/json (api-url client method) params))) (unless response (error (string-append "tg-api-call: HTTP request failed for " method))) (if (eq? (dict-ref response ok: #f) #t) (dict-ref response result: #f) (error (string-append "tg-api-call: " method " failed: " (or (dict-ref response description: #f) "unknown error")) (dict-ref response error_code: #f))))) ;; Helper to build params dict, adding optional keyword arguments (define (add-param params key value) (if value (dict-set params key value) params)) ;; ============================================================ ;; Bot Info ;; ============================================================ ;;; Get information about the bot. ;;; ;;; ```scheme ;;; (tg-get-me client) ; => #{ id: 123 is_bot: #t first_name: "MyBot" ... } ;;; ``` (define (tg-get-me client) (: tg-client? -> dict?) (tg-api-call client "getMe")) ;; ============================================================ ;; Sending Messages ;; ============================================================ ;;; Send a text message. ;;; ;;; Returns the sent message as a tg-message record. ;;; ;;; ```scheme ;;; (tg-send-message client 12345 "Hello!") ;;; ;;; (tg-send-message client 12345 ;;; (string-append (tg-bold "Hello") "\\!") ;;; parse-mode: "MarkdownV2") ;;; ``` (define (tg-send-message client chat-id text (keys: (parse-mode #f) (reply-to #f) (disable-notification #f) (reply-markup #f))) (: tg-client? (any-of integer? string?) string? (parse-mode: (maybe string?)) (reply-to: (maybe integer?)) (disable-notification: (maybe boolean?)) (reply-markup: (maybe dict?)) -> tg-message?) (let* ((params (dict chat_id: chat-id text: text)) (params (add-param params parse_mode: parse-mode)) (params (add-param params reply_to_message_id: reply-to)) (params (if disable-notification (dict-set params disable_notification: #t) params)) (params (add-param params reply_markup: reply-markup))) (dict->tg-message (tg-api-call client "sendMessage" params: params)))) ;;; Edit an existing message's text. ;;; ;;; ```scheme ;;; (tg-edit-message client 12345 678 "Updated text") ;;; ``` (define (tg-edit-message client chat-id message-id text (keys: (parse-mode #f) (reply-markup #f))) (: tg-client? (any-of integer? string?) integer? string? (parse-mode: (maybe string?)) (reply-markup: (maybe dict?)) -> any?) (let* ((params (dict chat_id: chat-id message_id: message-id text: text)) (params (add-param params parse_mode: parse-mode)) (params (add-param params reply_markup: reply-markup))) (tg-api-call client "editMessageText" params: params))) ;;; Delete a message. ;;; ;;; ```scheme ;;; (tg-delete-message client 12345 678) ;;; ``` (define (tg-delete-message client chat-id message-id) (: tg-client? (any-of integer? string?) integer? -> any?) (tg-api-call client "deleteMessage" params: (dict chat_id: chat-id message_id: message-id))) ;; ============================================================ ;; Media Sending (URL/file_id) ;; ============================================================ ;;; Send a photo by URL or file_id. ;;; ;;; The `photo` parameter must be a Telegram file_id string or an ;;; HTTP/HTTPS URL. For uploading a local file, use `tg-upload-photo`. ;;; ;;; ```scheme ;;; (tg-send-photo client 12345 "https://example.com/photo.jpg" ;;; caption: "A nice photo") ;;; ``` (define (tg-send-photo client chat-id photo (keys: (caption #f) (parse-mode #f) (reply-markup #f))) (: tg-client? (any-of integer? string?) string? (caption: (maybe string?)) (parse-mode: (maybe string?)) (reply-markup: (maybe dict?)) -> tg-message?) (let* ((params (dict chat_id: chat-id photo: photo)) (params (add-param params caption: caption)) (params (add-param params parse_mode: parse-mode)) (params (add-param params reply_markup: reply-markup))) (dict->tg-message (tg-api-call client "sendPhoto" params: params)))) ;;; Send a document by URL or file_id. ;;; ;;; The `document` parameter must be a Telegram file_id string or an ;;; HTTP/HTTPS URL. For uploading a local file, use `tg-upload-document`. ;;; ;;; ```scheme ;;; (tg-send-document client 12345 "https://example.com/file.pdf") ;;; ``` (define (tg-send-document client chat-id document (keys: (caption #f) (parse-mode #f) (reply-markup #f))) (: tg-client? (any-of integer? string?) string? (caption: (maybe string?)) (parse-mode: (maybe string?)) (reply-markup: (maybe dict?)) -> tg-message?) (let* ((params (dict chat_id: chat-id document: document)) (params (add-param params caption: caption)) (params (add-param params parse_mode: parse-mode)) (params (add-param params reply_markup: reply-markup))) (dict->tg-message (tg-api-call client "sendDocument" params: params)))) ;; ============================================================ ;; Multipart File Upload ;; ============================================================ ;;; Generate a unique multipart boundary string. (define (generate-boundary) (: -> string?) (let ((bytes (random-bytes 16))) (string-append "SigilBoundary" (base64-encode bytes)))) ;;; Encode a text form field as a bytevector. (define (encode-text-part boundary name value) (: string? string? string? -> bytevector?) (string->utf8 (string-append "--" boundary "\r\n" "Content-Disposition: form-data; name=\"" name "\"\r\n" "\r\n" value "\r\n"))) ;;; Encode a file form field as a bytevector. (define (encode-file-part boundary name filename content-type data) (: string? string? string? string? bytevector? -> bytevector?) (bytevector-append (string->utf8 (string-append "--" boundary "\r\n" "Content-Disposition: form-data; name=\"" name "\"; filename=\"" filename "\"\r\n" "Content-Type: " content-type "\r\n" "\r\n")) data (string->utf8 "\r\n"))) ;;; Build a complete multipart/form-data body as a bytevector. ;;; ;;; `fields` is a list of (name . value) pairs for text fields. ;;; `files` is a list of (name filename content-type bytevector) lists. ;;; Returns a pair: (boundary . body-bytevector). (define (build-multipart-body fields files) (: list? list? -> pair?) (let* ((boundary (generate-boundary)) (text-parts (map (lambda (field) (encode-text-part boundary (car field) (cdr field))) fields)) (file-parts (map (lambda (file) (encode-file-part boundary (list-ref file 0) (list-ref file 1) (list-ref file 2) (list-ref file 3))) files)) (closing (string->utf8 (string-append "--" boundary "--\r\n"))) (body (apply bytevector-append (append text-parts file-parts (list closing))))) (cons boundary body))) ;; Read a file into a bytevector (define (read-file-bytes path) (let* ((size (file-size path)) (port (open-binary-input-file path)) (data (read-bytevector size port))) (close-input-port port) data)) ;; Read all string data from a TLS connection until closed (define (read-all-tls-data conn) (let loop ((chunks '())) (let ((chunk (tls-read conn))) (cond ((or (not chunk) (eof-object? chunk)) (apply string-append (reverse chunks))) ((string=? chunk "") (apply string-append (reverse chunks))) (else (loop (cons chunk chunks))))))) ;; Parse a raw HTTP response string to extract JSON body (define (parse-tls-response data) (let ((header-end (string-find data "\r\n\r\n"))) (if header-end (let ((body (substring data (+ header-end 4) (string-length data)))) (json-decode body)) #f))) ;; Send a multipart API call via direct TLS connection (define (tg-api-call/upload client method fields files) (let* ((result (build-multipart-body fields files)) (boundary (car result)) (body (cdr result)) (path (string-append "/bot" (tg-client-token client) "/" method)) (headers (string-append "POST " path " HTTP/1.1\r\n" "Host: api.telegram.org\r\n" "User-Agent: Sigil/1.0\r\n" "Connection: close\r\n" "Content-Type: multipart/form-data; boundary=" boundary "\r\n" "Content-Length: " (number->string (bytevector-length body)) "\r\n" "\r\n")) (conn (tls-connect "api.telegram.org" 443))) (unless conn (error "tg-api-call/upload: failed to connect to api.telegram.org")) (tls-write conn headers) (tls-write conn body) (let* ((response-data (read-all-tls-data conn)) (_ (tls-close conn)) (response (parse-tls-response response-data))) (unless response (error (string-append "tg-api-call/upload: failed to parse response for " method))) (if (eq? (dict-ref response ok: #f) #t) (dict-ref response result: #f) (error (string-append "tg-api-call/upload: " method " failed: " (or (dict-ref response description: #f) "unknown error")) (dict-ref response error_code: #f)))))) ;;; Upload a local photo file. ;;; ;;; Reads the file at `path`, auto-detects its MIME type, and sends ;;; it to the Telegram API via multipart/form-data upload. ;;; ;;; ```scheme ;;; (tg-upload-photo client 12345 "/tmp/screenshot.png" ;;; caption: "Build output") ;;; ``` (define (tg-upload-photo client chat-id path (keys: (caption #f) (parse-mode #f) (reply-markup #f))) (: tg-client? (any-of integer? string?) string? (caption: (maybe string?)) (parse-mode: (maybe string?)) (reply-markup: (maybe dict?)) -> tg-message?) (let* ((data (read-file-bytes path)) (mime (mime-type-for-file path)) (filename (path-basename path)) (fields (let* ((f (list (cons "chat_id" (if (integer? chat-id) (number->string chat-id) chat-id)))) (f (if caption (cons (cons "caption" caption) f) f)) (f (if parse-mode (cons (cons "parse_mode" parse-mode) f) f)) (f (if reply-markup (cons (cons "reply_markup" (json-encode reply-markup)) f) f))) f)) (files (list (list "photo" filename mime data)))) (dict->tg-message (tg-api-call/upload client "sendPhoto" fields files)))) ;;; Upload a local document file. ;;; ;;; Reads the file at `path`, auto-detects its MIME type, and sends ;;; it to the Telegram API via multipart/form-data upload. ;;; ;;; ```scheme ;;; (tg-upload-document client 12345 "/tmp/report.pdf") ;;; ``` (define (tg-upload-document client chat-id path (keys: (caption #f) (parse-mode #f) (reply-markup #f))) (: tg-client? (any-of integer? string?) string? (caption: (maybe string?)) (parse-mode: (maybe string?)) (reply-markup: (maybe dict?)) -> tg-message?) (let* ((data (read-file-bytes path)) (mime (mime-type-for-file path)) (filename (path-basename path)) (fields (let* ((f (list (cons "chat_id" (if (integer? chat-id) (number->string chat-id) chat-id)))) (f (if caption (cons (cons "caption" caption) f) f)) (f (if parse-mode (cons (cons "parse_mode" parse-mode) f) f)) (f (if reply-markup (cons (cons "reply_markup" (json-encode reply-markup)) f) f))) f)) (files (list (list "document" filename mime data)))) (dict->tg-message (tg-api-call/upload client "sendDocument" fields files)))) ;; ============================================================ ;; Callback Queries ;; ============================================================ ;;; Answer a callback query (inline keyboard button press). ;;; ;;; ```scheme ;;; (tg-answer-callback client callback-query-id ;;; text: "Button pressed!") ;;; ``` (define (tg-answer-callback client callback-query-id (keys: (text #f) (show-alert #f))) (: tg-client? string? (text: (maybe string?)) (show-alert: (maybe boolean?)) -> any?) (let* ((params (dict callback_query_id: callback-query-id)) (params (add-param params text: text)) (params (if show-alert (dict-set params show_alert: #t) params))) (tg-api-call client "answerCallbackQuery" params: params))) ;; ============================================================ ;; Updates ;; ============================================================ ;;; Get updates using long-polling. ;;; ;;; Returns an array of update dicts, or #f on failure. ;;; Typically called by the bot framework, not directly. (define (tg-get-updates client (keys: (timeout 30) (allowed-updates #f))) (: tg-client? (timeout: integer?) (allowed-updates: (maybe list?)) -> any?) (let* ((offset (tg-client-last-update-id client)) (params (dict timeout: timeout)) (params (if (> offset 0) (dict-set params offset: offset) params)) (params (add-param params allowed_updates: allowed-updates))) (tg-api-call client "getUpdates" params: params))) ;;; Fetch all pending updates as a list of tg-update records. ;;; ;;; Returns immediately (non-blocking) with all unconfirmed updates, ;;; converts them to typed records, and confirms receipt with ;;; Telegram so they won't be returned again on the next call. ;;; ;;; This is the simplest way to check for new messages in a ;;; script that wakes up, processes messages, and exits. ;;; ;;; ```scheme ;;; (define client (tg-client token: "...")) ;;; (for-each ;;; (lambda (update) ;;; (let ((msg (tg-update-message update))) ;;; (when (and msg (tg-message-text msg)) ;;; (tg-send-message client ;;; (tg-chat-id (tg-message-chat msg)) ;;; "Got it!")))) ;;; (tg-fetch-updates client)) ;;; ``` (define (tg-fetch-updates client) (: tg-client? -> list?) (let ((raw-updates (tg-get-updates client timeout: 0))) (if (and raw-updates (array? raw-updates) (> (array-length raw-updates) 0)) (let ((updates (map dict->tg-update (array->list raw-updates)))) ;; Update offset and confirm receipt immediately so a ;; subsequent process won't see the same updates (let ((last (list-ref updates (- (length updates) 1)))) (set-tg-client-last-update-id! client (+ (tg-update-update-id last) 1)) (tg-get-updates client timeout: 0)) updates) '()))) ;; ============================================================ ;; Inline Keyboards ;; ============================================================ ;;; Build an inline keyboard markup dict. ;;; ;;; Takes a list of rows, where each row is a list of button dicts ;;; (created with `tg-button`). ;;; ;;; ```scheme ;;; (tg-inline-keyboard ;;; (list ;;; (list (tg-button "Yes" callback: "yes") ;;; (tg-button "No" callback: "no")))) ;;; ``` (define (tg-inline-keyboard rows) (: list? -> dict?) (dict inline_keyboard: (list->array (map (lambda (row) (list->array row)) rows)))) ;;; Build an inline keyboard button. ;;; ;;; Specify either `callback:` for a callback data string or ;;; `url:` for an external link. ;;; ;;; ```scheme ;;; (tg-button "Click me" callback: "btn_click") ;;; (tg-button "Visit" url: "https://example.com") ;;; ``` (define (tg-button text (keys: (callback #f) (url #f))) (: string? (callback: (maybe string?)) (url: (maybe string?)) -> dict?) (let ((btn (dict text: text))) (cond (callback (dict-set btn callback_data: callback)) (url (dict-set btn url: url)) (else btn)))) ))src/sigil/telegram/format.sgladded
;;; (sigil telegram format) - MarkdownV2 Formatting Helpers;;;;;; Provides escaping and formatting functions for Telegram's MarkdownV2;;; parse mode. Dynamic text must be escaped with `tg-escape-markdown`;;; before wrapping with formatting helpers.(define-library (sigil telegram format) (import (sigil core) (sigil string)) (export tg-escape-markdown tg-bold tg-italic tg-underline tg-strike tg-code tg-code-block tg-link tg-spoiler) (begin ;; Characters that must be escaped in MarkdownV2 outside of formatting (define %markdown-special-chars '("_" "*" "[" "]" "(" ")" "~" "`" ">" "#" "+" "-" "=" "|" "{" "}" "." "!")) ;;; Escape a string for Telegram MarkdownV2 format. ;;; ;;; Prepends a backslash before each special character that MarkdownV2 ;;; requires to be escaped. Call this on dynamic text before wrapping ;;; it with formatting helpers like `tg-bold` or `tg-italic`. ;;; ;;; ```scheme ;;; (tg-escape-markdown "Hello! How are you?") ;;; ; => "Hello\\! How are you\\?" ;;; ;;; (tg-escape-markdown "Price: $5.00 (USD)") ;;; ; => "Price: $5\\.00 \\(USD\\)" ;;; ``` (define (tg-escape-markdown text) (: string? -> string?) (fold (lambda (result char) (string-replace result char (string-append "\\" char))) text %markdown-special-chars)) ;;; Format text as bold in MarkdownV2. ;;; ;;; ```scheme ;;; (tg-bold "important") ; => "*important*" ;;; ``` (define (tg-bold text) (: string? -> string?) (string-append "*" text "*")) ;;; Format text as italic in MarkdownV2. ;;; ;;; ```scheme ;;; (tg-italic "emphasis") ; => "_emphasis_" ;;; ``` (define (tg-italic text) (: string? -> string?) (string-append "_" text "_")) ;;; Format text as underlined in MarkdownV2. ;;; ;;; ```scheme ;;; (tg-underline "noted") ; => "__noted__" ;;; ``` (define (tg-underline text) (: string? -> string?) (string-append "__" text "__")) ;;; Format text as strikethrough in MarkdownV2. ;;; ;;; ```scheme ;;; (tg-strike "removed") ; => "~removed~" ;;; ``` (define (tg-strike text) (: string? -> string?) (string-append "~" text "~")) ;;; Format text as inline code in MarkdownV2. ;;; ;;; ```scheme ;;; (tg-code "variable") ; => "`variable`" ;;; ``` (define (tg-code text) (: string? -> string?) (string-append "`" text "`")) ;;; Format text as a code block in MarkdownV2. ;;; ;;; Optionally specify a language for syntax highlighting. ;;; ;;; ```scheme ;;; (tg-code-block "(+ 1 2)") ;;; ; => "```\n(+ 1 2)```" ;;; ;;; (tg-code-block "(+ 1 2)" language: "scheme") ;;; ; => "```scheme\n(+ 1 2)```" ;;; ``` (define (tg-code-block text (keys: (language #f))) (: string? (language: (maybe string?)) -> string?) (if language (string-append "```" language "\n" text "```") (string-append "```\n" text "```"))) ;;; Format a MarkdownV2 inline link. ;;; ;;; ```scheme ;;; (tg-link "Click here" "https://example.com") ;;; ; => "[Click here](https://example.com)" ;;; ``` (define (tg-link text url) (: string? string? -> string?) (string-append "[" text "](" url ")")) ;;; Format text as a spoiler in MarkdownV2. ;;; ;;; ```scheme ;;; (tg-spoiler "hidden text") ; => "||hidden text||" ;;; ``` (define (tg-spoiler text) (: string? -> string?) (string-append "||" text "||")) ))src/sigil/telegram/types.sgladded
;;; (sigil telegram types) - Telegram API Data Types;;;;;; Defines structs for core Telegram objects (Update, Message, Chat, User);;; and conversion functions from JSON dicts to typed records.(define-library (sigil telegram types) (import (sigil core) (sigil struct)) (export ;; User tg-user tg-user? tg-user-id tg-user-is-bot tg-user-first-name tg-user-last-name tg-user-username dict->tg-user ;; Chat tg-chat tg-chat? tg-chat-id tg-chat-type tg-chat-title tg-chat-username dict->tg-chat ;; Message tg-message tg-message? tg-message-message-id tg-message-chat tg-message-from tg-message-date tg-message-text tg-message-photo tg-message-document tg-message-reply-to tg-message-raw dict->tg-message ;; Update tg-update tg-update? tg-update-update-id tg-update-message tg-update-callback-query tg-update-raw dict->tg-update) (begin ;; ============================================================ ;; User ;; ============================================================ (define-struct tg-user (id) (is-bot default: #f) (first-name default: "") (last-name default: #f) (username default: #f)) ;;; Convert a JSON dict to a tg-user record. (define (dict->tg-user d) (: dict? -> tg-user?) (tg-user id: (dict-ref d id: 0) is-bot: (dict-ref d is_bot: #f) first-name: (or (dict-ref d first_name: #f) "") last-name: (dict-ref d last_name: #f) username: (dict-ref d username: #f))) ;; ============================================================ ;; Chat ;; ============================================================ (define-struct tg-chat (id) (type default: "private") (title default: #f) (username default: #f)) ;;; Convert a JSON dict to a tg-chat record. (define (dict->tg-chat d) (: dict? -> tg-chat?) (tg-chat id: (dict-ref d id: 0) type: (or (dict-ref d type: #f) "private") title: (dict-ref d title: #f) username: (dict-ref d username: #f))) ;; ============================================================ ;; Message ;; ============================================================ (define-struct tg-message (message-id) (chat) (from default: #f) (date default: 0) (text default: #f) (photo default: #f) (document default: #f) (reply-to default: #f) (raw default: #{})) ;;; Convert a JSON dict to a tg-message record. (define (dict->tg-message d) (: dict? -> tg-message?) (let ((chat-dict (dict-ref d chat: #f)) (from-dict (dict-ref d from: #f)) (reply-dict (dict-ref d reply_to_message: #f))) (tg-message message-id: (dict-ref d message_id: 0) chat: (if chat-dict (dict->tg-chat chat-dict) (tg-chat id: 0)) from: (and from-dict (dict->tg-user from-dict)) date: (dict-ref d date: 0) text: (dict-ref d text: #f) photo: (dict-ref d photo: #f) document: (dict-ref d document: #f) reply-to: (and reply-dict (dict->tg-message reply-dict)) raw: d))) ;; ============================================================ ;; Update ;; ============================================================ (define-struct tg-update (update-id) (message default: #f) (callback-query default: #f) (raw default: #{})) ;;; Convert a JSON dict to a tg-update record. (define (dict->tg-update d) (: dict? -> tg-update?) (let ((msg-dict (dict-ref d message: #f)) (cb-dict (dict-ref d callback_query: #f))) (tg-update update-id: (dict-ref d update_id: 0) message: (and msg-dict (dict->tg-message msg-dict)) callback-query: cb-dict raw: d))) ))test/test-telegram.sgladded
(import (sigil test) (scheme base) (sigil string) (sigil telegram types) (sigil telegram client) (sigil telegram bot) (sigil telegram format));; ============================================================;; Type Parsing;; ============================================================(test-group "dict->tg-user" (test "parses all fields" (let ((user (dict->tg-user #{ id: 123 is_bot: #f first_name: "Alice" last_name: "Smith" username: "alice" }))) (assert-equal 123 (tg-user-id user)) (assert-false (tg-user-is-bot user)) (assert-equal "Alice" (tg-user-first-name user)) (assert-equal "Smith" (tg-user-last-name user)) (assert-equal "alice" (tg-user-username user)))) (test "handles missing optional fields" (let ((user (dict->tg-user #{ id: 456 is_bot: #t first_name: "Bot" }))) (assert-equal 456 (tg-user-id user)) (assert-true (tg-user-is-bot user)) (assert-false (tg-user-last-name user)) (assert-false (tg-user-username user)))))(test-group "dict->tg-chat" (test "parses private chat" (let ((chat (dict->tg-chat #{ id: 100 type: "private" username: "alice" }))) (assert-equal 100 (tg-chat-id chat)) (assert-equal "private" (tg-chat-type chat)) (assert-false (tg-chat-title chat)) (assert-equal "alice" (tg-chat-username chat)))) (test "parses group chat" (let ((chat (dict->tg-chat #{ id: -200 type: "supergroup" title: "My Group" }))) (assert-equal -200 (tg-chat-id chat)) (assert-equal "supergroup" (tg-chat-type chat)) (assert-equal "My Group" (tg-chat-title chat)))))(test-group "dict->tg-message" (test "parses text message" (let ((msg (dict->tg-message #{ message_id: 42 chat: #{ id: 100 type: "private" } from: #{ id: 123 is_bot: #f first_name: "Alice" } date: 1700000000 text: "Hello world" }))) (assert-equal 42 (tg-message-message-id msg)) (assert-equal 100 (tg-chat-id (tg-message-chat msg))) (assert-equal 123 (tg-user-id (tg-message-from msg))) (assert-equal 1700000000 (tg-message-date msg)) (assert-equal "Hello world" (tg-message-text msg)) (assert-false (tg-message-photo msg)) (assert-false (tg-message-document msg)))) (test "handles missing from field" (let ((msg (dict->tg-message #{ message_id: 1 chat: #{ id: 100 type: "channel" } date: 0 text: "Channel post" }))) (assert-false (tg-message-from msg)))) (test "preserves raw dict" (let* ((raw #{ message_id: 1 chat: #{ id: 1 type: "private" } text: "test" sticker: #{ file_id: "abc" } }) (msg (dict->tg-message raw))) (assert-equal "abc" (dict-ref (dict-ref (tg-message-raw msg) sticker:) file_id:)))))(test-group "dict->tg-update" (test "parses message update" (let ((update (dict->tg-update #{ update_id: 999 message: #{ message_id: 1 chat: #{ id: 100 type: "private" } text: "Hello" } }))) (assert-equal 999 (tg-update-update-id update)) (assert-true (tg-message? (tg-update-message update))) (assert-false (tg-update-callback-query update)))) (test "parses callback query update" (let ((update (dict->tg-update #{ update_id: 1000 callback_query: #{ id: "abc123" data: "btn_yes" } }))) (assert-equal 1000 (tg-update-update-id update)) (assert-false (tg-update-message update)) (assert-equal "abc123" (dict-ref (tg-update-callback-query update) id:)))));; ============================================================;; Command Extraction;; ============================================================(test-group "extract-command" (test "simple command" (assert-equal "/start" (extract-command "/start"))) (test "command with bot mention" (assert-equal "/help" (extract-command "/help@MyBot"))) (test "command with arguments" (assert-equal "/set" (extract-command "/set value 42"))) (test "command with mention and arguments" (assert-equal "/cmd" (extract-command "/cmd@Bot arg1 arg2"))) (test "single slash" (assert-equal "/" (extract-command "/"))));; ============================================================;; MarkdownV2 Escaping;; ============================================================(test-group "tg-escape-markdown" (test "escapes exclamation mark" (assert-equal "hello\\!" (tg-escape-markdown "hello!"))) (test "escapes period" (assert-equal "1\\.0" (tg-escape-markdown "1.0"))) (test "escapes multiple characters" (assert-equal "a\\.b\\~c" (tg-escape-markdown "a.b~c"))) (test "escapes parentheses" (assert-equal "\\(test\\)" (tg-escape-markdown "(test)"))) (test "plain text unchanged" (assert-equal "hello world" (tg-escape-markdown "hello world"))) (test "empty string" (assert-equal "" (tg-escape-markdown ""))));; ============================================================;; Formatting Helpers;; ============================================================(test-group "formatting" (test "bold" (assert-equal "*text*" (tg-bold "text"))) (test "italic" (assert-equal "_text_" (tg-italic "text"))) (test "underline" (assert-equal "__text__" (tg-underline "text"))) (test "strikethrough" (assert-equal "~text~" (tg-strike "text"))) (test "code" (assert-equal "`code`" (tg-code "code"))) (test "code block without language" (assert-equal "```\n(+ 1 2)```" (tg-code-block "(+ 1 2)"))) (test "code block with language" (assert-equal "```scheme\n(+ 1 2)```" (tg-code-block "(+ 1 2)" language: "scheme"))) (test "link" (assert-equal "[Click](https://example.com)" (tg-link "Click" "https://example.com"))) (test "spoiler" (assert-equal "||hidden||" (tg-spoiler "hidden"))));; ============================================================;; Inline Keyboard;; ============================================================(test-group "inline keyboard" (test "button with callback" (let ((btn (tg-button "Yes" callback: "yes"))) (assert-equal "Yes" (dict-ref btn text:)) (assert-equal "yes" (dict-ref btn callback_data:)))) (test "button with url" (let ((btn (tg-button "Visit" url: "https://example.com"))) (assert-equal "Visit" (dict-ref btn text:)) (assert-equal "https://example.com" (dict-ref btn url:)))) (test "keyboard structure" (let ((kb (tg-inline-keyboard (list (list (tg-button "A" callback: "a") (tg-button "B" callback: "b")))))) (assert-true (dict? kb)) (assert-true (array? (dict-ref kb inline_keyboard:))) (assert-equal 1 (array-length (dict-ref kb inline_keyboard:))) (assert-equal 2 (array-length (array-ref (dict-ref kb inline_keyboard:) 0))))));; ============================================================;; Authorization;; ============================================================(test-group "authorization" (define (make-test-bot (keys: (allowed-chats #f) (allowed-users #f))) (tg-bot client: (tg-client token: "test") allowed-chats: allowed-chats allowed-users: allowed-users)) (define (make-test-update chat-id user-id) (tg-update update-id: 1 message: (tg-message message-id: 1 chat: (tg-chat id: chat-id type: "private") from: (tg-user id: user-id first-name: "Test")))) (test "all allowed when no restrictions" (let ((bot (make-test-bot))) (assert-true (authorized? bot (make-test-update 100 200))))) (test "chat restriction allows matching chat" (let ((bot (make-test-bot allowed-chats: '(100 200)))) (assert-true (authorized? bot (make-test-update 100 999))))) (test "chat restriction blocks non-matching chat" (let ((bot (make-test-bot allowed-chats: '(100 200)))) (assert-false (authorized? bot (make-test-update 300 999))))) (test "user restriction allows matching user" (let ((bot (make-test-bot allowed-users: '(200 300)))) (assert-true (authorized? bot (make-test-update 999 200))))) (test "user restriction blocks non-matching user" (let ((bot (make-test-bot allowed-users: '(200 300)))) (assert-false (authorized? bot (make-test-update 999 400))))) (test "both restrictions must pass" (let ((bot (make-test-bot allowed-chats: '(100) allowed-users: '(200)))) (assert-true (authorized? bot (make-test-update 100 200))) (assert-false (authorized? bot (make-test-update 100 300))) (assert-false (authorized? bot (make-test-update 200 200))))) (test "non-message update passes through" (let ((bot (make-test-bot allowed-chats: '(100)))) (assert-true (authorized? bot (tg-update update-id: 1))))));; ============================================================;; Multipart Encoding;; ============================================================(test-group "multipart encoding" (test "text part format" (let* ((bv (encode-text-part "BOUNDARY" "chat_id" "12345")) (str (utf8->string bv))) (assert-true (string-contains? str "--BOUNDARY\r\n")) (assert-true (string-contains? str "Content-Disposition: form-data; name=\"chat_id\"")) (assert-true (string-contains? str "12345")))) (test "file part format" (let* ((data (string->utf8 "fake image data")) (bv (encode-file-part "BOUNDARY" "photo" "test.jpg" "image/jpeg" data)) (str (utf8->string bv))) (assert-true (string-contains? str "--BOUNDARY\r\n")) (assert-true (string-contains? str "name=\"photo\"; filename=\"test.jpg\"")) (assert-true (string-contains? str "Content-Type: image/jpeg")) (assert-true (string-contains? str "fake image data")))) (test "build-multipart-body produces boundary and body" (let* ((result (build-multipart-body (list (cons "chat_id" "12345")) (list (list "photo" "test.jpg" "image/jpeg" (string->utf8 "data"))))) (boundary (car result)) (body (cdr result))) (assert-true (string? boundary)) (assert-true (bytevector? body)) ;; Body should end with closing boundary (let ((str (utf8->string body))) (assert-true (string-contains? str (string-append "--" boundary "--")))))))(run-tests)