AtlatestRepositorycourier
1
;;; (courier telegram) - Telegram channel integration.2
;;;3
;;; Exposes send-message and send-media tools for sending messages4
;;; through Telegram or relays. Incoming Telegram polling lives in5
;;; (courier poller) -- it runs in an isolated child process so a6
;;; wedged poll cannot stall MCP request servicing.8
(define-library (courier telegram)9
(import (sigil core)10
(sigil string)11
(sigil math)12
(sigil time)13
(sigil fs)14
(sigil path)15
(sigil telegram)16
(only (sigil telegram client) tg-ack-unconfirmed?)17
(sigil mcp server)18
(sigil log)19
(courier config)20
(courier dedup)21
(courier relay))22
(export register-send-message-tool!23
register-send-media-tool!24
register-media-upload-handler!25
path-media-type26
resolve-media-type27
format-size28
;; exported so tests assert on the exact returned strings29
*dry-run-not-sent-message*30
*duplicate-suppressed-message*)31
(begin33
;; HTTP read timeout (seconds) for leader-side Telegram sends. A34
;; blackholed send-response read on the leader's blocking TLS read35
;; would otherwise freeze the whole leader (MCP servicing, watchdog,36
;; poller supervision) until the OS TCP timeout -- the likeliest37
;; cause of needing a manual /mcp re-init. Bounding the read makes a38
;; stalled send raise promptly so the tool call fails cleanly.39
(define *send-request-timeout* 25)41
;; TCP connect timeout for leader-side sends. Bounds the connect to42
;; api.telegram.org so a blackholed CDN IP fails fast instead of43
;; freezing the leader on the OS SYN timeout (the connect precedes the44
;; read, so the request timeout alone can't bound it).45
(define *send-connect-timeout* 10)47
;; Returned when COURIER_DISABLE_TELEGRAM_SEND gagged the send. It must48
;; be impossible to read as delivery: it does not begin with, and does not49
;; contain, a bare "Message sent". Named rather than inlined so the test50
;; suite asserts on the exact string the tool returns.51
(define *dry-run-not-sent-message*52
"Message NOT sent: COURIER_DISABLE_TELEGRAM_SEND is set (dry-run, nothing delivered).")54
;; Returned when the persistent dedup window suppressed a repeat. Under55
;; record-before-send this means "an identical message to this chat was56
;; already accepted for delivery", NOT "this call delivered something" --57
;; so it says which happened rather than reusing the plain success string.58
(define *duplicate-suppressed-message*59
"Message already sent (duplicate suppressed: an identical message to this chat was accepted for delivery within the dedup window; nothing was sent again).")61
;; Idempotency/dedup backstop for Telegram sends lives in62
;; (courier dedup): a PERSISTENT, restart-surviving (chat-id,text)63
;; window. It is the load-bearing defence against the send-path64
;; duplication storm — see that module's header. The in-memory65
;; version this replaces could not survive the SIGKILL-and-retry66
;; loop that caused the spam, because a fresh process started with67
;; an empty cache.69
;; ============================================================70
;; Send Message Tool71
;; ============================================================73
;;; Register the send-message tool with the MCP server.74
;;;75
;;; Routes messages to relays or Telegram based on the `to` parameter.76
;;; In worker mode, always sends to the leader via relay.77
(define (register-send-message-tool! server config relay-st worker-mode?)78
(: mcp-server? courier-config? relay-state? boolean? -> void?)79
(let ((token (courier-config-telegram-token config))80
(default-chat-id (courier-config-telegram-chat-id config))81
(api-url (or (courier-config-telegram-api-url config)82
"https://api.telegram.org"))83
(send-disabled (courier-config-telegram-send-disabled config))84
(send-delay (courier-config-telegram-send-delay config))85
;; Persistent dedup store (survives process restarts) + its86
;; sliding window. Resolved once at registration; both honour87
;; COURIER_SEND_DEDUP_FILE / COURIER_SEND_DEDUP_WINDOW.88
(dedup-path (send-dedup-path))89
(dedup-window (send-dedup-window))90
;; Per-invocation counter, logged at DEBUG (off by default).91
(send-invocation 0))92
(mcp-server-register-tool! server93
"send-message" "Send a message to a recipient (relay name, chat ID, or 'leader')"94
'((type . "object")95
(properties . ((text . ((type . "string")96
(description . "The message to send")))97
(to . ((type . "string")98
(description . "Recipient: relay name, Telegram chat ID, or 'leader' in worker mode. Uses default Telegram chat if omitted.")))))99
(required . ("text")))100
(lambda (args)101
(let* ((text (dict-ref args text: #f))102
(to (dict-ref args to: #f)))103
(if (not text)104
"Error: missing required argument 'text'"105
(cond106
;; Worker mode: always send to leader107
(worker-mode?108
(relay-worker-send! relay-st text))109
;; Relay recipient110
((and to (relay-has-name? relay-st to))111
(relay-send-message! relay-st to text))112
;; Telegram113
(else114
(let ((chat-id (if to115
(string->number to)116
default-chat-id)))117
(if (and token chat-id)118
(let* ((now (current-second))119
(key (send-dedup-key chat-id text))120
(inv (begin (set! send-invocation121
(+ send-invocation 1))122
send-invocation)))123
(log-debug "send-message handler" inv: inv124
chat-id: chat-id)125
;; Persistent, restart-surviving dedup. RECORD126
;; the key BEFORE attempting delivery: if the127
;; leader SIGKILLs courier mid-send and re-issues128
;; the same send (the storm), the fresh process129
;; sees the recorded key and suppresses it. An130
;; in-memory cache could not do this — a restart131
;; wiped it, which is why the spam recurred.132
(case (dedup-check-and-record! dedup-path key133
now dedup-window)134
((suppress)135
(log-info "Duplicate send-message suppressed"136
chat-id: chat-id)137
*duplicate-suppressed-message*)138
(else139
;; Test hook: latency simulation (inert by default).140
(when (and (number? send-delay) (> send-delay 0))141
(sleep send-delay))142
(if send-disabled143
;; Test hook: dry-run — no real delivery.144
;; This used to return the real success145
;; string, so every gagged send from146
;; 2026-07-06 to 2026-08-04 reported147
;; delivery it had not earned — and, worse,148
;; was a perfect mimic of the silent-drop149
;; bug it was masking, making production150
;; observation of that bug impossible.151
;; A dry-run is a DEFINITE non-delivery, so152
;; it also releases the dedup key, for the153
;; same reason a rejected send does. The154
;; key was recorded BEFORE this branch;155
;; leaving it would make the next156
;; identical send take the 'suppress path157
;; and claim the message "was accepted for158
;; delivery" when nothing ever accepted159
;; it. Measured on the pre-fix binary:160
;; gag on, gag off, same text -> zero161
;; deliveries while reporting success.162
;; Returning (not raising) is163
;; deliberate: an MCP error invites a client164
;; retry, and retry-against-a-killed-courier165
;; is the mechanism behind the send storm.166
(begin167
(dedup-unrecord! dedup-path key)168
(log-info "Telegram send disabled (dry-run) -- NOT delivered"169
chat-id: chat-id)170
*dry-run-not-sent-message*)171
;; Deliver exactly once. The key is already172
;; recorded. A transport error must NEVER173
;; escape this handler (an MCP error invites174
;; a client retry) and must never re-fire a175
;; send.176
(guard (e ((tg-ack-unconfirmed? e)177
;; Reached Telegram, ack read178
;; failed: treat as delivered,179
;; keep the recorded key.180
(log-info "Telegram delivered, ack unconfirmed"181
chat-id: chat-id)182
"Message sent (ack unconfirmed).")183
(else184
;; ok:true is the ONLY delivery185
;; path; any other error means186
;; Telegram did not accept the187
;; message (never-sent or188
;; rejected). Release the key so189
;; a genuine retry can go through,190
;; and return cleanly (no re-raise,191
;; no crash).192
(dedup-unrecord! dedup-path key)193
(log-warn "Telegram send failed"194
chat-id: chat-id195
error: (format "~a" e))196
"Error: Telegram send failed (message not delivered)."))197
(tg-send-message198
(tg-client token: token199
api-url: api-url200
request-timeout: *send-request-timeout*201
connect-timeout: *send-connect-timeout*)202
chat-id text)203
(log-info "Telegram message sent" chat-id: chat-id)204
"Message sent.")))))205
"Error: Telegram not configured (missing token or chat ID)"))))))))))207
;; ============================================================208
;; Send Media Tool209
;; ============================================================211
;; Map a file path's extension to a Telegram media kind.212
;; Returns the symbol 'photo, 'video, or 'document.213
(define (path-media-type path)214
(: string? -> symbol?)215
(let ((ext (string-downcase (path-extname path))))216
(cond217
((member ext '(".png" ".jpg" ".jpeg" ".gif" ".webp")) 'photo)218
((member ext '(".mp4" ".mkv" ".mov" ".webm")) 'video)219
(else 'document))))221
;; Resolve a caller-supplied type (string or #f) to a media kind symbol.222
;; Unknown values fall back to extension detection.223
(define (resolve-media-type type-arg path)224
(cond225
((or (not type-arg) (string=? type-arg "auto"))226
(path-media-type path))227
((string=? type-arg "photo") 'photo)228
((string=? type-arg "video") 'video)229
((string=? type-arg "document") 'document)230
(else (path-media-type path))))232
;; Format a byte count as a human-readable string ("412 KB", "2.4 MB").233
(define (format-size bytes)234
(: integer? -> string?)235
(cond236
((< bytes 1024)237
(string-append (number->string bytes) " B"))238
((< bytes (* 1024 1024))239
(string-append (number->string (quotient bytes 1024)) " KB"))240
(else241
(let* ((mb-x10 (quotient (* bytes 10) (* 1024 1024)))242
(whole (quotient mb-x10 10))243
(tenth (modulo mb-x10 10)))244
(string-append (number->string whole) "."245
(number->string tenth) " MB")))))247
;; Dispatch to the right tg-upload-* function for the resolved kind.248
(define (upload-by-kind client chat-id kind path caption)249
(case kind250
((photo)251
(tg-upload-photo client chat-id path caption: caption))252
((video)253
;; supports-streaming defaults on so Telegram users can scrub254
;; without waiting for the whole download.255
(tg-upload-video client chat-id path256
caption: caption supports-streaming: #t))257
(else258
(tg-upload-document client chat-id path caption: caption))))260
;; Leader-side handler that performs the actual upload and returns261
;; a result string. Used by both the in-process leader send-media262
;; tool and the relay media-upload envelope handler.263
(define (do-media-upload config path to-arg caption-arg type-arg)264
(let ((token (courier-config-telegram-token config))265
(default-chat-id (courier-config-telegram-chat-id config)))266
(cond267
((not (file-exists? path))268
(string-append "Error: file not found: " path))269
((not token)270
"Error: Telegram not configured (missing COURIER_TELEGRAM_TOKEN)")271
(else272
(let ((chat-id (if to-arg273
(string->number to-arg)274
default-chat-id)))275
(if (not chat-id)276
"Error: no chat ID (set COURIER_TELEGRAM_CHAT_ID or pass 'to')"277
(let* ((kind (resolve-media-type type-arg path))278
(size (file-size path))279
;; NOTE: media uploads go through tg-api-call/upload,280
;; which reads over a raw TLS connection (not281
;; sigil-http's http-post/json), so this timeout is282
;; NOT yet enforced on the upload read -- set for283
;; consistency/future-proofing. Bounding uploads needs284
;; a timeout on sigil-telegram's upload path (follow-up).285
(client (tg-client token: token286
request-timeout: *send-request-timeout*287
connect-timeout: *send-connect-timeout*)))288
(upload-by-kind client chat-id kind path caption-arg)289
(log-info "Telegram media sent"290
chat-id: chat-id kind: kind291
path: path bytes: size)292
(string-append "Media sent ("293
(symbol->string kind) ", "294
(format-size size) ")"))))))))296
;;; Register the send-media tool with the MCP server.297
;;;298
;;; Uploads a local file (photo, video, or document) to Telegram.299
;;; Type defaults to "auto", which infers the kind from the file300
;;; extension. In leader mode the upload runs locally; in worker301
;;; mode the request is framed as a `media-upload` envelope and302
;;; sent to the leader over the relay (the leader has the Telegram303
;;; credentials and a shared filesystem view of the file).304
(define (register-send-media-tool! server config relay-st worker-mode?)305
(: mcp-server? courier-config? relay-state? boolean? -> void?)306
(mcp-server-register-tool! server307
"send-media"308
"Upload a local media file (photo, video, or document) to a Telegram recipient. Use this to share screenshots, screen recordings, or other files with the user."309
'((type . "object")310
(properties . ((path . ((type . "string")311
(description . "Absolute path to the local file to upload")))312
(to . ((type . "string")313
(description . "Telegram chat ID. Defaults to the configured chat ID.")))314
(caption . ((type . "string")315
(description . "Optional caption shown beneath the media")))316
(type . ((type . "string")317
(enum . ("photo" "video" "document" "auto"))318
(description . "Media kind. 'auto' (default) infers from the file extension.")))))319
(required . ("path")))320
(lambda (args)321
(let* ((path (dict-ref args path: #f))322
(to (dict-ref args to: #f))323
(caption (dict-ref args caption: #f))324
(type-arg (dict-ref args type: #f)))325
(cond326
((not path)327
"Error: missing required argument 'path'")328
;; Worker mode: forward to leader. We do NOT pre-check329
;; file-exists?; the worker's filesystem may differ330
;; transiently from the leader's, and the leader is331
;; authoritative anyway.332
(worker-mode?333
(relay-worker-send-media! relay-st path334
to: to caption: caption media-type: type-arg))335
(else336
(do-media-upload config path to caption type-arg)))))))338
;;; Install the leader-side handler that processes `media-upload`339
;;; envelopes arriving from worker relays. Must be called before340
;;; any worker connects. The handler reads the file from the341
;;; shared filesystem, dispatches via tg-upload-*, and sends the342
;;; result back to the worker as a regular relay text message.343
(define (register-media-upload-handler! relay-st config)344
(: relay-state? courier-config? -> void?)345
(set-relay-state-media-upload-handler! relay-st346
(lambda (envelope info)347
(let* ((path (dict-ref envelope path: #f))348
(to (dict-ref envelope to: #f))349
(caption (dict-ref envelope caption: #f))350
(type-arg (dict-ref envelope media-type: #f))351
(sender-name (relay-info-name info))352
(result (cond353
((not path)354
"Error: media-upload envelope missing 'path'")355
(else356
(do-media-upload config path to caption type-arg)))))357
(log-info "media-upload dispatched"358
relay: sender-name path: (or path "?")359
result: result)360
;; Best-effort ack back to the worker. The worker sees this361
;; as a regular incoming relay message.362
(guard (e (else363
(log-warn "media-upload ack send failed"364
relay: sender-name365
error: (format "~a" e))))366
(relay-send-message! relay-st sender-name result))))))))