Commit35b17540Recorded30 Jun 2026Repositorycourier

Bump courier to 0.3.7: dedup backstop for send-message

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.

Changed
 package.sgl              |  2 +-
 src/courier/telegram.sgl | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
 2 files changed, 57 insertions(+), 11 deletions(-)
Diff
package.sglmodified
@@ -5,7 +5,7 @@
5
6
(package
7
name: "courier"
8
version: "0.3.6"
+8
version: "0.3.7"
9
description: "Chat and notification channel server for Claude Code sessions"
10
url: "https://codeberg.org/sigil/courier"
11
license: "BSD-3-Clause"
src/courier/telegram.sglmodified
@@ -9,6 +9,7 @@
9
(import (sigil core)
10
(sigil string)
11
(sigil math)
+12
(sigil time)
13
(sigil fs)
14
(sigil path)
15
(sigil telegram)
@@ -38,6 +39,34 @@
39
;; read, so the request timeout alone can't bound it).
40
(define *send-connect-timeout* 10)
41
+42
;; Idempotency/dedup backstop for Telegram sends. courier delivers
+43
;; exactly one message per send-message tool call and returns a prompt
+44
;; result; but if the upstream MCP client re-issues an identical tool
+45
;; call (e.g. a timeout-driven retry), courier would deliver it AGAIN.
+46
;; We dedupe identical (chat-id, text) sends within this window: a
+47
;; repeat skips the actual delivery and returns the SAME success (an
+48
;; error would only make the client retry harder). The window must
+49
;; comfortably cover a client's retry interval; a rare intentional
+50
;; identical re-send being suppressed is acceptable for a bot.
+51
(define *send-dedup-window* 30) ;; seconds
+52
+53
;; Drop cache entries older than the dedup window. Self-contained
+54
;; (no list-lib dependency); the cache only ever holds the last few
+55
;; seconds of sends, so O(n) is fine.
+56
(define (dedup-prune sends now)
+57
(cond
+58
((null? sends) '())
+59
((< (- now (cdr (car sends))) *send-dedup-window*)
+60
(cons (car sends) (dedup-prune (cdr sends) now)))
+61
(else (dedup-prune (cdr sends) now))))
+62
+63
;; Is `key` present in the (already-pruned) recent-sends alist?
+64
(define (dedup-seen? sends key)
+65
(cond
+66
((null? sends) #f)
+67
((string=? (car (car sends)) key) #t)
+68
(else (dedup-seen? (cdr sends) key))))
+69
70
;; ============================================================
71
;; Send Message Tool
72
;; ============================================================
@@ -51,7 +80,10 @@
80
(let ((token (courier-config-telegram-token config))
81
(default-chat-id (courier-config-telegram-chat-id config))
82
(api-url (or (courier-config-telegram-api-url config)
54
"https://api.telegram.org")))
+83
"https://api.telegram.org"))
+84
;; Mutable recent-sends cache (alist of (key . send-second)),
+85
;; updated via set!. Single-threaded MCP loop → no race.
+86
(recent-sends '()))
87
(mcp-server-register-tool! server
88
"send-message" "Send a message to a recipient (relay name, chat ID, or 'leader')"
89
'((type . "object")
@@ -78,15 +110,29 @@
110
(string->number to)
111
default-chat-id)))
112
(if (and token chat-id)
81
(begin
82
(tg-send-message
83
(tg-client token: token
84
api-url: api-url
85
request-timeout: *send-request-timeout*
86
connect-timeout: *send-connect-timeout*)
87
chat-id text)
88
(log-info "Telegram message sent" chat-id: chat-id)
89
"Message sent.")
+113
(let* ((now (current-second))
+114
(key (string-append (number->string chat-id)
+115
":" text)))
+116
;; Prune stale entries, then dedupe: an identical
+117
;; (chat-id, text) send within the window is an
+118
;; upstream retry — skip delivery, return success.
+119
(set! recent-sends (dedup-prune recent-sends now))
+120
(if (dedup-seen? recent-sends key)
+121
(begin
+122
(log-info "Duplicate send-message suppressed"
+123
chat-id: chat-id)
+124
"Message sent.")
+125
(begin
+126
(set! recent-sends
+127
(cons (cons key now) recent-sends))
+128
(tg-send-message
+129
(tg-client token: token
+130
api-url: api-url
+131
request-timeout: *send-request-timeout*
+132
connect-timeout: *send-connect-timeout*)
+133
chat-id text)
+134
(log-info "Telegram message sent" chat-id: chat-id)
+135
"Message sent.")))
136
"Error: Telegram not configured (missing token or chat ID)"))))))))))
137
138
;; ============================================================