telegram: inbound messages are lost, not delayed, when delivery has no listener
Summary. server.ts attempts delivery of an inbound Telegram message without persisting
it first, so a delivery that goes nowhere destroys the message text. In one class of failure —
which is easy to hit — there is no error, no log line, and no trace of what was lost.
The code path
In the message handler (v0.0.7):
958 ackReaction sent .catch(() => {}) — swallowed
970 mcp.notification({...}) .catch(err => stderr.write) — logged, not savedBetween those lines the message text exists only in process memory. The .catch at 970
records that a delivery failed; it does not preserve what failed to deliver.
The failure with no error at all
mcp.notification is fire-and-forget into the stdio transport. A Claude Code session started
without --channels registers no channel handler, so the notification resolves cleanly.
No rejection, so the .catch never runs.
This is reachable without doing anything unusual:
- Telegram permits one
getUpdatesconsumer per bot token. - This plugin's server starts in any session that has it installed, regardless of flags,
and on startup SIGTERMs whatever process holds the poll slot (
server.ts~63-77). - So opening an unrelated Claude Code session on the same machine silently takes the channel.
If that session has no
--channels, it receives every message and discards it.
The plugin correctly logs Channel notifications skipped: … not in --channels list for this session at startup — but that line appears once, in that session's own MCP log, and nothing
is logged per message afterwards.
Observed
On 2026-09-17 two such sessions held the slot for 4 h 57 m. Four messages were destroyed. They
could be counted only afterwards, by probing Telegram's message_id sequence with
editMessageReplyMarkup — nothing local had recorded them, and their content is unrecoverable.
Compounding it: the ack reaction is sent twelve lines before delivery, so the sender saw every lost message marked as read and then unanswered. From their side that is indistinguishable from being deliberately ignored.
Suggested fix
Persist before delivering. A small append-only journal next to the existing INBOX_DIR, capped,
mode 600, written immediately before mcp.notification — plus a tool to read it back, since the
scenario it exists for is exactly the one where nobody knows to look.
A patch doing this is included at the end of this issue: 75 insertions, 0 deletions, no change to existing behaviour. The journal function is fully wrapped in try/catch and cannot throw — important, because a throw in a grammy message handler stops polling permanently.
Notably, delivery confirmation is not possible from inside the server: it has no way to learn whether channel notifications were registered. So the journal is written unconditionally rather than only on failure; anything else would be guessing.
Happy to open a PR if that is the preferred route for external_plugins, or to adjust the
shape — the important part is persist-then-deliver, not this particular implementation.
external_plugins/telegram/server.ts @ ea0a38e1 (75 insertions, 0 deletions)diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts
index 6bc0ebc..05a3fc0 100644
--- a/external_plugins/telegram/server.ts
+++ b/external_plugins/telegram/server.ts
@@ -53,6 +53,43 @@ if (!TOKEN) {
process.exit(1)
}
const INBOX_DIR = join(STATE_DIR, 'inbox')
+const JOURNAL_FILE = join(STATE_DIR, 'inbound.jsonl')
+// Keep the journal bounded. Sized so a busy day survives a restart without review,
+// while the file stays small enough to read by eye when diagnosing a loss.
+const JOURNAL_MAX_LINES = 500
+
+// Write-ahead journal for inbound messages.
+//
+// `mcp.notification` is fire-and-forget into the stdio transport: it resolves whether or
+// not anything is listening. A Claude Code session started WITHOUT --channels registers no
+// channel handler, so the notification resolves cleanly into nothing — no rejection, no
+// stderr line, no trace. The message text exists only in this process's memory at that
+// moment, so it is gone.
+//
+// That is not hypothetical. Telegram permits one getUpdates consumer per token, and any
+// other session on the machine that loads this plugin seizes the slot on startup. If that
+// session lacks --channels it receives every message and discards it. Measured 2026-09-17:
+// two such sessions held the slot for 4h57m and four messages were destroyed; they were
+// identified only by probing Telegram's message-id sequence afterwards, since nothing local
+// recorded them.
+//
+// So: persist first, deliver second. A failed delivery becomes recoverable instead of fatal.
+function journalInbound(entry: Record<string, unknown>): void {
+ try {
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
+ let lines: string[] = []
+ try {
+ lines = readFileSync(JOURNAL_FILE, 'utf8').split('\n').filter(Boolean)
+ } catch {}
+ lines.push(JSON.stringify({ ...entry, journalled_at: new Date().toISOString() }))
+ if (lines.length > JOURNAL_MAX_LINES) lines = lines.slice(-JOURNAL_MAX_LINES)
+ writeFileSync(JOURNAL_FILE, lines.join('\n') + '\n', { mode: 0o600 })
+ } catch (err) {
+ // Journalling must never cost us the message it is meant to protect, and must never
+ // throw into the handler — grammy's default error handler stops polling permanently.
+ process.stderr.write(`telegram channel: could not journal inbound message: ${err}\n`)
+ }
+}
const PID_FILE = join(STATE_DIR, 'bot.pid')
// Telegram allows exactly one getUpdates consumer per token. If a previous
@@ -504,6 +541,16 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
required: ['file_id'],
},
},
+ {
+ name: 'recent_inbound',
+ description: "Read the write-ahead journal of messages this bot RECEIVED, newest last. Use after a reconnect, or whenever the sender says they wrote something you never saw: a message is journalled before delivery is attempted, so anything the channel failed to hand over is still here. Returns at most `limit` entries.",
+ inputSchema: {
+ type: 'object',
+ properties: {
+ limit: { type: 'number', description: 'How many of the most recent entries to return. Default 20.' },
+ },
+ },
+ },
{
name: 'edit_message',
description: 'Edit a message the bot previously sent. Useful for interim progress updates. Edits don\'t trigger push notifications — send a new reply when a long task completes so the user\'s device pings.',
@@ -526,6 +573,23 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
}))
mcp.setRequestHandler(CallToolRequestSchema, async req => {
+ if (req.params.name === 'recent_inbound') {
+ const limit = Math.max(1, Math.min(200, Number((req.params.arguments as any)?.limit ?? 20)))
+ let lines: string[] = []
+ try {
+ lines = readFileSync(JOURNAL_FILE, 'utf8').split('\n').filter(Boolean)
+ } catch {
+ return { content: [{ type: 'text', text: 'No inbound journal yet — no message has been received since this feature was installed.' }] }
+ }
+ const tail = lines.slice(-limit)
+ return {
+ content: [{
+ type: 'text',
+ text: `${tail.length} of ${lines.length} journalled inbound message(s), oldest first:\n` + tail.join('\n'),
+ }],
+ }
+ }
+
const args = (req.params.arguments ?? {}) as Record<string, unknown>
try {
switch (req.params.name) {
@@ -965,6 +1029,17 @@ async function handleInbound(
const imagePath = downloadImage ? await downloadImage() : undefined
+ // WRITE-AHEAD: persist before attempting delivery. See journalInbound().
+ journalInbound({
+ chat_id,
+ message_id: msgId != null ? String(msgId) : null,
+ user: from.username ?? String(from.id),
+ user_id: String(from.id),
+ ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
+ text,
+ ...(attachment ? { attachment_name: attachment.name ?? null } : {}),
+ })
+
// image_path goes in meta only — an in-content "[image attached — read: PATH]"
// annotation is forgeable by any allowlisted sender typing that string.
mcp.notification({Source: anthropics/claude-plugins-official