switchroom 0.18.32 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Agent-emitted inline-keyboard callback family (`agent:` prefix, #271)
3
+ * extracted verbatim from the gateway's `callback_query:data` dispatcher
4
+ * (switchroom#2996 P6 dispatcher drain).
5
+ *
6
+ * A tap is forwarded to the connected bridge as an InboundMessage with
7
+ * meta.button_callback_data set, routed through the SAME turn-safe delivery
8
+ * machinery as a normal inbound (injected deliverButtonTapInbound). Ack
9
+ * toast (#710), single-use keyboard strip, and the #789 "✅ You chose: X"
10
+ * annotation move with it.
11
+ *
12
+ * Returns true when the callback was an agent button (handled — dispatcher
13
+ * must `return`), false otherwise (dispatcher falls through to the perm:
14
+ * family).
15
+ *
16
+ * DI note (cluster-F pattern): the raw `bot.api.sendMessage` restarting
17
+ * notice stays in gateway.ts inside swallowingApiCall, injected as
18
+ * `sendRestartingNotice`. This module has no raw Bot API site.
19
+ */
20
+
21
+ import type { Context } from 'grammy'
22
+ import {
23
+ parseAgentCallback,
24
+ keyboardIsSingleUse,
25
+ resolveTapAnnotation,
26
+ applyTapAnnotationEdit,
27
+ type AgentButtonMeta,
28
+ } from '../inline-keyboard-callbacks.js'
29
+ import type { InboundMessage } from './ipc-protocol.js'
30
+
31
+ export interface AgentButtonCallbackDeps {
32
+ loadAccess: () => {
33
+ allowFrom: string[]
34
+ parseMode?: 'html' | 'markdownv2' | 'text'
35
+ button_choice_confirmation?: unknown
36
+ }
37
+ /** chatId:messageId → per-button meta stashed at send time (#710). */
38
+ agentButtonMeta: {
39
+ get: (key: string) => Map<string, AgentButtonMeta> | undefined
40
+ delete: (key: string) => void
41
+ }
42
+ /** Turn-safe button-tap delivery (#271) — buffers mid-turn, spools offline. */
43
+ deliverButtonTapInbound: (
44
+ agent: string,
45
+ msg: InboundMessage,
46
+ ) => Promise<string>
47
+ /** swallowingApiCall(() => bot.api.sendMessage(...)) — bound in gateway.ts. */
48
+ sendRestartingNotice: (chatId: string, threadId: number | undefined) => void
49
+ /** Warn-once registries (#789), keyed per agent-process. */
50
+ buttonConfirmSingleUseWarned: Set<string>
51
+ buttonConfirmParseModeWarned: Set<string>
52
+ /** stderr log sink. */
53
+ log: (line: string) => void
54
+ }
55
+
56
+ /** Handle an `agent:` callback. Returns false if `data` is not one. */
57
+ export async function handleAgentButtonCallback(
58
+ ctx: Context,
59
+ data: string,
60
+ deps: AgentButtonCallbackDeps,
61
+ ): Promise<boolean> {
62
+ const agentCb = parseAgentCallback(data)
63
+ if (agentCb == null) return false
64
+ // Authorization gate — same allowlist as inbound text messages.
65
+ // Without this any random Telegram user who could see a button
66
+ // could trigger an action on the agent's behalf.
67
+ const access = deps.loadAccess()
68
+ const senderId = String(ctx.from!.id)
69
+ if (!access.allowFrom.includes(senderId)) {
70
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
71
+ return true
72
+ }
73
+ const cbChatId = String(ctx.chat?.id ?? ctx.from!.id)
74
+ const cbMessageId = ctx.callbackQuery?.message?.message_id
75
+ // #710: look up agent-supplied per-button meta (ack_text / single_use)
76
+ // stashed at send time. Empty map after a gateway restart — defaults
77
+ // still give the user-visible toast + keyboard removal.
78
+ const metaForMessage = cbMessageId != null
79
+ ? deps.agentButtonMeta.get(`${cbChatId}:${cbMessageId}`)
80
+ : undefined
81
+ const tapMeta = metaForMessage?.get(agentCb.raw)
82
+ const ackText = (typeof tapMeta?.ack_text === 'string' && tapMeta.ack_text.length > 0)
83
+ ? tapMeta.ack_text
84
+ : '✓ received'
85
+ // Ack the spinner FIRST with a visible toast so the user knows the
86
+ // tap registered (#710 — empty ack showed nothing, users re-tapped).
87
+ await ctx.answerCallbackQuery({ text: ackText }).catch(() => {})
88
+ const buttonText = (() => {
89
+ // Best-effort: pull the tapped button's label from the source
90
+ // message's keyboard so the agent gets a human-readable echo.
91
+ const msg = ctx.callbackQuery?.message
92
+ const kb = (msg && 'reply_markup' in msg ? msg.reply_markup : undefined) as
93
+ | { inline_keyboard?: Array<Array<{ text?: string; callback_data?: string }>> }
94
+ | undefined
95
+ if (!kb?.inline_keyboard) return undefined
96
+ for (const row of kb.inline_keyboard) {
97
+ for (const btn of row) {
98
+ if (btn.callback_data === data && typeof btn.text === 'string') return btn.text
99
+ }
100
+ }
101
+ return undefined
102
+ })()
103
+ const cbThreadId = (() => {
104
+ const msg = ctx.callbackQuery?.message
105
+ if (msg && 'is_topic_message' in msg && msg.is_topic_message && 'message_thread_id' in msg) {
106
+ const tid = (msg as { message_thread_id?: number }).message_thread_id
107
+ return typeof tid === 'number' ? tid : undefined
108
+ }
109
+ return undefined
110
+ })()
111
+ const inboundText = buttonText
112
+ ? `[user tapped button: ${JSON.stringify(buttonText)} → ${agentCb.raw}]`
113
+ : `[user tapped button: ${agentCb.raw}]`
114
+ const inboundMsg: InboundMessage = {
115
+ type: 'inbound',
116
+ chatId: cbChatId,
117
+ ...(cbThreadId != null ? { threadId: cbThreadId } : {}),
118
+ messageId: cbMessageId ?? 0,
119
+ user: ctx.from!.username ?? String(ctx.from!.id),
120
+ userId: ctx.from!.id,
121
+ ts: Math.floor(Date.now() / 1000),
122
+ text: inboundText,
123
+ meta: {
124
+ chat_id: cbChatId,
125
+ ...(cbMessageId != null ? { message_id: String(cbMessageId) } : {}),
126
+ user: ctx.from!.username ?? String(ctx.from!.id),
127
+ user_id: String(ctx.from!.id),
128
+ ts: new Date().toISOString(),
129
+ ...(cbThreadId != null ? { message_thread_id: String(cbThreadId) } : {}),
130
+ button_callback: 'true',
131
+ button_callback_data: agentCb.raw,
132
+ ...(buttonText != null ? { button_text: buttonText } : {}),
133
+ },
134
+ }
135
+ deps.log(
136
+ `telegram gateway: button_callback chatId=${cbChatId} user=${ctx.from!.id} data=${JSON.stringify(agentCb.raw)} btnText=${JSON.stringify(buttonText ?? null)}\n`,
137
+ )
138
+ // #271 turn-safety: route the tap through the SAME turn-safe delivery
139
+ // machinery as a normal inbound (deliverButtonTapInbound) instead of a raw
140
+ // sendToAgent. A tap landing mid-turn now buffers until idle (never strands
141
+ // in the composer, #1556), a delivered tap is composer-cleared first and
142
+ // tracked so the redelivery sweep rescues a strand, and a bridge-offline tap
143
+ // still spools + shows the restart notice below (unchanged UX). The old raw
144
+ // path (sendToAgent → pendingInboundBuffer, drained by onClientRegistered)
145
+ // fixed only the bridge-mid-reconnect drop; it never gated on turn state.
146
+ const selfAgentBtn = process.env.SWITCHROOM_AGENT_NAME ?? ''
147
+ const btnOutcome = await deps.deliverButtonTapInbound(selfAgentBtn, inboundMsg)
148
+ if (btnOutcome === 'buffered-bridge-offline') {
149
+ // No registered bridge — the agent's mid-restart. Tell the user
150
+ // so they don't think the button silently swallowed their tap;
151
+ // the tap is genuinely buffered now and replays on reconnect.
152
+ // #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND.
153
+ deps.sendRestartingNotice(cbChatId, cbThreadId)
154
+ }
155
+ // #710: strip the keyboard after tap so the buttons can't be
156
+ // double-fired. Default policy is single-use — preserve the
157
+ // keyboard only if at least one button on the message explicitly
158
+ // opts out via `single_use: false`. With no stashed meta (e.g.
159
+ // gateway restarted between send and tap) the default fires too,
160
+ // which is the desired UX.
161
+ const singleUse = metaForMessage == null || keyboardIsSingleUse(metaForMessage)
162
+
163
+ // Source message body text — photos/stickers/etc. carry no `text`.
164
+ const cbMsg = ctx.callbackQuery?.message
165
+ const sourceText = cbMsg && 'text' in cbMsg && typeof cbMsg.text === 'string'
166
+ ? cbMsg.text
167
+ : undefined
168
+
169
+ // #789: pure decision for the "✅ You chose: X" body annotation. Per-message
170
+ // override (the tapped button's inline_keyboard_confirm) wins over the agent
171
+ // default; fires only on single-use keyboards with body text + a label and
172
+ // the default 'html' parse mode.
173
+ const annotation = resolveTapAnnotation({
174
+ ...(tapMeta?.inline_keyboard_confirm != null
175
+ ? { perMessageOverride: tapMeta.inline_keyboard_confirm }
176
+ : {}),
177
+ singleUse,
178
+ ...(access.button_choice_confirmation != null
179
+ ? { config: access.button_choice_confirmation as never }
180
+ : {}),
181
+ parseMode: access.parseMode ?? 'html',
182
+ ...(sourceText != null ? { sourceText } : {}),
183
+ ...(buttonText != null ? { label: buttonText } : {}),
184
+ // escapeLabel deliberately omitted → defaults to the real HTML-entity
185
+ // escaper (escapeHtmlEntities). The GFM-markdown escaper
186
+ // (escapeHtmlForTg, #2669) is WRONG here: it doesn't escape &/</> (a
187
+ // label containing them would 400 the HTML editMessageText) and it
188
+ // garbles text under HTML parse mode (`Do_it` → `Do\_it`).
189
+ })
190
+
191
+ const warnKey = process.env.SWITCHROOM_AGENT_NAME ?? ''
192
+ // Round-1 minor finding: a confirmation requested on a single_use:false
193
+ // (re-tappable) keyboard can never fire. Warn once per agent-process.
194
+ if (annotation.warnSingleUseMismatch && !deps.buttonConfirmSingleUseWarned.has(warnKey)) {
195
+ deps.buttonConfirmSingleUseWarned.add(warnKey)
196
+ deps.log(
197
+ `telegram gateway: button_choice_confirmation skipped — inline_keyboard_confirm requested on a single_use:false (re-tappable) keyboard; annotation only fires on single-use keyboards (#789)\n`,
198
+ )
199
+ }
200
+ // Parse-mode gate (Blocker 1): annotation is emitted with HTML. Warn once
201
+ // per agent-process when a non-html parseMode suppresses it.
202
+ if (annotation.warnParseMode && !deps.buttonConfirmParseModeWarned.has(warnKey)) {
203
+ deps.buttonConfirmParseModeWarned.add(warnKey)
204
+ deps.log(
205
+ `telegram gateway: button_choice_confirmation skipped — agent parseMode=${access.parseMode ?? 'html'}, only 'html' supported (#789)\n`,
206
+ )
207
+ }
208
+
209
+ if (annotation.annotate && cbMessageId != null) {
210
+ // Single API call (Blocker 2): editMessageText accepts reply_markup, so
211
+ // we annotate the body AND strip the keyboard in one edit. If the edit
212
+ // rejects (400 on an HTML edge case, over-long body, too-old message…),
213
+ // applyTapAnnotationEdit falls back to a keyboard-only strip so
214
+ // single-use protection still holds — the meta is deleted below either
215
+ // way, so a swallowed failure must not leave the keyboard live.
216
+ await applyTapAnnotationEdit(
217
+ {
218
+ editMessageText: (text, other) => ctx.editMessageText(text, other),
219
+ editMessageReplyMarkup: other => ctx.editMessageReplyMarkup(other),
220
+ },
221
+ annotation.text as string,
222
+ )
223
+ if (metaForMessage != null) {
224
+ deps.agentButtonMeta.delete(`${cbChatId}:${cbMessageId}`)
225
+ }
226
+ } else if (singleUse && cbMessageId != null) {
227
+ // No annotation (disabled, non-single-use, non-html parseMode, or no
228
+ // body text) — preserve the historical keyboard-only strip.
229
+ await ctx.editMessageReplyMarkup({
230
+ reply_markup: { inline_keyboard: [] },
231
+ }).catch(() => {})
232
+ if (metaForMessage != null) {
233
+ deps.agentButtonMeta.delete(`${cbChatId}:${cbMessageId}`)
234
+ }
235
+ }
236
+ return true
237
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ask_user option-button callback family (`aq:<idx>:<askId>`, #574) extracted
3
+ * verbatim from the gateway's `callback_query:data` dispatcher
4
+ * (switchroom#2996 P6 dispatcher drain).
5
+ *
6
+ * A tapped option resolves the originating ask_user tool call's deferred
7
+ * promise; the agent receives { kind: 'answered', choice, idx } as the tool
8
+ * result and continues. Same authorization gate as permission buttons — only
9
+ * allowFrom users can answer.
10
+ *
11
+ * Returns true when the callback was an ask callback (handled — dispatcher
12
+ * must `return`), false when it isn't (dispatcher falls through). The pending
13
+ * store and access loader are injected; ctx-methods (answerCallbackQuery /
14
+ * editMessageText) need no raw Bot API wrapping.
15
+ */
16
+
17
+ import type { Context } from 'grammy'
18
+ import { decodeAskCallback, type AskUserOutcome } from '../ask-user.js'
19
+ import { richMessage } from '../rich-send.js'
20
+ import { escapeHtmlForTg } from '../shared/bot-runtime.js'
21
+
22
+ export interface PendingAskUserEntry {
23
+ options: string[]
24
+ resolve: (outcome: AskUserOutcome) => void
25
+ timer: ReturnType<typeof setTimeout>
26
+ }
27
+
28
+ export interface AskCallbackDeps {
29
+ loadAccess: () => { allowFrom: string[] }
30
+ pendingAskUser: {
31
+ get: (id: string) => PendingAskUserEntry | undefined
32
+ delete: (id: string) => void
33
+ }
34
+ }
35
+
36
+ /** Handle an `aq:` callback. Returns false if `data` is not an ask callback. */
37
+ export async function handleAskCallback(
38
+ ctx: Context,
39
+ data: string,
40
+ deps: AskCallbackDeps,
41
+ ): Promise<boolean> {
42
+ const askMatch = decodeAskCallback(data)
43
+ if (askMatch == null) return false
44
+ const accessCheck = deps.loadAccess()
45
+ const senderId = String(ctx.from!.id)
46
+ if (!accessCheck.allowFrom.includes(senderId)) {
47
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
48
+ return true
49
+ }
50
+ const entry = deps.pendingAskUser.get(askMatch.askId)
51
+ if (entry == null) {
52
+ // Stale tap — TTL fired, prompt was cancelled, or chat closed
53
+ // before the user got back to it. Acknowledge politely so the
54
+ // user doesn't see the spinner stick.
55
+ await ctx.answerCallbackQuery({ text: 'This question expired.' }).catch(() => {})
56
+ return true
57
+ }
58
+ if (askMatch.idx >= entry.options.length) {
59
+ // Encoded an out-of-bounds index — should be impossible from
60
+ // our own encoding, but defend against a hostile callback.
61
+ await ctx.answerCallbackQuery({ text: 'Invalid option.' }).catch(() => {})
62
+ return true
63
+ }
64
+ const choice = entry.options[askMatch.idx]
65
+ clearTimeout(entry.timer)
66
+ deps.pendingAskUser.delete(askMatch.askId)
67
+ entry.resolve({ kind: 'answered', choice, idx: askMatch.idx })
68
+
69
+ // Edit the question in place to remove the buttons + show the
70
+ // chosen option as a checkmarked line. Makes the chat surface
71
+ // self-documenting — no orphaned-buttons graveyard.
72
+ //
73
+ // Escape `sourceMsg.text` before re-rendering with HTML parse
74
+ // mode. `msg.text` returns entities-stripped plain UTF-8; an
75
+ // agent-supplied `ask_user` question containing raw `<`/`>`/`&`
76
+ // (common in code-related questions like "Run `<command>`?")
77
+ // would crash the HTML re-parse, the try/catch would swallow,
78
+ // and the button keyboard would stay tappable — the exact bug
79
+ // PR #1158 caught on the operator-event card.
80
+ const sourceMsg = ctx.callbackQuery?.message
81
+ if (sourceMsg && 'text' in sourceMsg && sourceMsg.text != null) {
82
+ try {
83
+ await ctx.editMessageText(
84
+ richMessage(`${escapeHtmlForTg(sourceMsg.text)}\n\n✅ **${escapeHtmlForTg(choice)}**`),
85
+ )
86
+ } catch {
87
+ /* edit-failed is fine — the answer-callback below still acks */
88
+ }
89
+ }
90
+ await ctx.answerCallbackQuery({ text: '✅ ' + choice.slice(0, 60) }).catch(() => {})
91
+ return true
92
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Metadata-only attachment inbound handlers (switchroom#2996 P6 cluster C).
3
+ *
4
+ * The `message:*` attachment content types that carry a downloadable file the
5
+ * agent references by `file_id` and whose gateway handling is a pure metadata
6
+ * render — document, audio, video, video_note, sticker, animation. Each builds
7
+ * a compact text envelope (caption-or-fallback) plus an `AttachmentMeta` slot
8
+ * and hands both to the coalescing inbound pipeline; none of them download the
9
+ * file inline.
10
+ *
11
+ * Deliberately NOT here: `message:photo` (inline bounded CDN download +
12
+ * inbox write) and `message:voice` (Whisper transcription branch) stay inline
13
+ * in gateway.ts — they carry side-effecting IO / host-capability logic and get
14
+ * their own later clusters. The registration lines for all six handlers below
15
+ * stay in gateway.ts (their order is a load-bearing invariant pinned by
16
+ * gateway-handler-registration-wiring.test.ts); each delegates here.
17
+ *
18
+ * The coalescing dispatch surface is injected via `AttachmentHandlerDeps` — no
19
+ * gateway module-scope globals are read here (switchroom#2996 Amendment 9).
20
+ * `safeName` is imported from the cluster-A module (its single owner).
21
+ */
22
+
23
+ import type { Context, Filter } from 'grammy'
24
+ import { safeName } from './media-message-handlers.js'
25
+
26
+ /** Structural mirror of gateway.ts's `AttachmentMeta` — one attachment slot. */
27
+ export interface AttachmentMeta {
28
+ kind: string
29
+ file_id: string
30
+ size?: number
31
+ mime?: string
32
+ name?: string
33
+ }
34
+
35
+ /**
36
+ * Injected dispatch surface. `handleInboundCoalesced` is the normal coalescing
37
+ * inbound pipeline (same access gating / forward-origin parsing as every
38
+ * content handler); these handlers always pass `undefined` for the image
39
+ * downloader — the file flows through the `attachment` slot by `file_id`.
40
+ */
41
+ export interface AttachmentHandlerDeps {
42
+ handleInboundCoalesced: (
43
+ ctx: Context,
44
+ text: string,
45
+ downloadImage: undefined,
46
+ attachment?: AttachmentMeta,
47
+ ) => Promise<void>
48
+ }
49
+
50
+ export async function handleDocumentMessage(
51
+ ctx: Filter<Context, 'message:document'>,
52
+ deps: AttachmentHandlerDeps,
53
+ ): Promise<void> {
54
+ const doc = ctx.message.document
55
+ const name = safeName(doc.file_name)
56
+ await deps.handleInboundCoalesced(
57
+ ctx,
58
+ ctx.message.caption ?? `(document: ${name ?? 'file'})`,
59
+ undefined,
60
+ { kind: 'document', file_id: doc.file_id, size: doc.file_size, mime: doc.mime_type, name },
61
+ )
62
+ }
63
+
64
+ export async function handleAudioMessage(
65
+ ctx: Filter<Context, 'message:audio'>,
66
+ deps: AttachmentHandlerDeps,
67
+ ): Promise<void> {
68
+ const audio = ctx.message.audio
69
+ const name = safeName(audio.file_name)
70
+ await deps.handleInboundCoalesced(
71
+ ctx,
72
+ ctx.message.caption ?? `(audio: ${safeName(audio.title) ?? name ?? 'audio'})`,
73
+ undefined,
74
+ { kind: 'audio', file_id: audio.file_id, size: audio.file_size, mime: audio.mime_type, name },
75
+ )
76
+ }
77
+
78
+ export async function handleVideoMessage(
79
+ ctx: Filter<Context, 'message:video'>,
80
+ deps: AttachmentHandlerDeps,
81
+ ): Promise<void> {
82
+ const video = ctx.message.video
83
+ await deps.handleInboundCoalesced(
84
+ ctx,
85
+ ctx.message.caption ?? '(video)',
86
+ undefined,
87
+ { kind: 'video', file_id: video.file_id, size: video.file_size, mime: video.mime_type, name: safeName(video.file_name) },
88
+ )
89
+ }
90
+
91
+ export async function handleVideoNoteMessage(
92
+ ctx: Filter<Context, 'message:video_note'>,
93
+ deps: AttachmentHandlerDeps,
94
+ ): Promise<void> {
95
+ const vn = ctx.message.video_note
96
+ await deps.handleInboundCoalesced(
97
+ ctx,
98
+ '(video note)',
99
+ undefined,
100
+ { kind: 'video_note', file_id: vn.file_id, size: vn.file_size },
101
+ )
102
+ }
103
+
104
+ export async function handleStickerMessage(
105
+ ctx: Filter<Context, 'message:sticker'>,
106
+ deps: AttachmentHandlerDeps,
107
+ ): Promise<void> {
108
+ const sticker = ctx.message.sticker
109
+ // Render to a clearer text envelope so the agent has signal to
110
+ // respond to. Includes the emoji (semantic anchor — what mood the
111
+ // sticker maps to) and the set name (so the agent can say "that's
112
+ // from your <set>" if relevant). Both optional. The file_id flows
113
+ // through the attachment field as before, so the agent can echo it
114
+ // back via send_sticker if it wants to mirror the sticker.
115
+ const parts: string[] = []
116
+ if (sticker.emoji) parts.push(sticker.emoji)
117
+ if (sticker.set_name) parts.push(`from "${sticker.set_name}"`)
118
+ const text = parts.length > 0 ? `(sticker — ${parts.join(' ')})` : '(sticker)'
119
+ await deps.handleInboundCoalesced(
120
+ ctx,
121
+ text,
122
+ undefined,
123
+ { kind: 'sticker', file_id: sticker.file_id, size: sticker.file_size },
124
+ )
125
+ }
126
+
127
+ export async function handleAnimationMessage(
128
+ ctx: Filter<Context, 'message:animation'>,
129
+ deps: AttachmentHandlerDeps,
130
+ ): Promise<void> {
131
+ // Animation = Telegram's GIF type (MP4-encoded looping clips).
132
+ // Caption is optional; if present it carries the user's intent
133
+ // (e.g. "perfect 👌"); if absent, the GIF itself is the message.
134
+ // Surfaces to the agent so it can respond in kind via send_gif —
135
+ // mirroring is fine for assistant-style personas, less so for
136
+ // coding ones.
137
+ const animation = ctx.message.animation
138
+ const caption = ctx.message.caption
139
+ const text = caption ? `(gif) ${caption}` : '(gif)'
140
+ await deps.handleInboundCoalesced(
141
+ ctx,
142
+ text,
143
+ undefined,
144
+ {
145
+ kind: 'animation',
146
+ file_id: animation.file_id,
147
+ size: animation.file_size,
148
+ mime: animation.mime_type,
149
+ name: safeName(animation.file_name),
150
+ },
151
+ )
152
+ }