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.
- package/dist/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/gateway.ts +1347 -6758
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- 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
|
+
}
|