@llblab/pi-telegram 0.17.5 → 0.18.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/AGENTS.md +67 -32
- package/BACKLOG.md +59 -19
- package/CHANGELOG.md +36 -15
- package/README.md +63 -35
- package/docs/README.md +3 -1
- package/docs/architecture.md +55 -23
- package/docs/callback-namespaces.md +1 -1
- package/docs/inbound.md +1 -1
- package/docs/locks.md +0 -2
- package/docs/multi-instance-bus.md +483 -0
- package/docs/outbound.md +4 -3
- package/docs/public-api.md +12 -10
- package/docs/sections.md +2 -2
- package/docs/ui-style.md +76 -0
- package/index.ts +789 -32
- package/lib/bindings.ts +68 -12
- package/lib/bus-api.ts +314 -0
- package/lib/bus-follower.ts +853 -0
- package/lib/bus-leader.ts +915 -0
- package/lib/bus.ts +866 -0
- package/lib/command-templates.ts +9 -11
- package/lib/commands.ts +133 -47
- package/lib/config.ts +53 -5
- package/lib/lifecycle.ts +23 -7
- package/lib/locks.ts +230 -66
- package/lib/media.ts +30 -2
- package/lib/menu-model.ts +48 -17
- package/lib/menu-queue.ts +51 -20
- package/lib/menu-settings.ts +9 -5
- package/lib/menu-status.ts +3 -0
- package/lib/menu-thinking.ts +3 -0
- package/lib/menu.ts +67 -26
- package/lib/outbound-attachments.ts +102 -17
- package/lib/outbound-buttons.ts +6 -2
- package/lib/outbound-voice.ts +31 -11
- package/lib/outbound.ts +6 -4
- package/lib/ownership.ts +119 -0
- package/lib/pi.ts +26 -3
- package/lib/polling.ts +477 -7
- package/lib/preview.ts +141 -88
- package/lib/prompt-templates.ts +3 -3
- package/lib/prompts.ts +80 -30
- package/lib/queue.ts +193 -91
- package/lib/rendering.ts +0 -25
- package/lib/replies.ts +187 -55
- package/lib/routing.ts +1673 -9
- package/lib/runtime-log.ts +123 -0
- package/lib/runtime.ts +84 -12
- package/lib/sections.ts +28 -21
- package/lib/setup.ts +1 -1
- package/lib/status.ts +532 -9
- package/lib/sync.ts +618 -0
- package/lib/target.ts +49 -0
- package/lib/telegram-api.ts +405 -40
- package/lib/text-groups.ts +5 -1
- package/lib/thread-reconciler.ts +915 -0
- package/lib/threads.ts +2205 -0
- package/lib/turns.ts +48 -3
- package/lib/updates.ts +355 -32
- package/package.json +24 -2
- package/docs/telegram-bot-api-rich-messages.md +0 -890
package/lib/routing.ts
CHANGED
|
@@ -17,11 +17,377 @@ import * as PromptTemplates from "./prompt-templates.ts";
|
|
|
17
17
|
import * as Queue from "./queue.ts";
|
|
18
18
|
import type { TelegramBridgeRuntime } from "./runtime.ts";
|
|
19
19
|
import * as TextGroups from "./text-groups.ts";
|
|
20
|
+
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
20
21
|
import * as Turns from "./turns.ts";
|
|
22
|
+
|
|
23
|
+
function getContextCwd(ctx: unknown): string | undefined {
|
|
24
|
+
if (!ctx || typeof ctx !== "object") return undefined;
|
|
25
|
+
const cwd = (ctx as { cwd?: unknown }).cwd;
|
|
26
|
+
return typeof cwd === "string" && cwd.length > 0 ? cwd : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getLeaderTopicProfileKey(
|
|
30
|
+
ctx: unknown,
|
|
31
|
+
instanceId: string | undefined,
|
|
32
|
+
): string | undefined {
|
|
33
|
+
const cwd = getContextCwd(ctx);
|
|
34
|
+
if (cwd) return `cwd:${cwd}`;
|
|
35
|
+
return instanceId ? `leader:${instanceId}` : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isCurrentLeaderTopicRecord(
|
|
39
|
+
record: Threads.TelegramTopicTargetRecord,
|
|
40
|
+
profileKey: string | undefined,
|
|
41
|
+
instanceId: string | undefined,
|
|
42
|
+
): boolean {
|
|
43
|
+
if (instanceId && record.instanceId === instanceId) return true;
|
|
44
|
+
return !!profileKey && record.profileKey === profileKey;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hasActiveLeaderTopic(
|
|
48
|
+
records: Threads.TelegramTopicTargetRecord[],
|
|
49
|
+
profileKey: string | undefined,
|
|
50
|
+
instanceId: string | undefined,
|
|
51
|
+
): boolean {
|
|
52
|
+
return records.some((record) => {
|
|
53
|
+
if (record.status !== "active") return false;
|
|
54
|
+
return isCurrentLeaderTopicRecord(record, profileKey, instanceId);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function escapeHtml(text: string): string {
|
|
59
|
+
return text
|
|
60
|
+
.replace(/&/g, "&")
|
|
61
|
+
.replace(/</g, "<")
|
|
62
|
+
.replace(/>/g, ">");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface TelegramAllTabPendingCommand {
|
|
66
|
+
command: Commands.ParsedTelegramCommand;
|
|
67
|
+
text: string;
|
|
68
|
+
createdAtMs: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const TELEGRAM_ALL_TAB_MENU_CALLBACK_PREFIX = "allmenu:";
|
|
72
|
+
const TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX = "reroute:";
|
|
73
|
+
const TELEGRAM_UNBOUND_REROUTE_RESTORE_MENU_CALLBACK_PREFIX = "rerouterestore:";
|
|
74
|
+
const TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX = "reroutenew:";
|
|
75
|
+
|
|
76
|
+
function formatTelegramAllTabMenuCallbackData(
|
|
77
|
+
commandId: string,
|
|
78
|
+
threadId: number,
|
|
79
|
+
): string {
|
|
80
|
+
return `${TELEGRAM_ALL_TAB_MENU_CALLBACK_PREFIX}${commandId}:${threadId}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseTelegramAllTabMenuCallbackData(
|
|
84
|
+
data: string | undefined,
|
|
85
|
+
): { commandId: string; threadId: number } | undefined {
|
|
86
|
+
const match = data?.match(/^allmenu:([a-z0-9]+):(\d+)$/);
|
|
87
|
+
const commandId = match?.[1];
|
|
88
|
+
const threadId = Number(match?.[2]);
|
|
89
|
+
if (!commandId || !Number.isSafeInteger(threadId)) return undefined;
|
|
90
|
+
return { commandId, threadId };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatTelegramUnboundRerouteCallbackData(
|
|
94
|
+
rerouteId: string,
|
|
95
|
+
threadId: number,
|
|
96
|
+
): string {
|
|
97
|
+
return `${TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX}${rerouteId}:${threadId}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatTelegramUnboundRerouteRestoreMenuCallbackData(
|
|
101
|
+
rerouteId: string,
|
|
102
|
+
): string {
|
|
103
|
+
return `${TELEGRAM_UNBOUND_REROUTE_RESTORE_MENU_CALLBACK_PREFIX}${rerouteId}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function formatTelegramUnboundRerouteNewSlotCallbackData(
|
|
107
|
+
rerouteId: string,
|
|
108
|
+
threadId: number,
|
|
109
|
+
): string {
|
|
110
|
+
return `${TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX}${rerouteId}:${threadId}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseTelegramUnboundRerouteRestoreMenuCallbackData(
|
|
114
|
+
data: string | undefined,
|
|
115
|
+
): { rerouteId: string } | undefined {
|
|
116
|
+
const match = data?.match(/^rerouterestore:([a-z0-9]+)$/);
|
|
117
|
+
const rerouteId = match?.[1];
|
|
118
|
+
return rerouteId ? { rerouteId } : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseTelegramUnboundRerouteCallbackData(
|
|
122
|
+
data: string | undefined,
|
|
123
|
+
): { rerouteId: string; threadId: number; useNewSlot: boolean } | undefined {
|
|
124
|
+
const match = data?.match(/^(reroute|reroutenew):([a-z0-9]+):(\d+)$/);
|
|
125
|
+
const prefix = match?.[1];
|
|
126
|
+
const rerouteId = match?.[2];
|
|
127
|
+
const threadId = Number(match?.[3]);
|
|
128
|
+
if (!prefix || !rerouteId || !Number.isSafeInteger(threadId))
|
|
129
|
+
return undefined;
|
|
130
|
+
return { rerouteId, threadId, useNewSlot: prefix === "reroutenew" };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getTelegramThreadRecordLabel(
|
|
134
|
+
record: Threads.TelegramTopicTargetRecord,
|
|
135
|
+
): string {
|
|
136
|
+
return getRestoredThreadName(record, record.slot ?? "");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getTelegramRouteThreadButtonLabel(
|
|
140
|
+
record: Threads.TelegramTopicTargetRecord,
|
|
141
|
+
): string {
|
|
142
|
+
return `↪️ ${getTelegramThreadRecordLabel(record)}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getTelegramReplaceThreadButtonLabel(
|
|
146
|
+
record: Threads.TelegramTopicTargetRecord,
|
|
147
|
+
): string {
|
|
148
|
+
return `➡️ ${getTelegramThreadRecordLabel(record)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getNextTelegramSlotPreference(
|
|
152
|
+
slot: string | undefined,
|
|
153
|
+
): string | undefined {
|
|
154
|
+
if (!slot || !/^[A-Z]$/.test(slot)) return undefined;
|
|
155
|
+
const index = slot.charCodeAt(0) - "A".charCodeAt(0);
|
|
156
|
+
return String.fromCharCode("A".charCodeAt(0) + ((index + 1) % 26));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getRestoredThreadName(
|
|
160
|
+
record: Threads.TelegramTopicTargetRecord,
|
|
161
|
+
slot: string,
|
|
162
|
+
): string {
|
|
163
|
+
return record.threadName &&
|
|
164
|
+
Threads.isTelegramTopicThreadNameValidForSlot(record.threadName, slot)
|
|
165
|
+
? record.threadName
|
|
166
|
+
: (Threads.chooseTelegramThreadName({ slot }) ?? "Pi");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isTelegramLiveThreadTarget(
|
|
170
|
+
record: Threads.TelegramTopicTargetRecord,
|
|
171
|
+
liveTargets: readonly Queue.TelegramQueueTarget[] | undefined,
|
|
172
|
+
): boolean {
|
|
173
|
+
if (!liveTargets) return record.status === "active";
|
|
174
|
+
return liveTargets.some(
|
|
175
|
+
(target) =>
|
|
176
|
+
target.chatId === record.target.chatId &&
|
|
177
|
+
target.threadId === record.target.threadId,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function getTelegramRoutableThreadRecords(
|
|
182
|
+
records: readonly Threads.TelegramTopicTargetRecord[],
|
|
183
|
+
liveTargets: readonly Queue.TelegramQueueTarget[] | undefined,
|
|
184
|
+
): Threads.TelegramTopicTargetRecord[] {
|
|
185
|
+
return records.filter(
|
|
186
|
+
(record) =>
|
|
187
|
+
record.status === "active" &&
|
|
188
|
+
isTelegramLiveThreadTarget(record, liveTargets),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildTelegramAllTabMenuChooserMarkup(
|
|
193
|
+
commandId: string,
|
|
194
|
+
records: readonly Threads.TelegramTopicTargetRecord[],
|
|
195
|
+
): Menu.TelegramReplyMarkup {
|
|
196
|
+
const rows = records.map((record) => [
|
|
197
|
+
{
|
|
198
|
+
text: getTelegramRouteThreadButtonLabel(record),
|
|
199
|
+
callback_data: formatTelegramAllTabMenuCallbackData(
|
|
200
|
+
commandId,
|
|
201
|
+
record.target.threadId,
|
|
202
|
+
),
|
|
203
|
+
},
|
|
204
|
+
]);
|
|
205
|
+
return { inline_keyboard: rows };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function formatTelegramAllTabMenuChooserText(command: string): string {
|
|
209
|
+
return [
|
|
210
|
+
"<b>🧵 Choose target thread</b>",
|
|
211
|
+
"",
|
|
212
|
+
`You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.`,
|
|
213
|
+
"Select the Pi thread that should handle it:",
|
|
214
|
+
].join("\n");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildTelegramUnboundRerouteChooserMarkup(
|
|
218
|
+
rerouteId: string,
|
|
219
|
+
records: readonly Threads.TelegramTopicTargetRecord[],
|
|
220
|
+
options: {
|
|
221
|
+
currentLeaderProfileKey?: string;
|
|
222
|
+
currentInstanceId?: string;
|
|
223
|
+
} = {},
|
|
224
|
+
): Menu.TelegramReplyMarkup {
|
|
225
|
+
const activeRecords = records.filter((record) => record.status === "active");
|
|
226
|
+
const canRestoreCurrentLeader = activeRecords.some(
|
|
227
|
+
(record) =>
|
|
228
|
+
typeof record.rerouteConfirmedAtMs === "number" &&
|
|
229
|
+
isCurrentLeaderTopicRecord(
|
|
230
|
+
record,
|
|
231
|
+
options.currentLeaderProfileKey,
|
|
232
|
+
options.currentInstanceId,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
const rows = activeRecords.map((record) => [
|
|
236
|
+
{
|
|
237
|
+
text: getTelegramRouteThreadButtonLabel(record),
|
|
238
|
+
callback_data: formatTelegramUnboundRerouteCallbackData(
|
|
239
|
+
rerouteId,
|
|
240
|
+
record.target.threadId,
|
|
241
|
+
),
|
|
242
|
+
},
|
|
243
|
+
]);
|
|
244
|
+
return {
|
|
245
|
+
inline_keyboard: canRestoreCurrentLeader
|
|
246
|
+
? [
|
|
247
|
+
...rows,
|
|
248
|
+
[
|
|
249
|
+
{
|
|
250
|
+
text: "🔁 Replace/restore thread…",
|
|
251
|
+
callback_data:
|
|
252
|
+
formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId),
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
]
|
|
256
|
+
: rows,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function buildTelegramUnboundRerouteRestoreChooserMarkup(
|
|
261
|
+
rerouteId: string,
|
|
262
|
+
records: readonly Threads.TelegramTopicTargetRecord[],
|
|
263
|
+
): Menu.TelegramReplyMarkup {
|
|
264
|
+
return {
|
|
265
|
+
inline_keyboard: records
|
|
266
|
+
.filter((record) => record.status === "active")
|
|
267
|
+
.map((record) => [
|
|
268
|
+
{
|
|
269
|
+
text: getTelegramReplaceThreadButtonLabel(record),
|
|
270
|
+
callback_data: formatTelegramUnboundRerouteNewSlotCallbackData(
|
|
271
|
+
rerouteId,
|
|
272
|
+
record.target.threadId,
|
|
273
|
+
),
|
|
274
|
+
},
|
|
275
|
+
]),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function formatTelegramUnboundRerouteRestoreChooserText(): string {
|
|
280
|
+
return [
|
|
281
|
+
"<b>🧵 Replace/restore Telegram thread</b>",
|
|
282
|
+
"",
|
|
283
|
+
"Choose the Pi instance to move to this new Telegram thread:",
|
|
284
|
+
].join("\n");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function formatTelegramUnboundTopicGuidance(): string {
|
|
288
|
+
return [
|
|
289
|
+
"⚠️ <b>New thread is not a Pi instance</b>",
|
|
290
|
+
"",
|
|
291
|
+
"To create a bound Telegram tab:",
|
|
292
|
+
"<code>1.</code> Start another Pi instance in your terminal.",
|
|
293
|
+
"<code>2.</code> Run <code>/telegram-connect</code> in that instance.",
|
|
294
|
+
"<code>3.</code> The bridge will create and bind a fresh Telegram tab for it.",
|
|
295
|
+
].join("\n");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function formatTelegramTargetKey(target: Queue.TelegramQueueTarget): string {
|
|
299
|
+
return `${target.chatId}:${target.threadId ?? "all"}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function formatTelegramUnboundRerouteChooserText(
|
|
303
|
+
_records: readonly Threads.TelegramTopicTargetRecord[],
|
|
304
|
+
options: { includeGuidance?: boolean } = {},
|
|
305
|
+
): string {
|
|
306
|
+
const rerouteText = [
|
|
307
|
+
"🧵 <b>Choose target thread</b>",
|
|
308
|
+
"",
|
|
309
|
+
"Your message is still in this Telegram thread.",
|
|
310
|
+
"Select the Pi thread that should handle it:",
|
|
311
|
+
].join("\n");
|
|
312
|
+
return options.includeGuidance === false
|
|
313
|
+
? rerouteText
|
|
314
|
+
: [formatTelegramUnboundTopicGuidance(), "", rerouteText].join("\n");
|
|
315
|
+
}
|
|
316
|
+
|
|
21
317
|
import { getTelegramVoiceReplyMode } from "./voice.ts";
|
|
22
318
|
import type { TelegramUser } from "./updates.ts";
|
|
319
|
+
import * as Threads from "./threads.ts";
|
|
23
320
|
import * as Updates from "./updates.ts";
|
|
24
321
|
|
|
322
|
+
async function deleteReservedTelegramTopicThroughReconciler(
|
|
323
|
+
deps: {
|
|
324
|
+
callApi?: <TResponse>(
|
|
325
|
+
method: string,
|
|
326
|
+
body: Record<string, unknown>,
|
|
327
|
+
) => Promise<TResponse>;
|
|
328
|
+
threadStore?: Pick<
|
|
329
|
+
Threads.TelegramTopicTargetStore,
|
|
330
|
+
| "list"
|
|
331
|
+
| "listReservations"
|
|
332
|
+
| "listSyncObservations"
|
|
333
|
+
| "markStaleByTarget"
|
|
334
|
+
| "persist"
|
|
335
|
+
>;
|
|
336
|
+
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
337
|
+
getThreadReconciliationMachineState?: () =>
|
|
338
|
+
| ThreadReconciler.ThreadReconciliationMachineState
|
|
339
|
+
| undefined;
|
|
340
|
+
recordThreadReconciliationPlan?: (
|
|
341
|
+
plan: ThreadReconciler.ThreadReconciliationPlan,
|
|
342
|
+
) => void;
|
|
343
|
+
recordRuntimeEvent?: (
|
|
344
|
+
category: string,
|
|
345
|
+
error: unknown,
|
|
346
|
+
details?: Record<string, unknown>,
|
|
347
|
+
) => void;
|
|
348
|
+
},
|
|
349
|
+
target: { chatId: number; threadId: number },
|
|
350
|
+
messageId: number,
|
|
351
|
+
): Promise<boolean> {
|
|
352
|
+
if (!deps.threadStore) return false;
|
|
353
|
+
const nowMs = Date.now();
|
|
354
|
+
const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
355
|
+
const plan = ThreadReconciler.planThreadReconciliation({
|
|
356
|
+
nowMs,
|
|
357
|
+
currentLeaderEpoch,
|
|
358
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
359
|
+
records: deps.threadStore.list(),
|
|
360
|
+
reservations: deps.threadStore.listReservations(),
|
|
361
|
+
observations: deps.threadStore.listSyncObservations(),
|
|
362
|
+
reservedMessages: [
|
|
363
|
+
{
|
|
364
|
+
target,
|
|
365
|
+
observedAtMs: nowMs,
|
|
366
|
+
messageId,
|
|
367
|
+
...(currentLeaderEpoch !== undefined
|
|
368
|
+
? { leaderEpoch: currentLeaderEpoch }
|
|
369
|
+
: {}),
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
});
|
|
373
|
+
deps.recordThreadReconciliationPlan?.(plan);
|
|
374
|
+
await ThreadReconciler.applyThreadReconciliationPlan(plan, {
|
|
375
|
+
callApi: deps.callApi,
|
|
376
|
+
markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
|
|
377
|
+
deps.threadStore?.markStaleByTarget(
|
|
378
|
+
staleTarget,
|
|
379
|
+
syncStatus,
|
|
380
|
+
lastSyncError,
|
|
381
|
+
) ?? false,
|
|
382
|
+
persist: () => deps.threadStore?.persist() ?? Promise.resolve(),
|
|
383
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
384
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
385
|
+
});
|
|
386
|
+
return plan.actions.some(
|
|
387
|
+
(action) => action.kind === "close-delete-reserved-topic",
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
25
391
|
export type TelegramRoutedMessage = Updates.TelegramUpdateMessage &
|
|
26
392
|
Media.TelegramMediaMessage &
|
|
27
393
|
Media.TelegramMediaGroupMessage &
|
|
@@ -40,7 +406,41 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
40
406
|
configStore: Pick<
|
|
41
407
|
TelegramConfigStore,
|
|
42
408
|
"get" | "getAllowedUserId" | "setAllowedUserId" | "persist"
|
|
409
|
+
> & { set?: TelegramConfigStore["set"] };
|
|
410
|
+
callApi?: <TResponse>(
|
|
411
|
+
method: string,
|
|
412
|
+
body: Record<string, unknown>,
|
|
413
|
+
) => Promise<TResponse>;
|
|
414
|
+
getCurrentInstanceId?: () => string | undefined;
|
|
415
|
+
getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
|
|
416
|
+
getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
|
|
417
|
+
getLiveThreadTargets?: () => Queue.TelegramQueueTarget[];
|
|
418
|
+
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
419
|
+
getThreadReconciliationMachineState?: () =>
|
|
420
|
+
| ThreadReconciler.ThreadReconciliationMachineState
|
|
421
|
+
| undefined;
|
|
422
|
+
recordThreadReconciliationPlan?: (
|
|
423
|
+
plan: ThreadReconciler.ThreadReconciliationPlan,
|
|
424
|
+
) => void;
|
|
425
|
+
handleTelegramTopicLifecycleUpdate?: (
|
|
426
|
+
lifecycle: Updates.TelegramTopicLifecycleUpdate<TMessage>,
|
|
427
|
+
ctx: TContext,
|
|
428
|
+
) => Promise<void> | void;
|
|
429
|
+
handleTelegramThreadTargetObserved?: (
|
|
430
|
+
target: Threads.TelegramTopicTargetRecord["target"],
|
|
431
|
+
ctx: TContext,
|
|
432
|
+
) => Promise<void> | void;
|
|
433
|
+
foreignOwnedUpdateForwarder?: Updates.TelegramForeignOwnedUpdateForwarder<
|
|
434
|
+
TContext,
|
|
435
|
+
Updates.TelegramMessageReactionUpdated,
|
|
436
|
+
TCallbackQuery,
|
|
437
|
+
TMessage
|
|
43
438
|
>;
|
|
439
|
+
replaceFollowerThreadTarget?: (input: {
|
|
440
|
+
record: Threads.TelegramTopicTargetRecord;
|
|
441
|
+
target: Threads.TelegramTopicTargetRecord["target"];
|
|
442
|
+
oldTarget: Threads.TelegramTopicTargetRecord["target"];
|
|
443
|
+
}) => Promise<boolean>;
|
|
44
444
|
bridgeRuntime: TelegramBridgeRuntime;
|
|
45
445
|
activeTurnRuntime: Queue.TelegramActiveTurnStore;
|
|
46
446
|
mediaGroupRuntime: Media.TelegramMediaGroupController<TMessage, TContext>;
|
|
@@ -78,12 +478,17 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
78
478
|
) => Promise<boolean>;
|
|
79
479
|
buttonActionStore?: OutboundHandlers.TelegramButtonActionStore;
|
|
80
480
|
inboundHandlerRuntime: TelegramInboundHandlerRuntime<TContext>;
|
|
481
|
+
threadStore?: Threads.TelegramTopicTargetStore;
|
|
81
482
|
updateStatus: (ctx: TContext, error?: string) => void;
|
|
82
483
|
dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
|
|
83
484
|
requestDeferredDispatchNextQueuedTelegramTurn?: (
|
|
84
485
|
dispatch: (ctx: TContext) => void,
|
|
85
486
|
) => void;
|
|
86
|
-
startTypingLoop?: (
|
|
487
|
+
startTypingLoop?: (
|
|
488
|
+
ctx: TContext,
|
|
489
|
+
chatId?: number,
|
|
490
|
+
options?: { target?: { chatId: number; threadId?: number } },
|
|
491
|
+
) => void;
|
|
87
492
|
stopTypingLoop?: () => void;
|
|
88
493
|
answerCallbackQuery: (
|
|
89
494
|
callbackQueryId: string,
|
|
@@ -101,6 +506,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
101
506
|
text: string,
|
|
102
507
|
mode: "markdown" | "html" | "plain",
|
|
103
508
|
replyMarkup: Menu.TelegramReplyMarkup,
|
|
509
|
+
options?: { target?: Queue.TelegramQueueTarget; replyToMessageId?: number },
|
|
104
510
|
) => Promise<number | undefined>;
|
|
105
511
|
deleteMessage?: (chatId: number, messageId: number) => Promise<void>;
|
|
106
512
|
answerGuestQuery: (guestQueryId: string, text?: string) => Promise<void>;
|
|
@@ -108,6 +514,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
108
514
|
chatId: number,
|
|
109
515
|
replyToMessageId: number,
|
|
110
516
|
text: string,
|
|
517
|
+
options?: { parseMode?: "HTML"; target?: Queue.TelegramQueueTarget },
|
|
111
518
|
) => Promise<number | undefined>;
|
|
112
519
|
setMyCommands: Commands.TelegramBotCommandRegistrationDeps["setMyCommands"];
|
|
113
520
|
getCommands: () => Parameters<
|
|
@@ -141,6 +548,8 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
141
548
|
}
|
|
142
549
|
|
|
143
550
|
const TELEGRAM_OWNED_CALLBACK_PREFIXES = [
|
|
551
|
+
TELEGRAM_ALL_TAB_MENU_CALLBACK_PREFIX,
|
|
552
|
+
TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX,
|
|
144
553
|
"compact:",
|
|
145
554
|
"menu:",
|
|
146
555
|
"model:",
|
|
@@ -176,6 +585,74 @@ export function createTelegramInboundRouteRuntime<
|
|
|
176
585
|
TModel
|
|
177
586
|
>,
|
|
178
587
|
): Updates.TelegramUpdateRuntimeController<TContext, TUpdate> {
|
|
588
|
+
const pendingUnboundReroutes = new Map<
|
|
589
|
+
string,
|
|
590
|
+
{ messages: TMessage[]; createdAtMs: number }
|
|
591
|
+
>();
|
|
592
|
+
const pendingAllTabCommands = new Map<string, TelegramAllTabPendingCommand>();
|
|
593
|
+
const guidedUnboundTopicKeys = new Set<string>();
|
|
594
|
+
let nextUnboundRerouteId = 0;
|
|
595
|
+
let nextAllTabCommandId = 0;
|
|
596
|
+
const prunePendingUnboundReroutes = () => {
|
|
597
|
+
const nowMs = Date.now();
|
|
598
|
+
for (const [id, entry] of pendingUnboundReroutes) {
|
|
599
|
+
if (nowMs - entry.createdAtMs > 30 * 60_000) {
|
|
600
|
+
pendingUnboundReroutes.delete(id);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
while (pendingUnboundReroutes.size > 100) {
|
|
604
|
+
const oldest = pendingUnboundReroutes.keys().next().value;
|
|
605
|
+
if (!oldest) break;
|
|
606
|
+
pendingUnboundReroutes.delete(oldest);
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
const storePendingUnboundReroute = (messages: TMessage[]): string => {
|
|
610
|
+
prunePendingUnboundReroutes();
|
|
611
|
+
nextUnboundRerouteId += 1;
|
|
612
|
+
const id = nextUnboundRerouteId.toString(36);
|
|
613
|
+
pendingUnboundReroutes.set(id, { messages, createdAtMs: Date.now() });
|
|
614
|
+
return id;
|
|
615
|
+
};
|
|
616
|
+
const pendingUnboundRerouteMediaGroups = new Map<
|
|
617
|
+
string,
|
|
618
|
+
{
|
|
619
|
+
messages: TMessage[];
|
|
620
|
+
timer: ReturnType<typeof setTimeout>;
|
|
621
|
+
}
|
|
622
|
+
>();
|
|
623
|
+
const prunePendingAllTabCommands = () => {
|
|
624
|
+
const nowMs = Date.now();
|
|
625
|
+
for (const [id, entry] of pendingAllTabCommands) {
|
|
626
|
+
if (nowMs - entry.createdAtMs > 30 * 60_000) {
|
|
627
|
+
pendingAllTabCommands.delete(id);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
while (pendingAllTabCommands.size > 100) {
|
|
631
|
+
const oldest = pendingAllTabCommands.keys().next().value;
|
|
632
|
+
if (!oldest) break;
|
|
633
|
+
pendingAllTabCommands.delete(oldest);
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
const storePendingAllTabCommand = (
|
|
637
|
+
command: Commands.ParsedTelegramCommand,
|
|
638
|
+
text: string,
|
|
639
|
+
): string => {
|
|
640
|
+
prunePendingAllTabCommands();
|
|
641
|
+
const bareCommandText = `/${command.name}`;
|
|
642
|
+
const id = text.trim() === bareCommandText ? command.name : undefined;
|
|
643
|
+
if (id) {
|
|
644
|
+
pendingAllTabCommands.set(id, { command, text, createdAtMs: Date.now() });
|
|
645
|
+
return id;
|
|
646
|
+
}
|
|
647
|
+
nextAllTabCommandId += 1;
|
|
648
|
+
const generatedId = nextAllTabCommandId.toString(36);
|
|
649
|
+
pendingAllTabCommands.set(generatedId, {
|
|
650
|
+
command,
|
|
651
|
+
text,
|
|
652
|
+
createdAtMs: Date.now(),
|
|
653
|
+
});
|
|
654
|
+
return generatedId;
|
|
655
|
+
};
|
|
179
656
|
const menuCallbackHandler = Menu.createTelegramMenuCallbackHandlerForContext<
|
|
180
657
|
TCallbackQuery,
|
|
181
658
|
TContext,
|
|
@@ -206,13 +683,18 @@ export function createTelegramInboundRouteRuntime<
|
|
|
206
683
|
editInteractiveMessage: deps.editInteractiveMessage,
|
|
207
684
|
sendInteractiveMessage: deps.sendInteractiveMessage,
|
|
208
685
|
deleteMessage: deps.deleteMessage,
|
|
209
|
-
enqueueSectionPrompt: async (
|
|
210
|
-
|
|
686
|
+
enqueueSectionPrompt: async (
|
|
687
|
+
prompt: string,
|
|
688
|
+
ctx: TContext,
|
|
689
|
+
target?: Queue.TelegramQueueTarget,
|
|
690
|
+
) => {
|
|
691
|
+
const chatId = target?.chatId ?? deps.configStore.getAllowedUserId();
|
|
211
692
|
if (typeof chatId !== "number") return;
|
|
212
693
|
const order = deps.bridgeRuntime.queue.allocateItemOrder();
|
|
213
694
|
const turn: Queue.PendingTelegramTurn = {
|
|
214
695
|
kind: "prompt",
|
|
215
696
|
chatId,
|
|
697
|
+
...(target ? { target } : {}),
|
|
216
698
|
replyToMessageId: 0,
|
|
217
699
|
sourceMessageIds: [],
|
|
218
700
|
queueOrder: order,
|
|
@@ -233,10 +715,518 @@ export function createTelegramInboundRouteRuntime<
|
|
|
233
715
|
deps.dispatchNextQueuedTelegramTurn(ctx);
|
|
234
716
|
},
|
|
235
717
|
});
|
|
718
|
+
const cloneTelegramMessagesForThread = (
|
|
719
|
+
messages: TMessage[],
|
|
720
|
+
threadId: number,
|
|
721
|
+
): TMessage[] => {
|
|
722
|
+
return messages.map(
|
|
723
|
+
(message) =>
|
|
724
|
+
({
|
|
725
|
+
...message,
|
|
726
|
+
message_id: 0,
|
|
727
|
+
message_thread_id: threadId,
|
|
728
|
+
reply_to_message: undefined,
|
|
729
|
+
}) as TMessage,
|
|
730
|
+
);
|
|
731
|
+
};
|
|
732
|
+
const applyThreadCleanupPlan = async (
|
|
733
|
+
plan: ThreadReconciler.ThreadReconciliationPlan,
|
|
734
|
+
): Promise<void> => {
|
|
735
|
+
deps.recordThreadReconciliationPlan?.(plan);
|
|
736
|
+
await ThreadReconciler.applyThreadReconciliationPlan(plan, {
|
|
737
|
+
callApi: deps.callApi,
|
|
738
|
+
markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
|
|
739
|
+
deps.threadStore?.markStaleByTarget(
|
|
740
|
+
staleTarget,
|
|
741
|
+
syncStatus,
|
|
742
|
+
lastSyncError,
|
|
743
|
+
) ?? false,
|
|
744
|
+
persist: () => deps.threadStore?.persist() ?? Promise.resolve(),
|
|
745
|
+
removePendingProvisionById: (id) =>
|
|
746
|
+
deps.threadStore?.removePendingProvision(id) ?? false,
|
|
747
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
748
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
749
|
+
});
|
|
750
|
+
};
|
|
751
|
+
const dismissRerouteChooserMessage = async (
|
|
752
|
+
query: TCallbackQuery,
|
|
753
|
+
): Promise<void> => {
|
|
754
|
+
const chatId = query.message?.chat?.id;
|
|
755
|
+
const messageId = query.message?.message_id;
|
|
756
|
+
if (
|
|
757
|
+
typeof chatId !== "number" ||
|
|
758
|
+
typeof messageId !== "number" ||
|
|
759
|
+
!deps.deleteMessage
|
|
760
|
+
) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
try {
|
|
764
|
+
await deps.deleteMessage(chatId, messageId);
|
|
765
|
+
} catch (error) {
|
|
766
|
+
deps.recordRuntimeEvent?.("telegram", error, {
|
|
767
|
+
phase: "reroute-chooser-delete",
|
|
768
|
+
chatId,
|
|
769
|
+
messageId,
|
|
770
|
+
threadId: query.message?.message_thread_id,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
const closeReroutedUnboundTopic = async (
|
|
775
|
+
target: { chatId: number; threadId: number } | undefined,
|
|
776
|
+
messageId: number | undefined,
|
|
777
|
+
): Promise<void> => {
|
|
778
|
+
if (!target || !deps.threadStore) return;
|
|
779
|
+
const nowMs = Date.now();
|
|
780
|
+
const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
781
|
+
const plan = ThreadReconciler.planThreadReconciliation({
|
|
782
|
+
nowMs,
|
|
783
|
+
currentLeaderEpoch,
|
|
784
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
785
|
+
records: deps.threadStore.list(),
|
|
786
|
+
reservations: deps.threadStore.listReservations(),
|
|
787
|
+
pendingProvisions: deps.threadStore.listPendingProvisions(),
|
|
788
|
+
unboundMessages: [
|
|
789
|
+
{
|
|
790
|
+
target,
|
|
791
|
+
observedAtMs: nowMs,
|
|
792
|
+
...(typeof messageId === "number" ? { messageId } : {}),
|
|
793
|
+
...(currentLeaderEpoch !== undefined
|
|
794
|
+
? { leaderEpoch: currentLeaderEpoch }
|
|
795
|
+
: {}),
|
|
796
|
+
},
|
|
797
|
+
],
|
|
798
|
+
});
|
|
799
|
+
await applyThreadCleanupPlan(plan);
|
|
800
|
+
};
|
|
801
|
+
const closePreviousLeaderThread = async (
|
|
802
|
+
target: { chatId: number; threadId: number } | undefined,
|
|
803
|
+
): Promise<void> => {
|
|
804
|
+
if (!target || !deps.threadStore) return;
|
|
805
|
+
const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
806
|
+
await applyThreadCleanupPlan({
|
|
807
|
+
actions: [
|
|
808
|
+
{
|
|
809
|
+
kind: "close-delete-previous-leader-topic",
|
|
810
|
+
target,
|
|
811
|
+
reason: "previous-leader",
|
|
812
|
+
instanceId: deps.getCurrentInstanceId?.(),
|
|
813
|
+
...(currentLeaderEpoch !== undefined
|
|
814
|
+
? { leaderEpoch: currentLeaderEpoch }
|
|
815
|
+
: {}),
|
|
816
|
+
},
|
|
817
|
+
],
|
|
818
|
+
});
|
|
819
|
+
};
|
|
820
|
+
const closeReplacedFollowerThread = async (
|
|
821
|
+
target: { chatId: number; threadId: number } | undefined,
|
|
822
|
+
instanceId: string | undefined,
|
|
823
|
+
): Promise<void> => {
|
|
824
|
+
if (!target || !deps.threadStore) return;
|
|
825
|
+
const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
826
|
+
await applyThreadCleanupPlan({
|
|
827
|
+
actions: [
|
|
828
|
+
{
|
|
829
|
+
kind: "close-delete-replaced-follower-topic",
|
|
830
|
+
target,
|
|
831
|
+
reason: "replaced-follower",
|
|
832
|
+
instanceId,
|
|
833
|
+
...(currentLeaderEpoch !== undefined
|
|
834
|
+
? { leaderEpoch: currentLeaderEpoch }
|
|
835
|
+
: {}),
|
|
836
|
+
},
|
|
837
|
+
],
|
|
838
|
+
});
|
|
839
|
+
};
|
|
840
|
+
const handleUnboundRerouteRestoreMenuCallback = async (
|
|
841
|
+
query: TCallbackQuery,
|
|
842
|
+
_ctx: TContext,
|
|
843
|
+
): Promise<boolean> => {
|
|
844
|
+
const parsed = parseTelegramUnboundRerouteRestoreMenuCallbackData(
|
|
845
|
+
query.data,
|
|
846
|
+
);
|
|
847
|
+
if (!parsed) return false;
|
|
848
|
+
const chatId = query.message?.chat?.id;
|
|
849
|
+
const messageId = query.message?.message_id;
|
|
850
|
+
const pending = pendingUnboundReroutes.get(parsed.rerouteId);
|
|
851
|
+
if (
|
|
852
|
+
typeof chatId !== "number" ||
|
|
853
|
+
typeof messageId !== "number" ||
|
|
854
|
+
!deps.threadStore ||
|
|
855
|
+
!pending
|
|
856
|
+
) {
|
|
857
|
+
await deps.answerCallbackQuery(query.id, "Message route expired.");
|
|
858
|
+
return true;
|
|
859
|
+
}
|
|
860
|
+
await deps.threadStore.load();
|
|
861
|
+
const activeRecords = getTelegramRoutableThreadRecords(
|
|
862
|
+
deps.threadStore.list(),
|
|
863
|
+
deps.getLiveThreadTargets?.(),
|
|
864
|
+
);
|
|
865
|
+
const replyMarkup = buildTelegramUnboundRerouteRestoreChooserMarkup(
|
|
866
|
+
parsed.rerouteId,
|
|
867
|
+
activeRecords,
|
|
868
|
+
);
|
|
869
|
+
if (deps.editInteractiveMessage) {
|
|
870
|
+
await deps.editInteractiveMessage(
|
|
871
|
+
chatId,
|
|
872
|
+
messageId,
|
|
873
|
+
formatTelegramUnboundRerouteRestoreChooserText(),
|
|
874
|
+
"html",
|
|
875
|
+
replyMarkup,
|
|
876
|
+
);
|
|
877
|
+
} else if (deps.sendInteractiveMessage) {
|
|
878
|
+
await deps.sendInteractiveMessage(
|
|
879
|
+
chatId,
|
|
880
|
+
formatTelegramUnboundRerouteRestoreChooserText(),
|
|
881
|
+
"html",
|
|
882
|
+
replyMarkup,
|
|
883
|
+
typeof query.message?.message_thread_id === "number"
|
|
884
|
+
? {
|
|
885
|
+
target: { chatId, threadId: query.message.message_thread_id },
|
|
886
|
+
replyToMessageId: messageId,
|
|
887
|
+
}
|
|
888
|
+
: undefined,
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
|
|
892
|
+
return true;
|
|
893
|
+
};
|
|
894
|
+
const handleUnboundRerouteCallback = async (
|
|
895
|
+
query: TCallbackQuery,
|
|
896
|
+
ctx: TContext,
|
|
897
|
+
): Promise<boolean> => {
|
|
898
|
+
const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
|
|
899
|
+
if (!parsed) return false;
|
|
900
|
+
const chatId = query.message?.chat?.id;
|
|
901
|
+
const pending = pendingUnboundReroutes.get(parsed.rerouteId);
|
|
902
|
+
if (typeof chatId !== "number" || !deps.threadStore || !pending) {
|
|
903
|
+
await deps.answerCallbackQuery(query.id, "Message route expired.");
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
await deps.threadStore.load();
|
|
907
|
+
const record = getTelegramRoutableThreadRecords(
|
|
908
|
+
deps.threadStore.list(),
|
|
909
|
+
deps.getLiveThreadTargets?.(),
|
|
910
|
+
).find(
|
|
911
|
+
(candidate) =>
|
|
912
|
+
candidate.target.chatId === chatId &&
|
|
913
|
+
candidate.target.threadId === parsed.threadId,
|
|
914
|
+
);
|
|
915
|
+
if (!record) {
|
|
916
|
+
await deps.answerCallbackQuery(query.id, "Thread is not active yet.");
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
const reroutedMessages = cloneTelegramMessagesForThread(
|
|
920
|
+
pending.messages,
|
|
921
|
+
parsed.threadId,
|
|
922
|
+
);
|
|
923
|
+
const sourceTarget =
|
|
924
|
+
typeof query.message?.message_thread_id === "number"
|
|
925
|
+
? { chatId, threadId: query.message.message_thread_id }
|
|
926
|
+
: undefined;
|
|
927
|
+
const sourceMessageId = query.message?.message_id;
|
|
928
|
+
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
929
|
+
const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
|
|
930
|
+
const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(
|
|
931
|
+
record,
|
|
932
|
+
leaderProfileKey,
|
|
933
|
+
currentInstanceId,
|
|
934
|
+
);
|
|
935
|
+
if (parsed.useNewSlot && !isCurrentLeaderRecord) {
|
|
936
|
+
if (
|
|
937
|
+
!sourceTarget ||
|
|
938
|
+
!deps.replaceFollowerThreadTarget ||
|
|
939
|
+
!deps.foreignOwnedUpdateForwarder?.forwardMessage
|
|
940
|
+
) {
|
|
941
|
+
await deps.answerCallbackQuery(
|
|
942
|
+
query.id,
|
|
943
|
+
"Follower thread restore is not available yet.",
|
|
944
|
+
);
|
|
945
|
+
return true;
|
|
946
|
+
}
|
|
947
|
+
const replaced = await deps.replaceFollowerThreadTarget({
|
|
948
|
+
record,
|
|
949
|
+
target: sourceTarget,
|
|
950
|
+
oldTarget: record.target,
|
|
951
|
+
});
|
|
952
|
+
if (!replaced) {
|
|
953
|
+
await deps.answerCallbackQuery(
|
|
954
|
+
query.id,
|
|
955
|
+
"Follower thread is unavailable.",
|
|
956
|
+
);
|
|
957
|
+
return true;
|
|
958
|
+
}
|
|
959
|
+
const nowMs = Date.now();
|
|
960
|
+
const slot = record.slot ?? "?";
|
|
961
|
+
const threadName = getRestoredThreadName(record, slot);
|
|
962
|
+
deps.threadStore.markStaleByTarget(
|
|
963
|
+
record.target,
|
|
964
|
+
"deleted",
|
|
965
|
+
"Follower thread was replaced by restore source.",
|
|
966
|
+
);
|
|
967
|
+
deps.threadStore.upsert({
|
|
968
|
+
...record,
|
|
969
|
+
target: sourceTarget,
|
|
970
|
+
status: "active",
|
|
971
|
+
syncStatus: "open",
|
|
972
|
+
updatedAtMs: nowMs,
|
|
973
|
+
threadName,
|
|
974
|
+
lastSyncObservedAtMs: nowMs,
|
|
975
|
+
lastReconcileAction: "follower-thread-restore",
|
|
976
|
+
rerouteConfirmedAtMs: nowMs,
|
|
977
|
+
});
|
|
978
|
+
await deps.threadStore.persist();
|
|
979
|
+
if (deps.callApi) {
|
|
980
|
+
try {
|
|
981
|
+
await deps.callApi("editForumTopic", {
|
|
982
|
+
chat_id: sourceTarget.chatId,
|
|
983
|
+
message_thread_id: sourceTarget.threadId,
|
|
984
|
+
name: Threads.getTelegramTopicTitleForThreadName(threadName, slot),
|
|
985
|
+
});
|
|
986
|
+
} catch (renameError) {
|
|
987
|
+
deps.recordRuntimeEvent?.("telegram", renameError, {
|
|
988
|
+
phase: "follower-topic-reroute-restore-rename",
|
|
989
|
+
chatId: sourceTarget.chatId,
|
|
990
|
+
threadId: sourceTarget.threadId,
|
|
991
|
+
slot: record.slot,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
await closeReplacedFollowerThread(record.target, record.instanceId);
|
|
996
|
+
const forwarded = await Promise.all(
|
|
997
|
+
cloneTelegramMessagesForThread(
|
|
998
|
+
pending.messages,
|
|
999
|
+
sourceTarget.threadId,
|
|
1000
|
+
).map((message) =>
|
|
1001
|
+
deps.foreignOwnedUpdateForwarder!.forwardMessage!({
|
|
1002
|
+
message,
|
|
1003
|
+
ownership: { instanceId: record.instanceId! },
|
|
1004
|
+
ctx,
|
|
1005
|
+
}),
|
|
1006
|
+
),
|
|
1007
|
+
);
|
|
1008
|
+
const allForwarded = forwarded.every(Boolean);
|
|
1009
|
+
if (allForwarded) {
|
|
1010
|
+
pendingUnboundReroutes.delete(parsed.rerouteId);
|
|
1011
|
+
await dismissRerouteChooserMessage(query);
|
|
1012
|
+
}
|
|
1013
|
+
await deps.answerCallbackQuery(
|
|
1014
|
+
query.id,
|
|
1015
|
+
allForwarded ? "Message routed." : "Target thread is unavailable.",
|
|
1016
|
+
);
|
|
1017
|
+
return true;
|
|
1018
|
+
}
|
|
1019
|
+
if (
|
|
1020
|
+
record.instanceId &&
|
|
1021
|
+
record.instanceId !== currentInstanceId &&
|
|
1022
|
+
!isCurrentLeaderRecord
|
|
1023
|
+
) {
|
|
1024
|
+
if (!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
|
|
1025
|
+
await deps.answerCallbackQuery(
|
|
1026
|
+
query.id,
|
|
1027
|
+
"Open that thread and resend the message there.",
|
|
1028
|
+
);
|
|
1029
|
+
return true;
|
|
1030
|
+
}
|
|
1031
|
+
const forwarded = await Promise.all(
|
|
1032
|
+
reroutedMessages.map((message) =>
|
|
1033
|
+
deps.foreignOwnedUpdateForwarder!.forwardMessage!({
|
|
1034
|
+
message,
|
|
1035
|
+
ownership: { instanceId: record.instanceId! },
|
|
1036
|
+
ctx,
|
|
1037
|
+
}),
|
|
1038
|
+
),
|
|
1039
|
+
);
|
|
1040
|
+
const allForwarded = forwarded.every(Boolean);
|
|
1041
|
+
if (allForwarded) {
|
|
1042
|
+
pendingUnboundReroutes.delete(parsed.rerouteId);
|
|
1043
|
+
await dismissRerouteChooserMessage(query);
|
|
1044
|
+
await closeReroutedUnboundTopic(sourceTarget, sourceMessageId);
|
|
1045
|
+
}
|
|
1046
|
+
await deps.answerCallbackQuery(
|
|
1047
|
+
query.id,
|
|
1048
|
+
allForwarded ? "Message routed." : "Target thread is unavailable.",
|
|
1049
|
+
);
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
if (
|
|
1053
|
+
sourceTarget &&
|
|
1054
|
+
isCurrentLeaderRecord &&
|
|
1055
|
+
(parsed.useNewSlot || typeof record.rerouteConfirmedAtMs !== "number") &&
|
|
1056
|
+
(record.target.chatId !== sourceTarget.chatId ||
|
|
1057
|
+
record.target.threadId !== sourceTarget.threadId)
|
|
1058
|
+
) {
|
|
1059
|
+
deps.threadStore.markStaleByTarget(
|
|
1060
|
+
record.target,
|
|
1061
|
+
"deleted",
|
|
1062
|
+
parsed.useNewSlot
|
|
1063
|
+
? "Current leader thread was replaced by a new-slot reroute source."
|
|
1064
|
+
: "Current leader thread was replaced by reroute source.",
|
|
1065
|
+
);
|
|
1066
|
+
const slot = parsed.useNewSlot
|
|
1067
|
+
? (deps.threadStore.allocateSlot(
|
|
1068
|
+
leaderProfileKey ?? record.profileKey,
|
|
1069
|
+
getNextTelegramSlotPreference(record.slot),
|
|
1070
|
+
) ??
|
|
1071
|
+
record.slot ??
|
|
1072
|
+
"?")
|
|
1073
|
+
: (record.slot ?? "?");
|
|
1074
|
+
const nowMs = Date.now();
|
|
1075
|
+
const threadName = getRestoredThreadName(record, slot);
|
|
1076
|
+
deps.threadStore.upsert({
|
|
1077
|
+
...record,
|
|
1078
|
+
target: sourceTarget,
|
|
1079
|
+
status: "active",
|
|
1080
|
+
updatedAtMs: nowMs,
|
|
1081
|
+
threadName,
|
|
1082
|
+
instanceId: currentInstanceId,
|
|
1083
|
+
slot,
|
|
1084
|
+
lastReconcileAction: parsed.useNewSlot
|
|
1085
|
+
? "reroute-new-slot"
|
|
1086
|
+
: "reroute-reclaim",
|
|
1087
|
+
rerouteConfirmedAtMs: nowMs,
|
|
1088
|
+
});
|
|
1089
|
+
await deps.threadStore.persist();
|
|
1090
|
+
if (deps.callApi) {
|
|
1091
|
+
try {
|
|
1092
|
+
await deps.callApi("editForumTopic", {
|
|
1093
|
+
chat_id: sourceTarget.chatId,
|
|
1094
|
+
message_thread_id: sourceTarget.threadId,
|
|
1095
|
+
name: Threads.getTelegramTopicTitleForThreadName(threadName, slot),
|
|
1096
|
+
});
|
|
1097
|
+
} catch (renameError) {
|
|
1098
|
+
deps.recordRuntimeEvent?.("telegram", renameError, {
|
|
1099
|
+
phase: "leader-topic-reroute-reclaim-rename",
|
|
1100
|
+
chatId: sourceTarget.chatId,
|
|
1101
|
+
threadId: sourceTarget.threadId,
|
|
1102
|
+
slot,
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
deps.recordRuntimeEvent?.(
|
|
1107
|
+
"bus",
|
|
1108
|
+
"Bus leader reclaimed reroute source thread",
|
|
1109
|
+
{
|
|
1110
|
+
phase: "leader-topic-reroute-reclaim",
|
|
1111
|
+
chatId: sourceTarget.chatId,
|
|
1112
|
+
threadId: sourceTarget.threadId,
|
|
1113
|
+
staleThreadId: record.target.threadId,
|
|
1114
|
+
slot,
|
|
1115
|
+
},
|
|
1116
|
+
);
|
|
1117
|
+
if (parsed.useNewSlot) {
|
|
1118
|
+
await closePreviousLeaderThread(record.target);
|
|
1119
|
+
}
|
|
1120
|
+
await promptEnqueue(
|
|
1121
|
+
cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId),
|
|
1122
|
+
ctx,
|
|
1123
|
+
);
|
|
1124
|
+
pendingUnboundReroutes.delete(parsed.rerouteId);
|
|
1125
|
+
await dismissRerouteChooserMessage(query);
|
|
1126
|
+
await deps.answerCallbackQuery(query.id, "Message routed.");
|
|
1127
|
+
return true;
|
|
1128
|
+
}
|
|
1129
|
+
await promptEnqueue(reroutedMessages, ctx);
|
|
1130
|
+
pendingUnboundReroutes.delete(parsed.rerouteId);
|
|
1131
|
+
await dismissRerouteChooserMessage(query);
|
|
1132
|
+
await closeReroutedUnboundTopic(sourceTarget, sourceMessageId);
|
|
1133
|
+
await deps.answerCallbackQuery(query.id, "Message routed.");
|
|
1134
|
+
return true;
|
|
1135
|
+
};
|
|
1136
|
+
let dispatchAllTabCommandToTarget:
|
|
1137
|
+
| ((
|
|
1138
|
+
commandText: string,
|
|
1139
|
+
query: TCallbackQuery,
|
|
1140
|
+
threadId: number,
|
|
1141
|
+
ctx: TContext,
|
|
1142
|
+
) => Promise<void>)
|
|
1143
|
+
| undefined;
|
|
1144
|
+
const handleAllTabMenuCallback = async (
|
|
1145
|
+
query: TCallbackQuery,
|
|
1146
|
+
ctx: TContext,
|
|
1147
|
+
): Promise<boolean> => {
|
|
1148
|
+
const parsed = parseTelegramAllTabMenuCallbackData(query.data);
|
|
1149
|
+
if (!parsed) return false;
|
|
1150
|
+
const chatId = query.message?.chat?.id;
|
|
1151
|
+
const pending = pendingAllTabCommands.get(parsed.commandId);
|
|
1152
|
+
if (typeof chatId !== "number" || !deps.threadStore || !pending) {
|
|
1153
|
+
await deps.answerCallbackQuery(query.id, "Thread menu expired.");
|
|
1154
|
+
return true;
|
|
1155
|
+
}
|
|
1156
|
+
await deps.threadStore.load();
|
|
1157
|
+
const record = getTelegramRoutableThreadRecords(
|
|
1158
|
+
deps.threadStore.list(),
|
|
1159
|
+
deps.getLiveThreadTargets?.(),
|
|
1160
|
+
).find(
|
|
1161
|
+
(candidate) =>
|
|
1162
|
+
candidate.target.chatId === chatId &&
|
|
1163
|
+
candidate.target.threadId === parsed.threadId,
|
|
1164
|
+
);
|
|
1165
|
+
if (!record) {
|
|
1166
|
+
await deps.answerCallbackQuery(query.id, "Thread is not active yet.");
|
|
1167
|
+
return true;
|
|
1168
|
+
}
|
|
1169
|
+
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
1170
|
+
const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
|
|
1171
|
+
const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(
|
|
1172
|
+
record,
|
|
1173
|
+
leaderProfileKey,
|
|
1174
|
+
currentInstanceId,
|
|
1175
|
+
);
|
|
1176
|
+
if (
|
|
1177
|
+
record.instanceId &&
|
|
1178
|
+
record.instanceId !== currentInstanceId &&
|
|
1179
|
+
!isCurrentLeaderRecord
|
|
1180
|
+
) {
|
|
1181
|
+
if (!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
|
|
1182
|
+
await deps.answerCallbackQuery(
|
|
1183
|
+
query.id,
|
|
1184
|
+
"Open that thread and run /start there.",
|
|
1185
|
+
);
|
|
1186
|
+
return true;
|
|
1187
|
+
}
|
|
1188
|
+
const forwarded = await deps.foreignOwnedUpdateForwarder.forwardMessage({
|
|
1189
|
+
message: {
|
|
1190
|
+
...(query.message ?? {}),
|
|
1191
|
+
message_id: query.message?.message_id ?? 0,
|
|
1192
|
+
chat: { id: chatId, type: "private" },
|
|
1193
|
+
from: query.from,
|
|
1194
|
+
message_thread_id: parsed.threadId,
|
|
1195
|
+
text: pending.text,
|
|
1196
|
+
} as TMessage,
|
|
1197
|
+
ownership: { instanceId: record.instanceId },
|
|
1198
|
+
ctx,
|
|
1199
|
+
});
|
|
1200
|
+
if (forwarded) pendingAllTabCommands.delete(parsed.commandId);
|
|
1201
|
+
await deps.answerCallbackQuery(
|
|
1202
|
+
query.id,
|
|
1203
|
+
forwarded
|
|
1204
|
+
? "Opening in target thread."
|
|
1205
|
+
: "Target thread is unavailable.",
|
|
1206
|
+
);
|
|
1207
|
+
return true;
|
|
1208
|
+
}
|
|
1209
|
+
if (!dispatchAllTabCommandToTarget) {
|
|
1210
|
+
await deps.answerCallbackQuery(query.id, "Thread menu expired.");
|
|
1211
|
+
return true;
|
|
1212
|
+
}
|
|
1213
|
+
await dispatchAllTabCommandToTarget(
|
|
1214
|
+
pending.text,
|
|
1215
|
+
query,
|
|
1216
|
+
parsed.threadId,
|
|
1217
|
+
ctx,
|
|
1218
|
+
);
|
|
1219
|
+
pendingAllTabCommands.delete(parsed.commandId);
|
|
1220
|
+
await deps.answerCallbackQuery(query.id, "Opening in target thread.");
|
|
1221
|
+
return true;
|
|
1222
|
+
};
|
|
236
1223
|
const callbackHandler = async (
|
|
237
1224
|
query: TCallbackQuery,
|
|
238
1225
|
ctx: TContext,
|
|
239
1226
|
): Promise<void> => {
|
|
1227
|
+
if (await handleUnboundRerouteRestoreMenuCallback(query, ctx)) return;
|
|
1228
|
+
if (await handleUnboundRerouteCallback(query, ctx)) return;
|
|
1229
|
+
if (await handleAllTabMenuCallback(query, ctx)) return;
|
|
240
1230
|
if (deps.buttonActionStore) {
|
|
241
1231
|
const handled = await OutboundHandlers.handleTelegramButtonCallbackQuery(
|
|
242
1232
|
query,
|
|
@@ -253,6 +1243,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
253
1243
|
deps.queueMutationRuntime.append(
|
|
254
1244
|
OutboundHandlers.createTelegramButtonPromptTurn({
|
|
255
1245
|
chatId,
|
|
1246
|
+
target:
|
|
1247
|
+
typeof buttonQuery.message?.message_thread_id === "number"
|
|
1248
|
+
? {
|
|
1249
|
+
chatId,
|
|
1250
|
+
threadId: buttonQuery.message.message_thread_id,
|
|
1251
|
+
}
|
|
1252
|
+
: { chatId },
|
|
256
1253
|
replyToMessageId: messageId,
|
|
257
1254
|
queueOrder,
|
|
258
1255
|
action,
|
|
@@ -271,7 +1268,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
271
1268
|
ctx,
|
|
272
1269
|
answerCallbackQuery: deps.answerCallbackQuery,
|
|
273
1270
|
editInteractiveMessage: deps.editInteractiveMessage ?? (async () => {}),
|
|
274
|
-
runCompact: async (compactCtx, chatId, replyToMessageId) => {
|
|
1271
|
+
runCompact: async (compactCtx, chatId, replyToMessageId, target) => {
|
|
275
1272
|
await Commands.handleTelegramCompactCommand({
|
|
276
1273
|
isIdle: () => deps.isIdle(compactCtx),
|
|
277
1274
|
hasPendingMessages: () => deps.hasPendingMessages(compactCtx),
|
|
@@ -294,11 +1291,16 @@ export function createTelegramInboundRouteRuntime<
|
|
|
294
1291
|
: undefined,
|
|
295
1292
|
compact: (callbacks) => deps.compact(compactCtx, callbacks),
|
|
296
1293
|
startTypingLoop: deps.startTypingLoop
|
|
297
|
-
? () =>
|
|
1294
|
+
? () =>
|
|
1295
|
+
deps.startTypingLoop?.(compactCtx, chatId, {
|
|
1296
|
+
target,
|
|
1297
|
+
})
|
|
298
1298
|
: undefined,
|
|
299
1299
|
stopTypingLoop: deps.stopTypingLoop,
|
|
300
1300
|
sendTextReply: (text) =>
|
|
301
|
-
deps
|
|
1301
|
+
deps
|
|
1302
|
+
.sendTextReply(chatId, replyToMessageId, text, { target })
|
|
1303
|
+
.then(() => {}),
|
|
302
1304
|
suppressStartNotice: true,
|
|
303
1305
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
304
1306
|
});
|
|
@@ -343,6 +1345,22 @@ export function createTelegramInboundRouteRuntime<
|
|
|
343
1345
|
const mode = deps.configStore.get().voice?.replyMode;
|
|
344
1346
|
return mode === "manual" || mode === "mirror" || mode === "always";
|
|
345
1347
|
},
|
|
1348
|
+
getTelegramThreadLabel(message) {
|
|
1349
|
+
if (!deps.threadStore) return undefined;
|
|
1350
|
+
const chatId = message.chat.id;
|
|
1351
|
+
const threadId = message.message_thread_id;
|
|
1352
|
+
if (!threadId) return undefined;
|
|
1353
|
+
const records = deps.threadStore.list();
|
|
1354
|
+
for (const r of records) {
|
|
1355
|
+
if (r.target.chatId !== chatId || r.target.threadId !== threadId)
|
|
1356
|
+
continue;
|
|
1357
|
+
return r.threadName &&
|
|
1358
|
+
Threads.isTelegramTopicThreadNameValidForSlot(r.threadName, r.slot)
|
|
1359
|
+
? r.threadName
|
|
1360
|
+
: getRestoredThreadName(r, r.slot ?? "");
|
|
1361
|
+
}
|
|
1362
|
+
return undefined;
|
|
1363
|
+
},
|
|
346
1364
|
});
|
|
347
1365
|
const enqueueContinueTurn = async (
|
|
348
1366
|
message: TMessage,
|
|
@@ -429,28 +1447,207 @@ export function createTelegramInboundRouteRuntime<
|
|
|
429
1447
|
deps.bridgeRuntime.lifecycle.shouldFoldQueuedPromptsIntoHistory,
|
|
430
1448
|
setFoldQueuedPromptsIntoHistory:
|
|
431
1449
|
deps.bridgeRuntime.lifecycle.setFoldQueuedPromptsIntoHistory,
|
|
432
|
-
createTurn:
|
|
1450
|
+
createTurn: async (messages, historyTurns, turnCtx) => {
|
|
1451
|
+
const turn = await promptTurnBuilder(messages, historyTurns, turnCtx);
|
|
1452
|
+
return turn.replyToMessageId > 0
|
|
1453
|
+
? turn
|
|
1454
|
+
: { ...turn, replyToMessageId: 0 };
|
|
1455
|
+
},
|
|
433
1456
|
updateStatus: deps.updateStatus,
|
|
434
1457
|
dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
|
|
435
1458
|
}).enqueue;
|
|
1459
|
+
const sendUnboundRerouteChooserNow = async (
|
|
1460
|
+
messages: TMessage[],
|
|
1461
|
+
ctx: TContext,
|
|
1462
|
+
): Promise<void> => {
|
|
1463
|
+
const message = messages[0];
|
|
1464
|
+
if (!message || !deps.threadStore) return;
|
|
1465
|
+
const records = deps.threadStore.list();
|
|
1466
|
+
const activeRecords = getTelegramRoutableThreadRecords(
|
|
1467
|
+
records,
|
|
1468
|
+
deps.getLiveThreadTargets?.(),
|
|
1469
|
+
);
|
|
1470
|
+
const sourceTarget =
|
|
1471
|
+
typeof message.message_thread_id === "number"
|
|
1472
|
+
? { chatId: message.chat.id, threadId: message.message_thread_id }
|
|
1473
|
+
: undefined;
|
|
1474
|
+
const sourceKey = sourceTarget
|
|
1475
|
+
? formatTelegramTargetKey(sourceTarget)
|
|
1476
|
+
: undefined;
|
|
1477
|
+
const includeGuidance = sourceKey
|
|
1478
|
+
? !guidedUnboundTopicKeys.has(sourceKey)
|
|
1479
|
+
: true;
|
|
1480
|
+
if (sourceKey) guidedUnboundTopicKeys.add(sourceKey);
|
|
1481
|
+
if (activeRecords.length === 0) {
|
|
1482
|
+
await deps.sendTextReply(
|
|
1483
|
+
message.chat.id,
|
|
1484
|
+
message.message_id,
|
|
1485
|
+
[
|
|
1486
|
+
includeGuidance ? formatTelegramUnboundTopicGuidance() : undefined,
|
|
1487
|
+
"This thread is not bound to a Pi instance. Open an active Pi thread or run /telegram-connect from a Pi session to bind one.",
|
|
1488
|
+
]
|
|
1489
|
+
.filter((line): line is string => typeof line === "string")
|
|
1490
|
+
.join("\n\n"),
|
|
1491
|
+
{ parseMode: "HTML", target: sourceTarget },
|
|
1492
|
+
);
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
const rerouteId = storePendingUnboundReroute(messages);
|
|
1496
|
+
const text = formatTelegramUnboundRerouteChooserText(activeRecords, {
|
|
1497
|
+
includeGuidance,
|
|
1498
|
+
});
|
|
1499
|
+
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
1500
|
+
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
|
|
1501
|
+
rerouteId,
|
|
1502
|
+
activeRecords,
|
|
1503
|
+
{
|
|
1504
|
+
currentLeaderProfileKey: getLeaderTopicProfileKey(
|
|
1505
|
+
ctx,
|
|
1506
|
+
currentInstanceId,
|
|
1507
|
+
),
|
|
1508
|
+
currentInstanceId,
|
|
1509
|
+
},
|
|
1510
|
+
);
|
|
1511
|
+
if (deps.sendInteractiveMessage) {
|
|
1512
|
+
await deps.sendInteractiveMessage(
|
|
1513
|
+
message.chat.id,
|
|
1514
|
+
text,
|
|
1515
|
+
"html",
|
|
1516
|
+
replyMarkup,
|
|
1517
|
+
sourceTarget
|
|
1518
|
+
? { target: sourceTarget, replyToMessageId: message.message_id }
|
|
1519
|
+
: { replyToMessageId: message.message_id },
|
|
1520
|
+
);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
1524
|
+
parseMode: "HTML",
|
|
1525
|
+
target: sourceTarget,
|
|
1526
|
+
});
|
|
1527
|
+
};
|
|
1528
|
+
const sendUnboundRerouteChooser = async (
|
|
1529
|
+
message: TMessage,
|
|
1530
|
+
ctx: TContext,
|
|
1531
|
+
): Promise<void> => {
|
|
1532
|
+
const groupKey = Media.getTelegramMediaGroupKey(message);
|
|
1533
|
+
if (!groupKey) {
|
|
1534
|
+
await sendUnboundRerouteChooserNow([message], ctx);
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
const existing = pendingUnboundRerouteMediaGroups.get(groupKey);
|
|
1538
|
+
if (existing) clearTimeout(existing.timer);
|
|
1539
|
+
const messages = [...(existing?.messages ?? []), message];
|
|
1540
|
+
const timer = setTimeout(() => {
|
|
1541
|
+
pendingUnboundRerouteMediaGroups.delete(groupKey);
|
|
1542
|
+
void sendUnboundRerouteChooserNow(messages, ctx);
|
|
1543
|
+
}, 1200);
|
|
1544
|
+
timer.unref?.();
|
|
1545
|
+
pendingUnboundRerouteMediaGroups.set(groupKey, { messages, timer });
|
|
1546
|
+
};
|
|
1547
|
+
const getKnownTelegramAllTabCommand = (
|
|
1548
|
+
text: string,
|
|
1549
|
+
): Commands.ParsedTelegramCommand | undefined => {
|
|
1550
|
+
const command = Commands.parseTelegramCommand(text);
|
|
1551
|
+
if (!command) return undefined;
|
|
1552
|
+
if (reservedCommandNames().has(command.name)) return command;
|
|
1553
|
+
if (Commands.findTelegramExtensionCommand(command.name)) return command;
|
|
1554
|
+
if (
|
|
1555
|
+
getPromptTemplateCommands().some(
|
|
1556
|
+
(template) => template.command === command.name,
|
|
1557
|
+
)
|
|
1558
|
+
) {
|
|
1559
|
+
return command;
|
|
1560
|
+
}
|
|
1561
|
+
return undefined;
|
|
1562
|
+
};
|
|
1563
|
+
const sendAllTabCommandChooser = async (
|
|
1564
|
+
command: Commands.ParsedTelegramCommand,
|
|
1565
|
+
commandText: string,
|
|
1566
|
+
message: TMessage,
|
|
1567
|
+
options: {
|
|
1568
|
+
replyToSource?: boolean;
|
|
1569
|
+
target?: Queue.TelegramQueueTarget;
|
|
1570
|
+
} = {},
|
|
1571
|
+
): Promise<boolean> => {
|
|
1572
|
+
if (!deps.threadStore) return false;
|
|
1573
|
+
const records = deps.threadStore.list();
|
|
1574
|
+
const activeRecords = getTelegramRoutableThreadRecords(
|
|
1575
|
+
records,
|
|
1576
|
+
deps.getLiveThreadTargets?.(),
|
|
1577
|
+
);
|
|
1578
|
+
if (activeRecords.length === 0) return false;
|
|
1579
|
+
const commandId = storePendingAllTabCommand(command, commandText);
|
|
1580
|
+
const text = formatTelegramAllTabMenuChooserText(command.name);
|
|
1581
|
+
const replyMarkup = buildTelegramAllTabMenuChooserMarkup(
|
|
1582
|
+
commandId,
|
|
1583
|
+
activeRecords,
|
|
1584
|
+
);
|
|
1585
|
+
if (deps.sendInteractiveMessage) {
|
|
1586
|
+
await deps.sendInteractiveMessage(
|
|
1587
|
+
message.chat.id,
|
|
1588
|
+
text,
|
|
1589
|
+
"html",
|
|
1590
|
+
replyMarkup,
|
|
1591
|
+
options.target || options.replyToSource
|
|
1592
|
+
? {
|
|
1593
|
+
...(options.target ? { target: options.target } : {}),
|
|
1594
|
+
...(options.replyToSource
|
|
1595
|
+
? { replyToMessageId: message.message_id }
|
|
1596
|
+
: {}),
|
|
1597
|
+
}
|
|
1598
|
+
: undefined,
|
|
1599
|
+
);
|
|
1600
|
+
return true;
|
|
1601
|
+
}
|
|
1602
|
+
if (deps.callApi) {
|
|
1603
|
+
await deps.callApi("sendMessage", {
|
|
1604
|
+
chat_id: message.chat.id,
|
|
1605
|
+
text,
|
|
1606
|
+
parse_mode: "HTML",
|
|
1607
|
+
reply_markup: replyMarkup,
|
|
1608
|
+
...(typeof options.target?.threadId === "number"
|
|
1609
|
+
? { message_thread_id: options.target.threadId }
|
|
1610
|
+
: {}),
|
|
1611
|
+
...(options.replyToSource
|
|
1612
|
+
? {
|
|
1613
|
+
reply_parameters: {
|
|
1614
|
+
message_id: message.message_id,
|
|
1615
|
+
allow_sending_without_reply: true,
|
|
1616
|
+
},
|
|
1617
|
+
}
|
|
1618
|
+
: {}),
|
|
1619
|
+
});
|
|
1620
|
+
return true;
|
|
1621
|
+
}
|
|
1622
|
+
await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
1623
|
+
parseMode: "HTML",
|
|
1624
|
+
target: options.target,
|
|
1625
|
+
});
|
|
1626
|
+
return true;
|
|
1627
|
+
};
|
|
436
1628
|
const commandOrPrompt = Commands.createTelegramCommandOrPromptRuntime<
|
|
437
1629
|
TMessage,
|
|
438
1630
|
TContext
|
|
439
1631
|
>({
|
|
440
1632
|
extractRawText: Media.extractFirstTelegramMessageText,
|
|
1633
|
+
shouldIgnoreMessages: (messages) =>
|
|
1634
|
+
!Media.hasTelegramMessagesPromptContent(messages),
|
|
441
1635
|
handleCommand: commandHandler,
|
|
442
1636
|
executeExtensionCommand: async (command, message, ctx) => {
|
|
443
1637
|
const extensionCommand = Commands.findTelegramExtensionCommand(
|
|
444
1638
|
command.name,
|
|
445
1639
|
);
|
|
446
1640
|
if (!extensionCommand) return false;
|
|
1641
|
+
const sourceTarget = Updates.getTelegramMessageTarget(message);
|
|
447
1642
|
try {
|
|
448
1643
|
await extensionCommand.handler({
|
|
449
1644
|
name: command.name,
|
|
450
1645
|
args: command.args,
|
|
451
1646
|
reply: (text) =>
|
|
452
1647
|
deps
|
|
453
|
-
.sendTextReply(message.chat.id, message.message_id, text
|
|
1648
|
+
.sendTextReply(message.chat.id, message.message_id, text, {
|
|
1649
|
+
target: sourceTarget,
|
|
1650
|
+
})
|
|
454
1651
|
.then(() => {}),
|
|
455
1652
|
enqueuePrompt: (prompt) =>
|
|
456
1653
|
promptEnqueue(
|
|
@@ -472,6 +1669,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
472
1669
|
message.chat.id,
|
|
473
1670
|
message.message_id,
|
|
474
1671
|
"Command failed.",
|
|
1672
|
+
{ target: sourceTarget },
|
|
475
1673
|
);
|
|
476
1674
|
}
|
|
477
1675
|
return true;
|
|
@@ -486,6 +1684,24 @@ export function createTelegramInboundRouteRuntime<
|
|
|
486
1684
|
({ ...message, text, caption: undefined }) as TMessage,
|
|
487
1685
|
enqueueTurn: promptEnqueue,
|
|
488
1686
|
});
|
|
1687
|
+
dispatchAllTabCommandToTarget = async (commandText, query, threadId, ctx) => {
|
|
1688
|
+
const chatId = query.message?.chat?.id;
|
|
1689
|
+
if (typeof chatId !== "number") return;
|
|
1690
|
+
await commandOrPrompt.dispatchMessages(
|
|
1691
|
+
[
|
|
1692
|
+
{
|
|
1693
|
+
...(query.message ?? {}),
|
|
1694
|
+
message_id: 0,
|
|
1695
|
+
chat: { id: chatId, type: "private" },
|
|
1696
|
+
from: query.from,
|
|
1697
|
+
message_thread_id: threadId,
|
|
1698
|
+
text: commandText,
|
|
1699
|
+
caption: undefined,
|
|
1700
|
+
} as TMessage,
|
|
1701
|
+
],
|
|
1702
|
+
ctx,
|
|
1703
|
+
);
|
|
1704
|
+
};
|
|
489
1705
|
const mediaDispatch = Media.createTelegramMediaGroupDispatchRuntime<
|
|
490
1706
|
TMessage,
|
|
491
1707
|
TContext
|
|
@@ -508,6 +1724,16 @@ export function createTelegramInboundRouteRuntime<
|
|
|
508
1724
|
...deps.telegramQueueStore,
|
|
509
1725
|
updateStatus: deps.updateStatus,
|
|
510
1726
|
});
|
|
1727
|
+
const handleTelegramTopicLifecycleUpdate = async (
|
|
1728
|
+
lifecycle: Updates.TelegramTopicLifecycleUpdate<TMessage>,
|
|
1729
|
+
ctx: TContext,
|
|
1730
|
+
): Promise<void> => {
|
|
1731
|
+
await deps.handleTelegramTopicLifecycleUpdate?.(lifecycle, ctx);
|
|
1732
|
+
if (lifecycle.kind !== "created" || !deps.threadStore) {
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
await deps.threadStore.load();
|
|
1736
|
+
};
|
|
511
1737
|
const handleAuthorizedTelegramGuestMessage = async (
|
|
512
1738
|
guestMessage: Updates.TelegramGuestMessage & { from: TelegramUser },
|
|
513
1739
|
ctx: TContext,
|
|
@@ -609,6 +1835,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
609
1835
|
};
|
|
610
1836
|
return Updates.createTelegramPairedUpdateRuntime<TContext, TUpdate>({
|
|
611
1837
|
getAllowedUserId: deps.configStore.getAllowedUserId,
|
|
1838
|
+
getCurrentInstanceId: deps.getCurrentInstanceId,
|
|
1839
|
+
getMessageOwnership: deps.getMessageOwnership,
|
|
1840
|
+
getTargetOwnership: deps.getTargetOwnership,
|
|
1841
|
+
handleTelegramTopicLifecycleUpdate,
|
|
1842
|
+
foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
|
|
612
1843
|
setAllowedUserId: deps.configStore.setAllowedUserId,
|
|
613
1844
|
persistConfig: deps.configStore.persist,
|
|
614
1845
|
updateStatus: deps.updateStatus,
|
|
@@ -623,8 +1854,441 @@ export function createTelegramInboundRouteRuntime<
|
|
|
623
1854
|
answerGuestQuery: deps.answerGuestQuery,
|
|
624
1855
|
handleAuthorizedTelegramCallbackQuery: callbackHandler,
|
|
625
1856
|
sendTextReply: deps.sendTextReply,
|
|
626
|
-
handleAuthorizedTelegramMessage:
|
|
1857
|
+
handleAuthorizedTelegramMessage: async (message, ctx) => {
|
|
1858
|
+
if (typeof message.message_thread_id === "number") {
|
|
1859
|
+
await deps.handleTelegramThreadTargetObserved?.(
|
|
1860
|
+
{
|
|
1861
|
+
chatId: message.chat.id,
|
|
1862
|
+
threadId: message.message_thread_id,
|
|
1863
|
+
},
|
|
1864
|
+
ctx,
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
const text = Media.extractFirstTelegramMessageText([
|
|
1868
|
+
message as TMessage,
|
|
1869
|
+
]).trim();
|
|
1870
|
+
if (deps.threadStore && typeof message.message_thread_id !== "number") {
|
|
1871
|
+
await deps.threadStore.load();
|
|
1872
|
+
if (deps.threadStore.getBotState().threadMode === "disabled") {
|
|
1873
|
+
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
const records = deps.threadStore.list();
|
|
1877
|
+
const bindings = getTelegramRoutableThreadRecords(
|
|
1878
|
+
records,
|
|
1879
|
+
deps.getLiveThreadTargets?.(),
|
|
1880
|
+
);
|
|
1881
|
+
const command = getKnownTelegramAllTabCommand(text);
|
|
1882
|
+
if (bindings.length > 0 && command && command.name !== "thread") {
|
|
1883
|
+
if (
|
|
1884
|
+
await sendAllTabCommandChooser(command, text, message as TMessage, {
|
|
1885
|
+
replyToSource: true,
|
|
1886
|
+
})
|
|
1887
|
+
) {
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
if (bindings.length > 0 && !text.startsWith("/")) {
|
|
1892
|
+
const probeTarget = bindings[0]?.target;
|
|
1893
|
+
if (probeTarget?.threadId && deps.callApi) {
|
|
1894
|
+
try {
|
|
1895
|
+
await deps.callApi("sendChatAction", {
|
|
1896
|
+
chat_id: probeTarget.chatId,
|
|
1897
|
+
message_thread_id: probeTarget.threadId,
|
|
1898
|
+
action: "typing",
|
|
1899
|
+
});
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
if (
|
|
1902
|
+
Threads.isTelegramTopicModeUnavailableError(error) ||
|
|
1903
|
+
Threads.isTelegramTopicTargetStaleError(error)
|
|
1904
|
+
) {
|
|
1905
|
+
deps.threadStore.setBotState({
|
|
1906
|
+
threadMode: "disabled",
|
|
1907
|
+
updatedAtMs: Date.now(),
|
|
1908
|
+
lastReconcileAction:
|
|
1909
|
+
"thread-mode-unavailable-threadless-prompt",
|
|
1910
|
+
});
|
|
1911
|
+
await deps.threadStore.persist();
|
|
1912
|
+
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
deps.recordRuntimeEvent?.("telegram", error, {
|
|
1916
|
+
phase: "threadless-topic-capability-check",
|
|
1917
|
+
chatId: probeTarget.chatId,
|
|
1918
|
+
threadId: probeTarget.threadId,
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
await deps.sendTextReply(
|
|
1923
|
+
message.chat.id,
|
|
1924
|
+
message.message_id,
|
|
1925
|
+
"This bot is in threaded multi-instance mode. Send prompts in a bound Pi thread tab so they route to the right instance.",
|
|
1926
|
+
);
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
1931
|
+
},
|
|
627
1932
|
handleAuthorizedTelegramEditedMessage: editRuntime.updateFromEditedMessage,
|
|
628
1933
|
handleAuthorizedTelegramGuestMessage,
|
|
1934
|
+
handleUnboundTelegramTopicMessage: async (message, ctx) => {
|
|
1935
|
+
if (!deps.threadStore) {
|
|
1936
|
+
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
await deps.threadStore.load();
|
|
1940
|
+
if (deps.threadStore.getBotState().threadMode === "disabled") {
|
|
1941
|
+
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
const target = Updates.getTelegramMessageTarget(message);
|
|
1945
|
+
if (!target?.threadId) {
|
|
1946
|
+
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
const text = Media.extractFirstTelegramMessageText([
|
|
1950
|
+
message as TMessage,
|
|
1951
|
+
]).trim();
|
|
1952
|
+
const instanceId = deps.getCurrentInstanceId?.();
|
|
1953
|
+
const leaderProfileKey = getLeaderTopicProfileKey(ctx, instanceId);
|
|
1954
|
+
const existing = deps.threadStore.list().find((r) => {
|
|
1955
|
+
return (
|
|
1956
|
+
r.target.chatId === target.chatId &&
|
|
1957
|
+
r.target.threadId === target.threadId
|
|
1958
|
+
);
|
|
1959
|
+
});
|
|
1960
|
+
if (existing) {
|
|
1961
|
+
const isLeaderTopic =
|
|
1962
|
+
(instanceId && existing.instanceId === instanceId) ||
|
|
1963
|
+
(!!leaderProfileKey && existing.profileKey === leaderProfileKey);
|
|
1964
|
+
if (existing.status === "active" && isLeaderTopic) {
|
|
1965
|
+
if (typeof existing.rerouteConfirmedAtMs !== "number") {
|
|
1966
|
+
const nowMs = Date.now();
|
|
1967
|
+
deps.threadStore.upsert({
|
|
1968
|
+
...existing,
|
|
1969
|
+
updatedAtMs: nowMs,
|
|
1970
|
+
rerouteConfirmedAtMs: nowMs,
|
|
1971
|
+
});
|
|
1972
|
+
await deps.threadStore.persist();
|
|
1973
|
+
}
|
|
1974
|
+
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
if (existing.status === "starting") {
|
|
1978
|
+
await deps.sendTextReply(
|
|
1979
|
+
target.chatId,
|
|
1980
|
+
message.message_id,
|
|
1981
|
+
"Instance " +
|
|
1982
|
+
getTelegramThreadRecordLabel(existing) +
|
|
1983
|
+
" is starting. Please wait…",
|
|
1984
|
+
{ target },
|
|
1985
|
+
);
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
if (existing.status === "active") {
|
|
1989
|
+
await deps.sendTextReply(
|
|
1990
|
+
target.chatId,
|
|
1991
|
+
message.message_id,
|
|
1992
|
+
"Instance " +
|
|
1993
|
+
getTelegramThreadRecordLabel(existing) +
|
|
1994
|
+
" is not connected to the Telegram bus yet. Run /telegram-connect in that Pi instance; keeping this thread.",
|
|
1995
|
+
{ target },
|
|
1996
|
+
);
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
if (
|
|
2000
|
+
(existing.status === "stale" || existing.status === "offline") &&
|
|
2001
|
+
leaderProfileKey &&
|
|
2002
|
+
!hasActiveLeaderTopic(
|
|
2003
|
+
deps.threadStore.list(),
|
|
2004
|
+
leaderProfileKey,
|
|
2005
|
+
instanceId,
|
|
2006
|
+
)
|
|
2007
|
+
) {
|
|
2008
|
+
const priorLeaderRecord =
|
|
2009
|
+
deps.threadStore.getByProfileKey(leaderProfileKey);
|
|
2010
|
+
const slot =
|
|
2011
|
+
deps.threadStore.allocateSlot(
|
|
2012
|
+
leaderProfileKey,
|
|
2013
|
+
priorLeaderRecord?.slot ?? existing.slot,
|
|
2014
|
+
) ??
|
|
2015
|
+
priorLeaderRecord?.slot ??
|
|
2016
|
+
existing.slot ??
|
|
2017
|
+
"A";
|
|
2018
|
+
const threadName =
|
|
2019
|
+
priorLeaderRecord?.threadName ??
|
|
2020
|
+
existing.threadName ??
|
|
2021
|
+
Threads.chooseTelegramThreadName({ slot }) ??
|
|
2022
|
+
"Pi";
|
|
2023
|
+
deps.threadStore.upsert({
|
|
2024
|
+
profileKey: leaderProfileKey,
|
|
2025
|
+
owner: {
|
|
2026
|
+
kind: "leader",
|
|
2027
|
+
cwd:
|
|
2028
|
+
typeof (ctx as { cwd?: unknown }).cwd === "string"
|
|
2029
|
+
? (ctx as { cwd?: string }).cwd
|
|
2030
|
+
: undefined,
|
|
2031
|
+
instanceId,
|
|
2032
|
+
},
|
|
2033
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
2034
|
+
status: "active",
|
|
2035
|
+
createdAtMs: priorLeaderRecord?.createdAtMs ?? existing.createdAtMs,
|
|
2036
|
+
updatedAtMs: Date.now(),
|
|
2037
|
+
threadName,
|
|
2038
|
+
instanceId,
|
|
2039
|
+
slot,
|
|
2040
|
+
});
|
|
2041
|
+
await deps.threadStore.persist();
|
|
2042
|
+
if (deps.callApi) {
|
|
2043
|
+
try {
|
|
2044
|
+
await deps.callApi("editForumTopic", {
|
|
2045
|
+
chat_id: target.chatId,
|
|
2046
|
+
message_thread_id: target.threadId,
|
|
2047
|
+
name: Threads.getTelegramTopicTitleForThreadName(
|
|
2048
|
+
threadName,
|
|
2049
|
+
slot,
|
|
2050
|
+
),
|
|
2051
|
+
});
|
|
2052
|
+
} catch (renameError) {
|
|
2053
|
+
deps.recordRuntimeEvent?.("telegram", renameError, {
|
|
2054
|
+
phase: "leader-topic-reclaim-rename",
|
|
2055
|
+
chatId: target.chatId,
|
|
2056
|
+
threadId: target.threadId,
|
|
2057
|
+
slot,
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
deps.recordRuntimeEvent?.(
|
|
2062
|
+
"bus",
|
|
2063
|
+
"Bus leader reclaimed unbound thread",
|
|
2064
|
+
{
|
|
2065
|
+
phase: "leader-topic-reclaim",
|
|
2066
|
+
chatId: target.chatId,
|
|
2067
|
+
threadId: target.threadId,
|
|
2068
|
+
slot,
|
|
2069
|
+
profileKey: leaderProfileKey,
|
|
2070
|
+
},
|
|
2071
|
+
);
|
|
2072
|
+
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
await deps.sendTextReply(
|
|
2076
|
+
target.chatId,
|
|
2077
|
+
message.message_id,
|
|
2078
|
+
"Topic " +
|
|
2079
|
+
(existing.slot ?? "?") +
|
|
2080
|
+
" is " +
|
|
2081
|
+
existing.status +
|
|
2082
|
+
". Start a Pi instance to claim it.",
|
|
2083
|
+
{ target },
|
|
2084
|
+
);
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
const reservations = deps.threadStore.listReservations();
|
|
2088
|
+
const reservation = reservations.find(
|
|
2089
|
+
(reservation) =>
|
|
2090
|
+
reservation.target.chatId === target.chatId &&
|
|
2091
|
+
reservation.target.threadId === target.threadId,
|
|
2092
|
+
);
|
|
2093
|
+
if (reservation) {
|
|
2094
|
+
await deps.sendTextReply(
|
|
2095
|
+
target.chatId,
|
|
2096
|
+
message.message_id,
|
|
2097
|
+
"Previous leader thread (" +
|
|
2098
|
+
(reservation.slot ?? "?") +
|
|
2099
|
+
"). Closing and deleting this old topic. Use the current thread tab instead.",
|
|
2100
|
+
{ target },
|
|
2101
|
+
);
|
|
2102
|
+
await deleteReservedTelegramTopicThroughReconciler(
|
|
2103
|
+
deps,
|
|
2104
|
+
{ chatId: target.chatId, threadId: target.threadId },
|
|
2105
|
+
message.message_id,
|
|
2106
|
+
);
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
const records = deps.threadStore.list();
|
|
2110
|
+
const command = getKnownTelegramAllTabCommand(text);
|
|
2111
|
+
if (
|
|
2112
|
+
command &&
|
|
2113
|
+
getTelegramRoutableThreadRecords(records, deps.getLiveThreadTargets?.())
|
|
2114
|
+
.length > 0
|
|
2115
|
+
) {
|
|
2116
|
+
if (
|
|
2117
|
+
await sendAllTabCommandChooser(command, text, message as TMessage, {
|
|
2118
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
2119
|
+
replyToSource: true,
|
|
2120
|
+
})
|
|
2121
|
+
) {
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
if (leaderProfileKey && deps.callApi) {
|
|
2126
|
+
const currentLeaderRecord = records.find((record) => {
|
|
2127
|
+
if (record.status !== "active") return false;
|
|
2128
|
+
if (instanceId && record.instanceId === instanceId) return true;
|
|
2129
|
+
return record.profileKey === leaderProfileKey;
|
|
2130
|
+
});
|
|
2131
|
+
if (
|
|
2132
|
+
currentLeaderRecord &&
|
|
2133
|
+
(currentLeaderRecord.target.chatId !== target.chatId ||
|
|
2134
|
+
currentLeaderRecord.target.threadId !== target.threadId)
|
|
2135
|
+
) {
|
|
2136
|
+
let currentLeaderIsStale = false;
|
|
2137
|
+
try {
|
|
2138
|
+
await deps.callApi("sendChatAction", {
|
|
2139
|
+
chat_id: currentLeaderRecord.target.chatId,
|
|
2140
|
+
message_thread_id: currentLeaderRecord.target.threadId,
|
|
2141
|
+
action: "typing",
|
|
2142
|
+
});
|
|
2143
|
+
} catch (error) {
|
|
2144
|
+
currentLeaderIsStale =
|
|
2145
|
+
Threads.isTelegramTopicTargetStaleError(error);
|
|
2146
|
+
if (!currentLeaderIsStale) throw error;
|
|
2147
|
+
}
|
|
2148
|
+
if (currentLeaderIsStale) {
|
|
2149
|
+
deps.threadStore.markStaleByTarget(
|
|
2150
|
+
currentLeaderRecord.target,
|
|
2151
|
+
"deleted",
|
|
2152
|
+
"Current leader thread is stale during unbound prompt routing.",
|
|
2153
|
+
);
|
|
2154
|
+
const slot = currentLeaderRecord.slot ?? "A";
|
|
2155
|
+
const threadName = getRestoredThreadName(currentLeaderRecord, slot);
|
|
2156
|
+
deps.threadStore.upsert({
|
|
2157
|
+
...currentLeaderRecord,
|
|
2158
|
+
profileKey: leaderProfileKey,
|
|
2159
|
+
owner: {
|
|
2160
|
+
kind: "leader",
|
|
2161
|
+
cwd:
|
|
2162
|
+
typeof (ctx as { cwd?: unknown }).cwd === "string"
|
|
2163
|
+
? (ctx as { cwd?: string }).cwd
|
|
2164
|
+
: undefined,
|
|
2165
|
+
instanceId,
|
|
2166
|
+
},
|
|
2167
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
2168
|
+
status: "active",
|
|
2169
|
+
updatedAtMs: Date.now(),
|
|
2170
|
+
threadName,
|
|
2171
|
+
instanceId,
|
|
2172
|
+
slot,
|
|
2173
|
+
});
|
|
2174
|
+
await deps.threadStore.persist();
|
|
2175
|
+
try {
|
|
2176
|
+
await deps.callApi("editForumTopic", {
|
|
2177
|
+
chat_id: target.chatId,
|
|
2178
|
+
message_thread_id: target.threadId,
|
|
2179
|
+
name: Threads.getTelegramTopicTitleForThreadName(
|
|
2180
|
+
threadName,
|
|
2181
|
+
slot,
|
|
2182
|
+
),
|
|
2183
|
+
});
|
|
2184
|
+
} catch (renameError) {
|
|
2185
|
+
deps.recordRuntimeEvent?.("telegram", renameError, {
|
|
2186
|
+
phase: "leader-topic-unbound-reclaim-rename",
|
|
2187
|
+
chatId: target.chatId,
|
|
2188
|
+
threadId: target.threadId,
|
|
2189
|
+
slot,
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
deps.recordRuntimeEvent?.(
|
|
2193
|
+
"bus",
|
|
2194
|
+
"Bus leader reclaimed stale-current unbound thread",
|
|
2195
|
+
{
|
|
2196
|
+
phase: "leader-topic-unbound-stale-reclaim",
|
|
2197
|
+
chatId: target.chatId,
|
|
2198
|
+
threadId: target.threadId,
|
|
2199
|
+
staleThreadId: currentLeaderRecord.target.threadId,
|
|
2200
|
+
slot,
|
|
2201
|
+
profileKey: leaderProfileKey,
|
|
2202
|
+
},
|
|
2203
|
+
);
|
|
2204
|
+
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
if (
|
|
2210
|
+
leaderProfileKey &&
|
|
2211
|
+
!hasActiveLeaderTopic(records, leaderProfileKey, instanceId)
|
|
2212
|
+
) {
|
|
2213
|
+
const priorLeaderRecord =
|
|
2214
|
+
deps.threadStore.getByProfileKey(leaderProfileKey);
|
|
2215
|
+
const priorLeaderIdentity =
|
|
2216
|
+
deps.threadStore.getIdentityByProfileKey(leaderProfileKey);
|
|
2217
|
+
const slot =
|
|
2218
|
+
deps.threadStore.allocateSlot(
|
|
2219
|
+
leaderProfileKey,
|
|
2220
|
+
priorLeaderRecord?.slot ?? priorLeaderIdentity?.slot,
|
|
2221
|
+
) ??
|
|
2222
|
+
priorLeaderRecord?.slot ??
|
|
2223
|
+
priorLeaderIdentity?.slot ??
|
|
2224
|
+
"A";
|
|
2225
|
+
const identityThreadName =
|
|
2226
|
+
priorLeaderIdentity?.threadName &&
|
|
2227
|
+
Threads.isTelegramTopicThreadNameValidForSlot(
|
|
2228
|
+
priorLeaderIdentity.threadName,
|
|
2229
|
+
slot,
|
|
2230
|
+
)
|
|
2231
|
+
? priorLeaderIdentity.threadName
|
|
2232
|
+
: undefined;
|
|
2233
|
+
const threadName =
|
|
2234
|
+
priorLeaderRecord?.threadName ??
|
|
2235
|
+
identityThreadName ??
|
|
2236
|
+
Threads.chooseTelegramThreadName({ slot }) ??
|
|
2237
|
+
"Pi";
|
|
2238
|
+
deps.threadStore.upsert({
|
|
2239
|
+
profileKey: leaderProfileKey,
|
|
2240
|
+
owner: {
|
|
2241
|
+
kind: "leader",
|
|
2242
|
+
cwd:
|
|
2243
|
+
typeof (ctx as { cwd?: unknown }).cwd === "string"
|
|
2244
|
+
? (ctx as { cwd?: string }).cwd
|
|
2245
|
+
: undefined,
|
|
2246
|
+
instanceId,
|
|
2247
|
+
},
|
|
2248
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
2249
|
+
status: "active",
|
|
2250
|
+
createdAtMs: priorLeaderRecord?.createdAtMs ?? Date.now(),
|
|
2251
|
+
updatedAtMs: Date.now(),
|
|
2252
|
+
threadName,
|
|
2253
|
+
instanceId,
|
|
2254
|
+
slot,
|
|
2255
|
+
});
|
|
2256
|
+
await deps.threadStore.persist();
|
|
2257
|
+
if (deps.callApi) {
|
|
2258
|
+
try {
|
|
2259
|
+
await deps.callApi("editForumTopic", {
|
|
2260
|
+
chat_id: target.chatId,
|
|
2261
|
+
message_thread_id: target.threadId,
|
|
2262
|
+
name: Threads.getTelegramTopicTitleForThreadName(
|
|
2263
|
+
threadName,
|
|
2264
|
+
slot,
|
|
2265
|
+
),
|
|
2266
|
+
});
|
|
2267
|
+
} catch (renameError) {
|
|
2268
|
+
deps.recordRuntimeEvent?.("telegram", renameError, {
|
|
2269
|
+
phase: "leader-topic-reclaim-rename",
|
|
2270
|
+
chatId: target.chatId,
|
|
2271
|
+
threadId: target.threadId,
|
|
2272
|
+
slot,
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
deps.recordRuntimeEvent?.(
|
|
2277
|
+
"bus",
|
|
2278
|
+
"Bus leader reclaimed unbound thread",
|
|
2279
|
+
{
|
|
2280
|
+
phase: "leader-topic-reclaim",
|
|
2281
|
+
chatId: target.chatId,
|
|
2282
|
+
threadId: target.threadId,
|
|
2283
|
+
slot,
|
|
2284
|
+
profileKey: leaderProfileKey,
|
|
2285
|
+
},
|
|
2286
|
+
);
|
|
2287
|
+
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
await sendUnboundRerouteChooser(message as TMessage, ctx);
|
|
2291
|
+
return;
|
|
2292
|
+
},
|
|
629
2293
|
});
|
|
630
2294
|
}
|