Bump courier to 0.3.7: dedup backstop for send-message
Defense-in-depth against duplicate Telegram deliveries. Root cause (reproduced in isolation): courier's send path is strictly 1:1 and prompt, so the duplicates were an UPSTREAM MCP client re-issuing the send-message tool call; courier delivered each retry as a fresh send. The v0.3.5/v0.3.6 fixes removed the latency that triggered those retries; this makes courier retry-proof even if one fires.
The send-message handler now keeps a recent-sends cache keyed on (chat-id, text). An identical send within send-dedup-window (30s) skips the actual delivery and returns the SAME success result (an error would only provoke more retries); each suppression is logged. A different text still delivers (no false suppression). Scoped to the Telegram path; relay/worker paths unchanged. Also keeps the COURIERTELEGRAMAPI_URL test hook (default = real URL).
Validated in isolation (local mock, no live sends): 5 rapid identical calls -> 1 sendMessage POST, all 5 return success; different text -> delivers. 50/50 unit tests pass.
package.sgl | 2 +-
src/courier/telegram.sgl | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
2 files changed, 57 insertions(+), 11 deletions(-)package.sglmodified
(package name: "courier" version: "0.3.6" version: "0.3.7" description: "Chat and notification channel server for Claude Code sessions" url: "https://codeberg.org/sigil/courier" license: "BSD-3-Clause"src/courier/telegram.sglmodified
(import (sigil core) (sigil string) (sigil math) (sigil time) (sigil fs) (sigil path) (sigil telegram) ;; read, so the request timeout alone can't bound it). (define *send-connect-timeout* 10) ;; Idempotency/dedup backstop for Telegram sends. courier delivers ;; exactly one message per send-message tool call and returns a prompt ;; result; but if the upstream MCP client re-issues an identical tool ;; call (e.g. a timeout-driven retry), courier would deliver it AGAIN. ;; We dedupe identical (chat-id, text) sends within this window: a ;; repeat skips the actual delivery and returns the SAME success (an ;; error would only make the client retry harder). The window must ;; comfortably cover a client's retry interval; a rare intentional ;; identical re-send being suppressed is acceptable for a bot. (define *send-dedup-window* 30) ;; seconds ;; Drop cache entries older than the dedup window. Self-contained ;; (no list-lib dependency); the cache only ever holds the last few ;; seconds of sends, so O(n) is fine. (define (dedup-prune sends now) (cond ((null? sends) '()) ((< (- now (cdr (car sends))) *send-dedup-window*) (cons (car sends) (dedup-prune (cdr sends) now))) (else (dedup-prune (cdr sends) now)))) ;; Is `key` present in the (already-pruned) recent-sends alist? (define (dedup-seen? sends key) (cond ((null? sends) #f) ((string=? (car (car sends)) key) #t) (else (dedup-seen? (cdr sends) key)))) ;; ============================================================ ;; Send Message Tool ;; ============================================================ (let ((token (courier-config-telegram-token config)) (default-chat-id (courier-config-telegram-chat-id config)) (api-url (or (courier-config-telegram-api-url config) "https://api.telegram.org"))) "https://api.telegram.org")) ;; Mutable recent-sends cache (alist of (key . send-second)), ;; updated via set!. Single-threaded MCP loop → no race. (recent-sends '())) (mcp-server-register-tool! server "send-message" "Send a message to a recipient (relay name, chat ID, or 'leader')" '((type . "object") (string->number to) default-chat-id))) (if (and token chat-id) (begin (tg-send-message (tg-client token: token api-url: api-url request-timeout: *send-request-timeout* connect-timeout: *send-connect-timeout*) chat-id text) (log-info "Telegram message sent" chat-id: chat-id) "Message sent.") (let* ((now (current-second)) (key (string-append (number->string chat-id) ":" text))) ;; Prune stale entries, then dedupe: an identical ;; (chat-id, text) send within the window is an ;; upstream retry — skip delivery, return success. (set! recent-sends (dedup-prune recent-sends now)) (if (dedup-seen? recent-sends key) (begin (log-info "Duplicate send-message suppressed" chat-id: chat-id) "Message sent.") (begin (set! recent-sends (cons (cons key now) recent-sends)) (tg-send-message (tg-client token: token api-url: api-url request-timeout: *send-request-timeout* connect-timeout: *send-connect-timeout*) chat-id text) (log-info "Telegram message sent" chat-id: chat-id) "Message sent."))) "Error: Telegram not configured (missing token or chat ID)")))))))))) ;; ============================================================