pi-braid-telegram 0.0.0-stage → 0.1.5

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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * audio-normalize.ts — quiet-audio pre-amplification for STT ().
3
+ *
4
+ * Telegram voice notes arrive near-silence to a distant mic; the initial
5
+ * evidence showed raw transcription wobbling while +18 dB gain converged
6
+ * three runs. ffmpeg pipes the bytes in and out (no temp files), fixed
7
+ * volume=18dB, re-encoded to opus/ogg 48 kHz. ffmpeg absent → null (the
8
+ * caller retries with the raw bytes silently — normalization is a
9
+ * best-effort, never a blocker).
10
+ */
11
+
12
+ /** Default fixed gain (dB) — the value that stabilized the original quiet clip. */
13
+ export const DEFAULT_STT_NORMALIZE_GAIN_DB = 18;
14
+
15
+ /**
16
+ * ffmpeg argv for quiet-audio amplification: read ogg from stdin, apply the
17
+ * fixed volume filter, emit opus/ogg to stdout. Exposed for tests (the brief
18
+ * targets the arg builder; the spawn itself stays one shell invocation).
19
+ */
20
+ export function buildFfmpegNormalizeArgs(
21
+ gainDb: number = DEFAULT_STT_NORMALIZE_GAIN_DB,
22
+ ): string[] {
23
+ return [
24
+ "-hide_banner",
25
+ "-loglevel",
26
+ "error",
27
+ "-i",
28
+ "pipe:0",
29
+ "-af",
30
+ `volume=${gainDb}dB`,
31
+ "-c:a",
32
+ "libopus",
33
+ "-b:a",
34
+ "24k",
35
+ "-ar",
36
+ "48000",
37
+ "-f",
38
+ "ogg",
39
+ "pipe:1",
40
+ ];
41
+ }
42
+
43
+ /** Options for normalizeAudioBytes (all optional; ffmpeg looked up on PATH). */
44
+ export interface NormalizeAudioBytesOptions {
45
+ /** ffmpeg binary name/path (default "ffmpeg"). */
46
+ ffmpegPath?: string;
47
+ /** Fixed gain in dB (default 18). */
48
+ gainDb?: number;
49
+ }
50
+
51
+ /**
52
+ * Amplify quiet audio via ffmpeg stdin/stdout pipes. Returns the normalized
53
+ * bytes, or null when ffmpeg is missing, the spawn fails, or the output is
54
+ * empty — callers treat null as "skip normalization" silently.
55
+ */
56
+ export async function normalizeAudioBytes(
57
+ bytes: Uint8Array,
58
+ opts: NormalizeAudioBytesOptions = {},
59
+ ): Promise<Uint8Array | null> {
60
+ const { spawn } = await import("node:child_process");
61
+ return await new Promise((resolveNormalize) => {
62
+ let child: ReturnType<typeof spawn>;
63
+ try {
64
+ child = spawn(
65
+ opts.ffmpegPath ?? "ffmpeg",
66
+ buildFfmpegNormalizeArgs(opts.gainDb),
67
+ {
68
+ stdio: ["pipe", "pipe", "pipe"],
69
+ },
70
+ );
71
+ } catch {
72
+ resolveNormalize(null); // ffmpeg absent — skip silently
73
+ return;
74
+ }
75
+ const chunks: Buffer[] = [];
76
+ child.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk));
77
+ child.on("error", () => resolveNormalize(null)); // ENOENT etc.
78
+ child.on("close", (code) => {
79
+ const out = Buffer.concat(chunks);
80
+ if (code === 0 && out.byteLength > 0) {
81
+ resolveNormalize(new Uint8Array(out));
82
+ } else {
83
+ resolveNormalize(null);
84
+ }
85
+ });
86
+ child.stdin?.on("error", () => {
87
+ /* spawn-race: the close handler still settles the promise */
88
+ });
89
+ child.stdin?.end(Buffer.from(bytes));
90
+ });
91
+ }
@@ -0,0 +1,574 @@
1
+ /**
2
+ * Decision buttons — inline keyboards that carry the owner's choice back to
3
+ * the agent as a normal inbound message.
4
+ *
5
+ * Both protocol halves live here so a format change lands in one file:
6
+ * - send: `prepareDecisionOptions` turns rows of `{ label, value }` into a
7
+ * `reply_markup` whose buttons carry `dec:<token>:<value>` callback_data.
8
+ * The token is a short opaque correlation id (echoed back on the tap and
9
+ * in the tool result), so the agent can match a tap to the question it
10
+ * asked. Telegram caps callback_data at 64 bytes — enforced here, before
11
+ * anything is sent.
12
+ * - receive: `parseDecisionCallbackData` recognizes the prefix,
13
+ * `findDecisionOptionLabel` recovers the tapped button's label from the
14
+ * message markup, and `handleDecisionCallbackQuery` answers the tap
15
+ * (toast) and returns the delivery payload (an `IncomingOwnerMessage`
16
+ * the caller hands to the same inbound path as a text message). Each ROW
17
+ * of a keyboard is one question: answering it drops that row from the
18
+ * message and keeps the unanswered rows (Telegram shows the updated markup
19
+ * in place), a later tap of an answered row is acknowledged only, and a
20
+ * later tap of a still-open row delivers normally. Control callbacks
21
+ * (`pause`/`resume`/`status`) fail the prefix check and keep their own
22
+ * handler's path.
23
+ *
24
+ * Placement: this is the telegram package's domain protocol (its prefix, its
25
+ * token, its tap format). Nothing generic knows the `dec:` vocabulary; the
26
+ * tool/params layer treats options as rows of {label, value} and asks this
27
+ * module for the wire shape.
28
+ */
29
+
30
+ import type {
31
+ TelegramClient,
32
+ TelegramMessage,
33
+ TelegramReplyMarkup,
34
+ TelegramUpdate,
35
+ } from "./telegram-client.js";
36
+ import type { IncomingOwnerMessage } from "./inbound-poller.js";
37
+
38
+ /** callback_data prefix that marks a decision-button tap. */
39
+ export const DECISION_CALLBACK_PREFIX = "dec:";
40
+ /** Telegram's hard limit for callback_data (bytes, UTF-8). */
41
+ export const MAX_CALLBACK_DATA_BYTES = 64;
42
+ /** Layout limits: 1-2 rows, at most 4 buttons per row (a compact keyboard). */
43
+ export const MAX_DECISION_ROWS = 2;
44
+ export const MAX_DECISION_BUTTONS_PER_ROW = 4;
45
+ /**
46
+ * Token shape: short, no colons (the second colon separates value), URL-safe
47
+ * characters only. Not a secret — a correlation id, so a guess costs nothing.
48
+ */
49
+ const DECISION_TOKEN_RE = /^[A-Za-z0-9_-]{1,16}$/;
50
+
51
+ /** One button: `label` is what the owner sees, `value` what the agent gets. */
52
+ export interface DecisionOption {
53
+ label: string;
54
+ value: string;
55
+ }
56
+
57
+ /** Rows of options (Telegram's inline_keyboard layout). */
58
+ export type DecisionOptionRows = DecisionOption[][];
59
+
60
+ export type DecisionPrepOutcome =
61
+ | { ok: true; token: string; keyboard: TelegramReplyMarkup }
62
+ | { ok: false; error: string };
63
+
64
+ /**
65
+ * Generate a correlation token for a decision keyboard: up to 8 chars of
66
+ * base36. Callers that pass their own `decisionToken` keep it; this is the
67
+ * fallback and its result is returned to the agent in the tool outcome.
68
+ */
69
+ export function generateDecisionToken(): string {
70
+ let token = "";
71
+ while (!DECISION_TOKEN_RE.test(token)) {
72
+ token = Math.random().toString(36).slice(2, 10);
73
+ }
74
+ return token;
75
+ }
76
+
77
+ /** The callback_data a button sends back: `dec:<token>:<value>`. */
78
+ export function decisionCallbackData(token: string, value: string): string {
79
+ return `${DECISION_CALLBACK_PREFIX}${token}:${value}`;
80
+ }
81
+
82
+ /**
83
+ * Validate `options` + resolve the token, or explain the refusal. Malformed
84
+ * rows are REJECTED (the caller sends nothing): an explicit param that
85
+ * quietly loses its keyboard would leave the agent believing the owner can
86
+ * tap when they cannot. Pinned by tests.
87
+ */
88
+ export function prepareDecisionOptions(
89
+ options: unknown,
90
+ requestedToken?: unknown,
91
+ ): DecisionPrepOutcome {
92
+ if (options === undefined && requestedToken !== undefined) {
93
+ return {
94
+ ok: false,
95
+ error:
96
+ "telegram_send: `decisionToken` requires `options` — the token is the id the buttons echo back",
97
+ };
98
+ }
99
+ if (
100
+ !Array.isArray(options) ||
101
+ options.length === 0 ||
102
+ options.length > MAX_DECISION_ROWS
103
+ ) {
104
+ return {
105
+ ok: false,
106
+ error: `telegram_send: \`options\` must be 1-${MAX_DECISION_ROWS} rows of buttons`,
107
+ };
108
+ }
109
+
110
+ const rows: DecisionOptionRows = [];
111
+ for (const row of options) {
112
+ if (
113
+ !Array.isArray(row) ||
114
+ row.length === 0 ||
115
+ row.length > MAX_DECISION_BUTTONS_PER_ROW
116
+ ) {
117
+ return {
118
+ ok: false,
119
+ error: `telegram_send: every \`options\` row must carry 1-${MAX_DECISION_BUTTONS_PER_ROW} buttons`,
120
+ };
121
+ }
122
+ const built: DecisionOption[] = [];
123
+ for (const button of row) {
124
+ const raw = button as { label?: unknown; value?: unknown } | null;
125
+ const label = typeof raw?.label === "string" ? raw.label.trim() : "";
126
+ const value = typeof raw?.value === "string" ? raw.value.trim() : "";
127
+ if (!label || !value) {
128
+ return {
129
+ ok: false,
130
+ error:
131
+ "telegram_send: every `options` button needs a non-empty `label` and `value`",
132
+ };
133
+ }
134
+ built.push({ label, value });
135
+ }
136
+ rows.push(built);
137
+ }
138
+
139
+ let token: string;
140
+ if (requestedToken === undefined) {
141
+ token = generateDecisionToken();
142
+ } else if (
143
+ typeof requestedToken === "string" &&
144
+ DECISION_TOKEN_RE.test(requestedToken.trim())
145
+ ) {
146
+ token = requestedToken.trim();
147
+ } else {
148
+ return {
149
+ ok: false,
150
+ error:
151
+ "telegram_send: `decisionToken` must be 1-16 characters of A-Za-z0-9_- (no colons)",
152
+ };
153
+ }
154
+
155
+ // Byte cap: Telegram rejects the whole send when any callback_data is over
156
+ // 64 bytes, so refuse here with the offending value named.
157
+ for (const row of rows) {
158
+ for (const button of row) {
159
+ const data = decisionCallbackData(token, button.value);
160
+ const bytes = Buffer.byteLength(data, "utf8");
161
+ if (bytes > MAX_CALLBACK_DATA_BYTES) {
162
+ return {
163
+ ok: false,
164
+ error: `telegram_send: \`options\` value ${JSON.stringify(button.value)} makes callback_data ${bytes} bytes, over Telegram's ${MAX_CALLBACK_DATA_BYTES}-byte limit`,
165
+ };
166
+ }
167
+ }
168
+ }
169
+
170
+ return {
171
+ ok: true,
172
+ token,
173
+ keyboard: {
174
+ inline_keyboard: rows.map((row) =>
175
+ row.map((button) => ({
176
+ text: button.label,
177
+ callback_data: decisionCallbackData(token, button.value),
178
+ })),
179
+ ),
180
+ },
181
+ };
182
+ }
183
+
184
+ /** Parsed decision callback payload. */
185
+ export interface ParsedDecisionCallback {
186
+ token: string;
187
+ /** The button's value; may itself contain colons (only the first splits). */
188
+ value: string;
189
+ }
190
+
191
+ /**
192
+ * Parse `dec:<token>:<value>`; null for anything else. A malformed payload is
193
+ * not a decision tap — the caller keeps its existing callback path (the
194
+ * control handler answers unknown actions with a hint, never a crash).
195
+ */
196
+ export function parseDecisionCallbackData(
197
+ data: unknown,
198
+ ): ParsedDecisionCallback | null {
199
+ if (typeof data !== "string" || !data.startsWith(DECISION_CALLBACK_PREFIX)) {
200
+ return null;
201
+ }
202
+ const rest = data.slice(DECISION_CALLBACK_PREFIX.length);
203
+ const separator = rest.indexOf(":");
204
+ if (separator <= 0) return null;
205
+ const token = rest.slice(0, separator);
206
+ const value = rest.slice(separator + 1);
207
+ if (!DECISION_TOKEN_RE.test(token) || value.length === 0) return null;
208
+ return { token, value };
209
+ }
210
+
211
+ /**
212
+ * Recover the tapped button's label from the message the keyboard is attached
213
+ * to: the button whose callback_data matches the tap. Lookup is deliberately
214
+ * tolerant (unknown markup → undefined) so a tap still delivers with the raw
215
+ * value when the markup is unavailable.
216
+ */
217
+ export function findDecisionOptionLabel(
218
+ update: TelegramUpdate,
219
+ callbackData: string,
220
+ ): string | undefined {
221
+ const markup = (
222
+ update.callback_query?.message as { reply_markup?: unknown } | undefined
223
+ )?.reply_markup as { inline_keyboard?: unknown } | undefined;
224
+ const rows = markup?.inline_keyboard;
225
+ if (!Array.isArray(rows)) return undefined;
226
+ for (const row of rows) {
227
+ if (!Array.isArray(row)) continue;
228
+ for (const button of row) {
229
+ const candidate = button as { text?: unknown; callback_data?: unknown };
230
+ if (
231
+ candidate?.callback_data === callbackData &&
232
+ typeof candidate.text === "string" &&
233
+ candidate.text.trim()
234
+ ) {
235
+ return candidate.text.trim();
236
+ }
237
+ }
238
+ }
239
+ return undefined;
240
+ }
241
+
242
+ /**
243
+ * The inbound body the agent sees: `[decision] <token> → <label> (<value>)`.
244
+ * When the label equals the value (or is unknown) only the value is shown —
245
+ * repeating it would read as two different choices.
246
+ */
247
+ export function formatDecisionAnswerText(
248
+ token: string,
249
+ value: string,
250
+ label?: string,
251
+ ): string {
252
+ const shown = label && label !== value ? `${label} (${value})` : value;
253
+ return `[decision] ${token} → ${shown}`;
254
+ }
255
+
256
+ export interface DecisionCallbackDeps {
257
+ client: Pick<
258
+ TelegramClient,
259
+ "answerCallbackQuery" | "editMessageReplyMarkup"
260
+ >;
261
+ /** Bot token (the client's auth), not the decision token. */
262
+ token: string;
263
+ log?: (
264
+ level: "debug" | "info" | "warn" | "error",
265
+ message: string,
266
+ ...rest: unknown[]
267
+ ) => void;
268
+ }
269
+
270
+ export interface DecisionCallbackOutcome {
271
+ /** True when the tap WAS a well-formed decision payload. */
272
+ handled: boolean;
273
+ /** answerCallbackQuery succeeded (the tap was acknowledged). */
274
+ answered: boolean;
275
+ /** Present when the tap is deliverable — hand to the inbound delivery path. */
276
+ delivery?: IncomingOwnerMessage;
277
+ error?: string;
278
+ }
279
+
280
+ /** What one answered button of a decision token carried. */
281
+ interface AnsweredDecision {
282
+ value: string;
283
+ /** The tapped button's label, when the message markup was recoverable. */
284
+ label?: string;
285
+ /** Epoch ms of the answering tap (token insertion order for eviction). */
286
+ at: number;
287
+ }
288
+
289
+ /** Answers of one token, keyed by button value (insertion order = tap order). */
290
+ type TokenAnswers = Map<string, AnsweredDecision>;
291
+
292
+ /**
293
+ * Answered decision tokens, oldest first. Each button is single-use within its
294
+ * question: the first tap of a value answers that question for this process's
295
+ * lifetime; a later tap of the same value is acknowledged but not delivered.
296
+ * A token with several rows can therefore be answered several times (once per
297
+ * row) — the keyboard keeps the rows still open (see
298
+ * `updateDecisionKeyboard`). Process-global by design — one question asked
299
+ * once is answered once, across handler invocations.
300
+ *
301
+ * Bounded so a long-lived process cannot grow the map without limit: the most
302
+ * recent tokens are kept and the oldest evicted; an evicted token is treated
303
+ * as unanswered (its next tap delivers).
304
+ */
305
+ const ANSWERED_DECISIONS = new Map<string, TokenAnswers>();
306
+
307
+ /** Answered tokens remembered before the oldest is evicted. */
308
+ const MAX_ANSWERED_DECISIONS = 64;
309
+
310
+ /** Remember the answer to `value` under `token`, evicting the oldest token. */
311
+ function rememberAnsweredDecision(
312
+ token: string,
313
+ value: string,
314
+ label?: string,
315
+ ): TokenAnswers {
316
+ let answers = ANSWERED_DECISIONS.get(token);
317
+ if (!answers) {
318
+ answers = new Map();
319
+ ANSWERED_DECISIONS.set(token, answers);
320
+ }
321
+ answers.set(value, {
322
+ value,
323
+ ...(label ? { label } : {}),
324
+ at: Date.now(),
325
+ });
326
+ while (ANSWERED_DECISIONS.size > MAX_ANSWERED_DECISIONS) {
327
+ const oldest = ANSWERED_DECISIONS.keys().next().value;
328
+ if (oldest === undefined) break;
329
+ ANSWERED_DECISIONS.delete(oldest);
330
+ }
331
+ return answers;
332
+ }
333
+
334
+ /** Inline keyboard rows of a message, or undefined when it carries none. */
335
+ function inlineRows(
336
+ message: TelegramMessage | undefined,
337
+ ): TelegramReplyMarkup["inline_keyboard"] | undefined {
338
+ const markup = message?.reply_markup as
339
+ | { inline_keyboard?: unknown }
340
+ | undefined;
341
+ const rows = markup?.inline_keyboard;
342
+ if (!Array.isArray(rows)) return undefined;
343
+ return rows.filter(
344
+ (row): row is Array<{ text: string; callback_data: string }> =>
345
+ Array.isArray(row),
346
+ );
347
+ }
348
+
349
+ /** Values of one row that belong to `token`. */
350
+ function rowValues(row: unknown[], token: string): string[] {
351
+ const values: string[] = [];
352
+ for (const button of row) {
353
+ const parsed = parseDecisionCallbackData(
354
+ (button as { callback_data?: unknown } | null)?.callback_data,
355
+ );
356
+ if (parsed && parsed.token === token) values.push(parsed.value);
357
+ }
358
+ return values;
359
+ }
360
+
361
+ /**
362
+ * Is the tapped button on a question that is still open? A row is open while
363
+ * any of its buttons is unanswered; a button that is not on the keyboard any
364
+ * more (or a message with no markup, where nothing can be looked up) is not a
365
+ * new question — with no markup a first tap still delivers.
366
+ */
367
+ function tappedRowIsOpen(
368
+ message: TelegramMessage | undefined,
369
+ token: string,
370
+ callbackData: string,
371
+ answers: TokenAnswers,
372
+ ): boolean {
373
+ const rows = inlineRows(message);
374
+ if (rows === undefined) return true;
375
+ const row = rows.find((candidate) =>
376
+ candidate.some(
377
+ (button) =>
378
+ (button as { callback_data?: unknown } | null)?.callback_data ===
379
+ callbackData,
380
+ ),
381
+ );
382
+ if (!row) return false;
383
+ const values = rowValues(row, token);
384
+ return values.length > 0 && values.some((value) => !answers.has(value));
385
+ }
386
+
387
+ /**
388
+ * Shrink the tapped message's keyboard in place: the answered row (the row
389
+ * carrying the tapped button) is dropped and the open questions stay.
390
+ * Telegram does not update the markup itself, so without this edit the whole
391
+ * keyboard would collapse client-side after the first tap and the remaining
392
+ * questions of a multi-row message would become unanswerable (the F9 finding).
393
+ * Rows that were fully answered earlier (a stale markup still carrying them)
394
+ * are dropped too; foreign rows are left alone. Best-effort: an ok:false result
395
+ * or a thrown edit is swallowed — the answer is delivered either way. The
396
+ * first tap's failure is logged as a warn (the owner may still see the
397
+ * keyboard), the repeat path's as debug (the question is already answered
398
+ * either way).
399
+ */
400
+ async function updateDecisionKeyboard(
401
+ deps: DecisionCallbackDeps,
402
+ message: TelegramMessage,
403
+ token: string,
404
+ answers: TokenAnswers,
405
+ tappedData: string,
406
+ bestEffort = false,
407
+ ): Promise<void> {
408
+ const rows = inlineRows(message);
409
+ if (!rows || rows.length === 0) return;
410
+ const kept = rows.filter((row) => {
411
+ // The row of the tapped button is the question just answered.
412
+ if (row.some((button) => button?.callback_data === tappedData))
413
+ return false;
414
+ const values = rowValues(row, token);
415
+ // Foreign rows (not ours) are left alone; ours are kept while open.
416
+ return values.length === 0 || values.some((value) => !answers.has(value));
417
+ });
418
+ if (kept.length === rows.length) return; // nothing to shrink
419
+ let failure: string | undefined;
420
+ try {
421
+ const result = await deps.client.editMessageReplyMarkup(
422
+ deps.token,
423
+ message.chat.id,
424
+ message.message_id,
425
+ { replyMarkup: { inline_keyboard: kept } },
426
+ );
427
+ if (!result.ok) failure = result.error ?? "unknown error";
428
+ } catch (error) {
429
+ failure = error instanceof Error ? error.message : String(error);
430
+ }
431
+ if (failure === undefined) return;
432
+ const detail = `keyboard update failed for message ${message.message_id}: ${failure}`;
433
+ if (bestEffort) {
434
+ deps.log?.("debug", `telegram decision: repeat-tap ${detail}`);
435
+ return;
436
+ }
437
+ deps.log?.(
438
+ "warn",
439
+ `telegram decision: ${detail} — delivering the first answer anyway`,
440
+ );
441
+ }
442
+
443
+ /**
444
+ * Acknowledge a repeat tap: name the answered value (or the token's oldest
445
+ * answer when the tapped value was never delivered), re-shrink the keyboard
446
+ * when the tapped message still carries answered rows, deliver nothing.
447
+ */
448
+ async function answerRepeatTap(
449
+ deps: DecisionCallbackDeps,
450
+ callbackQueryId: string,
451
+ message: TelegramMessage | undefined,
452
+ token: string,
453
+ answers: TokenAnswers,
454
+ tappedValue: string,
455
+ tappedData: string,
456
+ ): Promise<DecisionCallbackOutcome> {
457
+ const named = answers.get(tappedValue) ?? answers.values().next().value;
458
+ const answer = await deps.client.answerCallbackQuery(
459
+ deps.token,
460
+ callbackQueryId,
461
+ {
462
+ text: `already answered: ${named?.label ?? named?.value ?? tappedValue}`,
463
+ },
464
+ );
465
+ if (message) {
466
+ await updateDecisionKeyboard(
467
+ deps,
468
+ message,
469
+ token,
470
+ answers,
471
+ tappedData,
472
+ true,
473
+ );
474
+ }
475
+ return { handled: true, answered: answer.ok };
476
+ }
477
+
478
+ /**
479
+ * Handle one decision-button tap: acknowledge it (toast with the label) and
480
+ * return the message to deliver. Never throws; a non-decision payload comes
481
+ * back `handled: false` with nothing answered, so the caller's control path
482
+ * stays byte-for-byte in charge of pause/resume/status/unknown.
483
+ *
484
+ * Single-use per QUESTION: the first tap of a value is recorded BEFORE any
485
+ * network call (two racing taps cannot both pass the record check), then
486
+ * acknowledged and the answered row is dropped from the keyboard while the
487
+ * open rows stay. A later tap of an answered value — or of a button that is no
488
+ * longer on the keyboard — is acknowledged with the answer named and delivers
489
+ * nothing; a later tap of a still-open row of the same token delivers, so a
490
+ * two-question message can be answered twice (F9).
491
+ *
492
+ * Delivery is NOT done here: the caller owns it (index.ts hands the payload
493
+ * to the same coalescing + steer handoff a text message takes).
494
+ */
495
+ export async function handleDecisionCallbackQuery(
496
+ deps: DecisionCallbackDeps,
497
+ update: TelegramUpdate,
498
+ ): Promise<DecisionCallbackOutcome> {
499
+ const callbackQuery = update.callback_query;
500
+ const parsed = parseDecisionCallbackData(callbackQuery?.data);
501
+ if (!callbackQuery?.id || !parsed) {
502
+ return { handled: false, answered: false };
503
+ }
504
+
505
+ const priorAnswers = ANSWERED_DECISIONS.get(parsed.token);
506
+ if (
507
+ priorAnswers &&
508
+ (priorAnswers.has(parsed.value) ||
509
+ !tappedRowIsOpen(
510
+ callbackQuery.message,
511
+ parsed.token,
512
+ callbackQuery.data ?? "",
513
+ priorAnswers,
514
+ ))
515
+ ) {
516
+ return answerRepeatTap(
517
+ deps,
518
+ callbackQuery.id,
519
+ callbackQuery.message,
520
+ parsed.token,
521
+ priorAnswers,
522
+ parsed.value,
523
+ callbackQuery.data ?? "",
524
+ );
525
+ }
526
+
527
+ const label = findDecisionOptionLabel(update, callbackQuery.data ?? "");
528
+ const shown = label ?? parsed.value;
529
+ // Record before the first network call: a racing duplicate of this tap sees
530
+ // the value answered and takes the repeat path instead of a second delivery.
531
+ const answers = rememberAnsweredDecision(parsed.token, parsed.value, label);
532
+
533
+ const answer = await deps.client.answerCallbackQuery(
534
+ deps.token,
535
+ callbackQuery.id,
536
+ { text: `✓ ${shown}` },
537
+ );
538
+
539
+ const message = callbackQuery.message;
540
+ const from = callbackQuery.from;
541
+ if (!message || typeof from?.id !== "number") {
542
+ deps.log?.(
543
+ "warn",
544
+ `telegram decision: tap ${callbackQuery.id} has no usable message context — acknowledged, not delivered`,
545
+ );
546
+ return {
547
+ handled: true,
548
+ answered: answer.ok,
549
+ error: "no message context for delivery",
550
+ };
551
+ }
552
+
553
+ await updateDecisionKeyboard(
554
+ deps,
555
+ message,
556
+ parsed.token,
557
+ answers,
558
+ callbackQuery.data ?? "",
559
+ );
560
+
561
+ const delivery: IncomingOwnerMessage = {
562
+ updateId: update.update_id,
563
+ messageId: message.message_id,
564
+ fromId: from.id,
565
+ ...(from.username ? { username: from.username } : {}),
566
+ ...(from.first_name ? { firstName: from.first_name } : {}),
567
+ chatId: message.chat.id,
568
+ chatType: message.chat.type ?? "private",
569
+ text: formatDecisionAnswerText(parsed.token, parsed.value, label),
570
+ receivedAt:
571
+ typeof message.date === "number" ? message.date * 1000 : Date.now(),
572
+ };
573
+ return { handled: true, answered: answer.ok, delivery };
574
+ }