@llblab/pi-telegram 0.42.3 → 0.43.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/BACKLOG.md +3 -0
- package/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/docs/README.md +1 -1
- package/docs/architecture.md +5 -3
- package/docs/compact-matrix-literal.md +39 -11
- package/docs/generative-apps.md +2 -2
- package/docs/multi-instance-bus.md +17 -5
- package/docs/outbound.md +6 -4
- package/docs/public-api.md +1 -1
- package/index.ts +13 -1
- package/lib/bindings.ts +12 -3
- package/lib/bus-follower.ts +29 -18
- package/lib/bus-leader.ts +15 -6
- package/lib/bus.ts +11 -4
- package/lib/keyboard.ts +5 -3
- package/lib/outbound-buttons.ts +72 -15
- package/lib/outbound-markup.ts +81 -9
- package/lib/outbound.ts +2 -0
- package/lib/replies.ts +1 -1
- package/lib/routing.ts +89 -32
- package/lib/sync.ts +74 -15
- package/lib/telegram-api.ts +32 -1
- package/lib/thread-reconciler.ts +17 -0
- package/lib/threads.ts +123 -15
- package/package.json +1 -1
- package/skills/generated-control-surface/SKILL.md +4 -2
- package/skills/generated-control-surface/references/layout-and-state.md +4 -2
- package/skills/generative-apps/SKILL.md +4 -3
- package/skills/telegram-bridge/SKILL.md +19 -8
package/lib/bus.ts
CHANGED
|
@@ -1450,18 +1450,25 @@ export function createTelegramBusFollowerThreadRestoreHandler(
|
|
|
1450
1450
|
return async ({ record, target, oldTarget }) => {
|
|
1451
1451
|
if (!record.instanceId) return false;
|
|
1452
1452
|
const follower = deps.followerRegistry.get(record.instanceId);
|
|
1453
|
-
if (!follower
|
|
1453
|
+
if (!follower?.registrationGeneration || !oldTarget ||
|
|
1454
|
+
follower.target?.chatId !== oldTarget.chatId ||
|
|
1455
|
+
follower.target.threadId !== oldTarget.threadId ||
|
|
1456
|
+
target.chatId !== oldTarget.chatId || target.threadId === oldTarget.threadId) return false;
|
|
1454
1457
|
const replaced = await deps.followerTargetController.replaceTarget({
|
|
1455
1458
|
follower,
|
|
1456
1459
|
target,
|
|
1457
1460
|
oldTarget,
|
|
1458
1461
|
reason: "thread-restore",
|
|
1459
1462
|
});
|
|
1460
|
-
|
|
1463
|
+
const current = deps.followerRegistry.get(record.instanceId);
|
|
1464
|
+
if (!replaced || !current ||
|
|
1465
|
+
current.registrationGeneration !== follower.registrationGeneration ||
|
|
1466
|
+
current.target?.chatId !== oldTarget.chatId ||
|
|
1467
|
+
current.target.threadId !== oldTarget.threadId) return false;
|
|
1461
1468
|
deps.followerRegistry.register({
|
|
1462
|
-
...
|
|
1469
|
+
...current,
|
|
1463
1470
|
target,
|
|
1464
|
-
connectedAtMs:
|
|
1471
|
+
connectedAtMs: current.connectedAtMs,
|
|
1465
1472
|
});
|
|
1466
1473
|
deps.onRestored?.();
|
|
1467
1474
|
return true;
|
package/lib/keyboard.ts
CHANGED
|
@@ -9,11 +9,13 @@ export type TelegramInlineKeyboardButtonStyle =
|
|
|
9
9
|
| "success"
|
|
10
10
|
| "primary";
|
|
11
11
|
|
|
12
|
-
export
|
|
12
|
+
export type TelegramInlineKeyboardButton = {
|
|
13
13
|
text: string;
|
|
14
|
-
callback_data: string;
|
|
15
14
|
style?: TelegramInlineKeyboardButtonStyle;
|
|
16
|
-
}
|
|
15
|
+
} & (
|
|
16
|
+
| { callback_data: string; disabled?: never }
|
|
17
|
+
| { disabled: Record<string, never>; callback_data?: never }
|
|
18
|
+
);
|
|
17
19
|
|
|
18
20
|
export interface TelegramInlineKeyboardMarkup {
|
|
19
21
|
inline_keyboard: TelegramInlineKeyboardButton[][];
|
package/lib/outbound-buttons.ts
CHANGED
|
@@ -12,6 +12,8 @@ import type {
|
|
|
12
12
|
} from "./keyboard.ts";
|
|
13
13
|
import {
|
|
14
14
|
parseTelegramActionPayloadRows,
|
|
15
|
+
parseTelegramButtonPayloadRows,
|
|
16
|
+
replaceTelegramButtonFences,
|
|
15
17
|
replaceTopLevelHtmlComments,
|
|
16
18
|
} from "./outbound-markup.ts";
|
|
17
19
|
import {
|
|
@@ -34,6 +36,7 @@ export interface TelegramOutboundButtonAction {
|
|
|
34
36
|
prompt: string;
|
|
35
37
|
binding?: TelegramOutboundButtonBinding;
|
|
36
38
|
selectedStyle?: TelegramInlineKeyboardButtonStyle;
|
|
39
|
+
disabled?: true;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export interface TelegramOutboundButtonStoredAction extends TelegramOutboundButtonAction {
|
|
@@ -115,6 +118,12 @@ function parseTelegramButtonAction(
|
|
|
115
118
|
const explicitLabel = getTelegramButtonString(payload, "label");
|
|
116
119
|
const explicitPrompt = getTelegramButtonString(payload, "prompt");
|
|
117
120
|
const label = explicitLabel ?? value ?? explicitPrompt;
|
|
121
|
+
if (payload.disabled !== undefined && typeof payload.disabled !== "boolean") {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
if (payload.disabled === true) {
|
|
125
|
+
return { text: label ?? "", prompt: "", disabled: true };
|
|
126
|
+
}
|
|
118
127
|
const prompt = explicitPrompt ?? value ?? explicitLabel;
|
|
119
128
|
if (!label || !prompt) return undefined;
|
|
120
129
|
const selectedStyle = payload.selected_style;
|
|
@@ -159,6 +168,7 @@ export function createTelegramButtonActionStore(
|
|
|
159
168
|
return {
|
|
160
169
|
text: action.text,
|
|
161
170
|
prompt: action.prompt,
|
|
171
|
+
...(action.disabled ? { disabled: true as const } : {}),
|
|
162
172
|
...(action.binding ? { binding: action.binding } : {}),
|
|
163
173
|
...(action.selectedStyle
|
|
164
174
|
? { selectedStyle: action.selectedStyle }
|
|
@@ -171,33 +181,75 @@ export function createTelegramButtonActionStore(
|
|
|
171
181
|
const DEFAULT_TELEGRAM_BUTTON_REPLY_MARKDOWN =
|
|
172
182
|
"☑️ **Choose an option:**";
|
|
173
183
|
|
|
184
|
+
function escapeTelegramRichButtonText(text: string): string {
|
|
185
|
+
return text.replace(/[&<>"'`\\*_\[\]{}$~\r\n]/g, (character) =>
|
|
186
|
+
`&#${character.charCodeAt(0)};`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function renderTelegramRichButtonRow(
|
|
191
|
+
row: TelegramOutboundButtonMarkup["inline_keyboard"][number],
|
|
192
|
+
): string {
|
|
193
|
+
return `<tg-button-row>${row.map((button) => {
|
|
194
|
+
const attributes = button.disabled
|
|
195
|
+
? 'type="disabled"'
|
|
196
|
+
: `type="callback_data" data="${escapeTelegramRichButtonText(button.callback_data)}"`;
|
|
197
|
+
return `<tg-button ${attributes}>${escapeTelegramRichButtonText(button.text)}</tg-button>`;
|
|
198
|
+
}).join("")}</tg-button-row>`;
|
|
199
|
+
}
|
|
200
|
+
|
|
174
201
|
export function planTelegramButtonReply(
|
|
175
202
|
markdown: string,
|
|
176
203
|
deps: {
|
|
177
204
|
registerAction: (action: TelegramOutboundButtonAction) => string;
|
|
178
205
|
binding?: TelegramOutboundButtonBinding;
|
|
206
|
+
rendering?: "rich" | "html";
|
|
179
207
|
},
|
|
180
208
|
): TelegramButtonReplyPlan {
|
|
181
209
|
const keyboard: TelegramOutboundButtonMarkup["inline_keyboard"] = [];
|
|
182
|
-
const
|
|
210
|
+
const buildRows = (
|
|
211
|
+
payloadRows: Record<string, unknown>[][],
|
|
212
|
+
rich: boolean,
|
|
213
|
+
): TelegramOutboundButtonMarkup["inline_keyboard"] | undefined => {
|
|
214
|
+
const actions = payloadRows.map((row) => row.map(parseTelegramButtonAction));
|
|
215
|
+
if (actions.some((row) => row.some((action) => !action))) return undefined;
|
|
216
|
+
if (rich && actions.some((row) => {
|
|
217
|
+
const projected = row.map((action) => action!.disabled
|
|
218
|
+
? { text: action!.text || "\u00a0", disabled: {} }
|
|
219
|
+
: { text: action!.text, callback_data: "x".repeat(64) });
|
|
220
|
+
return row.length > 8 || renderTelegramRichButtonRow(projected).length > 32768;
|
|
221
|
+
})) return undefined;
|
|
222
|
+
return actions.map((row) => row.map((action) => action!.disabled
|
|
223
|
+
? { text: action!.text || "\u00a0", disabled: {} }
|
|
224
|
+
: {
|
|
225
|
+
text: action!.text,
|
|
226
|
+
callback_data: deps.registerAction({
|
|
227
|
+
...action!,
|
|
228
|
+
...(deps.binding ? { binding: deps.binding } : {}),
|
|
229
|
+
}),
|
|
230
|
+
}));
|
|
231
|
+
};
|
|
232
|
+
const withRichButtons = replaceTelegramButtonFences(markdown, (payload, closed) => {
|
|
233
|
+
if (!closed) return "";
|
|
234
|
+
const payloadRows = parseTelegramButtonPayloadRows(payload);
|
|
235
|
+
if (!payloadRows) return "";
|
|
236
|
+
const rich = deps.rendering !== "html";
|
|
237
|
+
const rows = buildRows(payloadRows, rich);
|
|
238
|
+
if (!rows) return "";
|
|
239
|
+
if (!rich) {
|
|
240
|
+
keyboard.push(...rows);
|
|
241
|
+
return "";
|
|
242
|
+
}
|
|
243
|
+
return `\n${rows.map(renderTelegramRichButtonRow).join("\n\n")}\n`;
|
|
244
|
+
});
|
|
245
|
+
const stripped = replaceTopLevelHtmlComments(withRichButtons, (comment) => {
|
|
183
246
|
const command = "telegram_button";
|
|
184
247
|
const normalizedContent = comment.content.replace(/^\s+/, "").replace(/^!/, "");
|
|
185
248
|
if (!normalizedContent.startsWith(command)) return comment.raw;
|
|
186
249
|
const payloadRows = parseTelegramActionPayloadRows(comment, command);
|
|
187
250
|
if (!payloadRows) return "";
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
);
|
|
191
|
-
if (actionRows.some((row) => row.some((action) => !action))) return "";
|
|
192
|
-
for (const actionRow of actionRows) {
|
|
193
|
-
keyboard.push(actionRow.map((action) => ({
|
|
194
|
-
text: action!.text,
|
|
195
|
-
callback_data: deps.registerAction({
|
|
196
|
-
...action!,
|
|
197
|
-
...(deps.binding ? { binding: deps.binding } : {}),
|
|
198
|
-
}),
|
|
199
|
-
})));
|
|
200
|
-
}
|
|
251
|
+
const rows = buildRows(payloadRows, false);
|
|
252
|
+
if (rows) keyboard.push(...rows);
|
|
201
253
|
return "";
|
|
202
254
|
});
|
|
203
255
|
const visibleMarkdown = normalizeMarkdownAfterButtonExtraction(stripped);
|
|
@@ -254,7 +306,7 @@ export function markTelegramButtonSelected(
|
|
|
254
306
|
let matched = false;
|
|
255
307
|
const inlineKeyboard = replyMarkup.inline_keyboard.map((row) =>
|
|
256
308
|
row.map((button) => {
|
|
257
|
-
if (button.callback_data !== callbackData) return { ...button };
|
|
309
|
+
if (button.disabled || button.callback_data !== callbackData) return { ...button };
|
|
258
310
|
matched = true;
|
|
259
311
|
return { ...button, style: selectedStyle };
|
|
260
312
|
}),
|
|
@@ -277,6 +329,11 @@ export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
|
|
|
277
329
|
return false;
|
|
278
330
|
}
|
|
279
331
|
|
|
332
|
+
if (action.disabled) {
|
|
333
|
+
await deps.answerCallbackQuery(query.id, "Button action unavailable.");
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
|
|
280
337
|
const chatId = query.message?.chat?.id;
|
|
281
338
|
const messageId = query.message?.message_id;
|
|
282
339
|
if (typeof chatId !== "number" || typeof messageId !== "number") {
|
package/lib/outbound-markup.ts
CHANGED
|
@@ -90,6 +90,50 @@ export function collectTopLevelHtmlComments(markdown: string): {
|
|
|
90
90
|
return { comments };
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
export function replaceTelegramButtonFences(
|
|
94
|
+
markdown: string,
|
|
95
|
+
replace: (payload: string, closed: boolean) => string,
|
|
96
|
+
): string {
|
|
97
|
+
let result = "";
|
|
98
|
+
let copied = 0;
|
|
99
|
+
let offset = 0;
|
|
100
|
+
let fence: TelegramTopLevelFenceState | undefined;
|
|
101
|
+
let actionStart: number | undefined;
|
|
102
|
+
let contentStart = 0;
|
|
103
|
+
while (offset < markdown.length) {
|
|
104
|
+
const end = getMarkdownLineEnd(markdown, offset);
|
|
105
|
+
const line = getMarkdownLineText(markdown, offset, end);
|
|
106
|
+
if (fence) {
|
|
107
|
+
if (isTopLevelClosingFence(line, fence)) {
|
|
108
|
+
if (actionStart !== undefined) {
|
|
109
|
+
result += markdown.slice(copied, actionStart);
|
|
110
|
+
result += replace(markdown.slice(contentStart, offset), true) + "\n";
|
|
111
|
+
copied = end;
|
|
112
|
+
actionStart = undefined;
|
|
113
|
+
}
|
|
114
|
+
fence = undefined;
|
|
115
|
+
}
|
|
116
|
+
} else if (line.includes("<!--")) {
|
|
117
|
+
const close = markdown.indexOf("-->", offset + line.indexOf("<!--") + 4);
|
|
118
|
+
if (close < 0) break;
|
|
119
|
+
offset = getMarkdownLineEnd(markdown, close + 3);
|
|
120
|
+
continue;
|
|
121
|
+
} else {
|
|
122
|
+
fence = getTopLevelOpeningFence(line);
|
|
123
|
+
if (fence && /^```telegram_button[ \t]*$/.test(line)) {
|
|
124
|
+
actionStart = offset;
|
|
125
|
+
contentStart = end;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
offset = end;
|
|
129
|
+
}
|
|
130
|
+
if (actionStart !== undefined) {
|
|
131
|
+
result += markdown.slice(copied, actionStart);
|
|
132
|
+
return result + replace(markdown.slice(contentStart), false);
|
|
133
|
+
}
|
|
134
|
+
return result + markdown.slice(copied);
|
|
135
|
+
}
|
|
136
|
+
|
|
93
137
|
export function replaceTopLevelHtmlComments(
|
|
94
138
|
markdown: string,
|
|
95
139
|
replacer: (comment: TelegramTopLevelHtmlComment) => string,
|
|
@@ -275,17 +319,26 @@ type TelegramCompactActionPayloadParser = (
|
|
|
275
319
|
function parseTelegramButtonCompactActionPayload(
|
|
276
320
|
atoms: readonly string[],
|
|
277
321
|
): Record<string, unknown> | undefined {
|
|
278
|
-
const [label, prompt, selectedStyle] = atoms;
|
|
322
|
+
const [label, prompt, selectedStyle, disabled] = atoms;
|
|
279
323
|
if (atoms.length === 1) return label ? { value: label } : undefined;
|
|
280
|
-
|
|
324
|
+
const isDisabled = disabled === "1" || disabled === "true";
|
|
325
|
+
if (!prompt && !(atoms.length === 4 && isDisabled)) return undefined;
|
|
281
326
|
const action = label ? { label, prompt } : { prompt };
|
|
282
327
|
if (atoms.length === 2) return action;
|
|
283
328
|
if (
|
|
284
329
|
selectedStyle !== "primary" &&
|
|
285
330
|
selectedStyle !== "success" &&
|
|
286
|
-
selectedStyle !== "danger"
|
|
331
|
+
selectedStyle !== "danger" &&
|
|
332
|
+
!(atoms.length === 4 && selectedStyle === "")
|
|
287
333
|
) return undefined;
|
|
288
|
-
|
|
334
|
+
if (atoms.length === 4 && !isDisabled && disabled !== "0" && disabled !== "false") {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
...action,
|
|
339
|
+
...(selectedStyle ? { selected_style: selectedStyle } : {}),
|
|
340
|
+
...(atoms.length === 4 ? { disabled: isDisabled } : {}),
|
|
341
|
+
};
|
|
289
342
|
}
|
|
290
343
|
|
|
291
344
|
function parseTelegramVoiceCompactActionPayload(
|
|
@@ -303,7 +356,7 @@ function parseTelegramVoiceCompactActionPayload(
|
|
|
303
356
|
function parseTelegramAdaptiveActionPayloadRows(
|
|
304
357
|
source: string,
|
|
305
358
|
parseCompactPayload: TelegramCompactActionPayloadParser,
|
|
306
|
-
options: { allowTrailing?: boolean } = {},
|
|
359
|
+
options: { allowTrailing?: boolean; maxCompactAtoms?: number } = {},
|
|
307
360
|
): { rows: Record<string, unknown>[][]; end: number } | undefined {
|
|
308
361
|
let offset = 0;
|
|
309
362
|
const isStructuralWhitespace = (character: string | undefined): boolean =>
|
|
@@ -343,7 +396,7 @@ function parseTelegramAdaptiveActionPayloadRows(
|
|
|
343
396
|
continue;
|
|
344
397
|
}
|
|
345
398
|
if (character === "|") {
|
|
346
|
-
if (atomSources.length >= 3) return undefined;
|
|
399
|
+
if (atomSources.length >= (options.maxCompactAtoms ?? 3)) return undefined;
|
|
347
400
|
atomSources.push([]);
|
|
348
401
|
offset += 1;
|
|
349
402
|
continue;
|
|
@@ -509,6 +562,16 @@ function isPlausibleTelegramMatrixStart(
|
|
|
509
562
|
);
|
|
510
563
|
}
|
|
511
564
|
|
|
565
|
+
export function parseTelegramButtonPayloadRows(
|
|
566
|
+
source: string,
|
|
567
|
+
): Record<string, unknown>[][] | undefined {
|
|
568
|
+
return parseTelegramAdaptiveActionPayloadRows(
|
|
569
|
+
source.trim(),
|
|
570
|
+
parseTelegramButtonCompactActionPayload,
|
|
571
|
+
{ maxCompactAtoms: 4 },
|
|
572
|
+
)?.rows;
|
|
573
|
+
}
|
|
574
|
+
|
|
512
575
|
export function parseTelegramActionPayloadRows(
|
|
513
576
|
comment: TelegramTopLevelHtmlComment,
|
|
514
577
|
command: string,
|
|
@@ -533,7 +596,7 @@ export function parseTelegramActionPayloadRows(
|
|
|
533
596
|
const parsed = parseTelegramAdaptiveActionPayloadRows(
|
|
534
597
|
content.slice(offset),
|
|
535
598
|
parseTelegramButtonCompactActionPayload,
|
|
536
|
-
{ allowTrailing: true },
|
|
599
|
+
{ allowTrailing: true, maxCompactAtoms: 4 },
|
|
537
600
|
);
|
|
538
601
|
if (parsed) return parsed.rows;
|
|
539
602
|
const end = findTelegramStructuredPayloadEnd(content, offset);
|
|
@@ -546,8 +609,15 @@ export function parseTelegramActionPayloadRows(
|
|
|
546
609
|
"prompt",
|
|
547
610
|
"value",
|
|
548
611
|
"selected_style",
|
|
612
|
+
"disabled",
|
|
549
613
|
]);
|
|
550
|
-
|
|
614
|
+
if (!attributes) return undefined;
|
|
615
|
+
return [[{
|
|
616
|
+
...attributes,
|
|
617
|
+
...(attributes.disabled === "true" || attributes.disabled === "false"
|
|
618
|
+
? { disabled: attributes.disabled === "true" }
|
|
619
|
+
: {}),
|
|
620
|
+
}]];
|
|
551
621
|
}
|
|
552
622
|
|
|
553
623
|
export function normalizeMarkdownAfterVoiceExtraction(
|
|
@@ -589,7 +659,9 @@ function stripTelegramHtmlCommentBlocks(markdown: string): string {
|
|
|
589
659
|
}
|
|
590
660
|
|
|
591
661
|
export function stripTelegramCommentMarkupForPreview(markdown: string): string {
|
|
592
|
-
const withoutClosedBlocks = stripTelegramHtmlCommentBlocks(
|
|
662
|
+
const withoutClosedBlocks = stripTelegramHtmlCommentBlocks(
|
|
663
|
+
replaceTelegramButtonFences(markdown, () => ""),
|
|
664
|
+
);
|
|
593
665
|
const openBlockIndex =
|
|
594
666
|
findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
|
|
595
667
|
const previewMarkdown =
|
package/lib/outbound.ts
CHANGED
|
@@ -835,6 +835,7 @@ export {
|
|
|
835
835
|
|
|
836
836
|
export function createTelegramOutboundReplyPlanner(
|
|
837
837
|
store: Pick<TelegramButtonActionStore, "register">,
|
|
838
|
+
getRenderingMode: () => "rich" | "html" = () => "rich",
|
|
838
839
|
): (
|
|
839
840
|
markdown: string,
|
|
840
841
|
options?: { binding?: TelegramOutboundButtonBinding },
|
|
@@ -842,6 +843,7 @@ export function createTelegramOutboundReplyPlanner(
|
|
|
842
843
|
return (markdown, options) => {
|
|
843
844
|
const buttonReply = planTelegramButtonReply(markdown, {
|
|
844
845
|
registerAction: store.register,
|
|
846
|
+
rendering: getRenderingMode(),
|
|
845
847
|
...(options?.binding ? { binding: options.binding } : {}),
|
|
846
848
|
});
|
|
847
849
|
|
package/lib/replies.ts
CHANGED
|
@@ -532,7 +532,7 @@ function splitTelegramNativeMarkdownCountedBlocks(block: string): string[] {
|
|
|
532
532
|
function countTelegramNativeMarkdownBlocks(block: string): number {
|
|
533
533
|
if (/^ {0,3}(`{3,}|~{3,})/.test(block)) return 1;
|
|
534
534
|
const lines = block.split("\n").filter((line) => line.trim().length > 0);
|
|
535
|
-
if (lines.some((line) => /^\s*([-*+] |\d+\.
|
|
535
|
+
if (lines.some((line) => /^\s*([-*+] |\d+\. |>|\||<tg-button-row>)/.test(line))) {
|
|
536
536
|
return Math.max(1, lines.length);
|
|
537
537
|
}
|
|
538
538
|
return 1;
|
package/lib/routing.ts
CHANGED
|
@@ -281,19 +281,17 @@ function formatTelegramAllTabMenuChooserText(command: string): string {
|
|
|
281
281
|
"",
|
|
282
282
|
`You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.`,
|
|
283
283
|
"Select the Pi thread that should handle it:",
|
|
284
|
+
"To restore into a new thread, send a plain message in that destination thread first.",
|
|
284
285
|
].join("\n");
|
|
285
286
|
}
|
|
286
287
|
|
|
287
288
|
function buildTelegramUnboundRerouteChooserMarkup(
|
|
288
289
|
rerouteId: string,
|
|
289
290
|
records: readonly Threads.TelegramTopicTargetRecord[],
|
|
290
|
-
|
|
291
|
-
currentLeaderProfileKey?: string;
|
|
292
|
-
currentInstanceId?: string;
|
|
293
|
-
} = {},
|
|
291
|
+
options: { canRestore: boolean },
|
|
294
292
|
): Menu.TelegramReplyMarkup {
|
|
295
293
|
const activeRecords = records.filter((record) => record.status === "active");
|
|
296
|
-
const canRestoreAnyLiveThread = activeRecords.length > 0;
|
|
294
|
+
const canRestoreAnyLiveThread = options.canRestore && activeRecords.length > 0;
|
|
297
295
|
const rows = activeRecords.map((record) => [
|
|
298
296
|
{
|
|
299
297
|
text: getTelegramRouteThreadButtonLabel(record),
|
|
@@ -433,6 +431,7 @@ async function deleteReservedTelegramTopicThroughReconciler(
|
|
|
433
431
|
});
|
|
434
432
|
deps.recordThreadReconciliationPlan?.(plan);
|
|
435
433
|
await ThreadReconciler.applyThreadReconciliationPlan(plan, {
|
|
434
|
+
isCleanupTargetProtected: Threads.createTelegramCleanupTargetProtection(deps.threadStore),
|
|
436
435
|
callApi: deps.callApi,
|
|
437
436
|
markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
|
|
438
437
|
deps.threadStore?.markStaleByTarget(
|
|
@@ -734,6 +733,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
734
733
|
instanceId?: string;
|
|
735
734
|
};
|
|
736
735
|
type PendingUnboundReroute = {
|
|
736
|
+
sourceTarget: Queue.TelegramQueueTarget;
|
|
737
|
+
chooserMessageId?: number;
|
|
737
738
|
messages: TMessage[];
|
|
738
739
|
createdAtMs: number;
|
|
739
740
|
dispatchKind: "prompt" | "command";
|
|
@@ -842,12 +843,33 @@ export function createTelegramInboundRouteRuntime<
|
|
|
842
843
|
nextUnboundRerouteId += 1;
|
|
843
844
|
const id = nextUnboundRerouteId.toString(36);
|
|
844
845
|
pendingUnboundReroutes.set(id, {
|
|
846
|
+
sourceTarget: {
|
|
847
|
+
chatId: messages[0]!.chat.id,
|
|
848
|
+
...(typeof messages[0]!.message_thread_id === "number"
|
|
849
|
+
? { threadId: messages[0]!.message_thread_id }
|
|
850
|
+
: {}),
|
|
851
|
+
},
|
|
845
852
|
messages,
|
|
846
853
|
createdAtMs: Date.now(),
|
|
847
854
|
dispatchKind,
|
|
848
855
|
});
|
|
849
856
|
return id;
|
|
850
857
|
};
|
|
858
|
+
const rememberRerouteChooser = (id: string, messageId: number | undefined): void => {
|
|
859
|
+
const pending = pendingUnboundReroutes.get(id);
|
|
860
|
+
if (pending) pending.chooserMessageId = messageId;
|
|
861
|
+
};
|
|
862
|
+
const matchesRerouteChooser = (
|
|
863
|
+
pending: PendingUnboundReroute,
|
|
864
|
+
query: TCallbackQuery,
|
|
865
|
+
): boolean => {
|
|
866
|
+
const message = query.message;
|
|
867
|
+
return !!message && pending.chooserMessageId !== undefined &&
|
|
868
|
+
message.message_id === pending.chooserMessageId &&
|
|
869
|
+
message.chat.id === pending.sourceTarget.chatId &&
|
|
870
|
+
(message.message_thread_id === undefined ||
|
|
871
|
+
message.message_thread_id === pending.sourceTarget.threadId);
|
|
872
|
+
};
|
|
851
873
|
const pendingUnboundRerouteMediaGroups = new Map<
|
|
852
874
|
string,
|
|
853
875
|
{
|
|
@@ -951,6 +973,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
951
973
|
assertExecutionCurrent?.();
|
|
952
974
|
deps.recordThreadReconciliationPlan?.(plan);
|
|
953
975
|
const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
|
|
976
|
+
isCleanupTargetProtected(target) {
|
|
977
|
+
assertExecutionCurrent?.();
|
|
978
|
+
return isRerouteTargetProtected(target);
|
|
979
|
+
},
|
|
954
980
|
callApi: deps.callApi,
|
|
955
981
|
markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
|
|
956
982
|
deps.threadStore?.markStaleByTarget(
|
|
@@ -967,6 +993,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
967
993
|
assertExecutionCurrent?.();
|
|
968
994
|
return (result.incompleteActions?.length ?? 0) === 0;
|
|
969
995
|
};
|
|
996
|
+
const isRerouteTargetProtected = (target: Queue.TelegramQueueTarget): boolean => {
|
|
997
|
+
const matches = (candidate: Queue.TelegramQueueTarget): boolean =>
|
|
998
|
+
candidate.chatId === target.chatId && candidate.threadId === target.threadId;
|
|
999
|
+
return (deps.getLiveThreadTargets?.() ?? []).some(matches) ||
|
|
1000
|
+
(deps.threadStore?.list() ?? []).some((record) => matches(record.target)) ||
|
|
1001
|
+
(deps.threadStore?.listReservations() ?? []).some((record) => matches(record.target)) ||
|
|
1002
|
+
(deps.threadStore?.listPendingProvisions() ?? []).some((record) => !!record.target && matches(record.target));
|
|
1003
|
+
};
|
|
970
1004
|
const dismissRerouteChooserMessage = async (
|
|
971
1005
|
query: TCallbackQuery,
|
|
972
1006
|
assertExecutionCurrent?: () => void,
|
|
@@ -986,6 +1020,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
986
1020
|
assertExecutionCurrent?.();
|
|
987
1021
|
return true;
|
|
988
1022
|
} catch (error) {
|
|
1023
|
+
assertExecutionCurrent?.();
|
|
989
1024
|
deps.recordRuntimeEvent?.("telegram", error, {
|
|
990
1025
|
phase: "reroute-chooser-delete",
|
|
991
1026
|
chatId,
|
|
@@ -1103,12 +1138,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1103
1138
|
pending: PendingUnboundReroute,
|
|
1104
1139
|
query: TCallbackQuery,
|
|
1105
1140
|
successMessage: string,
|
|
1106
|
-
assertExecutionCurrent
|
|
1141
|
+
assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query),
|
|
1107
1142
|
): Promise<void> => {
|
|
1143
|
+
assertExecutionCurrent();
|
|
1108
1144
|
const dismissed = await dismissRerouteChooserMessage(
|
|
1109
1145
|
query,
|
|
1110
1146
|
assertExecutionCurrent,
|
|
1111
1147
|
);
|
|
1148
|
+
assertExecutionCurrent();
|
|
1112
1149
|
if (dismissed) {
|
|
1113
1150
|
pendingUnboundReroutes.delete(rerouteId);
|
|
1114
1151
|
await deps.answerCallbackQuery(query.id, successMessage);
|
|
@@ -1169,6 +1206,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1169
1206
|
query.data,
|
|
1170
1207
|
);
|
|
1171
1208
|
if (!parsed) return false;
|
|
1209
|
+
const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query);
|
|
1210
|
+
assertExecutionCurrent();
|
|
1172
1211
|
const chatId = query.message?.chat?.id;
|
|
1173
1212
|
const messageId = query.message?.message_id;
|
|
1174
1213
|
const pending = pendingUnboundReroutes.get(parsed.rerouteId);
|
|
@@ -1176,12 +1215,18 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1176
1215
|
typeof chatId !== "number" ||
|
|
1177
1216
|
typeof messageId !== "number" ||
|
|
1178
1217
|
!deps.threadStore ||
|
|
1179
|
-
!pending
|
|
1218
|
+
!pending ||
|
|
1219
|
+
!matchesRerouteChooser(pending, query)
|
|
1180
1220
|
) {
|
|
1181
1221
|
await deps.answerCallbackQuery(query.id, "Message route expired.");
|
|
1182
1222
|
return true;
|
|
1183
1223
|
}
|
|
1224
|
+
if (pending.sourceTarget.threadId === undefined) {
|
|
1225
|
+
await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
|
|
1226
|
+
return true;
|
|
1227
|
+
}
|
|
1184
1228
|
await deps.threadStore.load();
|
|
1229
|
+
assertExecutionCurrent();
|
|
1185
1230
|
const activeRecords = getTelegramRoutableThreadRecords(
|
|
1186
1231
|
deps.threadStore.list(),
|
|
1187
1232
|
deps.getLiveThreadTargets?.(),
|
|
@@ -1199,19 +1244,17 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1199
1244
|
replyMarkup,
|
|
1200
1245
|
);
|
|
1201
1246
|
} else if (deps.sendInteractiveMessage) {
|
|
1202
|
-
await deps.sendInteractiveMessage(
|
|
1247
|
+
const chooserId = await deps.sendInteractiveMessage(
|
|
1203
1248
|
chatId,
|
|
1204
1249
|
formatTelegramUnboundRerouteRestoreChooserText(),
|
|
1205
1250
|
"html",
|
|
1206
1251
|
replyMarkup,
|
|
1207
|
-
|
|
1208
|
-
? {
|
|
1209
|
-
target: { chatId, threadId: query.message.message_thread_id },
|
|
1210
|
-
replyToMessageId: messageId,
|
|
1211
|
-
}
|
|
1212
|
-
: undefined,
|
|
1252
|
+
{ target: pending.sourceTarget, replyToMessageId: messageId },
|
|
1213
1253
|
);
|
|
1254
|
+
assertExecutionCurrent();
|
|
1255
|
+
rememberRerouteChooser(parsed.rerouteId, chooserId);
|
|
1214
1256
|
}
|
|
1257
|
+
assertExecutionCurrent();
|
|
1215
1258
|
await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
|
|
1216
1259
|
return true;
|
|
1217
1260
|
};
|
|
@@ -1226,7 +1269,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1226
1269
|
assertExecutionCurrent();
|
|
1227
1270
|
const chatId = query.message?.chat?.id;
|
|
1228
1271
|
const pending = pendingUnboundReroutes.get(parsed.rerouteId);
|
|
1229
|
-
if (typeof chatId !== "number" || !deps.threadStore || !pending
|
|
1272
|
+
if (typeof chatId !== "number" || !deps.threadStore || !pending ||
|
|
1273
|
+
!matchesRerouteChooser(pending, query)) {
|
|
1230
1274
|
await deps.answerCallbackQuery(query.id, "Message route expired.");
|
|
1231
1275
|
return true;
|
|
1232
1276
|
}
|
|
@@ -1300,10 +1344,23 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1300
1344
|
parsed.threadId,
|
|
1301
1345
|
);
|
|
1302
1346
|
const sourceTarget =
|
|
1303
|
-
typeof
|
|
1304
|
-
? { chatId, threadId:
|
|
1347
|
+
typeof pending.sourceTarget.threadId === "number"
|
|
1348
|
+
? { chatId, threadId: pending.sourceTarget.threadId }
|
|
1305
1349
|
: undefined;
|
|
1306
|
-
const sourceMessageId =
|
|
1350
|
+
const sourceMessageId = pending.chooserMessageId;
|
|
1351
|
+
if (parsed.useNewSlot && !sourceTarget) {
|
|
1352
|
+
await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
|
|
1353
|
+
return true;
|
|
1354
|
+
}
|
|
1355
|
+
if (parsed.useNewSlot && sourceTarget && record.target.chatId === sourceTarget.chatId &&
|
|
1356
|
+
record.target.threadId === sourceTarget.threadId) {
|
|
1357
|
+
await deps.answerCallbackQuery(query.id, "🚫 Selected thread is already the destination.");
|
|
1358
|
+
return true;
|
|
1359
|
+
}
|
|
1360
|
+
if (parsed.useNewSlot && sourceTarget && isRerouteTargetProtected(sourceTarget)) {
|
|
1361
|
+
await deps.answerCallbackQuery(query.id, "🚫 Thread restore source is already owned.");
|
|
1362
|
+
return true;
|
|
1363
|
+
}
|
|
1307
1364
|
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
1308
1365
|
const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
|
|
1309
1366
|
const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(
|
|
@@ -1507,6 +1564,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1507
1564
|
rerouteConfirmedAtMs: nowMs,
|
|
1508
1565
|
});
|
|
1509
1566
|
await deps.threadStore.persist();
|
|
1567
|
+
assertExecutionCurrent();
|
|
1510
1568
|
deps.setCurrentLeaderIdentity?.({
|
|
1511
1569
|
target: sourceTarget,
|
|
1512
1570
|
slot,
|
|
@@ -1944,7 +2002,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1944
2002
|
};
|
|
1945
2003
|
const sendUnboundRerouteChooserNow = async (
|
|
1946
2004
|
messages: TMessage[],
|
|
1947
|
-
|
|
2005
|
+
_ctx: TContext,
|
|
1948
2006
|
reportDeferred = true,
|
|
1949
2007
|
): Promise<void> => {
|
|
1950
2008
|
const message = messages[0];
|
|
@@ -1988,20 +2046,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1988
2046
|
const text = formatTelegramUnboundRerouteChooserText(activeRecords, {
|
|
1989
2047
|
includeGuidance,
|
|
1990
2048
|
});
|
|
1991
|
-
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
1992
2049
|
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
|
|
1993
2050
|
rerouteId,
|
|
1994
2051
|
activeRecords,
|
|
1995
|
-
{
|
|
1996
|
-
currentLeaderProfileKey: getLeaderTopicProfileKey(
|
|
1997
|
-
ctx,
|
|
1998
|
-
currentInstanceId,
|
|
1999
|
-
),
|
|
2000
|
-
currentInstanceId,
|
|
2001
|
-
},
|
|
2052
|
+
{ canRestore: sourceTarget !== undefined },
|
|
2002
2053
|
);
|
|
2003
2054
|
if (deps.sendInteractiveMessage) {
|
|
2004
|
-
await deps.sendInteractiveMessage(
|
|
2055
|
+
const chooserId = await deps.sendInteractiveMessage(
|
|
2005
2056
|
message.chat.id,
|
|
2006
2057
|
text,
|
|
2007
2058
|
"html",
|
|
@@ -2010,12 +2061,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2010
2061
|
? { target: sourceTarget, replyToMessageId: message.message_id }
|
|
2011
2062
|
: { replyToMessageId: message.message_id },
|
|
2012
2063
|
);
|
|
2064
|
+
rememberRerouteChooser(rerouteId, chooserId);
|
|
2013
2065
|
return;
|
|
2014
2066
|
}
|
|
2015
|
-
await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
2067
|
+
const chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
2016
2068
|
parseMode: "HTML",
|
|
2017
2069
|
target: sourceTarget,
|
|
2018
2070
|
});
|
|
2071
|
+
rememberRerouteChooser(rerouteId, chooserId);
|
|
2019
2072
|
};
|
|
2020
2073
|
const sendUnboundRerouteChooser = async (
|
|
2021
2074
|
message: TMessage,
|
|
@@ -2080,9 +2133,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2080
2133
|
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
|
|
2081
2134
|
rerouteId,
|
|
2082
2135
|
activeRecords,
|
|
2136
|
+
{ canRestore: typeof message.message_thread_id === "number" },
|
|
2083
2137
|
);
|
|
2084
2138
|
if (deps.sendInteractiveMessage) {
|
|
2085
|
-
await deps.sendInteractiveMessage(
|
|
2139
|
+
const chooserId = await deps.sendInteractiveMessage(
|
|
2086
2140
|
message.chat.id,
|
|
2087
2141
|
text,
|
|
2088
2142
|
"html",
|
|
@@ -2096,10 +2150,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2096
2150
|
}
|
|
2097
2151
|
: undefined,
|
|
2098
2152
|
);
|
|
2153
|
+
rememberRerouteChooser(rerouteId, chooserId);
|
|
2099
2154
|
return true;
|
|
2100
2155
|
}
|
|
2101
2156
|
if (deps.callApi) {
|
|
2102
|
-
await deps.callApi("sendMessage", {
|
|
2157
|
+
const chooser = await deps.callApi<{ message_id?: number }>("sendMessage", {
|
|
2103
2158
|
chat_id: message.chat.id,
|
|
2104
2159
|
text,
|
|
2105
2160
|
parse_mode: "HTML",
|
|
@@ -2116,12 +2171,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2116
2171
|
}
|
|
2117
2172
|
: {}),
|
|
2118
2173
|
});
|
|
2174
|
+
rememberRerouteChooser(rerouteId, chooser?.message_id);
|
|
2119
2175
|
return true;
|
|
2120
2176
|
}
|
|
2121
|
-
await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
2177
|
+
const chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
2122
2178
|
parseMode: "HTML",
|
|
2123
2179
|
target: options.target,
|
|
2124
2180
|
});
|
|
2181
|
+
rememberRerouteChooser(rerouteId, chooserId);
|
|
2125
2182
|
return true;
|
|
2126
2183
|
};
|
|
2127
2184
|
const commandOrPrompt = Commands.createTelegramCommandOrPromptRuntime<
|