Skip to content

feat(telegram): MarkdownV2 + summarized streaming + approval re-keying#191

Merged
finedesignz merged 1 commit into
mainfrom
feat/tg-markdownv2-streaming
May 30, 2026
Merged

feat(telegram): MarkdownV2 + summarized streaming + approval re-keying#191
finedesignz merged 1 commit into
mainfrom
feat/tg-markdownv2-streaming

Conversation

@finedesignz
Copy link
Copy Markdown
Owner

Builds on #189. Three deliverables for the hub Telegram bridge in one PR (cohesive, interdependent — streaming uses the new MarkdownV2 helpers).

1. MarkdownV2 formatting

  • New sendMessageMd / editMessageTextMd in client.ts: send with parse_mode: MarkdownV2, and on a 400 retry once as plain text so an unbalanced reserved char never silently drops a session reply (editMessageTextMd also treats message is not modified as a benign success).
  • Reuses the existing escapeMarkdownV2 (full reserved set _ * [ ] ( ) ~ \ > # + - = | { } . !+`). Callers escape dynamic content, keep their own *bold* / code.
  • Outbound final messages, the working/streaming message, and the inline Approve/Deny prompt all go MarkdownV2 with the fallback. Inline keyboard unaffected.

2. Summarized streaming

  • New outbound-only event bus events/session-activity-events.ts (tool_use one-liners), emitted from the existing tool_use spot in ws/agent.ts. Not a dispatch path — hub/src/dispatch/ and the non-bypassable cost-cap gate are untouched.
  • Bridge keeps one editable ⏳ Working… message per (chat, session): each tool_use appends a collapsed one-liner (🔧 Edit hub/src/foo.ts, throttled ~900ms, last-12 cap); thinking/text_delta dropped. A typing chat-action fires immediately then ~every 4s. On assistant_message:final the bridge stops typing and edits the same message to the full answer (>4096 chars → follow-up send).
  • Reversible behind config.telegram.summarizedStreaming (TELEGRAM_SUMMARIZED_STREAMING=false → single final-blob send). Finalization + MarkdownV2 work regardless.

3. MED fast-follow from the #189 security review

  • approvals.ts registry was keyed by requestId alone → when two users share one default session the bridge fanned the prompt to both and the second rememberPendingPrompt clobbered the first, locking out a valid approver with "Not allowed" (fail-closed but wrong).
  • Re-keyed by (sessionId, requestId) holding a map of every authorized userId → {chatId, messageId}. takePendingPrompt(requestId, userId) folds the authorization check into the take and resolves the whole entry exactly once for whichever authorized user taps first.

QC

  • bun run check-baseline GREEN: pass=956 skip=129 fail=0 (within tolerance, fail_max=0).
  • New/updated tests: escaper reserved-char coverage (existing), 400→plaintext fallback (telegram-client-fallback.test.ts, cache-busted import to avoid cross-file Bun mock.module pollution), approval re-keying incl. the two-users-same-default-session case and cross-session isolation. All 60 telegram tests pass isolated and combined.
  • docs/telegram-bridge.md updated in the same commit. No routes changed → no OpenAPI/docs:sync delta.

Constraints honored

  • Cost cap non-bypassable; no parallel dispatch path (activity bus is read-only fanout).
  • telegram-webhook.ts raw-body-before-parse + mount order untouched (mount-order.test.ts passes).
  • schema.sql not touched. Smallest-diff, surgical.

Prod-code diff ~390 LOC; slightly over the 250 split threshold but kept as one PR since the three pieces are interdependent (streaming consumes the MarkdownV2 helpers).

🤖 Generated with Claude Code

Three deliverables for the hub Telegram bridge (one cohesive feature):

1. MarkdownV2 formatting. All user-facing bridge messages now send with
   parse_mode MarkdownV2. New sendMessageMd / editMessageTextMd wrap the send
   with a 400->plain-text single retry so an unbalanced reserved char never
   silently drops a session reply. Reuses the existing escapeMarkdownV2 (full
   reserved set). Inline approve/deny prompt is MarkdownV2 with the same fallback;
   keyboard unaffected.

2. Summarized streaming. New session-activity event bus (tool_use one-liners)
   emitted from ws/agent.ts. The bridge maintains one editable "Working..."
   message per (chat, session): each tool_use appends a collapsed one-liner
   (throttled edit), a typing chat-action refreshes ~every 4s, and
   assistant_message:final edits the same message to the full answer. Gated on
   config.telegram.summarizedStreaming (TELEGRAM_SUMMARIZED_STREAMING=false
   reverts to a single final-blob send). No parallel dispatch path; cost-cap gate
   untouched.

3. MED fast-follow (#189 review). approvals.ts pending-prompt registry was keyed
   by requestId alone -> a shared default session clobbered the first binding and
   locked out a valid approver. Re-keyed by (sessionId, requestId) holding a set
   of authorized userIds; takePendingPrompt(requestId, userId) folds in the
   authorization check and resolves exactly once for whichever authorized user
   taps first.

Tests: escaper (every reserved char, existing) + 400->plaintext fallback (new
file, cache-busted import to dodge cross-file Bun mock pollution) + approval
re-keying incl. two-users-same-default-session. check-baseline GREEN
(956 pass / 0 fail). docs/telegram-bridge.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@finedesignz finedesignz merged commit 27db617 into main May 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant