From 5a71459c03148c5952923bcdf6d399bc4da8dc70 Mon Sep 17 00:00:00 2001 From: Noah Zweben Date: Thu, 23 Apr 2026 12:02:34 -0700 Subject: [PATCH] telegram: gate /start, /help, /status behind dmPolicy (#894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bot command handlers bypassed access control — they responded to any DM user regardless of dmPolicy, leaking bot presence and contradicting ACCESS.md's "Drop silently. No reply." contract for allowlist mode. Add dmCommandGate() that applies the same disabled/allowlist checks as gate() without the pairing side effects, and route all three handlers through it. Also prune expired pending codes before /status iterates them. Fixes #854 Co-authored-by: Claude --- external_plugins/telegram/server.ts | 32 +++++++++++++++++------------ 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index bfc551f..23a21b0 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -284,6 +284,19 @@ function gate(ctx: Context): GateResult { return { action: 'drop' } } +// Like gate() but for bot commands: no pairing side effects, just allow/drop. +function dmCommandGate(ctx: Context): { access: Access; senderId: string } | null { + if (ctx.chat?.type !== 'private') return null + if (!ctx.from) return null + const senderId = String(ctx.from.id) + const access = loadAccess() + const pruned = pruneExpired(access) + if (pruned) saveAccess(access) + if (access.dmPolicy === 'disabled') return null + if (access.dmPolicy === 'allowlist' && !access.allowFrom.includes(senderId)) return null + return { access, senderId } +} + function isMentioned(ctx: Context, extraPatterns?: string[]): boolean { const entities = ctx.message?.entities ?? ctx.message?.caption_entities ?? [] const text = ctx.message?.text ?? ctx.message?.caption ?? '' @@ -669,12 +682,7 @@ setInterval(() => { // the gate's behavior for unrecognized groups. bot.command('start', async ctx => { - if (ctx.chat?.type !== 'private') return - const access = loadAccess() - if (access.dmPolicy === 'disabled') { - await ctx.reply(`This bot isn't accepting new connections.`) - return - } + if (!dmCommandGate(ctx)) return await ctx.reply( `This bot bridges Telegram to a Claude Code session.\n\n` + `To pair:\n` + @@ -685,7 +693,7 @@ bot.command('start', async ctx => { }) bot.command('help', async ctx => { - if (ctx.chat?.type !== 'private') return + if (!dmCommandGate(ctx)) return await ctx.reply( `Messages you send here route to a paired Claude Code session. ` + `Text and photos are forwarded; replies and reactions come back.\n\n` + @@ -695,14 +703,12 @@ bot.command('help', async ctx => { }) bot.command('status', async ctx => { - if (ctx.chat?.type !== 'private') return - const from = ctx.from - if (!from) return - const senderId = String(from.id) - const access = loadAccess() + const gated = dmCommandGate(ctx) + if (!gated) return + const { access, senderId } = gated if (access.allowFrom.includes(senderId)) { - const name = from.username ? `@${from.username}` : senderId + const name = ctx.from!.username ? `@${ctx.from!.username}` : senderId await ctx.reply(`Paired as ${name}.`) return }