AtlatestRenderedmarkdown
Readme

Telegram

Telegram Bot API client with long-polling, command handlers, MarkdownV2 formatting, and file uploads.
(import (sigil telegram))

Two Approaches

There 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:

(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:

(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

;; 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

;; These characters must be escaped: _ * [ ] ( ) ~ ` > # + - = | { } . !
(tg-escape-markdown "Hello! Price: $5.00 (USD)")
; => "Hello\\! Price: $5\\.00 \\(USD\\)"

Formatting Helpers

All helpers wrap text with the appropriate MarkdownV2 syntax. They do NOT auto-escape their input, so escape dynamic content first.

(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 Messages

Escape dynamic text with tg-escape-markdown, then wrap with formatting helpers:

(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 Documents

Three ways to send media:

By URL

Telegram downloads the file from the URL:

(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_id

Reference a file already on Telegram's servers (e.g., from a received message):

(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 Upload

Upload a file from the local filesystem. MIME type is auto-detected:

(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

(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 Keyboards

Build interactive buttons that trigger callback queries:

;; 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

(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 Handlers

All handlers receive (bot msg) as arguments:

;; 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

;; 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

;; 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 Client

For direct API access without the bot framework:

(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

(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

(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

(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 Fields

Any Telegram API field not modeled as a struct field is accessible via tg-message-raw:

;; 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

(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

(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 Script

Process pending messages without a long-running loop. Ideal for cron jobs, AI agents, or any script that runs periodically:

(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 Sender

Send one-shot messages from scripts or AI agents without checking for updates:

(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

(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)