@llblab/pi-telegram 0.46.0 → 0.48.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 +4 -2
- package/BACKLOG.md +0 -6
- package/CHANGELOG.md +14 -0
- package/README.md +5 -2
- package/dist/api/activity.d.ts +6 -0
- package/dist/api/activity.js +6 -0
- package/dist/api/commands.d.ts +6 -0
- package/dist/api/commands.js +6 -0
- package/dist/api/delivery.d.ts +6 -0
- package/dist/api/delivery.js +6 -0
- package/dist/api/inbound.d.ts +6 -0
- package/dist/api/inbound.js +6 -0
- package/dist/api/keyboard.d.ts +6 -0
- package/dist/api/keyboard.js +6 -0
- package/dist/api/outbound.d.ts +6 -0
- package/dist/api/outbound.js +6 -0
- package/dist/api/sections.d.ts +7 -0
- package/dist/api/sections.js +6 -0
- package/dist/api/status.d.ts +6 -0
- package/dist/api/status.js +6 -0
- package/dist/api/updates.d.ts +6 -0
- package/dist/api/updates.js +6 -0
- package/dist/api/voice.d.ts +6 -0
- package/dist/api/voice.js +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/lib/activity-verbosity.d.ts +52 -0
- package/dist/lib/activity-verbosity.js +596 -0
- package/dist/lib/activity.d.ts +220 -0
- package/dist/lib/activity.js +574 -0
- package/dist/lib/agent-messages.d.ts +28 -0
- package/dist/lib/agent-messages.js +86 -0
- package/dist/lib/bindings.d.ts +250 -0
- package/dist/lib/bindings.js +936 -0
- package/dist/lib/bus-api.d.ts +18 -0
- package/dist/lib/bus-api.js +254 -0
- package/dist/lib/bus-follower.d.ts +464 -0
- package/dist/lib/bus-follower.js +1686 -0
- package/dist/lib/bus-leader.d.ts +292 -0
- package/dist/lib/bus-leader.js +2242 -0
- package/dist/lib/bus-transport.d.ts +64 -0
- package/dist/lib/bus-transport.js +140 -0
- package/dist/lib/bus.d.ts +518 -0
- package/dist/lib/bus.js +2055 -0
- package/dist/lib/channel-posts.d.ts +170 -0
- package/dist/lib/channel-posts.js +611 -0
- package/dist/lib/command-templates.d.ts +70 -0
- package/dist/lib/command-templates.js +744 -0
- package/dist/lib/commands.d.ts +541 -0
- package/dist/lib/commands.js +1155 -0
- package/dist/lib/config.d.ts +252 -0
- package/dist/lib/config.js +889 -0
- package/dist/lib/delivery.d.ts +163 -0
- package/dist/lib/delivery.js +542 -0
- package/dist/lib/extension.d.ts +7 -0
- package/dist/lib/extension.js +1604 -0
- package/dist/lib/generative-app-worker.mjs +104 -0
- package/dist/lib/generative-apps.d.ts +212 -0
- package/dist/lib/generative-apps.js +1006 -0
- package/dist/lib/inbound.d.ts +91 -0
- package/dist/lib/inbound.js +502 -0
- package/dist/lib/journal.d.ts +665 -0
- package/dist/lib/journal.js +3701 -0
- package/dist/lib/keyboard.d.ts +23 -0
- package/dist/lib/keyboard.js +37 -0
- package/dist/lib/lifecycle.d.ts +157 -0
- package/dist/lib/lifecycle.js +395 -0
- package/dist/lib/locks.d.ts +164 -0
- package/dist/lib/locks.js +1208 -0
- package/dist/lib/logging.d.ts +50 -0
- package/dist/lib/logging.js +274 -0
- package/dist/lib/media.d.ts +181 -0
- package/dist/lib/media.js +602 -0
- package/dist/lib/menu-model.d.ts +217 -0
- package/dist/lib/menu-model.js +663 -0
- package/dist/lib/menu-queue.d.ts +37 -0
- package/dist/lib/menu-queue.js +408 -0
- package/dist/lib/menu-settings.d.ts +115 -0
- package/dist/lib/menu-settings.js +595 -0
- package/dist/lib/menu-status.d.ts +32 -0
- package/dist/lib/menu-status.js +112 -0
- package/dist/lib/menu-thinking.d.ts +29 -0
- package/dist/lib/menu-thinking.js +82 -0
- package/dist/lib/menu.d.ts +171 -0
- package/dist/lib/menu.js +323 -0
- package/dist/lib/model.d.ts +127 -0
- package/dist/lib/model.js +409 -0
- package/dist/lib/outbound-attachments.d.ts +241 -0
- package/dist/lib/outbound-attachments.js +639 -0
- package/dist/lib/outbound-buttons.d.ts +69 -0
- package/dist/lib/outbound-buttons.js +248 -0
- package/dist/lib/outbound-markup.d.ts +42 -0
- package/dist/lib/outbound-markup.js +678 -0
- package/dist/lib/outbound-voice.d.ts +55 -0
- package/dist/lib/outbound-voice.js +152 -0
- package/dist/lib/outbound.d.ts +185 -0
- package/dist/lib/outbound.js +516 -0
- package/dist/lib/ownership.d.ts +77 -0
- package/dist/lib/ownership.js +174 -0
- package/dist/lib/paths.d.ts +40 -0
- package/dist/lib/paths.js +94 -0
- package/dist/lib/pi.d.ts +72 -0
- package/dist/lib/pi.js +121 -0
- package/dist/lib/polling.d.ts +351 -0
- package/dist/lib/polling.js +1196 -0
- package/dist/lib/preview.d.ts +192 -0
- package/dist/lib/preview.js +614 -0
- package/dist/lib/prompt-templates.d.ts +24 -0
- package/dist/lib/prompt-templates.js +118 -0
- package/dist/lib/prompts.d.ts +57 -0
- package/dist/lib/prompts.js +167 -0
- package/dist/lib/queue.d.ts +766 -0
- package/dist/lib/queue.js +1965 -0
- package/dist/lib/recovery.d.ts +86 -0
- package/dist/lib/recovery.js +285 -0
- package/dist/lib/rendering.d.ts +20 -0
- package/dist/lib/rendering.js +983 -0
- package/dist/lib/replies.d.ts +214 -0
- package/dist/lib/replies.js +737 -0
- package/dist/lib/routing.d.ts +207 -0
- package/dist/lib/routing.js +2282 -0
- package/dist/lib/runtime.d.ts +172 -0
- package/dist/lib/runtime.js +402 -0
- package/dist/lib/sections.d.ts +165 -0
- package/dist/lib/sections.js +327 -0
- package/dist/lib/setup.d.ts +82 -0
- package/dist/lib/setup.js +150 -0
- package/dist/lib/skills.d.ts +8 -0
- package/dist/lib/skills.js +12 -0
- package/dist/lib/status.d.ts +442 -0
- package/dist/lib/status.js +1008 -0
- package/dist/lib/sync.d.ts +179 -0
- package/dist/lib/sync.js +782 -0
- package/dist/lib/target.d.ts +20 -0
- package/dist/lib/target.js +27 -0
- package/dist/lib/telegram-api.d.ts +541 -0
- package/dist/lib/telegram-api.js +1159 -0
- package/dist/lib/text-groups.d.ts +89 -0
- package/dist/lib/text-groups.js +317 -0
- package/dist/lib/thread-cleanup-manager.d.ts +288 -0
- package/dist/lib/thread-cleanup-manager.js +560 -0
- package/dist/lib/thread-display.d.ts +46 -0
- package/dist/lib/thread-display.js +257 -0
- package/dist/lib/thread-naming.d.ts +46 -0
- package/dist/lib/thread-naming.js +78 -0
- package/dist/lib/thread-reconciler.d.ts +239 -0
- package/dist/lib/thread-reconciler.js +644 -0
- package/dist/lib/threads.d.ts +621 -0
- package/dist/lib/threads.js +3679 -0
- package/dist/lib/time-injection.d.ts +15 -0
- package/dist/lib/time-injection.js +56 -0
- package/dist/lib/turns.d.ts +109 -0
- package/dist/lib/turns.js +500 -0
- package/dist/lib/updates.d.ts +1290 -0
- package/dist/lib/updates.js +3634 -0
- package/dist/lib/voice.d.ts +117 -0
- package/dist/lib/voice.js +174 -0
- package/dist/lib/workspace-admission.d.ts +264 -0
- package/dist/lib/workspace-admission.js +1136 -0
- package/dist/lib/workspace-retirement.d.ts +219 -0
- package/dist/lib/workspace-retirement.js +587 -0
- package/dist/lib/workspace-slots.d.ts +30 -0
- package/dist/lib/workspace-slots.js +54 -0
- package/dist/package.json +126 -0
- package/dist/pi-telegram/index.js +1 -0
- package/dist/skills/generated-control-surface/SKILL.md +107 -0
- package/dist/skills/generated-control-surface/references/capability-adapters.md +27 -0
- package/dist/skills/generated-control-surface/references/layout-and-state.md +37 -0
- package/dist/skills/generative-apps/SKILL.md +115 -0
- package/dist/skills/show-me/SKILL.md +166 -0
- package/dist/skills/show-me/references/telegram-surfaces.md +43 -0
- package/dist/skills/telegram-bridge/SKILL.md +129 -0
- package/dist/skills/telegram-bridge/references/configuration.md +15 -0
- package/dist/skills/telegram-bridge/references/delivery-and-threads.md +27 -0
- package/dist/skills/telegram-bridge/references/diagnosis.md +18 -0
- package/docs/README.md +2 -0
- package/docs/architecture.md +1 -1
- package/docs/compact-matrix-literal.md +10 -3
- package/docs/generative-apps.md +15 -13
- package/docs/multi-instance-bus.md +6 -4
- package/docs/outbound.md +1 -1
- package/docs/public-api.md +3 -3
- package/lib/bindings.ts +89 -6
- package/lib/bus-follower.ts +7 -3
- package/lib/bus-leader.ts +44 -19
- package/lib/bus.ts +4 -1
- package/lib/commands.ts +2 -2
- package/lib/config.ts +14 -7
- package/lib/delivery.ts +38 -6
- package/lib/extension.ts +7 -0
- package/lib/generative-apps.ts +379 -13
- package/lib/menu-settings.ts +20 -6
- package/lib/outbound-markup.ts +16 -7
- package/lib/status.ts +15 -12
- package/lib/telegram-api.ts +12 -1
- package/lib/thread-display.ts +96 -18
- package/package.json +56 -13
- package/scripts/build-dist.mjs +44 -0
- package/scripts/measure-bus.mjs +8 -1
- package/scripts/measure-workspace.mjs +8 -1
- package/skills/generative-apps/SKILL.md +1 -1
- package/skills/show-me/SKILL.md +1 -1
- package/skills/show-me/references/telegram-surfaces.md +1 -1
- package/skills/telegram-bridge/SKILL.md +1 -1
|
@@ -0,0 +1,2282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram inbound routing composition
|
|
3
|
+
* Zones: telegram inbound, orchestration, queue/menu/command composition
|
|
4
|
+
* Wires authorized updates into menus, commands, media grouping, and prompt queueing, and owns exact assistant-output target/route authority capture
|
|
5
|
+
*/
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { basename, dirname } from "node:path";
|
|
8
|
+
import * as Bus from "./bus.js";
|
|
9
|
+
import * as Commands from "./commands.js";
|
|
10
|
+
import * as Media from "./media.js";
|
|
11
|
+
import * as Menu from "./menu.js";
|
|
12
|
+
import * as OutboundHandlers from "./outbound.js";
|
|
13
|
+
import * as PromptTemplates from "./prompt-templates.js";
|
|
14
|
+
import * as Queue from "./queue.js";
|
|
15
|
+
import * as Replies from "./replies.js";
|
|
16
|
+
import * as TextGroups from "./text-groups.js";
|
|
17
|
+
import * as ThreadNaming from "./thread-naming.js";
|
|
18
|
+
import * as ThreadReconciler from "./thread-reconciler.js";
|
|
19
|
+
import * as Turns from "./turns.js";
|
|
20
|
+
function formatTelegramPromptPeer(peer) {
|
|
21
|
+
if (!peer)
|
|
22
|
+
return undefined;
|
|
23
|
+
if (typeof peer.username === "string" && peer.username.length > 0) {
|
|
24
|
+
return peer.username;
|
|
25
|
+
}
|
|
26
|
+
const displayName = [peer.first_name, peer.last_name]
|
|
27
|
+
.filter((part) => typeof part === "string" && part.length > 0)
|
|
28
|
+
.join(" ");
|
|
29
|
+
if (displayName)
|
|
30
|
+
return displayName;
|
|
31
|
+
if (typeof peer.title === "string" && peer.title.length > 0) {
|
|
32
|
+
return peer.title;
|
|
33
|
+
}
|
|
34
|
+
return typeof peer.id === "number" ? String(peer.id) : undefined;
|
|
35
|
+
}
|
|
36
|
+
function isTelegramPromptOwnerPeer(peer, ownerUserId) {
|
|
37
|
+
return ownerUserId !== undefined && peer?.id === ownerUserId;
|
|
38
|
+
}
|
|
39
|
+
function isTelegramPromptBotPeer(peer) {
|
|
40
|
+
return peer?.is_bot === true;
|
|
41
|
+
}
|
|
42
|
+
export function resolveTelegramGuestPromptPeer(input) {
|
|
43
|
+
if (input.chatType !== "private") {
|
|
44
|
+
return formatTelegramPromptPeer(input.chat);
|
|
45
|
+
}
|
|
46
|
+
if (!isTelegramPromptOwnerPeer(input.from, input.ownerUserId) &&
|
|
47
|
+
!isTelegramPromptBotPeer(input.from)) {
|
|
48
|
+
return formatTelegramPromptPeer(input.from);
|
|
49
|
+
}
|
|
50
|
+
for (const candidate of [
|
|
51
|
+
input.chat,
|
|
52
|
+
input.guestBotCallerUser,
|
|
53
|
+
input.guestBotCallerChat,
|
|
54
|
+
input.replyFrom,
|
|
55
|
+
]) {
|
|
56
|
+
if (isTelegramPromptOwnerPeer(candidate, input.ownerUserId) ||
|
|
57
|
+
isTelegramPromptBotPeer(candidate)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const peer = formatTelegramPromptPeer(candidate);
|
|
61
|
+
if (peer)
|
|
62
|
+
return peer;
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
function appendTelegramSourceAttachmentSection(text, from, files, outputs = []) {
|
|
67
|
+
if (files.length === 0 && outputs.length === 0)
|
|
68
|
+
return text;
|
|
69
|
+
const dirs = [...new Set(files.map((file) => dirname(file.path)))];
|
|
70
|
+
const sameDir = dirs.length === 1;
|
|
71
|
+
const source = from ? `|from:${from}` : "";
|
|
72
|
+
const header = sameDir
|
|
73
|
+
? `[attachments${source}] ${dirs[0]}`
|
|
74
|
+
: `[attachments${source}]`;
|
|
75
|
+
const items = sameDir
|
|
76
|
+
? files.map((file) => `/${basename(file.path)}`)
|
|
77
|
+
: files.map((file) => file.path);
|
|
78
|
+
const sections = text ? [text] : [];
|
|
79
|
+
if (items.length > 0) {
|
|
80
|
+
sections.push(`${header}\n${items.map((item) => `- ${item}`).join("\n")}`);
|
|
81
|
+
}
|
|
82
|
+
if (outputs.length > 0) {
|
|
83
|
+
const outputHeader = `[outputs${source}]`;
|
|
84
|
+
sections.push(`${outputHeader}\n${outputs.map((output) => `- ${output}`).join("\n")}`);
|
|
85
|
+
}
|
|
86
|
+
return sections.join("\n\n");
|
|
87
|
+
}
|
|
88
|
+
function getContextCwd(ctx) {
|
|
89
|
+
if (!ctx || typeof ctx !== "object")
|
|
90
|
+
return undefined;
|
|
91
|
+
const cwd = ctx.cwd;
|
|
92
|
+
return typeof cwd === "string" && cwd.length > 0 ? cwd : undefined;
|
|
93
|
+
}
|
|
94
|
+
function getLeaderTopicProfileKey(ctx, instanceId) {
|
|
95
|
+
const cwd = getContextCwd(ctx);
|
|
96
|
+
if (cwd)
|
|
97
|
+
return `cwd:${cwd}`;
|
|
98
|
+
return instanceId ? `leader:${instanceId}` : undefined;
|
|
99
|
+
}
|
|
100
|
+
function isCurrentLeaderTopicRecord(record, profileKey, instanceId) {
|
|
101
|
+
if (instanceId && record.instanceId === instanceId)
|
|
102
|
+
return true;
|
|
103
|
+
return !!profileKey && record.profileKey === profileKey;
|
|
104
|
+
}
|
|
105
|
+
function hasActiveLeaderTopic(records, profileKey, instanceId) {
|
|
106
|
+
return records.some((record) => {
|
|
107
|
+
if (record.status !== "active")
|
|
108
|
+
return false;
|
|
109
|
+
return isCurrentLeaderTopicRecord(record, profileKey, instanceId);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
function escapeHtml(text) {
|
|
113
|
+
return text
|
|
114
|
+
.replace(/&/g, "&")
|
|
115
|
+
.replace(/</g, "<")
|
|
116
|
+
.replace(/>/g, ">");
|
|
117
|
+
}
|
|
118
|
+
const TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX = "reroute:";
|
|
119
|
+
const TELEGRAM_UNBOUND_REROUTE_RESTORE_MENU_CALLBACK_PREFIX = "rerouterestore:";
|
|
120
|
+
const TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX = "reroutenew:";
|
|
121
|
+
const TELEGRAM_SLOT_CAPACITY_MESSAGE = "No Telegram instance slot is available. Automatic reclamation is disabled for safety.";
|
|
122
|
+
function formatTelegramUnboundRerouteCallbackData(rerouteId, threadId) {
|
|
123
|
+
return `${TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX}${rerouteId}:${threadId}`;
|
|
124
|
+
}
|
|
125
|
+
function formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId) {
|
|
126
|
+
return `${TELEGRAM_UNBOUND_REROUTE_RESTORE_MENU_CALLBACK_PREFIX}${rerouteId}`;
|
|
127
|
+
}
|
|
128
|
+
function formatTelegramUnboundRerouteNewSlotCallbackData(rerouteId, threadId) {
|
|
129
|
+
return `${TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX}${rerouteId}:${threadId}`;
|
|
130
|
+
}
|
|
131
|
+
function parseTelegramUnboundRerouteRestoreMenuCallbackData(data) {
|
|
132
|
+
const match = data?.match(/^rerouterestore:([a-z0-9]+)$/);
|
|
133
|
+
const rerouteId = match?.[1];
|
|
134
|
+
return rerouteId ? { rerouteId } : undefined;
|
|
135
|
+
}
|
|
136
|
+
function parseTelegramUnboundRerouteCallbackData(data) {
|
|
137
|
+
const match = data?.match(/^(reroute|reroutenew):([a-z0-9]+):(\d+)$/);
|
|
138
|
+
const prefix = match?.[1];
|
|
139
|
+
const rerouteId = match?.[2];
|
|
140
|
+
const threadId = Number(match?.[3]);
|
|
141
|
+
if (!prefix || !rerouteId || !Number.isSafeInteger(threadId))
|
|
142
|
+
return undefined;
|
|
143
|
+
return { rerouteId, threadId, useNewSlot: prefix === "reroutenew" };
|
|
144
|
+
}
|
|
145
|
+
function getTelegramThreadRecordLabel(record, getDisplayTitle) {
|
|
146
|
+
return getDisplayTitle?.(record.target) ?? getRestoredThreadName(record, record.slot ?? "");
|
|
147
|
+
}
|
|
148
|
+
function getNextTelegramSlotPreference(slot) {
|
|
149
|
+
if (!slot || !/^[A-Z]$/.test(slot))
|
|
150
|
+
return undefined;
|
|
151
|
+
const index = slot.charCodeAt(0) - "A".charCodeAt(0);
|
|
152
|
+
return String.fromCharCode("A".charCodeAt(0) + ((index + 1) % 26));
|
|
153
|
+
}
|
|
154
|
+
function getRestoredThreadName(record, slot) {
|
|
155
|
+
return record.threadName &&
|
|
156
|
+
Threads.isTelegramTopicThreadNameValidForSlot(record.threadName, slot)
|
|
157
|
+
? record.threadName
|
|
158
|
+
: (Threads.chooseTelegramThreadName({ slot }) ?? "Pi");
|
|
159
|
+
}
|
|
160
|
+
function isTelegramLiveThreadTarget(record, liveTargets) {
|
|
161
|
+
if (!liveTargets)
|
|
162
|
+
return record.status === "active";
|
|
163
|
+
return liveTargets.some((target) => target.chatId === record.target.chatId &&
|
|
164
|
+
target.threadId === record.target.threadId);
|
|
165
|
+
}
|
|
166
|
+
function getTelegramRoutableThreadRecords(records, liveTargets) {
|
|
167
|
+
return records.filter((record) => record.status === "active" &&
|
|
168
|
+
isTelegramLiveThreadTarget(record, liveTargets));
|
|
169
|
+
}
|
|
170
|
+
function formatTelegramAllTabMenuChooserText(command) {
|
|
171
|
+
return [
|
|
172
|
+
"<b>🧵 Choose target thread:</b>",
|
|
173
|
+
"",
|
|
174
|
+
`You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.`,
|
|
175
|
+
"Select the Pi thread that should handle it:",
|
|
176
|
+
"To restore into a new thread, send a plain message in that destination thread first.",
|
|
177
|
+
].join("\n");
|
|
178
|
+
}
|
|
179
|
+
function buildTelegramUnboundRerouteChooserMarkup(rerouteId, records, options) {
|
|
180
|
+
const activeRecords = records.filter((record) => record.status === "active");
|
|
181
|
+
const canRestoreAnyLiveThread = options.canRestore && activeRecords.length > 0;
|
|
182
|
+
const rows = activeRecords.map((record) => [
|
|
183
|
+
{
|
|
184
|
+
text: `↪️ ${getTelegramThreadRecordLabel(record, options.getDisplayTitle)}`,
|
|
185
|
+
callback_data: formatTelegramUnboundRerouteCallbackData(rerouteId, record.target.threadId),
|
|
186
|
+
},
|
|
187
|
+
]);
|
|
188
|
+
return {
|
|
189
|
+
inline_keyboard: canRestoreAnyLiveThread
|
|
190
|
+
? [
|
|
191
|
+
...rows,
|
|
192
|
+
[
|
|
193
|
+
{
|
|
194
|
+
text: "🔁 Replace/restore thread…",
|
|
195
|
+
callback_data: formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId),
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
]
|
|
199
|
+
: rows,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function buildTelegramUnboundRerouteRestoreChooserMarkup(rerouteId, records, getDisplayTitle) {
|
|
203
|
+
return {
|
|
204
|
+
inline_keyboard: records
|
|
205
|
+
.filter((record) => record.status === "active")
|
|
206
|
+
.map((record) => [
|
|
207
|
+
{
|
|
208
|
+
text: `➡️ ${getTelegramThreadRecordLabel(record, getDisplayTitle)}`,
|
|
209
|
+
callback_data: formatTelegramUnboundRerouteNewSlotCallbackData(rerouteId, record.target.threadId),
|
|
210
|
+
},
|
|
211
|
+
]),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function formatTelegramUnboundRerouteRestoreChooserText() {
|
|
215
|
+
return [
|
|
216
|
+
"<b>🧵 Replace/restore Telegram thread:</b>",
|
|
217
|
+
"",
|
|
218
|
+
"Choose the Pi instance to move to this new Telegram thread:",
|
|
219
|
+
].join("\n");
|
|
220
|
+
}
|
|
221
|
+
function formatTelegramUnboundTopicGuidance() {
|
|
222
|
+
return [
|
|
223
|
+
"<b>⚠️ New thread is not a Pi instance.</b>",
|
|
224
|
+
"",
|
|
225
|
+
"To create a bound Telegram tab:",
|
|
226
|
+
"<code>1.</code> Start another Pi instance in your terminal.",
|
|
227
|
+
"<code>2.</code> Run <code>/telegram-connect</code> in that instance.",
|
|
228
|
+
"<code>3.</code> The bridge will create and bind a fresh Telegram tab for it.",
|
|
229
|
+
].join("\n");
|
|
230
|
+
}
|
|
231
|
+
function formatTelegramTargetKey(target) {
|
|
232
|
+
return `${target.chatId}:${target.threadId ?? "all"}`;
|
|
233
|
+
}
|
|
234
|
+
function formatTelegramUnboundRerouteChooserText(_records, options = {}) {
|
|
235
|
+
const rerouteText = [
|
|
236
|
+
"<b>🧵 Choose target thread:</b>",
|
|
237
|
+
"",
|
|
238
|
+
"Your message is still in this Telegram thread.",
|
|
239
|
+
"Select the Pi thread that should handle it:",
|
|
240
|
+
].join("\n");
|
|
241
|
+
return options.includeGuidance === false
|
|
242
|
+
? rerouteText
|
|
243
|
+
: [formatTelegramUnboundTopicGuidance(), "", rerouteText].join("\n");
|
|
244
|
+
}
|
|
245
|
+
import * as Threads from "./threads.js";
|
|
246
|
+
import * as Updates from "./updates.js";
|
|
247
|
+
import { getTelegramVoiceReplyMode } from "./voice.js";
|
|
248
|
+
async function deleteReservedTelegramTopicThroughReconciler(deps, target, messageId) {
|
|
249
|
+
if (!deps.threadStore)
|
|
250
|
+
return false;
|
|
251
|
+
const nowMs = Date.now();
|
|
252
|
+
const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
253
|
+
const plan = ThreadReconciler.planThreadReconciliation({
|
|
254
|
+
nowMs,
|
|
255
|
+
currentLeaderEpoch,
|
|
256
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
257
|
+
records: deps.threadStore.list(),
|
|
258
|
+
reservations: deps.threadStore.listReservations(),
|
|
259
|
+
observations: deps.threadStore.listSyncObservations(),
|
|
260
|
+
reservedMessages: [
|
|
261
|
+
{
|
|
262
|
+
target,
|
|
263
|
+
observedAtMs: nowMs,
|
|
264
|
+
messageId,
|
|
265
|
+
...(currentLeaderEpoch !== undefined
|
|
266
|
+
? { leaderEpoch: currentLeaderEpoch }
|
|
267
|
+
: {}),
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
deps.recordThreadReconciliationPlan?.(plan);
|
|
272
|
+
await ThreadReconciler.applyThreadReconciliationPlan(plan, {
|
|
273
|
+
isCleanupTargetProtected: Threads.createTelegramCleanupTargetProtection(deps.threadStore),
|
|
274
|
+
callApi: deps.callApi,
|
|
275
|
+
markStaleByTarget: (staleTarget, syncStatus, lastSyncError) => deps.threadStore?.markStaleByTarget(staleTarget, syncStatus, lastSyncError) ?? false,
|
|
276
|
+
persist: () => deps.threadStore?.persist() ?? Promise.resolve(),
|
|
277
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
278
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
279
|
+
});
|
|
280
|
+
return plan.actions.some((action) => action.kind === "close-delete-reserved-topic");
|
|
281
|
+
}
|
|
282
|
+
export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
|
|
283
|
+
export function isTelegramAllTabCommandExpired(message, nowMs = Date.now()) {
|
|
284
|
+
return message.message_thread_id === undefined &&
|
|
285
|
+
typeof message.date === "number" && Number.isFinite(message.date) &&
|
|
286
|
+
message.date > 0 && Number.isFinite(nowMs) &&
|
|
287
|
+
nowMs - message.date * 1000 >= TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS;
|
|
288
|
+
}
|
|
289
|
+
export function createTelegramInboundBusProjectionRuntime(deps) {
|
|
290
|
+
return {
|
|
291
|
+
getTargetOwnership(target) {
|
|
292
|
+
return Bus.getTelegramFollowerTargetOwnership({
|
|
293
|
+
target,
|
|
294
|
+
followers: deps.listFollowers(),
|
|
295
|
+
activeThreadRecords: deps.listThreadRecords(),
|
|
296
|
+
currentInstanceId: deps.instanceId,
|
|
297
|
+
});
|
|
298
|
+
},
|
|
299
|
+
getLiveThreadTargets() {
|
|
300
|
+
return Bus.listTelegramBusLiveThreadTargets({
|
|
301
|
+
leaderTarget: deps.getLeaderTarget(),
|
|
302
|
+
followers: deps.listFollowers(),
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
getLocalThreadLabelForTarget(target) {
|
|
306
|
+
const followerTarget = deps.getFollowerTarget();
|
|
307
|
+
const leaderTarget = deps.getLeaderTarget();
|
|
308
|
+
const isLocalFollowerTarget = deps.isFollowerRegistered() &&
|
|
309
|
+
followerTarget?.chatId === target.chatId &&
|
|
310
|
+
followerTarget.threadId === target.threadId;
|
|
311
|
+
const isLocalLeaderTarget = leaderTarget?.chatId === target.chatId &&
|
|
312
|
+
leaderTarget.threadId === target.threadId;
|
|
313
|
+
if (!isLocalFollowerTarget && !isLocalLeaderTarget)
|
|
314
|
+
return undefined;
|
|
315
|
+
return deps.getCurrentIdentity(target).threadName;
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const TELEGRAM_OWNED_CALLBACK_PREFIXES = [
|
|
320
|
+
"allmenu:",
|
|
321
|
+
TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX,
|
|
322
|
+
"compact:",
|
|
323
|
+
"menu:",
|
|
324
|
+
"model:",
|
|
325
|
+
"queue:",
|
|
326
|
+
"section:",
|
|
327
|
+
"settings:",
|
|
328
|
+
"status:",
|
|
329
|
+
"tgbtn:",
|
|
330
|
+
"thinking:",
|
|
331
|
+
];
|
|
332
|
+
function isTelegramOwnedCallbackData(data) {
|
|
333
|
+
return TELEGRAM_OWNED_CALLBACK_PREFIXES.some((prefix) => data.startsWith(prefix));
|
|
334
|
+
}
|
|
335
|
+
export function createTelegramInboundRouteRuntime(deps) {
|
|
336
|
+
const pendingUnboundReroutes = new Map();
|
|
337
|
+
const guidedUnboundTopicKeys = new Set();
|
|
338
|
+
let nextUnboundRerouteId = 0;
|
|
339
|
+
const requestDispatchNextQueuedTelegramTurn = (ctx) => {
|
|
340
|
+
deps.dispatchNextQueuedTelegramTurn(ctx);
|
|
341
|
+
if (deps.requestDeferredDispatchNextQueuedTelegramTurn &&
|
|
342
|
+
deps.hasDeferredDispatchContext?.() !== false) {
|
|
343
|
+
deps.requestDeferredDispatchNextQueuedTelegramTurn(deps.dispatchNextQueuedTelegramTurn);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
const resolveTelegramThreadLabel = (message) => {
|
|
347
|
+
const chatId = message.chat.id;
|
|
348
|
+
const threadId = message.message_thread_id;
|
|
349
|
+
if (!threadId)
|
|
350
|
+
return undefined;
|
|
351
|
+
const localLabel = deps.getDisplayTitle?.({ chatId, threadId }) ??
|
|
352
|
+
deps.getLocalThreadLabelForTarget?.({ chatId, threadId });
|
|
353
|
+
if (localLabel)
|
|
354
|
+
return localLabel;
|
|
355
|
+
if (!deps.threadStore)
|
|
356
|
+
return undefined;
|
|
357
|
+
const records = deps.threadStore.list();
|
|
358
|
+
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
359
|
+
for (const record of records) {
|
|
360
|
+
if (record.target.chatId !== chatId ||
|
|
361
|
+
record.target.threadId !== threadId) {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (currentInstanceId &&
|
|
365
|
+
record.instanceId &&
|
|
366
|
+
record.instanceId !== currentInstanceId) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
return record.threadName &&
|
|
370
|
+
Threads.isTelegramTopicThreadNameValidForSlot(record.threadName, record.slot)
|
|
371
|
+
? record.threadName
|
|
372
|
+
: getRestoredThreadName(record, record.slot ?? "");
|
|
373
|
+
}
|
|
374
|
+
return undefined;
|
|
375
|
+
};
|
|
376
|
+
const createAdmissionReceipts = (queueKind, sources) => {
|
|
377
|
+
const sourceUpdateIds = Updates.collectTelegramAdmissionSourceUpdateIds(sources);
|
|
378
|
+
if (sourceUpdateIds.length === 0)
|
|
379
|
+
return [];
|
|
380
|
+
const receipt = Queue.createTelegramQueueAdmissionReceipt({
|
|
381
|
+
queueKind,
|
|
382
|
+
scope: deps.getAdmissionScope?.() ?? "",
|
|
383
|
+
sourceUpdateIds,
|
|
384
|
+
});
|
|
385
|
+
const journalBindingKey = deps.getAdmissionJournalBinding?.();
|
|
386
|
+
return receipt
|
|
387
|
+
? [{
|
|
388
|
+
...receipt,
|
|
389
|
+
...(journalBindingKey ? { journalBindingKey } : {}),
|
|
390
|
+
}]
|
|
391
|
+
: [];
|
|
392
|
+
};
|
|
393
|
+
const reportQueueAdmission = (sources, receipts) => {
|
|
394
|
+
Updates.reportTelegramQueueAdmission(sources, receipts);
|
|
395
|
+
};
|
|
396
|
+
const removePendingReroute = (id) => {
|
|
397
|
+
pendingUnboundReroutes.get(id)?.stopExpiry?.();
|
|
398
|
+
pendingUnboundReroutes.delete(id);
|
|
399
|
+
};
|
|
400
|
+
const expirePendingCommand = (id, pending) => {
|
|
401
|
+
if (pending.destinationSelected || pending.expiresAtMs === undefined ||
|
|
402
|
+
Date.now() < pending.expiresAtMs)
|
|
403
|
+
return false;
|
|
404
|
+
if (pendingUnboundReroutes.get(id) !== pending)
|
|
405
|
+
return true;
|
|
406
|
+
for (const message of pending.messages)
|
|
407
|
+
Updates.reportTelegramUpdateCompleted(message);
|
|
408
|
+
removePendingReroute(id);
|
|
409
|
+
return true;
|
|
410
|
+
};
|
|
411
|
+
const armPendingCommandExpiry = (id, pending) => {
|
|
412
|
+
if (pending.dispatchKind !== "command" || pending.sourceTarget.threadId !== undefined)
|
|
413
|
+
return;
|
|
414
|
+
pending.stopExpiry?.();
|
|
415
|
+
const execution = Updates.getTelegramUpdateExecutionFence(pending.messages[0]);
|
|
416
|
+
const onAbort = () => removePendingReroute(id);
|
|
417
|
+
let timer;
|
|
418
|
+
let stopped = false;
|
|
419
|
+
pending.pauseExpiry = () => {
|
|
420
|
+
if (timer !== undefined)
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
timer = undefined;
|
|
423
|
+
};
|
|
424
|
+
pending.stopExpiry = () => {
|
|
425
|
+
stopped = true;
|
|
426
|
+
pending.pauseExpiry?.();
|
|
427
|
+
execution?.signal.removeEventListener("abort", onAbort);
|
|
428
|
+
};
|
|
429
|
+
execution?.signal.addEventListener("abort", onAbort, { once: true });
|
|
430
|
+
if (execution?.signal.aborted) {
|
|
431
|
+
onAbort();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (pending.expiresAtMs === undefined) {
|
|
435
|
+
const date = pending.messages[0]?.date;
|
|
436
|
+
if (typeof date !== "number" || !Number.isFinite(date) || date <= 0 || date * 1000 > Date.now())
|
|
437
|
+
return;
|
|
438
|
+
pending.expiresAtMs = date * 1000 + TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS;
|
|
439
|
+
}
|
|
440
|
+
const schedule = () => {
|
|
441
|
+
if (stopped || pending.destinationSelected || pendingUnboundReroutes.get(id) !== pending)
|
|
442
|
+
return;
|
|
443
|
+
if (expirePendingCommand(id, pending))
|
|
444
|
+
return;
|
|
445
|
+
const delay = Math.min(TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS, pending.expiresAtMs - Date.now());
|
|
446
|
+
timer = setTimeout(schedule, Math.max(1, delay));
|
|
447
|
+
timer.unref?.();
|
|
448
|
+
};
|
|
449
|
+
schedule();
|
|
450
|
+
};
|
|
451
|
+
const prunePendingUnboundReroutes = () => {
|
|
452
|
+
const nowMs = Date.now();
|
|
453
|
+
for (const [id, entry] of pendingUnboundReroutes) {
|
|
454
|
+
if (entry.dispatchKind === "command" && entry.sourceTarget.threadId === undefined) {
|
|
455
|
+
expirePendingCommand(id, entry);
|
|
456
|
+
}
|
|
457
|
+
else if (nowMs - entry.createdAtMs > 30 * 60_000) {
|
|
458
|
+
removePendingReroute(id);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
const storePendingUnboundReroute = (messages, dispatchKind = "prompt") => {
|
|
463
|
+
prunePendingUnboundReroutes();
|
|
464
|
+
if (pendingUnboundReroutes.size >= 100) {
|
|
465
|
+
throw new Error("Telegram route chooser capacity reached; source remains retryable.");
|
|
466
|
+
}
|
|
467
|
+
nextUnboundRerouteId += 1;
|
|
468
|
+
const id = nextUnboundRerouteId.toString(36);
|
|
469
|
+
pendingUnboundReroutes.set(id, {
|
|
470
|
+
sourceTarget: {
|
|
471
|
+
chatId: messages[0].chat.id,
|
|
472
|
+
...(typeof messages[0].message_thread_id === "number"
|
|
473
|
+
? { threadId: messages[0].message_thread_id }
|
|
474
|
+
: {}),
|
|
475
|
+
},
|
|
476
|
+
messages,
|
|
477
|
+
createdAtMs: Date.now(),
|
|
478
|
+
dispatchKind,
|
|
479
|
+
});
|
|
480
|
+
const pending = pendingUnboundReroutes.get(id);
|
|
481
|
+
armPendingCommandExpiry(id, pending);
|
|
482
|
+
return id;
|
|
483
|
+
};
|
|
484
|
+
const rememberRerouteChooser = (id, messageId) => {
|
|
485
|
+
const pending = pendingUnboundReroutes.get(id);
|
|
486
|
+
if (!pending)
|
|
487
|
+
return;
|
|
488
|
+
pending.chooserMessageId = messageId;
|
|
489
|
+
if (messageId === undefined || pending.dispatchKind !== "command" ||
|
|
490
|
+
pending.sourceTarget.threadId !== undefined || pending.selectionAttempted)
|
|
491
|
+
return;
|
|
492
|
+
const source = pending.messages[0];
|
|
493
|
+
const text = source?.text?.trim();
|
|
494
|
+
const execution = Updates.getTelegramUpdateExecutionFence(source);
|
|
495
|
+
const sourceIds = Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages);
|
|
496
|
+
if (!text || Commands.parseTelegramCommand(text)?.name !== "start" ||
|
|
497
|
+
source?.from?.id === undefined || !execution?.isCurrent() || sourceIds.length !== 1 ||
|
|
498
|
+
expirePendingCommand(id, pending))
|
|
499
|
+
return;
|
|
500
|
+
for (const [oldId, old] of pendingUnboundReroutes) {
|
|
501
|
+
if (oldId === id || old.dispatchKind !== "command" || old.selectionAttempted || old.dispatching ||
|
|
502
|
+
old.sourceTarget.threadId !== undefined || old.sourceTarget.chatId !== pending.sourceTarget.chatId)
|
|
503
|
+
continue;
|
|
504
|
+
const oldSource = old.messages[0];
|
|
505
|
+
const oldIds = Updates.collectTelegramAdmissionSourceUpdateIds(old.messages);
|
|
506
|
+
if (oldSource?.from?.id !== source.from.id || oldSource?.text?.trim() !== text ||
|
|
507
|
+
oldIds.length !== 1 || oldIds[0] >= sourceIds[0] ||
|
|
508
|
+
Updates.getTelegramUpdateExecutionFence(oldSource)?.signal !== execution.signal)
|
|
509
|
+
continue;
|
|
510
|
+
if (Updates.reportTelegramUpdateCompleted(oldSource))
|
|
511
|
+
removePendingReroute(oldId);
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
const matchesRerouteChooser = (pending, query) => {
|
|
515
|
+
const message = query.message;
|
|
516
|
+
return !!message && pending.chooserMessageId !== undefined &&
|
|
517
|
+
message.message_id === pending.chooserMessageId &&
|
|
518
|
+
message.chat.id === pending.sourceTarget.chatId &&
|
|
519
|
+
(message.message_thread_id === undefined ||
|
|
520
|
+
message.message_thread_id === pending.sourceTarget.threadId);
|
|
521
|
+
};
|
|
522
|
+
const pendingUnboundRerouteMediaGroups = new Map();
|
|
523
|
+
const threadNameDialog = ThreadNaming.createTelegramThreadNameDialogRuntime();
|
|
524
|
+
const getThreadNameDialogScope = () => deps.getCurrentInstanceId?.() ?? "local";
|
|
525
|
+
const menuCallbackHandler = Menu.createTelegramMenuCallbackHandlerForContext({
|
|
526
|
+
getStoredModelMenuState: deps.modelMenuRuntime.getState,
|
|
527
|
+
getActiveModel: deps.currentModelRuntime.get,
|
|
528
|
+
getThinkingLevel: deps.getThinkingLevel,
|
|
529
|
+
setThinkingLevel: deps.setThinkingLevel,
|
|
530
|
+
updateStatus: deps.updateStatus,
|
|
531
|
+
updateModelMenuMessage: deps.menuActions.updateModelMenuMessage,
|
|
532
|
+
updateThinkingMenuMessage: deps.menuActions.updateThinkingMenuMessage,
|
|
533
|
+
updateStatusMessage: deps.menuActions.updateStatusMessage,
|
|
534
|
+
updateSettingsMenuMessage: deps.updateSettingsMenuMessage,
|
|
535
|
+
answerCallbackQuery: deps.answerCallbackQuery,
|
|
536
|
+
isIdle: deps.isIdle,
|
|
537
|
+
hasActiveTelegramTurn: deps.activeTurnRuntime.has,
|
|
538
|
+
hasAbortHandler: deps.bridgeRuntime.abort.hasHandler,
|
|
539
|
+
getActiveToolExecutions: deps.bridgeRuntime.lifecycle.getActiveToolExecutions,
|
|
540
|
+
persistScopedModelPatterns: deps.persistScopedModelPatterns,
|
|
541
|
+
setModel: deps.setModel,
|
|
542
|
+
setCurrentModel: deps.currentModelRuntime.setCurrentModel,
|
|
543
|
+
stagePendingModelSwitch: deps.modelSwitchController.stagePendingSwitch,
|
|
544
|
+
restartInterruptedTelegramTurn: deps.modelSwitchController.restartInterruptedTurn,
|
|
545
|
+
sectionRegistry: deps.sectionRegistry,
|
|
546
|
+
editInteractiveMessage: deps.editInteractiveMessage,
|
|
547
|
+
sendInteractiveMessage: deps.sendInteractiveMessage,
|
|
548
|
+
sendSectionRichMessage: deps.sendSectionRichMessage,
|
|
549
|
+
deleteMessage: deps.deleteMessage,
|
|
550
|
+
enqueueSectionPrompt: async (prompt, ctx, target, source) => {
|
|
551
|
+
const chatId = target?.chatId ?? deps.configStore.getAllowedUserId();
|
|
552
|
+
if (typeof chatId !== "number")
|
|
553
|
+
return;
|
|
554
|
+
const order = deps.bridgeRuntime.queue.allocateItemOrder();
|
|
555
|
+
const admissionReceipts = createAdmissionReceipts("prompt", source === undefined ? [] : [source]);
|
|
556
|
+
const turn = {
|
|
557
|
+
kind: "prompt",
|
|
558
|
+
chatId,
|
|
559
|
+
...(target ? { target } : {}),
|
|
560
|
+
replyToMessageId: 0,
|
|
561
|
+
sourceMessageIds: [],
|
|
562
|
+
queueOrder: order,
|
|
563
|
+
queueLane: "default",
|
|
564
|
+
laneOrder: order,
|
|
565
|
+
queuedAttachments: [],
|
|
566
|
+
content: [
|
|
567
|
+
{
|
|
568
|
+
type: "text",
|
|
569
|
+
text: `[telegram] ${prompt}`,
|
|
570
|
+
},
|
|
571
|
+
],
|
|
572
|
+
historyText: Turns.truncateTelegramQueueSummary(prompt),
|
|
573
|
+
statusSummary: Turns.truncateTelegramQueueSummary(prompt),
|
|
574
|
+
...(admissionReceipts.length > 0 ? { admissionReceipts } : {}),
|
|
575
|
+
};
|
|
576
|
+
deps.queueMutationRuntime.append(turn, ctx);
|
|
577
|
+
reportQueueAdmission(source === undefined ? [] : [source], admissionReceipts);
|
|
578
|
+
deps.updateStatus(ctx);
|
|
579
|
+
requestDispatchNextQueuedTelegramTurn(ctx);
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
const cloneTelegramMessagesForThread = (messages, threadId) => {
|
|
583
|
+
return messages.map((message) => Updates.carryTelegramUpdateExecutionFence(message, {
|
|
584
|
+
...message,
|
|
585
|
+
message_id: 0,
|
|
586
|
+
message_thread_id: threadId,
|
|
587
|
+
reply_to_message: undefined,
|
|
588
|
+
}));
|
|
589
|
+
};
|
|
590
|
+
const applyThreadCleanupPlan = async (plan, assertExecutionCurrent) => {
|
|
591
|
+
assertExecutionCurrent?.();
|
|
592
|
+
deps.recordThreadReconciliationPlan?.(plan);
|
|
593
|
+
const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
|
|
594
|
+
isCleanupTargetProtected(target) {
|
|
595
|
+
assertExecutionCurrent?.();
|
|
596
|
+
return isRerouteTargetProtected(target);
|
|
597
|
+
},
|
|
598
|
+
callApi: deps.callApi,
|
|
599
|
+
markStaleByTarget: (staleTarget, syncStatus, lastSyncError) => deps.threadStore?.markStaleByTarget(staleTarget, syncStatus, lastSyncError) ?? false,
|
|
600
|
+
persist: () => deps.threadStore?.persist() ?? Promise.resolve(),
|
|
601
|
+
removePendingProvisionById: (id) => deps.threadStore?.removePendingProvision(id) ?? false,
|
|
602
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
603
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
604
|
+
});
|
|
605
|
+
assertExecutionCurrent?.();
|
|
606
|
+
return (result.incompleteActions?.length ?? 0) === 0;
|
|
607
|
+
};
|
|
608
|
+
const isRerouteTargetProtected = (target) => {
|
|
609
|
+
const matches = (candidate) => candidate.chatId === target.chatId && candidate.threadId === target.threadId;
|
|
610
|
+
return (deps.getLiveThreadTargets?.() ?? []).some(matches) ||
|
|
611
|
+
(deps.threadStore?.list() ?? []).some((record) => matches(record.target)) ||
|
|
612
|
+
(deps.threadStore?.listReservations() ?? []).some((record) => matches(record.target)) ||
|
|
613
|
+
(deps.threadStore?.listPendingProvisions() ?? []).some((record) => !!record.target && matches(record.target));
|
|
614
|
+
};
|
|
615
|
+
const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
|
|
616
|
+
const chatId = query.message?.chat?.id;
|
|
617
|
+
const messageId = query.message?.message_id;
|
|
618
|
+
if (typeof chatId !== "number" ||
|
|
619
|
+
typeof messageId !== "number" ||
|
|
620
|
+
!deps.deleteMessage) {
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
assertExecutionCurrent?.();
|
|
625
|
+
await deps.deleteMessage(chatId, messageId);
|
|
626
|
+
assertExecutionCurrent?.();
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
assertExecutionCurrent?.();
|
|
631
|
+
deps.recordRuntimeEvent?.("telegram", error, {
|
|
632
|
+
phase: "reroute-chooser-delete",
|
|
633
|
+
chatId,
|
|
634
|
+
messageId,
|
|
635
|
+
threadId: query.message?.message_thread_id,
|
|
636
|
+
});
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
const closeReroutedUnboundTopic = async (target, messageId, assertExecutionCurrent) => {
|
|
641
|
+
if (!target || !deps.threadStore)
|
|
642
|
+
return true;
|
|
643
|
+
const nowMs = Date.now();
|
|
644
|
+
const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
645
|
+
const plan = ThreadReconciler.planThreadReconciliation({
|
|
646
|
+
nowMs,
|
|
647
|
+
currentLeaderEpoch,
|
|
648
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
649
|
+
records: deps.threadStore.list(),
|
|
650
|
+
reservations: deps.threadStore.listReservations(),
|
|
651
|
+
pendingProvisions: deps.threadStore.listPendingProvisions(),
|
|
652
|
+
unboundMessages: [
|
|
653
|
+
{
|
|
654
|
+
target,
|
|
655
|
+
observedAtMs: nowMs,
|
|
656
|
+
...(typeof messageId === "number" ? { messageId } : {}),
|
|
657
|
+
...(currentLeaderEpoch !== undefined
|
|
658
|
+
? { leaderEpoch: currentLeaderEpoch }
|
|
659
|
+
: {}),
|
|
660
|
+
},
|
|
661
|
+
],
|
|
662
|
+
});
|
|
663
|
+
return applyThreadCleanupPlan(plan, assertExecutionCurrent);
|
|
664
|
+
};
|
|
665
|
+
const closePreviousLeaderThread = async (target, assertExecutionCurrent) => {
|
|
666
|
+
if (!target || !deps.threadStore)
|
|
667
|
+
return true;
|
|
668
|
+
const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
669
|
+
return applyThreadCleanupPlan({
|
|
670
|
+
actions: [
|
|
671
|
+
{
|
|
672
|
+
kind: "close-delete-previous-leader-topic",
|
|
673
|
+
target,
|
|
674
|
+
reason: "previous-leader",
|
|
675
|
+
instanceId: deps.getCurrentInstanceId?.(),
|
|
676
|
+
...(currentLeaderEpoch !== undefined
|
|
677
|
+
? { leaderEpoch: currentLeaderEpoch }
|
|
678
|
+
: {}),
|
|
679
|
+
},
|
|
680
|
+
],
|
|
681
|
+
}, assertExecutionCurrent);
|
|
682
|
+
};
|
|
683
|
+
const closeReplacedFollowerThread = async (target, instanceId, assertExecutionCurrent) => {
|
|
684
|
+
if (!target || !deps.threadStore)
|
|
685
|
+
return true;
|
|
686
|
+
const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
687
|
+
return applyThreadCleanupPlan({
|
|
688
|
+
actions: [
|
|
689
|
+
{
|
|
690
|
+
kind: "close-delete-replaced-follower-topic",
|
|
691
|
+
target,
|
|
692
|
+
reason: "replaced-follower",
|
|
693
|
+
instanceId,
|
|
694
|
+
...(currentLeaderEpoch !== undefined
|
|
695
|
+
? { leaderEpoch: currentLeaderEpoch }
|
|
696
|
+
: {}),
|
|
697
|
+
},
|
|
698
|
+
],
|
|
699
|
+
}, assertExecutionCurrent);
|
|
700
|
+
};
|
|
701
|
+
const retryPendingRerouteCleanup = async (cleanup, assertExecutionCurrent) => {
|
|
702
|
+
if (cleanup.kind === "unbound") {
|
|
703
|
+
return closeReroutedUnboundTopic(cleanup.target, cleanup.messageId, assertExecutionCurrent);
|
|
704
|
+
}
|
|
705
|
+
if (cleanup.kind === "previous-leader") {
|
|
706
|
+
return closePreviousLeaderThread(cleanup.target, assertExecutionCurrent);
|
|
707
|
+
}
|
|
708
|
+
return closeReplacedFollowerThread(cleanup.target, cleanup.instanceId, assertExecutionCurrent);
|
|
709
|
+
};
|
|
710
|
+
let dispatchReroutedCommandMessages;
|
|
711
|
+
const dispatchPendingRerouteMessages = async (pending, messages, ctx) => {
|
|
712
|
+
if (pending.dispatchKind === "command" && dispatchReroutedCommandMessages) {
|
|
713
|
+
await dispatchReroutedCommandMessages(messages, ctx);
|
|
714
|
+
if (pending.sourceTarget.threadId === undefined) {
|
|
715
|
+
for (const message of messages)
|
|
716
|
+
Updates.reportTelegramUpdateCompleted(message);
|
|
717
|
+
}
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
await promptEnqueue(messages, ctx);
|
|
721
|
+
};
|
|
722
|
+
const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query)) => {
|
|
723
|
+
assertExecutionCurrent();
|
|
724
|
+
const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
|
|
725
|
+
assertExecutionCurrent();
|
|
726
|
+
if (dismissed) {
|
|
727
|
+
removePendingReroute(rerouteId);
|
|
728
|
+
await deps.answerCallbackQuery(query.id, successMessage);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
pending.finalizeMessage = successMessage;
|
|
732
|
+
await deps.answerCallbackQuery(query.id, `${successMessage} Chooser cleanup is still pending. Try again.`);
|
|
733
|
+
};
|
|
734
|
+
const forwardPendingRerouteMessages = async (pending, instanceId, threadId, ctx, assertExecutionCurrent) => {
|
|
735
|
+
assertExecutionCurrent?.();
|
|
736
|
+
const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
|
|
737
|
+
if (!forwardMessage)
|
|
738
|
+
return false;
|
|
739
|
+
const messages = cloneTelegramMessagesForThread(pending.messages, threadId);
|
|
740
|
+
const outcomes = await Promise.allSettled(messages.map((message) => forwardMessage({
|
|
741
|
+
message,
|
|
742
|
+
ownership: { instanceId },
|
|
743
|
+
ctx,
|
|
744
|
+
})));
|
|
745
|
+
assertExecutionCurrent?.();
|
|
746
|
+
pending.messages = pending.messages.filter((_, index) => {
|
|
747
|
+
const outcome = outcomes[index];
|
|
748
|
+
if (outcome?.status === "fulfilled" &&
|
|
749
|
+
outcome.value.status === "accepted") {
|
|
750
|
+
if (pending.dispatchKind === "command" && pending.sourceTarget.threadId === undefined) {
|
|
751
|
+
Updates.reportTelegramUpdateCompleted(pending.messages[index]);
|
|
752
|
+
}
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
if (outcome?.status === "rejected") {
|
|
756
|
+
deps.recordRuntimeEvent?.("bus", outcome.reason, {
|
|
757
|
+
phase: "reroute-foreign-forward",
|
|
758
|
+
instanceId,
|
|
759
|
+
threadId,
|
|
760
|
+
messageIndex: index,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
return true;
|
|
764
|
+
});
|
|
765
|
+
return pending.messages.length === 0;
|
|
766
|
+
};
|
|
767
|
+
const handleUnboundRerouteRestoreMenuCallback = async (query, _ctx) => {
|
|
768
|
+
const parsed = parseTelegramUnboundRerouteRestoreMenuCallbackData(query.data);
|
|
769
|
+
if (!parsed)
|
|
770
|
+
return false;
|
|
771
|
+
const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query);
|
|
772
|
+
assertExecutionCurrent();
|
|
773
|
+
const chatId = query.message?.chat?.id;
|
|
774
|
+
const messageId = query.message?.message_id;
|
|
775
|
+
const pending = pendingUnboundReroutes.get(parsed.rerouteId);
|
|
776
|
+
if (typeof chatId !== "number" ||
|
|
777
|
+
typeof messageId !== "number" ||
|
|
778
|
+
!deps.threadStore ||
|
|
779
|
+
!pending ||
|
|
780
|
+
!matchesRerouteChooser(pending, query) ||
|
|
781
|
+
expirePendingCommand(parsed.rerouteId, pending)) {
|
|
782
|
+
await deps.answerCallbackQuery(query.id, "Message route expired.");
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
785
|
+
if (pending.sourceTarget.threadId === undefined) {
|
|
786
|
+
await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
await deps.threadStore.load();
|
|
790
|
+
assertExecutionCurrent();
|
|
791
|
+
const activeRecords = getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.());
|
|
792
|
+
const replyMarkup = buildTelegramUnboundRerouteRestoreChooserMarkup(parsed.rerouteId, activeRecords, deps.getDisplayTitle);
|
|
793
|
+
if (deps.editInteractiveMessage) {
|
|
794
|
+
await deps.editInteractiveMessage(chatId, messageId, formatTelegramUnboundRerouteRestoreChooserText(), "html", replyMarkup);
|
|
795
|
+
}
|
|
796
|
+
else if (deps.sendInteractiveMessage) {
|
|
797
|
+
const chooserId = await deps.sendInteractiveMessage(chatId, formatTelegramUnboundRerouteRestoreChooserText(), "html", replyMarkup, { target: pending.sourceTarget, replyToMessageId: messageId });
|
|
798
|
+
assertExecutionCurrent();
|
|
799
|
+
rememberRerouteChooser(parsed.rerouteId, chooserId);
|
|
800
|
+
}
|
|
801
|
+
assertExecutionCurrent();
|
|
802
|
+
await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
|
|
803
|
+
return true;
|
|
804
|
+
};
|
|
805
|
+
const executeUnboundRerouteCallbackOperation = async (query, ctx) => {
|
|
806
|
+
const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
|
|
807
|
+
if (!parsed)
|
|
808
|
+
return false;
|
|
809
|
+
const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query);
|
|
810
|
+
assertExecutionCurrent();
|
|
811
|
+
const chatId = query.message?.chat?.id;
|
|
812
|
+
const pending = pendingUnboundReroutes.get(parsed.rerouteId);
|
|
813
|
+
if (typeof chatId !== "number" || !deps.threadStore || !pending ||
|
|
814
|
+
!matchesRerouteChooser(pending, query) || expirePendingCommand(parsed.rerouteId, pending)) {
|
|
815
|
+
await deps.answerCallbackQuery(query.id, "Message route expired.");
|
|
816
|
+
return true;
|
|
817
|
+
}
|
|
818
|
+
await deps.threadStore.load();
|
|
819
|
+
assertExecutionCurrent();
|
|
820
|
+
if (pendingUnboundReroutes.get(parsed.rerouteId) !== pending ||
|
|
821
|
+
expirePendingCommand(parsed.rerouteId, pending)) {
|
|
822
|
+
await deps.answerCallbackQuery(query.id, "Message route expired.");
|
|
823
|
+
return true;
|
|
824
|
+
}
|
|
825
|
+
if (pending.finalizeMessage) {
|
|
826
|
+
await finalizePendingReroute(parsed.rerouteId, pending, query, pending.finalizeMessage, assertExecutionCurrent);
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
if (pending.foreignRetry) {
|
|
830
|
+
const retry = pending.foreignRetry;
|
|
831
|
+
const allForwarded = await forwardPendingRerouteMessages(pending, retry.instanceId, retry.threadId, ctx, assertExecutionCurrent);
|
|
832
|
+
if (!allForwarded) {
|
|
833
|
+
await deps.answerCallbackQuery(query.id, "Target thread is unavailable; retrying will send only remaining messages.");
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
836
|
+
pending.foreignRetry = undefined;
|
|
837
|
+
if (retry.cleanup)
|
|
838
|
+
pending.cleanup = retry.cleanup;
|
|
839
|
+
}
|
|
840
|
+
if (pending.cleanup) {
|
|
841
|
+
const cleanupComplete = await retryPendingRerouteCleanup(pending.cleanup, assertExecutionCurrent);
|
|
842
|
+
if (!cleanupComplete) {
|
|
843
|
+
await deps.answerCallbackQuery(query.id, "Message routed, but thread cleanup is still pending. Try again.");
|
|
844
|
+
return true;
|
|
845
|
+
}
|
|
846
|
+
pending.cleanup = undefined;
|
|
847
|
+
await finalizePendingReroute(parsed.rerouteId, pending, query, "Thread cleanup completed.", assertExecutionCurrent);
|
|
848
|
+
return true;
|
|
849
|
+
}
|
|
850
|
+
const record = getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.()).find((candidate) => candidate.target.chatId === chatId &&
|
|
851
|
+
candidate.target.threadId === parsed.threadId);
|
|
852
|
+
if (!record) {
|
|
853
|
+
await deps.answerCallbackQuery(query.id, "Thread is not active yet.");
|
|
854
|
+
return true;
|
|
855
|
+
}
|
|
856
|
+
const reroutedMessages = cloneTelegramMessagesForThread(pending.messages, parsed.threadId);
|
|
857
|
+
const sourceTarget = typeof pending.sourceTarget.threadId === "number"
|
|
858
|
+
? { chatId, threadId: pending.sourceTarget.threadId }
|
|
859
|
+
: undefined;
|
|
860
|
+
const sourceMessageId = pending.chooserMessageId;
|
|
861
|
+
if (parsed.useNewSlot && !sourceTarget) {
|
|
862
|
+
await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
|
|
863
|
+
return true;
|
|
864
|
+
}
|
|
865
|
+
if (parsed.useNewSlot && sourceTarget && record.target.chatId === sourceTarget.chatId &&
|
|
866
|
+
record.target.threadId === sourceTarget.threadId) {
|
|
867
|
+
await deps.answerCallbackQuery(query.id, "🚫 Selected thread is already the destination.");
|
|
868
|
+
return true;
|
|
869
|
+
}
|
|
870
|
+
if (parsed.useNewSlot && sourceTarget && isRerouteTargetProtected(sourceTarget)) {
|
|
871
|
+
await deps.answerCallbackQuery(query.id, "🚫 Thread restore source is already owned.");
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
pending.destinationSelected = true;
|
|
875
|
+
pending.selectionAttempted = true;
|
|
876
|
+
pending.pauseExpiry?.();
|
|
877
|
+
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
878
|
+
const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
|
|
879
|
+
const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(record, leaderProfileKey, currentInstanceId);
|
|
880
|
+
if (parsed.useNewSlot && !isCurrentLeaderRecord) {
|
|
881
|
+
if (!record.slot || !/^[A-Z]$/u.test(record.slot)) {
|
|
882
|
+
await deps.answerCallbackQuery(query.id, "Follower thread slot authority is unavailable.");
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
if (!sourceTarget ||
|
|
886
|
+
!deps.replaceFollowerThreadTarget ||
|
|
887
|
+
!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
|
|
888
|
+
await deps.answerCallbackQuery(query.id, "Follower thread restore is not available yet.");
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
891
|
+
assertExecutionCurrent();
|
|
892
|
+
const replaced = await deps.replaceFollowerThreadTarget({
|
|
893
|
+
record,
|
|
894
|
+
target: sourceTarget,
|
|
895
|
+
oldTarget: record.target,
|
|
896
|
+
});
|
|
897
|
+
assertExecutionCurrent();
|
|
898
|
+
if (!replaced) {
|
|
899
|
+
await deps.answerCallbackQuery(query.id, "Follower thread is unavailable.");
|
|
900
|
+
return true;
|
|
901
|
+
}
|
|
902
|
+
const nowMs = Date.now();
|
|
903
|
+
const slot = record.slot;
|
|
904
|
+
const threadName = getRestoredThreadName(record, slot);
|
|
905
|
+
assertExecutionCurrent();
|
|
906
|
+
deps.threadStore.markStaleByTarget(record.target, "deleted", "Follower thread was replaced by restore source.");
|
|
907
|
+
deps.threadStore.upsert({
|
|
908
|
+
...record,
|
|
909
|
+
target: sourceTarget,
|
|
910
|
+
status: "active",
|
|
911
|
+
syncStatus: "open",
|
|
912
|
+
updatedAtMs: nowMs,
|
|
913
|
+
threadName,
|
|
914
|
+
lastSyncObservedAtMs: nowMs,
|
|
915
|
+
lastReconcileAction: "follower-thread-restore",
|
|
916
|
+
rerouteConfirmedAtMs: nowMs,
|
|
917
|
+
});
|
|
918
|
+
await deps.threadStore.persist();
|
|
919
|
+
assertExecutionCurrent();
|
|
920
|
+
if (deps.callApi) {
|
|
921
|
+
try {
|
|
922
|
+
assertExecutionCurrent();
|
|
923
|
+
await deps.callApi("editForumTopic", {
|
|
924
|
+
chat_id: sourceTarget.chatId,
|
|
925
|
+
message_thread_id: sourceTarget.threadId,
|
|
926
|
+
name: Threads.getTelegramTopicTitleForThreadName(threadName, slot),
|
|
927
|
+
});
|
|
928
|
+
assertExecutionCurrent();
|
|
929
|
+
}
|
|
930
|
+
catch (renameError) {
|
|
931
|
+
deps.recordRuntimeEvent?.("telegram", renameError, {
|
|
932
|
+
phase: "follower-topic-reroute-restore-rename",
|
|
933
|
+
chatId: sourceTarget.chatId,
|
|
934
|
+
threadId: sourceTarget.threadId,
|
|
935
|
+
slot: record.slot,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
const cleanup = {
|
|
940
|
+
kind: "replaced-follower",
|
|
941
|
+
target: record.target,
|
|
942
|
+
instanceId: record.instanceId,
|
|
943
|
+
};
|
|
944
|
+
const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, sourceTarget.threadId, ctx, assertExecutionCurrent);
|
|
945
|
+
if (!allForwarded) {
|
|
946
|
+
pending.foreignRetry = {
|
|
947
|
+
instanceId: record.instanceId,
|
|
948
|
+
threadId: sourceTarget.threadId,
|
|
949
|
+
cleanup,
|
|
950
|
+
};
|
|
951
|
+
await deps.answerCallbackQuery(query.id, "Thread restored; retrying will send only remaining messages before old-thread cleanup.");
|
|
952
|
+
return true;
|
|
953
|
+
}
|
|
954
|
+
const cleanupComplete = await retryPendingRerouteCleanup(cleanup, assertExecutionCurrent);
|
|
955
|
+
if (!cleanupComplete) {
|
|
956
|
+
pending.cleanup = cleanup;
|
|
957
|
+
await deps.answerCallbackQuery(query.id, "Thread restored, but old-thread cleanup is still pending. Try again.");
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
|
|
961
|
+
return true;
|
|
962
|
+
}
|
|
963
|
+
if (record.instanceId &&
|
|
964
|
+
record.instanceId !== currentInstanceId &&
|
|
965
|
+
!isCurrentLeaderRecord) {
|
|
966
|
+
if (!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
|
|
967
|
+
await deps.answerCallbackQuery(query.id, "Open that thread and resend the message there.");
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, parsed.threadId, ctx, assertExecutionCurrent);
|
|
971
|
+
if (!allForwarded) {
|
|
972
|
+
await deps.answerCallbackQuery(query.id, "Target thread is unavailable; retrying will send only remaining messages.");
|
|
973
|
+
return true;
|
|
974
|
+
}
|
|
975
|
+
const cleanupComplete = await closeReroutedUnboundTopic(sourceTarget, sourceMessageId, assertExecutionCurrent);
|
|
976
|
+
if (!cleanupComplete && sourceTarget) {
|
|
977
|
+
pending.cleanup = {
|
|
978
|
+
kind: "unbound",
|
|
979
|
+
target: sourceTarget,
|
|
980
|
+
...(typeof sourceMessageId === "number"
|
|
981
|
+
? { messageId: sourceMessageId }
|
|
982
|
+
: {}),
|
|
983
|
+
};
|
|
984
|
+
await deps.answerCallbackQuery(query.id, "Message routed, but thread cleanup is still pending. Try again.");
|
|
985
|
+
return true;
|
|
986
|
+
}
|
|
987
|
+
await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
|
|
988
|
+
return true;
|
|
989
|
+
}
|
|
990
|
+
if (sourceTarget &&
|
|
991
|
+
isCurrentLeaderRecord &&
|
|
992
|
+
parsed.useNewSlot &&
|
|
993
|
+
(record.target.chatId !== sourceTarget.chatId ||
|
|
994
|
+
record.target.threadId !== sourceTarget.threadId)) {
|
|
995
|
+
const slot = deps.threadStore.allocateSlot(leaderProfileKey ?? record.profileKey, getNextTelegramSlotPreference(record.slot), undefined, { excludeCurrentRecord: true });
|
|
996
|
+
if (!slot) {
|
|
997
|
+
await deps.answerCallbackQuery(query.id, TELEGRAM_SLOT_CAPACITY_MESSAGE);
|
|
998
|
+
return true;
|
|
999
|
+
}
|
|
1000
|
+
deps.threadStore.markStaleByTarget(record.target, "deleted", "Current leader thread was replaced by a new-slot reroute source.");
|
|
1001
|
+
const nowMs = Date.now();
|
|
1002
|
+
const threadName = getRestoredThreadName(record, slot);
|
|
1003
|
+
deps.threadStore.upsert({
|
|
1004
|
+
...record,
|
|
1005
|
+
target: sourceTarget,
|
|
1006
|
+
status: "active",
|
|
1007
|
+
updatedAtMs: nowMs,
|
|
1008
|
+
threadName,
|
|
1009
|
+
instanceId: currentInstanceId,
|
|
1010
|
+
slot,
|
|
1011
|
+
lastReconcileAction: "reroute-new-slot",
|
|
1012
|
+
rerouteConfirmedAtMs: nowMs,
|
|
1013
|
+
});
|
|
1014
|
+
await deps.threadStore.persist();
|
|
1015
|
+
assertExecutionCurrent();
|
|
1016
|
+
deps.setCurrentLeaderIdentity?.({
|
|
1017
|
+
target: sourceTarget,
|
|
1018
|
+
slot,
|
|
1019
|
+
threadName,
|
|
1020
|
+
});
|
|
1021
|
+
if (deps.callApi) {
|
|
1022
|
+
try {
|
|
1023
|
+
assertExecutionCurrent();
|
|
1024
|
+
await deps.callApi("editForumTopic", {
|
|
1025
|
+
chat_id: sourceTarget.chatId,
|
|
1026
|
+
message_thread_id: sourceTarget.threadId,
|
|
1027
|
+
name: Threads.getTelegramTopicTitleForThreadName(threadName, slot),
|
|
1028
|
+
});
|
|
1029
|
+
assertExecutionCurrent();
|
|
1030
|
+
}
|
|
1031
|
+
catch (renameError) {
|
|
1032
|
+
deps.recordRuntimeEvent?.("telegram", renameError, {
|
|
1033
|
+
phase: "leader-topic-reroute-reclaim-rename",
|
|
1034
|
+
chatId: sourceTarget.chatId,
|
|
1035
|
+
threadId: sourceTarget.threadId,
|
|
1036
|
+
slot,
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
deps.recordRuntimeEvent?.("bus", "Bus leader reclaimed reroute source thread", {
|
|
1041
|
+
phase: "leader-topic-reroute-reclaim",
|
|
1042
|
+
chatId: sourceTarget.chatId,
|
|
1043
|
+
threadId: sourceTarget.threadId,
|
|
1044
|
+
staleThreadId: record.target.threadId,
|
|
1045
|
+
slot,
|
|
1046
|
+
});
|
|
1047
|
+
await dispatchPendingRerouteMessages(pending, cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId), ctx);
|
|
1048
|
+
pending.messages = [];
|
|
1049
|
+
const cleanupComplete = await closePreviousLeaderThread(record.target, assertExecutionCurrent);
|
|
1050
|
+
if (!cleanupComplete) {
|
|
1051
|
+
pending.cleanup = {
|
|
1052
|
+
kind: "previous-leader",
|
|
1053
|
+
target: record.target,
|
|
1054
|
+
};
|
|
1055
|
+
await deps.answerCallbackQuery(query.id, "Thread restored, but old-thread cleanup is still pending. Try again.");
|
|
1056
|
+
return true;
|
|
1057
|
+
}
|
|
1058
|
+
await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
|
|
1059
|
+
return true;
|
|
1060
|
+
}
|
|
1061
|
+
await dispatchPendingRerouteMessages(pending, reroutedMessages, ctx);
|
|
1062
|
+
pending.messages = [];
|
|
1063
|
+
const cleanupComplete = await closeReroutedUnboundTopic(sourceTarget, sourceMessageId, assertExecutionCurrent);
|
|
1064
|
+
if (!cleanupComplete && sourceTarget) {
|
|
1065
|
+
pending.cleanup = {
|
|
1066
|
+
kind: "unbound",
|
|
1067
|
+
target: sourceTarget,
|
|
1068
|
+
...(typeof sourceMessageId === "number"
|
|
1069
|
+
? { messageId: sourceMessageId }
|
|
1070
|
+
: {}),
|
|
1071
|
+
};
|
|
1072
|
+
await deps.answerCallbackQuery(query.id, "Message routed, but thread cleanup is still pending. Try again.");
|
|
1073
|
+
return true;
|
|
1074
|
+
}
|
|
1075
|
+
await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
|
|
1076
|
+
return true;
|
|
1077
|
+
};
|
|
1078
|
+
const executeUnboundRerouteCallback = (query, ctx) => {
|
|
1079
|
+
if (!parseTelegramUnboundRerouteCallbackData(query.data) ||
|
|
1080
|
+
!deps.runWorkspaceOperation) {
|
|
1081
|
+
return executeUnboundRerouteCallbackOperation(query, ctx);
|
|
1082
|
+
}
|
|
1083
|
+
return deps.runWorkspaceOperation({
|
|
1084
|
+
operationId: `workspace-reroute:${query.id}`,
|
|
1085
|
+
operationKind: "workspace.route-unbound-thread",
|
|
1086
|
+
scopes: [{ kind: "profile" }],
|
|
1087
|
+
}, () => executeUnboundRerouteCallbackOperation(query, ctx));
|
|
1088
|
+
};
|
|
1089
|
+
const handleUnboundRerouteCallback = async (query, ctx) => {
|
|
1090
|
+
const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
|
|
1091
|
+
const pending = parsed && pendingUnboundReroutes.get(parsed.rerouteId);
|
|
1092
|
+
if (!parsed || !pending || pending.dispatchKind !== "command" ||
|
|
1093
|
+
pending.sourceTarget.threadId !== undefined || !matchesRerouteChooser(pending, query)) {
|
|
1094
|
+
return executeUnboundRerouteCallback(query, ctx);
|
|
1095
|
+
}
|
|
1096
|
+
if (pending.dispatching) {
|
|
1097
|
+
await deps.answerCallbackQuery(query.id, "Command routing is already in progress.");
|
|
1098
|
+
return true;
|
|
1099
|
+
}
|
|
1100
|
+
pending.dispatching = true;
|
|
1101
|
+
try {
|
|
1102
|
+
return await executeUnboundRerouteCallback(query, ctx);
|
|
1103
|
+
}
|
|
1104
|
+
finally {
|
|
1105
|
+
pending.dispatching = false;
|
|
1106
|
+
if (pendingUnboundReroutes.get(parsed.rerouteId) === pending &&
|
|
1107
|
+
pending.destinationSelected && pending.messages.length > 0 &&
|
|
1108
|
+
!pending.cleanup && !pending.finalizeMessage) {
|
|
1109
|
+
pending.destinationSelected = false;
|
|
1110
|
+
armPendingCommandExpiry(parsed.rerouteId, pending);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
const callbackHandler = async (query, ctx) => {
|
|
1115
|
+
const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query);
|
|
1116
|
+
assertExecutionCurrent();
|
|
1117
|
+
if (await handleUnboundRerouteRestoreMenuCallback(query, ctx))
|
|
1118
|
+
return;
|
|
1119
|
+
if (await handleUnboundRerouteCallback(query, ctx))
|
|
1120
|
+
return;
|
|
1121
|
+
if (deps.buttonActionStore) {
|
|
1122
|
+
const handled = await OutboundHandlers.handleTelegramButtonCallbackQuery(query, ctx, {
|
|
1123
|
+
resolveAction: deps.buttonActionStore.resolve,
|
|
1124
|
+
answerCallbackQuery: deps.answerCallbackQuery,
|
|
1125
|
+
...(deps.invokeBoundButtonAction
|
|
1126
|
+
? {
|
|
1127
|
+
invokeBoundAction: (buttonQuery, action, context) => deps.invokeBoundButtonAction(action, buttonQuery, context),
|
|
1128
|
+
}
|
|
1129
|
+
: {}),
|
|
1130
|
+
editMessageReplyMarkup: deps.editMessageReplyMarkup
|
|
1131
|
+
? async (chatId, messageId, replyMarkup) => {
|
|
1132
|
+
try {
|
|
1133
|
+
await deps.editMessageReplyMarkup?.(chatId, messageId, replyMarkup);
|
|
1134
|
+
}
|
|
1135
|
+
catch (error) {
|
|
1136
|
+
deps.recordRuntimeEvent?.("telegram", error, {
|
|
1137
|
+
phase: "button-selection-mark",
|
|
1138
|
+
chatId,
|
|
1139
|
+
messageId,
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
: undefined,
|
|
1144
|
+
enqueueButtonPrompt: (buttonQuery, action, context) => {
|
|
1145
|
+
const chatId = buttonQuery.message?.chat?.id;
|
|
1146
|
+
const messageId = buttonQuery.message?.message_id;
|
|
1147
|
+
if (typeof chatId !== "number" || typeof messageId !== "number")
|
|
1148
|
+
return false;
|
|
1149
|
+
const queueOrder = deps.bridgeRuntime.queue.allocateItemOrder();
|
|
1150
|
+
const admissionReceipts = createAdmissionReceipts("prompt", [
|
|
1151
|
+
buttonQuery,
|
|
1152
|
+
]);
|
|
1153
|
+
const turn = {
|
|
1154
|
+
...OutboundHandlers.createTelegramButtonPromptTurn({
|
|
1155
|
+
chatId,
|
|
1156
|
+
target: typeof buttonQuery.message?.message_thread_id === "number"
|
|
1157
|
+
? {
|
|
1158
|
+
chatId,
|
|
1159
|
+
threadId: buttonQuery.message.message_thread_id,
|
|
1160
|
+
}
|
|
1161
|
+
: { chatId },
|
|
1162
|
+
replyToMessageId: messageId,
|
|
1163
|
+
queueOrder,
|
|
1164
|
+
action,
|
|
1165
|
+
telegramPrefix: Turns.createTelegramTurnPrefix({
|
|
1166
|
+
thread: resolveTelegramThreadLabel({
|
|
1167
|
+
chat: { id: chatId },
|
|
1168
|
+
message_thread_id: buttonQuery.message?.message_thread_id,
|
|
1169
|
+
}),
|
|
1170
|
+
}),
|
|
1171
|
+
}),
|
|
1172
|
+
...(admissionReceipts.length > 0 ? { admissionReceipts } : {}),
|
|
1173
|
+
};
|
|
1174
|
+
const result = Queue.appendTelegramPromptTurnOnce(deps.telegramQueueStore.getQueuedItems(), turn);
|
|
1175
|
+
if (!result.appended) {
|
|
1176
|
+
reportQueueAdmission([buttonQuery], admissionReceipts);
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
Updates.assertTelegramUpdateExecutionCurrent(buttonQuery);
|
|
1180
|
+
deps.telegramQueueStore.setQueuedItems(result.items);
|
|
1181
|
+
reportQueueAdmission([buttonQuery], admissionReceipts);
|
|
1182
|
+
deps.updateStatus(context);
|
|
1183
|
+
requestDispatchNextQueuedTelegramTurn(context);
|
|
1184
|
+
return true;
|
|
1185
|
+
},
|
|
1186
|
+
});
|
|
1187
|
+
assertExecutionCurrent();
|
|
1188
|
+
if (handled)
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
if (query.data?.startsWith("thread-name:")) {
|
|
1192
|
+
const chatId = query.message?.chat?.id;
|
|
1193
|
+
const dialogMessageId = query.message?.message_id;
|
|
1194
|
+
if (typeof chatId !== "number" || typeof dialogMessageId !== "number") {
|
|
1195
|
+
await deps.answerCallbackQuery(query.id, "⌛ Rename dialog expired.");
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
const target = typeof query.message?.message_thread_id === "number"
|
|
1199
|
+
? { chatId, threadId: query.message.message_thread_id }
|
|
1200
|
+
: { chatId };
|
|
1201
|
+
const action = query.data.slice("thread-name:".length);
|
|
1202
|
+
if (action !== "reset" && action !== "cancel") {
|
|
1203
|
+
await deps.answerCallbackQuery(query.id, "⌛ Rename dialog expired.");
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
const selected = threadNameDialog.select({
|
|
1207
|
+
scope: getThreadNameDialogScope(),
|
|
1208
|
+
target,
|
|
1209
|
+
dialogMessageId,
|
|
1210
|
+
action,
|
|
1211
|
+
});
|
|
1212
|
+
if (selected.kind === "expired") {
|
|
1213
|
+
await deps.answerCallbackQuery(query.id, "⌛ Rename dialog expired.");
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
if (selected.kind === "cancel") {
|
|
1217
|
+
await deps.editInteractiveMessage?.(chatId, dialogMessageId, "<b>✖ Rename cancelled. Returning to normal agent mode.</b>", "html", { inline_keyboard: [] });
|
|
1218
|
+
await deps.answerCallbackQuery(query.id);
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
try {
|
|
1222
|
+
const result = await deps.resetCurrentThreadName?.(target);
|
|
1223
|
+
if (!result?.ok) {
|
|
1224
|
+
throw new Error(result?.message ?? "Thread display name reset is unavailable.");
|
|
1225
|
+
}
|
|
1226
|
+
await deps.editInteractiveMessage?.(chatId, dialogMessageId, result.message
|
|
1227
|
+
? Commands.formatTelegramInformationHeading("✅", result.message)
|
|
1228
|
+
: Commands.formatTelegramAutomaticThreadDisplayNameRestoredHeading(result.threadName ?? "automatic"), "html", { inline_keyboard: [] });
|
|
1229
|
+
await deps.answerCallbackQuery(query.id);
|
|
1230
|
+
}
|
|
1231
|
+
catch (error) {
|
|
1232
|
+
threadNameDialog.open({
|
|
1233
|
+
scope: getThreadNameDialogScope(), target, dialogMessageId,
|
|
1234
|
+
});
|
|
1235
|
+
deps.recordRuntimeEvent?.("telegram-command", error, {
|
|
1236
|
+
command: "name", phase: "reset",
|
|
1237
|
+
});
|
|
1238
|
+
await deps.answerCallbackQuery(query.id, "⚠️ Thread name reset failed.");
|
|
1239
|
+
}
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
const handledByCompact = await Commands.handleTelegramCompactConfirmationCallback(query, {
|
|
1243
|
+
ctx,
|
|
1244
|
+
answerCallbackQuery: deps.answerCallbackQuery,
|
|
1245
|
+
editInteractiveMessage: deps.editInteractiveMessage ?? (async () => { }),
|
|
1246
|
+
runCompact: async (compactCtx, chatId, replyToMessageId, target) => {
|
|
1247
|
+
await Commands.handleTelegramCompactCommand({
|
|
1248
|
+
isIdle: () => deps.isIdle(compactCtx),
|
|
1249
|
+
hasPendingMessages: () => deps.hasPendingMessages(compactCtx),
|
|
1250
|
+
hasActiveTelegramTurn: deps.activeTurnRuntime.has,
|
|
1251
|
+
hasDispatchPending: deps.bridgeRuntime.lifecycle.hasDispatchPending,
|
|
1252
|
+
hasQueuedTelegramItems: deps.telegramQueueStore.hasQueuedItems,
|
|
1253
|
+
isCompactionInProgress: deps.bridgeRuntime.lifecycle.isCompactionInProgress,
|
|
1254
|
+
setCompactionInProgress: deps.bridgeRuntime.lifecycle.setCompactionInProgress,
|
|
1255
|
+
updateStatus: () => deps.updateStatus(compactCtx),
|
|
1256
|
+
dispatchNextQueuedTelegramTurn: () => deps.dispatchNextQueuedTelegramTurn(compactCtx),
|
|
1257
|
+
requestDeferredDispatchNextQueuedTelegramTurn: deps.requestDeferredDispatchNextQueuedTelegramTurn
|
|
1258
|
+
? (dispatch) => deps.requestDeferredDispatchNextQueuedTelegramTurn?.(() => dispatch())
|
|
1259
|
+
: undefined,
|
|
1260
|
+
compact: (callbacks) => deps.compact(compactCtx, callbacks),
|
|
1261
|
+
startTypingLoop: deps.startTypingLoop
|
|
1262
|
+
? () => deps.startTypingLoop?.(compactCtx, chatId, {
|
|
1263
|
+
target,
|
|
1264
|
+
})
|
|
1265
|
+
: undefined,
|
|
1266
|
+
stopTypingLoop: deps.stopTypingLoop,
|
|
1267
|
+
sendTextReply: (text, options) => deps
|
|
1268
|
+
.sendTextReply(chatId, replyToMessageId, text, {
|
|
1269
|
+
target,
|
|
1270
|
+
parseMode: options?.parseMode,
|
|
1271
|
+
})
|
|
1272
|
+
.then(() => { }),
|
|
1273
|
+
suppressStartNotice: true,
|
|
1274
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1275
|
+
});
|
|
1276
|
+
},
|
|
1277
|
+
});
|
|
1278
|
+
assertExecutionCurrent();
|
|
1279
|
+
if (handledByCompact)
|
|
1280
|
+
return;
|
|
1281
|
+
const handledByQueue = await deps.queueMenuCallbackHandler(query, ctx);
|
|
1282
|
+
assertExecutionCurrent();
|
|
1283
|
+
if (handledByQueue)
|
|
1284
|
+
return;
|
|
1285
|
+
const handledBySettings = await deps.settingsMenuCallbackHandler?.(query, ctx);
|
|
1286
|
+
assertExecutionCurrent();
|
|
1287
|
+
if (handledBySettings)
|
|
1288
|
+
return;
|
|
1289
|
+
const callbackData = query.data;
|
|
1290
|
+
if (callbackData && !isTelegramOwnedCallbackData(callbackData)) {
|
|
1291
|
+
const chatId = query.message?.chat?.id;
|
|
1292
|
+
const messageId = query.message?.message_id;
|
|
1293
|
+
if (typeof chatId === "number" && typeof messageId === "number") {
|
|
1294
|
+
const queueOrder = deps.bridgeRuntime.queue.allocateItemOrder();
|
|
1295
|
+
const target = typeof query.message?.message_thread_id === "number"
|
|
1296
|
+
? { chatId, threadId: query.message.message_thread_id }
|
|
1297
|
+
: { chatId };
|
|
1298
|
+
const admissionReceipts = createAdmissionReceipts("prompt", [query]);
|
|
1299
|
+
const turn = {
|
|
1300
|
+
kind: "prompt",
|
|
1301
|
+
chatId,
|
|
1302
|
+
target,
|
|
1303
|
+
replyToMessageId: messageId,
|
|
1304
|
+
sourceMessageIds: [messageId],
|
|
1305
|
+
queueOrder,
|
|
1306
|
+
queueLane: "priority",
|
|
1307
|
+
laneOrder: queueOrder,
|
|
1308
|
+
queuedAttachments: [],
|
|
1309
|
+
content: [{ type: "text", text: `[callback] ${callbackData}` }],
|
|
1310
|
+
historyText: callbackData,
|
|
1311
|
+
statusSummary: callbackData,
|
|
1312
|
+
...(admissionReceipts.length > 0 ? { admissionReceipts } : {}),
|
|
1313
|
+
};
|
|
1314
|
+
const result = Queue.appendTelegramPromptTurnOnce(deps.telegramQueueStore.getQueuedItems(), turn);
|
|
1315
|
+
if (result.appended) {
|
|
1316
|
+
Updates.assertTelegramUpdateExecutionCurrent(query);
|
|
1317
|
+
deps.telegramQueueStore.setQueuedItems(result.items);
|
|
1318
|
+
reportQueueAdmission([query], admissionReceipts);
|
|
1319
|
+
deps.updateStatus(ctx);
|
|
1320
|
+
requestDispatchNextQueuedTelegramTurn(ctx);
|
|
1321
|
+
}
|
|
1322
|
+
else {
|
|
1323
|
+
reportQueueAdmission([query], admissionReceipts);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
await deps.answerCallbackQuery(query.id);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
await menuCallbackHandler(query, ctx);
|
|
1330
|
+
};
|
|
1331
|
+
const preparePromptTurn = Turns.createTelegramPromptTurnRuntimePreparer({
|
|
1332
|
+
allocateQueueOrder: deps.bridgeRuntime.queue.allocateItemOrder,
|
|
1333
|
+
downloadFile: deps.downloadFile,
|
|
1334
|
+
processAttachments: deps.inboundHandlerRuntime.process,
|
|
1335
|
+
resolveTimeLine: deps.resolveTimeLine,
|
|
1336
|
+
getAllowedUserId: deps.configStore.getAllowedUserId,
|
|
1337
|
+
getAdmissionScope: deps.getAdmissionScope,
|
|
1338
|
+
getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
|
|
1339
|
+
assertExecutionCurrent(message) {
|
|
1340
|
+
Updates.assertTelegramUpdateExecutionCurrent(message);
|
|
1341
|
+
},
|
|
1342
|
+
// Voice policy resolves missing, invalid, and legacy manual config to hidden.
|
|
1343
|
+
getVoiceReplyMode: () => getTelegramVoiceReplyMode(deps.configStore.get()),
|
|
1344
|
+
getTelegramThreadLabel: resolveTelegramThreadLabel,
|
|
1345
|
+
});
|
|
1346
|
+
const enqueueContinueTurn = async (message, ctx) => {
|
|
1347
|
+
deps.bridgeRuntime.lifecycle.setFoldQueuedPromptsIntoHistory(false);
|
|
1348
|
+
const continueMessage = {
|
|
1349
|
+
...message,
|
|
1350
|
+
text: "continue",
|
|
1351
|
+
caption: undefined,
|
|
1352
|
+
};
|
|
1353
|
+
const buildTurn = await preparePromptTurn([continueMessage], ctx);
|
|
1354
|
+
const turn = buildTurn([]);
|
|
1355
|
+
const continueTurn = {
|
|
1356
|
+
...turn,
|
|
1357
|
+
queueLane: "control",
|
|
1358
|
+
laneOrder: deps.bridgeRuntime.queue.allocateControlOrder(),
|
|
1359
|
+
statusSummary: "continue",
|
|
1360
|
+
};
|
|
1361
|
+
Updates.assertTelegramUpdateExecutionCurrent(message);
|
|
1362
|
+
deps.queueMutationRuntime.append(continueTurn, ctx);
|
|
1363
|
+
reportQueueAdmission([continueMessage], continueTurn.admissionReceipts ?? []);
|
|
1364
|
+
requestDispatchNextQueuedTelegramTurn(ctx);
|
|
1365
|
+
};
|
|
1366
|
+
const reservedCommandNames = () => new Set(Commands.getTelegramReservedCommandNames());
|
|
1367
|
+
const getPromptTemplateCommands = () => PromptTemplates.getTelegramPromptTemplateCommands(deps.getCommands(), reservedCommandNames());
|
|
1368
|
+
const commandHandler = Commands.createTelegramCommandHandlerTargetRuntime({
|
|
1369
|
+
assertExecutionCurrent(message) {
|
|
1370
|
+
Updates.assertTelegramUpdateExecutionCurrent(message);
|
|
1371
|
+
},
|
|
1372
|
+
hasAbortHandler: deps.bridgeRuntime.abort.hasHandler,
|
|
1373
|
+
clearPendingModelSwitch: deps.modelSwitchController.clearPendingSwitch,
|
|
1374
|
+
hasQueuedTelegramItems: deps.telegramQueueStore.hasQueuedItems,
|
|
1375
|
+
clearQueuedTelegramItems: deps.queueMutationRuntime.clear,
|
|
1376
|
+
setFoldQueuedPromptsIntoHistory: deps.bridgeRuntime.lifecycle.setFoldQueuedPromptsIntoHistory,
|
|
1377
|
+
abortCurrentTurn: deps.bridgeRuntime.abort.abortTurn,
|
|
1378
|
+
isIdle: deps.isIdle,
|
|
1379
|
+
hasPendingMessages: deps.hasPendingMessages,
|
|
1380
|
+
hasActiveTelegramTurn: deps.activeTurnRuntime.has,
|
|
1381
|
+
hasDispatchPending: deps.bridgeRuntime.lifecycle.hasDispatchPending,
|
|
1382
|
+
isCompactionInProgress: deps.bridgeRuntime.lifecycle.isCompactionInProgress,
|
|
1383
|
+
setCompactionInProgress: deps.bridgeRuntime.lifecycle.setCompactionInProgress,
|
|
1384
|
+
updateStatus: deps.updateStatus,
|
|
1385
|
+
isContextActive: deps.isContextActive,
|
|
1386
|
+
dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
|
|
1387
|
+
requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
|
|
1388
|
+
requestDeferredDispatchNextQueuedTelegramTurn: deps.requestDeferredDispatchNextQueuedTelegramTurn,
|
|
1389
|
+
startTypingLoop: deps.startTypingLoop,
|
|
1390
|
+
stopTypingLoop: deps.stopTypingLoop,
|
|
1391
|
+
enqueueContinueTurn,
|
|
1392
|
+
compact: deps.compact,
|
|
1393
|
+
allocateItemOrder: deps.bridgeRuntime.queue.allocateItemOrder,
|
|
1394
|
+
allocateControlOrder: deps.bridgeRuntime.queue.allocateControlOrder,
|
|
1395
|
+
appendControlItem: deps.queueMutationRuntime.append,
|
|
1396
|
+
getAdmissionScope: deps.getAdmissionScope,
|
|
1397
|
+
getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
|
|
1398
|
+
onControlQueued: (message, receipt) => reportQueueAdmission([message], [receipt]),
|
|
1399
|
+
showStatus: deps.menuActions.sendStatusMessage,
|
|
1400
|
+
openModelMenu: deps.menuActions.openModelMenu,
|
|
1401
|
+
openThinkingMenu: (message, ctx) => {
|
|
1402
|
+
const chatId = message.chat.id;
|
|
1403
|
+
return deps.menuActions.openThinkingMenu(chatId, message.message_id, ctx);
|
|
1404
|
+
},
|
|
1405
|
+
openQueueMenu: (message, ctx) => {
|
|
1406
|
+
const chatId = message.chat.id;
|
|
1407
|
+
return deps.openQueueMenu(chatId, message.message_id, ctx);
|
|
1408
|
+
},
|
|
1409
|
+
openSettingsMenu: deps.openSettingsMenu,
|
|
1410
|
+
getAllowedUserId: deps.configStore.getAllowedUserId,
|
|
1411
|
+
persistAllowedUserId: deps.configStore.persistAllowedUserId,
|
|
1412
|
+
setMyCommands: deps.setMyCommands,
|
|
1413
|
+
validateThreadName: deps.validateThreadName,
|
|
1414
|
+
renameCurrentThread: deps.renameCurrentThread,
|
|
1415
|
+
resetCurrentThreadName: deps.resetCurrentThreadName,
|
|
1416
|
+
openThreadNameDialog: async (message) => {
|
|
1417
|
+
const target = Updates.getTelegramMessageTarget(message);
|
|
1418
|
+
if (!deps.sendInteractiveMessage || !target) {
|
|
1419
|
+
await deps.sendTextReply(message.chat.id, message.message_id, Commands.formatTelegramInformationHeading("🏷️", "Usage: /name Navigator"), { parseMode: "HTML", target });
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
const hasManualName = deps.threadStore?.listWorkspaceBindings().some((binding) => binding.target.chatId === target.chatId &&
|
|
1423
|
+
binding.target.threadId === target.threadId &&
|
|
1424
|
+
typeof binding.manualThreadName === "string") ?? false;
|
|
1425
|
+
const instructions = hasManualName
|
|
1426
|
+
? "<b>🏷️ Send a new Thread name using printable ASCII, reset to automatic, or cancel.</b>"
|
|
1427
|
+
: "<b>🏷️ Send a Thread name using printable ASCII, or cancel.</b>";
|
|
1428
|
+
const buttons = hasManualName
|
|
1429
|
+
? [
|
|
1430
|
+
{ text: "↩️ Reset to automatic", callback_data: "thread-name:reset" },
|
|
1431
|
+
{ text: "✖ Cancel rename", callback_data: "thread-name:cancel" },
|
|
1432
|
+
]
|
|
1433
|
+
: [{ text: "✖ Cancel rename", callback_data: "thread-name:cancel" }];
|
|
1434
|
+
const dialogMessageId = await deps.sendInteractiveMessage(target.chatId, instructions, "html", { inline_keyboard: buttons.map((button) => [button]) }, { target });
|
|
1435
|
+
if (typeof dialogMessageId !== "number")
|
|
1436
|
+
return;
|
|
1437
|
+
threadNameDialog.open({
|
|
1438
|
+
scope: getThreadNameDialogScope(),
|
|
1439
|
+
target,
|
|
1440
|
+
dialogMessageId,
|
|
1441
|
+
});
|
|
1442
|
+
},
|
|
1443
|
+
getPromptTemplateCommands,
|
|
1444
|
+
sendTextReply: deps.sendTextReply,
|
|
1445
|
+
getActiveTurnReply: () => {
|
|
1446
|
+
const activeTurn = deps.activeTurnRuntime.get();
|
|
1447
|
+
if (!activeTurn)
|
|
1448
|
+
return undefined;
|
|
1449
|
+
return async (text, options) => {
|
|
1450
|
+
await deps.sendTextReply(activeTurn.chatId, activeTurn.replyToMessageId, text, { target: activeTurn.target, parseMode: options?.parseMode });
|
|
1451
|
+
};
|
|
1452
|
+
},
|
|
1453
|
+
sendInteractiveMessage: deps.sendInteractiveMessage,
|
|
1454
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1455
|
+
});
|
|
1456
|
+
const promptEnqueueController = Queue.createTelegramPromptEnqueueController({
|
|
1457
|
+
...deps.telegramQueueStore,
|
|
1458
|
+
hasPendingDispatch: deps.bridgeRuntime.lifecycle.hasDispatchPending,
|
|
1459
|
+
getFoldQueuedPromptsIntoHistory: deps.bridgeRuntime.lifecycle.shouldFoldQueuedPromptsIntoHistory,
|
|
1460
|
+
setFoldQueuedPromptsIntoHistory: deps.bridgeRuntime.lifecycle.setFoldQueuedPromptsIntoHistory,
|
|
1461
|
+
prepareTurn: async (messages, turnCtx) => {
|
|
1462
|
+
const buildTurn = await preparePromptTurn(messages, turnCtx);
|
|
1463
|
+
return (historyTurns) => {
|
|
1464
|
+
const turn = buildTurn(historyTurns);
|
|
1465
|
+
return turn.replyToMessageId > 0
|
|
1466
|
+
? turn
|
|
1467
|
+
: { ...turn, replyToMessageId: 0 };
|
|
1468
|
+
};
|
|
1469
|
+
},
|
|
1470
|
+
updateStatus: deps.updateStatus,
|
|
1471
|
+
dispatchNextQueuedTelegramTurn: requestDispatchNextQueuedTelegramTurn,
|
|
1472
|
+
assertExecutionCurrent: (messages) => Updates.assertTelegramUpdateExecutionCurrent(messages[0]),
|
|
1473
|
+
});
|
|
1474
|
+
const promptEnqueue = async (messages, ctx) => {
|
|
1475
|
+
return promptEnqueueController.enqueue(messages, ctx, (turn) => {
|
|
1476
|
+
reportQueueAdmission(messages, turn.admissionReceipts ?? []);
|
|
1477
|
+
});
|
|
1478
|
+
};
|
|
1479
|
+
const sendUnboundRerouteChooserNow = async (messages, _ctx, reportDeferred = true) => {
|
|
1480
|
+
const message = messages[0];
|
|
1481
|
+
if (!message || !deps.threadStore)
|
|
1482
|
+
return;
|
|
1483
|
+
const records = deps.threadStore.list();
|
|
1484
|
+
const activeRecords = getTelegramRoutableThreadRecords(records, deps.getLiveThreadTargets?.());
|
|
1485
|
+
const sourceTarget = typeof message.message_thread_id === "number"
|
|
1486
|
+
? { chatId: message.chat.id, threadId: message.message_thread_id }
|
|
1487
|
+
: undefined;
|
|
1488
|
+
const sourceKey = sourceTarget
|
|
1489
|
+
? formatTelegramTargetKey(sourceTarget)
|
|
1490
|
+
: undefined;
|
|
1491
|
+
const includeGuidance = sourceKey
|
|
1492
|
+
? !guidedUnboundTopicKeys.has(sourceKey)
|
|
1493
|
+
: true;
|
|
1494
|
+
if (sourceKey)
|
|
1495
|
+
guidedUnboundTopicKeys.add(sourceKey);
|
|
1496
|
+
if (activeRecords.length === 0) {
|
|
1497
|
+
await deps.sendTextReply(message.chat.id, message.message_id, [
|
|
1498
|
+
includeGuidance ? formatTelegramUnboundTopicGuidance() : undefined,
|
|
1499
|
+
`This thread is not bound to a Pi instance. Open an active Pi thread or run ${Commands.formatTelegramPiCommandHtml("/telegram-connect")} from a Pi session to bind one.`,
|
|
1500
|
+
]
|
|
1501
|
+
.filter((line) => typeof line === "string")
|
|
1502
|
+
.join("\n\n"), { parseMode: "HTML", target: sourceTarget });
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
const rerouteId = storePendingUnboundReroute(messages);
|
|
1506
|
+
if (reportDeferred) {
|
|
1507
|
+
for (const source of messages) {
|
|
1508
|
+
Updates.reportTelegramUpdateDeferred(source);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
const text = formatTelegramUnboundRerouteChooserText(activeRecords, {
|
|
1512
|
+
includeGuidance,
|
|
1513
|
+
});
|
|
1514
|
+
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(rerouteId, activeRecords, { canRestore: sourceTarget !== undefined, getDisplayTitle: deps.getDisplayTitle });
|
|
1515
|
+
if (deps.sendInteractiveMessage) {
|
|
1516
|
+
const chooserId = await deps.sendInteractiveMessage(message.chat.id, text, "html", replyMarkup, sourceTarget
|
|
1517
|
+
? { target: sourceTarget, replyToMessageId: message.message_id }
|
|
1518
|
+
: { replyToMessageId: message.message_id });
|
|
1519
|
+
rememberRerouteChooser(rerouteId, chooserId);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
const chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
1523
|
+
parseMode: "HTML",
|
|
1524
|
+
target: sourceTarget,
|
|
1525
|
+
});
|
|
1526
|
+
rememberRerouteChooser(rerouteId, chooserId);
|
|
1527
|
+
};
|
|
1528
|
+
const sendUnboundRerouteChooser = async (message, ctx) => {
|
|
1529
|
+
const groupKey = Media.getTelegramMediaGroupKey(message);
|
|
1530
|
+
if (!groupKey) {
|
|
1531
|
+
await sendUnboundRerouteChooserNow([message], ctx);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
const existing = pendingUnboundRerouteMediaGroups.get(groupKey);
|
|
1535
|
+
if (existing)
|
|
1536
|
+
clearTimeout(existing.timer);
|
|
1537
|
+
const messages = [...(existing?.messages ?? []), message];
|
|
1538
|
+
const timer = setTimeout(() => {
|
|
1539
|
+
pendingUnboundRerouteMediaGroups.delete(groupKey);
|
|
1540
|
+
void sendUnboundRerouteChooserNow(messages, ctx, false);
|
|
1541
|
+
}, 1200);
|
|
1542
|
+
timer.unref?.();
|
|
1543
|
+
pendingUnboundRerouteMediaGroups.set(groupKey, { messages, timer });
|
|
1544
|
+
Updates.reportTelegramUpdateDeferred(message);
|
|
1545
|
+
};
|
|
1546
|
+
const getKnownTelegramAllTabCommand = (text) => {
|
|
1547
|
+
const command = Commands.parseTelegramCommand(text);
|
|
1548
|
+
if (!command)
|
|
1549
|
+
return undefined;
|
|
1550
|
+
if (reservedCommandNames().has(command.name))
|
|
1551
|
+
return command;
|
|
1552
|
+
if (Commands.findTelegramExtensionCommand(command.name))
|
|
1553
|
+
return command;
|
|
1554
|
+
if (getPromptTemplateCommands().some((template) => template.command === command.name)) {
|
|
1555
|
+
return command;
|
|
1556
|
+
}
|
|
1557
|
+
return undefined;
|
|
1558
|
+
};
|
|
1559
|
+
const sendAllTabCommandChooser = async (command, commandText, message, options = {}) => {
|
|
1560
|
+
if (!deps.threadStore)
|
|
1561
|
+
return false;
|
|
1562
|
+
const records = deps.threadStore.list();
|
|
1563
|
+
const activeRecords = getTelegramRoutableThreadRecords(records, deps.getLiveThreadTargets?.());
|
|
1564
|
+
if (activeRecords.length === 0)
|
|
1565
|
+
return false;
|
|
1566
|
+
const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
|
|
1567
|
+
...message,
|
|
1568
|
+
text: commandText,
|
|
1569
|
+
caption: undefined,
|
|
1570
|
+
});
|
|
1571
|
+
const rerouteId = storePendingUnboundReroute([commandMessage], "command");
|
|
1572
|
+
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
1573
|
+
const text = formatTelegramAllTabMenuChooserText(command.name);
|
|
1574
|
+
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(rerouteId, activeRecords, { canRestore: typeof message.message_thread_id === "number", getDisplayTitle: deps.getDisplayTitle });
|
|
1575
|
+
let chooserId;
|
|
1576
|
+
try {
|
|
1577
|
+
if (deps.sendInteractiveMessage) {
|
|
1578
|
+
chooserId = await deps.sendInteractiveMessage(message.chat.id, text, "html", replyMarkup, options.target || options.replyToSource
|
|
1579
|
+
? {
|
|
1580
|
+
...(options.target ? { target: options.target } : {}),
|
|
1581
|
+
...(options.replyToSource
|
|
1582
|
+
? { replyToMessageId: message.message_id }
|
|
1583
|
+
: {}),
|
|
1584
|
+
}
|
|
1585
|
+
: undefined);
|
|
1586
|
+
}
|
|
1587
|
+
else if (deps.callApi) {
|
|
1588
|
+
const chooser = await deps.callApi("sendMessage", {
|
|
1589
|
+
chat_id: message.chat.id,
|
|
1590
|
+
text,
|
|
1591
|
+
parse_mode: "HTML",
|
|
1592
|
+
reply_markup: replyMarkup,
|
|
1593
|
+
...(typeof options.target?.threadId === "number"
|
|
1594
|
+
? { message_thread_id: options.target.threadId }
|
|
1595
|
+
: {}),
|
|
1596
|
+
...(options.replyToSource
|
|
1597
|
+
? {
|
|
1598
|
+
reply_parameters: {
|
|
1599
|
+
message_id: message.message_id,
|
|
1600
|
+
allow_sending_without_reply: true,
|
|
1601
|
+
},
|
|
1602
|
+
}
|
|
1603
|
+
: {}),
|
|
1604
|
+
});
|
|
1605
|
+
chooserId = chooser?.message_id;
|
|
1606
|
+
}
|
|
1607
|
+
else {
|
|
1608
|
+
chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
1609
|
+
parseMode: "HTML",
|
|
1610
|
+
target: options.target,
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
catch (error) {
|
|
1615
|
+
removePendingReroute(rerouteId);
|
|
1616
|
+
throw error;
|
|
1617
|
+
}
|
|
1618
|
+
rememberRerouteChooser(rerouteId, chooserId);
|
|
1619
|
+
Updates.reportTelegramUpdateCompleted(commandMessage);
|
|
1620
|
+
return true;
|
|
1621
|
+
};
|
|
1622
|
+
const commandOrPrompt = Commands.createTelegramCommandOrPromptRuntime({
|
|
1623
|
+
extractRawText: Media.extractFirstTelegramMessageText,
|
|
1624
|
+
assertExecutionCurrent(message) {
|
|
1625
|
+
Updates.assertTelegramUpdateExecutionCurrent(message);
|
|
1626
|
+
},
|
|
1627
|
+
shouldIgnoreMessages: (messages) => !Media.hasTelegramMessagesPromptContent(messages),
|
|
1628
|
+
consumeThreadNameInput: async (messages) => {
|
|
1629
|
+
const message = messages[0];
|
|
1630
|
+
if (!message || messages.length !== 1)
|
|
1631
|
+
return false;
|
|
1632
|
+
const target = Updates.getTelegramMessageTarget(message);
|
|
1633
|
+
if (!target)
|
|
1634
|
+
return false;
|
|
1635
|
+
const candidate = threadNameDialog.inspect(target);
|
|
1636
|
+
if (!candidate || candidate.scope !== getThreadNameDialogScope() ||
|
|
1637
|
+
candidate.phase !== "input")
|
|
1638
|
+
return false;
|
|
1639
|
+
const name = Media.extractFirstTelegramMessageText(messages).trim();
|
|
1640
|
+
if (/^[A-Z]$/.test(name) && deps.resetCurrentThreadName) {
|
|
1641
|
+
const consumed = threadNameDialog.consumeName({
|
|
1642
|
+
scope: getThreadNameDialogScope(), target, text: name,
|
|
1643
|
+
});
|
|
1644
|
+
if (consumed.kind !== "name")
|
|
1645
|
+
return false;
|
|
1646
|
+
const result = await deps.resetCurrentThreadName(target);
|
|
1647
|
+
if (!result.ok) {
|
|
1648
|
+
threadNameDialog.open({
|
|
1649
|
+
scope: getThreadNameDialogScope(), target,
|
|
1650
|
+
dialogMessageId: candidate.dialogMessageId,
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
await deps.sendTextReply(target.chatId, message.message_id, result.ok && !result.message
|
|
1654
|
+
? Commands.formatTelegramAutomaticThreadDisplayNameRestoredHeading(result.threadName ?? name)
|
|
1655
|
+
: Commands.formatTelegramInformationHeading(result.ok ? "✅" : "⚠️", result.message ?? "Thread display name reset failed."), { parseMode: "HTML", target });
|
|
1656
|
+
return true;
|
|
1657
|
+
}
|
|
1658
|
+
const validationError = deps.validateThreadName?.(name);
|
|
1659
|
+
if (!name || validationError) {
|
|
1660
|
+
await deps.sendTextReply(target.chatId, message.message_id, validationError
|
|
1661
|
+
? Commands.formatTelegramInvalidInstanceName(validationError)
|
|
1662
|
+
: Commands.formatTelegramInformationHeading("⚠️", "Send 1–96 printable ASCII characters."), { parseMode: "HTML", target });
|
|
1663
|
+
return true;
|
|
1664
|
+
}
|
|
1665
|
+
const consumed = threadNameDialog.consumeName({
|
|
1666
|
+
scope: getThreadNameDialogScope(), target, text: name,
|
|
1667
|
+
});
|
|
1668
|
+
if (consumed.kind !== "name")
|
|
1669
|
+
return false;
|
|
1670
|
+
const result = await deps.renameCurrentThread?.(target, consumed.name);
|
|
1671
|
+
if (!result?.ok) {
|
|
1672
|
+
threadNameDialog.open({
|
|
1673
|
+
scope: getThreadNameDialogScope(),
|
|
1674
|
+
target,
|
|
1675
|
+
dialogMessageId: candidate.dialogMessageId,
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
await deps.sendTextReply(target.chatId, message.message_id, result?.ok && !result.message
|
|
1679
|
+
? Commands.formatTelegramThreadDisplayNameSavedHeading(result.threadName ?? consumed.name)
|
|
1680
|
+
: Commands.formatTelegramInformationHeading(result?.ok ? "✅" : "⚠️", result?.message ?? "Thread display name update failed."), { parseMode: "HTML", target });
|
|
1681
|
+
return true;
|
|
1682
|
+
},
|
|
1683
|
+
handleCommand: commandHandler,
|
|
1684
|
+
executeExtensionCommand: async (command, message, ctx) => {
|
|
1685
|
+
const extensionCommand = Commands.findTelegramExtensionCommand(command.name);
|
|
1686
|
+
if (!extensionCommand)
|
|
1687
|
+
return false;
|
|
1688
|
+
const sourceTarget = Updates.getTelegramMessageTarget(message);
|
|
1689
|
+
const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(message);
|
|
1690
|
+
try {
|
|
1691
|
+
assertExecutionCurrent();
|
|
1692
|
+
await extensionCommand.handler({
|
|
1693
|
+
name: command.name,
|
|
1694
|
+
args: command.args,
|
|
1695
|
+
reply: async (text) => {
|
|
1696
|
+
assertExecutionCurrent();
|
|
1697
|
+
await deps.sendTextReply(message.chat.id, message.message_id, text, { target: sourceTarget });
|
|
1698
|
+
assertExecutionCurrent();
|
|
1699
|
+
},
|
|
1700
|
+
enqueuePrompt: async (prompt) => {
|
|
1701
|
+
assertExecutionCurrent();
|
|
1702
|
+
await promptEnqueue([
|
|
1703
|
+
{
|
|
1704
|
+
...message,
|
|
1705
|
+
text: prompt,
|
|
1706
|
+
caption: undefined,
|
|
1707
|
+
},
|
|
1708
|
+
], ctx);
|
|
1709
|
+
},
|
|
1710
|
+
});
|
|
1711
|
+
assertExecutionCurrent();
|
|
1712
|
+
}
|
|
1713
|
+
catch (error) {
|
|
1714
|
+
deps.recordRuntimeEvent?.("telegram-command", error, {
|
|
1715
|
+
command: command.name,
|
|
1716
|
+
});
|
|
1717
|
+
assertExecutionCurrent();
|
|
1718
|
+
await deps.sendTextReply(message.chat.id, message.message_id, "Command failed.", { target: sourceTarget });
|
|
1719
|
+
}
|
|
1720
|
+
return true;
|
|
1721
|
+
},
|
|
1722
|
+
expandPromptTemplateCommand: (commandName, args) => PromptTemplates.expandTelegramPromptTemplateCommand(commandName, args, getPromptTemplateCommands()),
|
|
1723
|
+
replaceMessageText: (message, text) => ({ ...message, text, caption: undefined }),
|
|
1724
|
+
enqueueTurn: async (messages, ctx) => {
|
|
1725
|
+
await promptEnqueue(messages, ctx);
|
|
1726
|
+
},
|
|
1727
|
+
});
|
|
1728
|
+
dispatchReroutedCommandMessages = (messages, ctx) => commandOrPrompt.dispatchMessages(messages, ctx);
|
|
1729
|
+
const mediaDispatch = Media.createTelegramMediaGroupDispatchRuntime({
|
|
1730
|
+
mediaGroups: deps.mediaGroupRuntime,
|
|
1731
|
+
dispatchMessages: commandOrPrompt.dispatchMessages,
|
|
1732
|
+
onDeferredMessage: Updates.reportTelegramUpdateDeferred,
|
|
1733
|
+
});
|
|
1734
|
+
const textDispatch = TextGroups.createTelegramTextGroupDispatchRuntime({
|
|
1735
|
+
textGroups: deps.textGroupRuntime,
|
|
1736
|
+
dispatchMessages: commandOrPrompt.dispatchMessages,
|
|
1737
|
+
dispatchSingleMessage: mediaDispatch.handleMessage,
|
|
1738
|
+
onDeferredMessage: Updates.reportTelegramUpdateDeferred,
|
|
1739
|
+
});
|
|
1740
|
+
const editRuntime = Turns.createTelegramQueuedPromptEditRuntime({
|
|
1741
|
+
...deps.telegramQueueStore,
|
|
1742
|
+
updateStatus: deps.updateStatus,
|
|
1743
|
+
});
|
|
1744
|
+
const handleTelegramTopicLifecycleUpdate = async (lifecycle, ctx) => {
|
|
1745
|
+
const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(lifecycle.message);
|
|
1746
|
+
assertExecutionCurrent();
|
|
1747
|
+
await deps.handleTelegramTopicLifecycleUpdate?.(lifecycle, ctx);
|
|
1748
|
+
assertExecutionCurrent();
|
|
1749
|
+
if (lifecycle.kind !== "created" || !deps.threadStore) {
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
await deps.threadStore.load();
|
|
1753
|
+
assertExecutionCurrent();
|
|
1754
|
+
};
|
|
1755
|
+
// Answer the guest query immediately so the agent-end edit can replace the
|
|
1756
|
+
// early ACK once the turn settles. The ACK is the first placeholder frame and
|
|
1757
|
+
// the loop rotates through the remaining frames until the replacement.
|
|
1758
|
+
const TELEGRAM_GUEST_ACK_HTML = Replies.buildTelegramGuestPlaceholderFrame(0);
|
|
1759
|
+
const handleAuthorizedTelegramGuestMessage = async (guestMessage, ctx) => {
|
|
1760
|
+
const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(guestMessage);
|
|
1761
|
+
assertExecutionCurrent();
|
|
1762
|
+
let guestInlineMessageId;
|
|
1763
|
+
if (deps.answerGuestQueryForInlineMessage) {
|
|
1764
|
+
try {
|
|
1765
|
+
guestInlineMessageId = await deps.answerGuestQueryForInlineMessage(guestMessage.guest_query_id, TELEGRAM_GUEST_ACK_HTML, { parseMode: "HTML" });
|
|
1766
|
+
if (guestInlineMessageId) {
|
|
1767
|
+
deps.startGuestPlaceholder?.(guestInlineMessageId);
|
|
1768
|
+
}
|
|
1769
|
+
deps.recordRuntimeEvent?.("guest", new Error("Guest ACK answered the guest query"), {
|
|
1770
|
+
phase: "guest-ack-sent",
|
|
1771
|
+
guestQueryId: guestMessage.guest_query_id,
|
|
1772
|
+
hasInlineMessageId: !!guestInlineMessageId,
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
catch (error) {
|
|
1776
|
+
deps.recordRuntimeEvent?.("guest", error, {
|
|
1777
|
+
phase: "guest-ack-failed",
|
|
1778
|
+
guestQueryId: guestMessage.guest_query_id,
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
assertExecutionCurrent();
|
|
1782
|
+
}
|
|
1783
|
+
const text = guestMessage.text ?? "";
|
|
1784
|
+
const gm = guestMessage;
|
|
1785
|
+
// Build telegram prefix with guest context
|
|
1786
|
+
const chatRaw = gm.chat;
|
|
1787
|
+
const chatType = chatRaw?.type;
|
|
1788
|
+
const fromRaw = gm.from;
|
|
1789
|
+
const replyMsg = gm.reply_to_message;
|
|
1790
|
+
const replyFromRaw = replyMsg?.from;
|
|
1791
|
+
const guestBotCallerUser = gm.guest_bot_caller_user;
|
|
1792
|
+
const guestBotCallerChat = gm.guest_bot_caller_chat;
|
|
1793
|
+
const ownerUserId = deps.configStore.getAllowedUserId();
|
|
1794
|
+
const replyPeer = formatTelegramPromptPeer(replyFromRaw);
|
|
1795
|
+
const guestPeer = resolveTelegramGuestPromptPeer({
|
|
1796
|
+
chatType,
|
|
1797
|
+
chat: chatRaw,
|
|
1798
|
+
from: fromRaw,
|
|
1799
|
+
replyFrom: replyFromRaw,
|
|
1800
|
+
guestBotCallerUser,
|
|
1801
|
+
guestBotCallerChat,
|
|
1802
|
+
ownerUserId,
|
|
1803
|
+
});
|
|
1804
|
+
const prefixParts = ["telegram"];
|
|
1805
|
+
if (guestPeer) {
|
|
1806
|
+
prefixParts.push(`guest:${guestPeer}`);
|
|
1807
|
+
}
|
|
1808
|
+
else if (chatType === "private") {
|
|
1809
|
+
deps.recordRuntimeEvent?.("guest", new Error("Private Guest Mode remote peer could not be resolved"), {
|
|
1810
|
+
phase: "peer-attribution",
|
|
1811
|
+
chatId: typeof chatRaw?.id === "number" ? chatRaw.id : undefined,
|
|
1812
|
+
fromId: typeof fromRaw?.id === "number" ? fromRaw.id : undefined,
|
|
1813
|
+
hasReplyFrom: !!replyFromRaw,
|
|
1814
|
+
hasCallerUser: !!guestBotCallerUser,
|
|
1815
|
+
hasCallerChat: !!guestBotCallerChat,
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
const telegramPrefix = `[${prefixParts.join("|")}]`;
|
|
1819
|
+
// Extract reply context
|
|
1820
|
+
const replyText = replyMsg
|
|
1821
|
+
? (replyMsg.text || replyMsg.caption || "").trim()
|
|
1822
|
+
: "";
|
|
1823
|
+
// Download files, run inbound handlers
|
|
1824
|
+
const guestMsg = guestMessage;
|
|
1825
|
+
const replyFiles = guestMsg.reply_to_message
|
|
1826
|
+
? await Media.downloadTelegramMessageFiles([guestMsg.reply_to_message], { downloadFile: deps.downloadFile })
|
|
1827
|
+
: [];
|
|
1828
|
+
assertExecutionCurrent();
|
|
1829
|
+
const processedReply = replyFiles.length > 0
|
|
1830
|
+
? await deps.inboundHandlerRuntime.process(replyFiles, "", ctx)
|
|
1831
|
+
: undefined;
|
|
1832
|
+
assertExecutionCurrent();
|
|
1833
|
+
const files = await Media.downloadTelegramMessageFiles([guestMsg], {
|
|
1834
|
+
downloadFile: deps.downloadFile,
|
|
1835
|
+
});
|
|
1836
|
+
assertExecutionCurrent();
|
|
1837
|
+
const processed = await deps.inboundHandlerRuntime.process(files, text, ctx);
|
|
1838
|
+
assertExecutionCurrent();
|
|
1839
|
+
const rawText = processed.rawText || text;
|
|
1840
|
+
let sourceContext = "";
|
|
1841
|
+
if (replyMsg) {
|
|
1842
|
+
const replyHeader = replyPeer ? `[reply|from:${replyPeer}]` : "[reply]";
|
|
1843
|
+
const replyBlock = replyText
|
|
1844
|
+
? `${replyHeader} ${replyText}`
|
|
1845
|
+
: replyHeader;
|
|
1846
|
+
sourceContext = appendTelegramSourceAttachmentSection(replyBlock, replyPeer, processedReply?.promptFiles ?? replyFiles, processedReply?.handlerOutputs);
|
|
1847
|
+
}
|
|
1848
|
+
const promptText = Turns.buildTelegramTurnPrompt({
|
|
1849
|
+
telegramPrefix,
|
|
1850
|
+
rawText,
|
|
1851
|
+
files,
|
|
1852
|
+
promptFiles: processed.promptFiles,
|
|
1853
|
+
handlerOutputs: processed.handlerOutputs,
|
|
1854
|
+
sourceContext,
|
|
1855
|
+
// Guest Mode allows exactly one reply within Telegram's limited response
|
|
1856
|
+
// window; the note travels with the turn text so the agent sees it at
|
|
1857
|
+
// execution time without a guest-specific system prompt variant.
|
|
1858
|
+
guestTurn: true,
|
|
1859
|
+
});
|
|
1860
|
+
const order = deps.bridgeRuntime.queue.allocateItemOrder();
|
|
1861
|
+
const content = [
|
|
1862
|
+
{ type: "text", text: promptText },
|
|
1863
|
+
];
|
|
1864
|
+
for (const file of processed.promptFiles) {
|
|
1865
|
+
if (file.isImage && file.mimeType) {
|
|
1866
|
+
try {
|
|
1867
|
+
const buffer = await readFile(file.path);
|
|
1868
|
+
assertExecutionCurrent();
|
|
1869
|
+
content.push({
|
|
1870
|
+
type: "image",
|
|
1871
|
+
data: Buffer.from(buffer).toString("base64"),
|
|
1872
|
+
mimeType: file.mimeType,
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
catch {
|
|
1876
|
+
// skip unreadable files
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
const admissionReceipts = createAdmissionReceipts("prompt", [guestMessage]);
|
|
1881
|
+
const guestTurn = {
|
|
1882
|
+
kind: "prompt",
|
|
1883
|
+
chatId: 0,
|
|
1884
|
+
replyToMessageId: 0,
|
|
1885
|
+
guestQueryId: guestMessage.guest_query_id,
|
|
1886
|
+
...(guestInlineMessageId ? { guestInlineMessageId } : {}),
|
|
1887
|
+
sourceMessageIds: [],
|
|
1888
|
+
queueOrder: order,
|
|
1889
|
+
queueLane: "default",
|
|
1890
|
+
laneOrder: order,
|
|
1891
|
+
queuedAttachments: [],
|
|
1892
|
+
content,
|
|
1893
|
+
historyText: Turns.formatTelegramTurnStatusSummary(processed.rawText || text, processed.promptFiles, processed.handlerOutputs),
|
|
1894
|
+
statusSummary: Turns.truncateTelegramQueueSummary(processed.rawText || text),
|
|
1895
|
+
...(admissionReceipts.length > 0 ? { admissionReceipts } : {}),
|
|
1896
|
+
};
|
|
1897
|
+
const items = deps.telegramQueueStore.getQueuedItems();
|
|
1898
|
+
Updates.assertTelegramUpdateExecutionCurrent(guestMessage);
|
|
1899
|
+
deps.telegramQueueStore.setQueuedItems(Queue.appendTelegramQueueItem(items, guestTurn));
|
|
1900
|
+
reportQueueAdmission([guestMessage], admissionReceipts);
|
|
1901
|
+
deps.updateStatus(ctx);
|
|
1902
|
+
requestDispatchNextQueuedTelegramTurn(ctx);
|
|
1903
|
+
};
|
|
1904
|
+
return Updates.createTelegramPairedUpdateRuntime({
|
|
1905
|
+
getAllowedUserId: deps.configStore.getAllowedUserId,
|
|
1906
|
+
getCurrentInstanceId: deps.getCurrentInstanceId,
|
|
1907
|
+
getMessageOwnership: deps.getMessageOwnership,
|
|
1908
|
+
getTargetOwnership: deps.getTargetOwnership,
|
|
1909
|
+
recordMessageOwnership: deps.recordMessageOwnership,
|
|
1910
|
+
handleTelegramTopicLifecycleUpdate,
|
|
1911
|
+
foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
|
|
1912
|
+
persistAllowedUserId: deps.configStore.persistAllowedUserId,
|
|
1913
|
+
updateStatus: deps.updateStatus,
|
|
1914
|
+
removePendingMediaGroupMessages: deps.mediaGroupRuntime.removeMessages,
|
|
1915
|
+
flushPendingMediaGroupMessage: deps.mediaGroupRuntime.flushMessage,
|
|
1916
|
+
flushPendingTextGroupMessage: deps.textGroupRuntime.flushMessage,
|
|
1917
|
+
removeQueuedTelegramTurnsByMessageIds: deps.queueMutationRuntime.removeByMessageIds,
|
|
1918
|
+
applyQueuedTelegramTurnReactionByMessageId: deps.queueMutationRuntime.applyReactionByMessageId,
|
|
1919
|
+
answerCallbackQuery: deps.answerCallbackQuery,
|
|
1920
|
+
answerGuestQuery: deps.answerGuestQuery,
|
|
1921
|
+
handleAuthorizedTelegramCallbackQuery: callbackHandler,
|
|
1922
|
+
sendTextReply: deps.sendTextReply,
|
|
1923
|
+
handleAuthorizedTelegramMessage: async (message, ctx) => {
|
|
1924
|
+
const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(message);
|
|
1925
|
+
assertExecutionCurrent();
|
|
1926
|
+
if (typeof message.message_thread_id === "number") {
|
|
1927
|
+
await deps.handleTelegramThreadTargetObserved?.({
|
|
1928
|
+
chatId: message.chat.id,
|
|
1929
|
+
threadId: message.message_thread_id,
|
|
1930
|
+
}, ctx);
|
|
1931
|
+
assertExecutionCurrent();
|
|
1932
|
+
}
|
|
1933
|
+
const text = Media.extractFirstTelegramMessageText([
|
|
1934
|
+
message,
|
|
1935
|
+
]).trim();
|
|
1936
|
+
if (deps.threadStore && typeof message.message_thread_id !== "number") {
|
|
1937
|
+
await deps.threadStore.load();
|
|
1938
|
+
assertExecutionCurrent();
|
|
1939
|
+
if (deps.threadStore.getBotState().threadMode === "disabled") {
|
|
1940
|
+
await textDispatch.handleMessage(message, ctx);
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
const records = deps.threadStore.list();
|
|
1944
|
+
const bindings = getTelegramRoutableThreadRecords(records, deps.getLiveThreadTargets?.());
|
|
1945
|
+
const command = getKnownTelegramAllTabCommand(text);
|
|
1946
|
+
// Returning before deferral lets the admission worker terminally settle expired replay.
|
|
1947
|
+
if (command && command.name !== "thread" && isTelegramAllTabCommandExpired(message))
|
|
1948
|
+
return;
|
|
1949
|
+
if (bindings.length > 0 && command && command.name !== "thread") {
|
|
1950
|
+
if (await sendAllTabCommandChooser(command, text, message, {
|
|
1951
|
+
replyToSource: true,
|
|
1952
|
+
})) {
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
if (bindings.length > 0 && !text.startsWith("/")) {
|
|
1957
|
+
const probeTarget = bindings[0]?.target;
|
|
1958
|
+
if (probeTarget?.threadId && deps.callApi) {
|
|
1959
|
+
try {
|
|
1960
|
+
await deps.callApi("sendChatAction", {
|
|
1961
|
+
chat_id: probeTarget.chatId,
|
|
1962
|
+
message_thread_id: probeTarget.threadId,
|
|
1963
|
+
action: "typing",
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
catch (error) {
|
|
1967
|
+
if (Threads.isTelegramTopicModeUnavailableError(error) ||
|
|
1968
|
+
Threads.isTelegramTopicTargetStaleError(error)) {
|
|
1969
|
+
deps.threadStore.setBotState({
|
|
1970
|
+
threadMode: "disabled",
|
|
1971
|
+
updatedAtMs: Date.now(),
|
|
1972
|
+
lastReconcileAction: "thread-mode-unavailable-threadless-prompt",
|
|
1973
|
+
});
|
|
1974
|
+
await deps.threadStore.persist();
|
|
1975
|
+
assertExecutionCurrent();
|
|
1976
|
+
await textDispatch.handleMessage(message, ctx);
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
deps.recordRuntimeEvent?.("telegram", error, {
|
|
1980
|
+
phase: "threadless-topic-capability-check",
|
|
1981
|
+
chatId: probeTarget.chatId,
|
|
1982
|
+
threadId: probeTarget.threadId,
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
await deps.sendTextReply(message.chat.id, message.message_id, "This bot is in threaded multi-instance mode. Send prompts in a bound Pi thread tab so they route to the right instance.");
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
await textDispatch.handleMessage(message, ctx);
|
|
1991
|
+
},
|
|
1992
|
+
handleAuthorizedTelegramEditedMessage: editRuntime.updateFromEditedMessage,
|
|
1993
|
+
handleAuthorizedTelegramGuestMessage,
|
|
1994
|
+
handleUnboundTelegramTopicMessage: (message, ctx) => {
|
|
1995
|
+
const operation = async () => {
|
|
1996
|
+
const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(message);
|
|
1997
|
+
assertExecutionCurrent();
|
|
1998
|
+
if (!deps.threadStore) {
|
|
1999
|
+
await textDispatch.handleMessage(message, ctx);
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
await deps.threadStore.load();
|
|
2003
|
+
assertExecutionCurrent();
|
|
2004
|
+
if (deps.threadStore.getBotState().threadMode === "disabled") {
|
|
2005
|
+
await textDispatch.handleMessage(message, ctx);
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
const target = Updates.getTelegramMessageTarget(message);
|
|
2009
|
+
if (!target?.threadId) {
|
|
2010
|
+
await textDispatch.handleMessage(message, ctx);
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
const text = Media.extractFirstTelegramMessageText([
|
|
2014
|
+
message,
|
|
2015
|
+
]).trim();
|
|
2016
|
+
const instanceId = deps.getCurrentInstanceId?.();
|
|
2017
|
+
const leaderProfileKey = getLeaderTopicProfileKey(ctx, instanceId);
|
|
2018
|
+
const records = deps.threadStore.list();
|
|
2019
|
+
const routableRecords = getTelegramRoutableThreadRecords(records, deps.getLiveThreadTargets?.());
|
|
2020
|
+
const hasAnyRoutableThread = routableRecords.length > 0;
|
|
2021
|
+
const existing = records.find((r) => {
|
|
2022
|
+
return (r.target.chatId === target.chatId &&
|
|
2023
|
+
r.target.threadId === target.threadId);
|
|
2024
|
+
});
|
|
2025
|
+
if (existing) {
|
|
2026
|
+
const isLeaderTopic = (instanceId && existing.instanceId === instanceId) ||
|
|
2027
|
+
(!!leaderProfileKey && existing.profileKey === leaderProfileKey);
|
|
2028
|
+
if (existing.status === "active" && isLeaderTopic) {
|
|
2029
|
+
if (typeof existing.rerouteConfirmedAtMs !== "number") {
|
|
2030
|
+
const nowMs = Date.now();
|
|
2031
|
+
deps.threadStore.upsert({
|
|
2032
|
+
...existing,
|
|
2033
|
+
updatedAtMs: nowMs,
|
|
2034
|
+
rerouteConfirmedAtMs: nowMs,
|
|
2035
|
+
});
|
|
2036
|
+
await deps.threadStore.persist();
|
|
2037
|
+
assertExecutionCurrent();
|
|
2038
|
+
}
|
|
2039
|
+
await textDispatch.handleMessage(message, ctx);
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
if (existing.status === "starting") {
|
|
2043
|
+
await deps.sendTextReply(target.chatId, message.message_id, "Instance " +
|
|
2044
|
+
getTelegramThreadRecordLabel(existing, deps.getDisplayTitle) +
|
|
2045
|
+
" is starting. Please wait…", { target });
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
if (existing.status === "active") {
|
|
2049
|
+
await deps.sendTextReply(target.chatId, message.message_id, "Instance " +
|
|
2050
|
+
escapeHtml(getTelegramThreadRecordLabel(existing, deps.getDisplayTitle)) +
|
|
2051
|
+
` is not currently registered with the Telegram bus. This thread is preserved; retry shortly. If it does not recover, run ${Commands.formatTelegramPiCommandHtml("/telegram-connect")} in that Pi instance.`, { parseMode: "HTML", target });
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
await deps.sendTextReply(target.chatId, message.message_id, "Topic " +
|
|
2055
|
+
(existing.slot ?? "?") +
|
|
2056
|
+
" is " +
|
|
2057
|
+
existing.status +
|
|
2058
|
+
". Start a Pi instance to claim it.", { target });
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
const deletedObservation = deps.threadStore
|
|
2062
|
+
.listSyncObservations()
|
|
2063
|
+
.find((observation) => observation.syncStatus === "deleted" &&
|
|
2064
|
+
observation.target.chatId === target.chatId &&
|
|
2065
|
+
observation.target.threadId === target.threadId);
|
|
2066
|
+
if (deletedObservation) {
|
|
2067
|
+
deps.recordRuntimeEvent?.("inbound-worker", "Discarded update from a confirmed deleted Telegram thread", {
|
|
2068
|
+
phase: "discard-deleted-thread",
|
|
2069
|
+
chatId: target.chatId,
|
|
2070
|
+
threadId: target.threadId,
|
|
2071
|
+
messageId: message.message_id,
|
|
2072
|
+
});
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
const reservations = deps.threadStore.listReservations();
|
|
2076
|
+
const reservation = reservations.find((reservation) => reservation.target.chatId === target.chatId &&
|
|
2077
|
+
reservation.target.threadId === target.threadId);
|
|
2078
|
+
if (reservation) {
|
|
2079
|
+
await deps.sendTextReply(target.chatId, message.message_id, "Previous leader thread (" +
|
|
2080
|
+
(reservation.slot ?? "?") +
|
|
2081
|
+
"). Closing and deleting this old topic. Use the current thread tab instead.", { target });
|
|
2082
|
+
await deleteReservedTelegramTopicThroughReconciler(deps, { chatId: target.chatId, threadId: target.threadId }, message.message_id);
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
const command = getKnownTelegramAllTabCommand(text);
|
|
2086
|
+
if (command && hasAnyRoutableThread) {
|
|
2087
|
+
if (await sendAllTabCommandChooser(command, text, message, {
|
|
2088
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
2089
|
+
replyToSource: true,
|
|
2090
|
+
})) {
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
if (leaderProfileKey && deps.callApi) {
|
|
2095
|
+
const currentLeaderRecord = records.find((record) => {
|
|
2096
|
+
if (record.status !== "active")
|
|
2097
|
+
return false;
|
|
2098
|
+
if (instanceId && record.instanceId === instanceId)
|
|
2099
|
+
return true;
|
|
2100
|
+
return record.profileKey === leaderProfileKey;
|
|
2101
|
+
});
|
|
2102
|
+
if (currentLeaderRecord &&
|
|
2103
|
+
(currentLeaderRecord.target.chatId !== target.chatId ||
|
|
2104
|
+
currentLeaderRecord.target.threadId !== target.threadId)) {
|
|
2105
|
+
let currentLeaderIsStale = false;
|
|
2106
|
+
try {
|
|
2107
|
+
await deps.callApi("sendChatAction", {
|
|
2108
|
+
chat_id: currentLeaderRecord.target.chatId,
|
|
2109
|
+
message_thread_id: currentLeaderRecord.target.threadId,
|
|
2110
|
+
action: "typing",
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
catch (error) {
|
|
2114
|
+
currentLeaderIsStale =
|
|
2115
|
+
Threads.isTelegramTopicTargetStaleError(error);
|
|
2116
|
+
if (!currentLeaderIsStale)
|
|
2117
|
+
throw error;
|
|
2118
|
+
}
|
|
2119
|
+
if (currentLeaderIsStale) {
|
|
2120
|
+
const slot = deps.threadStore.allocateSlot(leaderProfileKey);
|
|
2121
|
+
if (!slot) {
|
|
2122
|
+
deps.threadStore.markStaleByTarget(currentLeaderRecord.target, "deleted", "Current leader thread is stale during unbound prompt routing.");
|
|
2123
|
+
await deps.threadStore.persist();
|
|
2124
|
+
assertExecutionCurrent();
|
|
2125
|
+
await deps.sendTextReply(target.chatId, message.message_id, TELEGRAM_SLOT_CAPACITY_MESSAGE, { target });
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
deps.threadStore.markStaleByTarget(currentLeaderRecord.target, "deleted", "Current leader thread is stale during unbound prompt routing.");
|
|
2129
|
+
const threadName = getRestoredThreadName(currentLeaderRecord, slot);
|
|
2130
|
+
deps.threadStore.upsert({
|
|
2131
|
+
...currentLeaderRecord,
|
|
2132
|
+
profileKey: leaderProfileKey,
|
|
2133
|
+
owner: {
|
|
2134
|
+
kind: "leader",
|
|
2135
|
+
cwd: typeof ctx.cwd === "string"
|
|
2136
|
+
? ctx.cwd
|
|
2137
|
+
: undefined,
|
|
2138
|
+
instanceId,
|
|
2139
|
+
},
|
|
2140
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
2141
|
+
status: "active",
|
|
2142
|
+
updatedAtMs: Date.now(),
|
|
2143
|
+
threadName,
|
|
2144
|
+
instanceId,
|
|
2145
|
+
slot,
|
|
2146
|
+
});
|
|
2147
|
+
await deps.threadStore.persist();
|
|
2148
|
+
assertExecutionCurrent();
|
|
2149
|
+
deps.setCurrentLeaderIdentity?.({
|
|
2150
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
2151
|
+
slot,
|
|
2152
|
+
threadName,
|
|
2153
|
+
});
|
|
2154
|
+
deps.recordRuntimeEvent?.("bus", "Bus leader reclaimed stale-current unbound thread", {
|
|
2155
|
+
phase: "leader-topic-unbound-stale-reclaim",
|
|
2156
|
+
chatId: target.chatId,
|
|
2157
|
+
threadId: target.threadId,
|
|
2158
|
+
staleThreadId: currentLeaderRecord.target.threadId,
|
|
2159
|
+
slot,
|
|
2160
|
+
profileKey: leaderProfileKey,
|
|
2161
|
+
});
|
|
2162
|
+
await textDispatch.handleMessage(message, ctx);
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
if (leaderProfileKey &&
|
|
2168
|
+
!hasActiveLeaderTopic(records, leaderProfileKey, instanceId) &&
|
|
2169
|
+
!hasAnyRoutableThread) {
|
|
2170
|
+
const priorLeaderRecord = deps.threadStore.getByProfileKey(leaderProfileKey);
|
|
2171
|
+
const priorLeaderIdentity = deps.threadStore.getIdentityByProfileKey(leaderProfileKey);
|
|
2172
|
+
const slot = deps.threadStore.allocateSlot(leaderProfileKey, priorLeaderRecord?.slot ?? priorLeaderIdentity?.slot);
|
|
2173
|
+
if (!slot) {
|
|
2174
|
+
await deps.sendTextReply(target.chatId, message.message_id, TELEGRAM_SLOT_CAPACITY_MESSAGE, { target });
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
const identityThreadName = priorLeaderIdentity?.threadName &&
|
|
2178
|
+
Threads.isTelegramTopicThreadNameValidForSlot(priorLeaderIdentity.threadName, slot)
|
|
2179
|
+
? priorLeaderIdentity.threadName
|
|
2180
|
+
: undefined;
|
|
2181
|
+
const threadName = priorLeaderRecord?.threadName ??
|
|
2182
|
+
identityThreadName ??
|
|
2183
|
+
Threads.chooseTelegramThreadName({ slot }) ??
|
|
2184
|
+
"Pi";
|
|
2185
|
+
deps.threadStore.upsert({
|
|
2186
|
+
profileKey: leaderProfileKey,
|
|
2187
|
+
owner: {
|
|
2188
|
+
kind: "leader",
|
|
2189
|
+
cwd: typeof ctx.cwd === "string"
|
|
2190
|
+
? ctx.cwd
|
|
2191
|
+
: undefined,
|
|
2192
|
+
instanceId,
|
|
2193
|
+
},
|
|
2194
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
2195
|
+
status: "active",
|
|
2196
|
+
createdAtMs: priorLeaderRecord?.createdAtMs ?? Date.now(),
|
|
2197
|
+
updatedAtMs: Date.now(),
|
|
2198
|
+
threadName,
|
|
2199
|
+
instanceId,
|
|
2200
|
+
slot,
|
|
2201
|
+
});
|
|
2202
|
+
await deps.threadStore.persist();
|
|
2203
|
+
assertExecutionCurrent();
|
|
2204
|
+
deps.setCurrentLeaderIdentity?.({
|
|
2205
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
2206
|
+
slot,
|
|
2207
|
+
threadName,
|
|
2208
|
+
});
|
|
2209
|
+
deps.recordRuntimeEvent?.("bus", "Bus leader reclaimed unbound thread", {
|
|
2210
|
+
phase: "leader-topic-reclaim",
|
|
2211
|
+
chatId: target.chatId,
|
|
2212
|
+
threadId: target.threadId,
|
|
2213
|
+
slot,
|
|
2214
|
+
profileKey: leaderProfileKey,
|
|
2215
|
+
});
|
|
2216
|
+
await textDispatch.handleMessage(message, ctx);
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
await sendUnboundRerouteChooser(message, ctx);
|
|
2220
|
+
return;
|
|
2221
|
+
};
|
|
2222
|
+
if (!deps.runWorkspaceOperation)
|
|
2223
|
+
return operation();
|
|
2224
|
+
return deps.runWorkspaceOperation({
|
|
2225
|
+
operationId: `workspace-unbound:${message.chat.id}:${message.message_id}`,
|
|
2226
|
+
operationKind: "workspace.route-unbound-thread",
|
|
2227
|
+
scopes: [{ kind: "profile" }],
|
|
2228
|
+
}, operation);
|
|
2229
|
+
},
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
export function createTelegramAssistantOutputAuthorityRuntime(deps) {
|
|
2233
|
+
const getCurrentTarget = () => {
|
|
2234
|
+
const preferred = deps.getPreferredTarget();
|
|
2235
|
+
if (preferred)
|
|
2236
|
+
return { ...preferred };
|
|
2237
|
+
const chatId = deps.getFallbackChatId();
|
|
2238
|
+
return chatId === undefined ? undefined : { chatId };
|
|
2239
|
+
};
|
|
2240
|
+
return {
|
|
2241
|
+
captureAuthority() {
|
|
2242
|
+
const target = getCurrentTarget();
|
|
2243
|
+
const directEpoch = deps.ownsDirect() ? deps.getDirectEpoch() : undefined;
|
|
2244
|
+
const followerGeneration = deps.isFollowerRegistered()
|
|
2245
|
+
? deps.getFollowerGeneration()
|
|
2246
|
+
: undefined;
|
|
2247
|
+
return {
|
|
2248
|
+
transportStamp: deps.getTransportStamp(),
|
|
2249
|
+
route: directEpoch !== undefined
|
|
2250
|
+
? "direct"
|
|
2251
|
+
: followerGeneration !== undefined
|
|
2252
|
+
? "follower"
|
|
2253
|
+
: "none",
|
|
2254
|
+
directEpoch,
|
|
2255
|
+
followerGeneration,
|
|
2256
|
+
target,
|
|
2257
|
+
};
|
|
2258
|
+
},
|
|
2259
|
+
isAuthorityActive(authority) {
|
|
2260
|
+
if (!deps.isTransportStampActive(authority.transportStamp))
|
|
2261
|
+
return false;
|
|
2262
|
+
const target = getCurrentTarget();
|
|
2263
|
+
if (authority.target === undefined ||
|
|
2264
|
+
target?.chatId !== authority.target.chatId ||
|
|
2265
|
+
target?.threadId !== authority.target.threadId) {
|
|
2266
|
+
return false;
|
|
2267
|
+
}
|
|
2268
|
+
if (authority.route === "direct") {
|
|
2269
|
+
return (deps.ownsDirect() && deps.getDirectEpoch() === authority.directEpoch);
|
|
2270
|
+
}
|
|
2271
|
+
if (authority.route === "follower") {
|
|
2272
|
+
return (!deps.ownsDirect() &&
|
|
2273
|
+
deps.isFollowerRegistered() &&
|
|
2274
|
+
deps.getFollowerGeneration() === authority.followerGeneration);
|
|
2275
|
+
}
|
|
2276
|
+
return false;
|
|
2277
|
+
},
|
|
2278
|
+
canDeliver() {
|
|
2279
|
+
return deps.ownsDirect() || deps.isFollowerRegistered();
|
|
2280
|
+
},
|
|
2281
|
+
};
|
|
2282
|
+
}
|