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.
- package/LICENSE +21 -0
- package/README.md +221 -2
- package/build-info.json +9 -0
- package/docs/live-update.md +190 -0
- package/docs/pause-control.md +151 -0
- package/docs/turn-complete-notify.md +110 -0
- package/package.json +62 -4
- package/src/index.ts +1853 -0
- package/src/lib/audio-normalize.ts +91 -0
- package/src/lib/decision-buttons.ts +574 -0
- package/src/lib/document.ts +203 -0
- package/src/lib/format.ts +822 -0
- package/src/lib/html-check.ts +280 -0
- package/src/lib/inbound-poller.ts +1248 -0
- package/src/lib/inbound-queue.ts +157 -0
- package/src/lib/local-time.ts +181 -0
- package/src/lib/notify/agent-delivery.ts +29 -0
- package/src/lib/notify/craft.ts +157 -0
- package/src/lib/notify/live-update.ts +716 -0
- package/src/lib/notify/model.ts +197 -0
- package/src/lib/notify/rate-limit.ts +44 -0
- package/src/lib/notify/turn-notify.ts +138 -0
- package/src/lib/pause-control.ts +425 -0
- package/src/lib/photo.ts +182 -0
- package/src/lib/poller-lock.ts +127 -0
- package/src/lib/session-role.ts +32 -0
- package/src/lib/state-paths.ts +164 -0
- package/src/lib/stt-keys.ts +211 -0
- package/src/lib/telegram-client.ts +699 -0
- package/src/lib/telegram-inbox.ts +203 -0
- package/src/lib/telegram-settings.ts +92 -0
- package/src/lib/tool-logic.ts +604 -0
- package/src/lib/transcribe.ts +574 -0
- package/src/lib/tts.ts +451 -0
- package/src/lib/venice-catalog-base-url.ts +45 -0
- package/src/lib/voice.ts +192 -0
- package/src/lib/watchdog-tunnel.ts +102 -0
|
@@ -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
|
+
}
|