@llblab/pi-kit 0.36.1 → 0.36.3
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/CHANGELOG.md +10 -0
- package/README.md +3 -3
- package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +4 -0
- package/node_modules/@llblab/pi-claude-usage/README.md +2 -2
- package/node_modules/@llblab/pi-claude-usage/lib/status-format.ts +2 -3
- package/node_modules/@llblab/pi-claude-usage/package.json +1 -1
- package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +5 -0
- package/node_modules/@llblab/pi-codex-usage/README.md +1 -1
- package/node_modules/@llblab/pi-codex-usage/lib/status-format.ts +2 -3
- package/node_modules/@llblab/pi-codex-usage/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +43 -5
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +45 -0
- package/node_modules/@llblab/pi-telegram/README.md +4 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +17 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +23 -11
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +24 -6
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +2 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +7 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +7 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +22 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +52 -10
- package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.js +9 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +3 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +4 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/prompts.d.ts +6 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/prompts.js +50 -9
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +3 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +120 -14
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +13 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +12 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +39 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +144 -16
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.js +15 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +14 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +122 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +9 -6
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +4 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +3 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +52 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +17 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +25 -0
- package/node_modules/@llblab/pi-telegram/dist/package.json +2 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +10 -10
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +23 -3
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -0
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +24 -1
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +30 -13
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +25 -8
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +11 -0
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +34 -8
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +54 -11
- package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +12 -0
- package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +2 -0
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +4 -3
- package/node_modules/@llblab/pi-telegram/lib/menu.ts +4 -4
- package/node_modules/@llblab/pi-telegram/lib/pi.ts +2 -0
- package/node_modules/@llblab/pi-telegram/lib/prompts.ts +79 -16
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +142 -26
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +18 -0
- package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +173 -17
- package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +14 -8
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +166 -10
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +9 -6
- package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +8 -7
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +56 -2
- package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +44 -0
- package/node_modules/@llblab/pi-telegram/package.json +2 -1
- package/node_modules/@llblab/pi-telegram/scripts/run-macos-tests.mjs +43 -0
- package/package.json +4 -4
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Telegram prompt injection helpers
|
|
3
3
|
* Zones: pi agent prompts, telegram guidance
|
|
4
|
-
* Owns Telegram
|
|
4
|
+
* Owns stable Telegram guidance, turn-scoped context, and compatibility fallback hooks
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { BeforeAgentStartEvent } from "./pi.ts";
|
|
7
|
+
import type { BeforeAgentStartEvent, BeforeAgentStartEventResult } from "./pi.ts";
|
|
8
8
|
import { TELEGRAM_PREFIX } from "./turns.ts";
|
|
9
9
|
|
|
10
10
|
export const TELEGRAM_CONNECTED_CONTEXT_MESSAGE =
|
|
@@ -164,18 +164,34 @@ type TelegramBeforeAgentStartResult = {
|
|
|
164
164
|
systemPrompt: TelegramSystemPrompt;
|
|
165
165
|
};
|
|
166
166
|
|
|
167
|
+
type TelegramProactiveBeforeAgentStartResult = Omit<BeforeAgentStartEventResult, "systemPrompt"> & {
|
|
168
|
+
systemPrompt?: TelegramSystemPrompt;
|
|
169
|
+
};
|
|
170
|
+
|
|
167
171
|
type TelegramBeforeAgentStartHook = (
|
|
168
172
|
event: TelegramBeforeAgentStartEvent,
|
|
169
173
|
) => TelegramBeforeAgentStartResult;
|
|
170
174
|
|
|
171
|
-
|
|
175
|
+
// Returning any systemPrompt makes Pi replace its prompt, which defeats prompt
|
|
176
|
+
// caching; return an override only when the computed prompt actually differs.
|
|
177
|
+
function overrideSystemPromptIfChanged(
|
|
178
|
+
incoming: TelegramSystemPrompt | null | undefined,
|
|
179
|
+
computed: TelegramSystemPrompt,
|
|
180
|
+
): TelegramBeforeAgentStartResult | undefined {
|
|
181
|
+
const base = incoming ?? "";
|
|
182
|
+
const unchanged =
|
|
183
|
+
Array.isArray(base) && Array.isArray(computed)
|
|
184
|
+
? base.length === computed.length &&
|
|
185
|
+
base.every((block, index) => block === computed[index])
|
|
186
|
+
: base === computed;
|
|
187
|
+
return unchanged ? undefined : { systemPrompt: computed };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildTelegramTurnSuffix(options: {
|
|
172
191
|
prompt?: string;
|
|
173
|
-
systemPrompt?: TelegramSystemPrompt | null;
|
|
174
192
|
telegramPrefix?: string;
|
|
175
|
-
localSystemPromptSuffix: string;
|
|
176
193
|
telegramTurnSystemPromptSuffix: string;
|
|
177
|
-
}):
|
|
178
|
-
const basePrompt = options.systemPrompt ?? "";
|
|
194
|
+
}): string {
|
|
179
195
|
const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
|
|
180
196
|
const telegramHead = telegramPrefix.endsWith("]")
|
|
181
197
|
? telegramPrefix.slice(0, -1)
|
|
@@ -184,9 +200,20 @@ export function buildTelegramBridgeSystemPrompt(options: {
|
|
|
184
200
|
const telegramTurn =
|
|
185
201
|
trimmedPrompt.startsWith(`${telegramHead}]`) ||
|
|
186
202
|
trimmedPrompt.startsWith(`${telegramHead}|`);
|
|
187
|
-
|
|
203
|
+
return telegramTurn
|
|
188
204
|
? `${options.telegramTurnSystemPromptSuffix}\n- The current user message came from Telegram.`
|
|
189
205
|
: "";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function buildTelegramBridgeSystemPrompt(options: {
|
|
209
|
+
prompt?: string;
|
|
210
|
+
systemPrompt?: TelegramSystemPrompt | null;
|
|
211
|
+
telegramPrefix?: string;
|
|
212
|
+
localSystemPromptSuffix: string;
|
|
213
|
+
telegramTurnSystemPromptSuffix: string;
|
|
214
|
+
}): TelegramBeforeAgentStartResult {
|
|
215
|
+
const basePrompt = options.systemPrompt ?? "";
|
|
216
|
+
const telegramSuffix = buildTelegramTurnSuffix(options);
|
|
190
217
|
return {
|
|
191
218
|
systemPrompt: Array.isArray(basePrompt)
|
|
192
219
|
? [...basePrompt, options.localSystemPromptSuffix + telegramSuffix]
|
|
@@ -236,22 +263,58 @@ export interface TelegramProactivePromptHookDeps<TContext> {
|
|
|
236
263
|
isAvailable: (ctx: TContext) => boolean;
|
|
237
264
|
}
|
|
238
265
|
|
|
266
|
+
const TELEGRAM_TOOL_NAMES = ["telegram_attach", "telegram_message"];
|
|
267
|
+
|
|
268
|
+
// Sections cannot modify an opaque prompt owned by an earlier handler.
|
|
269
|
+
// Preserve append/strip compatibility there instead of silently losing guidance.
|
|
270
|
+
function applyTelegramPromptSections(
|
|
271
|
+
event: TelegramBeforeAgentStartEvent,
|
|
272
|
+
available: boolean,
|
|
273
|
+
): boolean {
|
|
274
|
+
const options = event.systemPromptOptions as
|
|
275
|
+
| Partial<TelegramBeforeAgentStartEvent["systemPromptOptions"]>
|
|
276
|
+
| undefined;
|
|
277
|
+
const sections = options?.sections;
|
|
278
|
+
if (typeof sections !== "object" || sections === null || options?.forceSystemPrompt !== undefined) return false;
|
|
279
|
+
if (available) {
|
|
280
|
+
sections.telegram = LOCAL_SYSTEM_PROMPT_SUFFIX.trim();
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
delete sections.telegram;
|
|
284
|
+
for (const name of TELEGRAM_TOOL_NAMES) {
|
|
285
|
+
if (options?.toolSnippets) delete options.toolSnippets[name];
|
|
286
|
+
if (options?.toolGuidelines) delete options.toolGuidelines[name];
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
239
291
|
export function createTelegramProactiveBeforeAgentStartHook<TContext>(
|
|
240
292
|
deps: TelegramProactivePromptHookDeps<TContext>,
|
|
241
293
|
): (
|
|
242
294
|
event: TelegramBeforeAgentStartEvent,
|
|
243
295
|
ctx: TContext,
|
|
244
|
-
) => Promise<
|
|
296
|
+
) => Promise<TelegramProactiveBeforeAgentStartResult | undefined> {
|
|
245
297
|
const baseHook = deps.baseHook ?? createTelegramBeforeAgentStartHook();
|
|
246
298
|
return async (event, ctx) => {
|
|
247
299
|
deps.reconcileAvailability?.();
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
300
|
+
const available = deps.isAvailable(ctx);
|
|
301
|
+
// A caller-supplied baseHook owns its own prompt text, so it stays on the forced-prompt path.
|
|
302
|
+
if (!deps.baseHook && applyTelegramPromptSections(event, available)) {
|
|
303
|
+
const turnSuffix = available ? buildTelegramTurnSuffix({
|
|
304
|
+
prompt: event.messageType === "custom" ? undefined : event.prompt,
|
|
305
|
+
telegramTurnSystemPromptSuffix: TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX,
|
|
306
|
+
}).trim() : "";
|
|
307
|
+
return turnSuffix ? { message: { customType: "telegram-turn", content: turnSuffix, display: false } } : undefined;
|
|
308
|
+
}
|
|
309
|
+
if (!available) {
|
|
310
|
+
return overrideSystemPromptIfChanged(
|
|
311
|
+
event.systemPrompt,
|
|
312
|
+
stripTelegramToolMetadataFromSystemPrompt(event.systemPrompt),
|
|
313
|
+
);
|
|
254
314
|
}
|
|
255
|
-
return
|
|
315
|
+
return overrideSystemPromptIfChanged(
|
|
316
|
+
event.systemPrompt,
|
|
317
|
+
baseHook(event).systemPrompt,
|
|
318
|
+
);
|
|
256
319
|
};
|
|
257
320
|
}
|
|
@@ -1499,6 +1499,8 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
1499
1499
|
getCurrentInstanceId?: () => string | undefined;
|
|
1500
1500
|
getAdmissionScope?: () => string | undefined;
|
|
1501
1501
|
getAdmissionJournalBinding?: () => string | undefined;
|
|
1502
|
+
getCommandReplyTarget?:
|
|
1503
|
+
Commands.TelegramCommandTargetRuntimeDeps<TContext>["getReplyTarget"];
|
|
1502
1504
|
getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
|
|
1503
1505
|
getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
|
|
1504
1506
|
recordMessageOwnership?: Updates.TelegramMessageOwnershipRecorder;
|
|
@@ -1640,7 +1642,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
1640
1642
|
) => Promise<void>;
|
|
1641
1643
|
openSettingsMenu?: (
|
|
1642
1644
|
chatId: number,
|
|
1643
|
-
replyToMessageId: number,
|
|
1645
|
+
replyToMessageId: number | undefined,
|
|
1644
1646
|
ctx: TContext,
|
|
1645
1647
|
threadId?: number,
|
|
1646
1648
|
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
@@ -1728,7 +1730,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
1728
1730
|
startGuestPlaceholder?: (inlineMessageId: string) => void;
|
|
1729
1731
|
sendTextReply: (
|
|
1730
1732
|
chatId: number,
|
|
1731
|
-
replyToMessageId: number,
|
|
1733
|
+
replyToMessageId: number | undefined,
|
|
1732
1734
|
text: string,
|
|
1733
1735
|
options?: {
|
|
1734
1736
|
parseMode?: "HTML";
|
|
@@ -2026,6 +2028,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2026
2028
|
): boolean =>
|
|
2027
2029
|
pending.phase.kind !== "waiting" && pending.phase.kind !== "released";
|
|
2028
2030
|
const pendingUnboundReroutes = new Map<string, PendingUnboundReroute>();
|
|
2031
|
+
// Body-free, bounded warm deduplication: album members can expire through the same chooser.
|
|
2032
|
+
const expiredRoutingViews = new Set<string>();
|
|
2029
2033
|
const implicitThreadCreations = new Map<
|
|
2030
2034
|
string,
|
|
2031
2035
|
{
|
|
@@ -2542,6 +2546,20 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2542
2546
|
temporaryDeleteConfirmed = true;
|
|
2543
2547
|
return result;
|
|
2544
2548
|
} catch (error) {
|
|
2549
|
+
const missingTarget = TelegramApi.getTelegramApiErrorRequestTarget(error);
|
|
2550
|
+
if (method === "deleteForumTopic" && missingTarget && error instanceof Error &&
|
|
2551
|
+
TelegramApi.isTelegramApiRequestRejected(error, method) &&
|
|
2552
|
+
TelegramApi.isTelegramStaleTargetHttpError(error) &&
|
|
2553
|
+
ThreadReconciler.isTelegramTopicDeletedErrorMessage(error.message) &&
|
|
2554
|
+
missingTarget?.chatId === temporaryCleanup.target?.chatId &&
|
|
2555
|
+
missingTarget?.threadId === temporaryCleanup.target?.threadId &&
|
|
2556
|
+
missingTarget?.chatId === body.chat_id && missingTarget?.threadId === body.message_thread_id) {
|
|
2557
|
+
// Only the client's parsed exact-target HTTP 400 absence closes the one-shot grant.
|
|
2558
|
+
// Closed/denied/foreign/forged responses remain uncertain and never license another request.
|
|
2559
|
+
assertExecutionCurrent?.();
|
|
2560
|
+
temporaryDeleteConfirmed = true;
|
|
2561
|
+
return true as TResponse;
|
|
2562
|
+
}
|
|
2545
2563
|
temporaryTransportUncertain = true;
|
|
2546
2564
|
throw error;
|
|
2547
2565
|
}
|
|
@@ -8102,7 +8120,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8102
8120
|
signal: AbortSignal,
|
|
8103
8121
|
): Promise<void> => {
|
|
8104
8122
|
const operator = deps.configStore.getAllowedUserId(),
|
|
8105
|
-
epoch = deps.getCurrentLeaderEpoch?.()
|
|
8123
|
+
epoch = deps.getCurrentLeaderEpoch?.(),
|
|
8124
|
+
generation = deps.getSessionGeneration?.(),
|
|
8125
|
+
instanceId = deps.getCurrentInstanceId?.(),
|
|
8126
|
+
scope = deps.getAdmissionScope?.();
|
|
8127
|
+
const sendTextReply = deps.sendTextReply, editInteractiveMessage = deps.editInteractiveMessage;
|
|
8106
8128
|
const message = source.original.update.message as
|
|
8107
8129
|
Partial<TMessage> | undefined;
|
|
8108
8130
|
const runtimeCurrent = () =>
|
|
@@ -8112,6 +8134,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8112
8134
|
deps.isContextActive?.(ctx) === true &&
|
|
8113
8135
|
deps.configStore.getAllowedUserId() === operator &&
|
|
8114
8136
|
deps.getCurrentLeaderEpoch?.() === epoch &&
|
|
8137
|
+
deps.getSessionGeneration?.() === generation &&
|
|
8138
|
+
deps.getCurrentInstanceId?.() === instanceId &&
|
|
8139
|
+
deps.getAdmissionScope?.() === scope &&
|
|
8115
8140
|
deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
|
|
8116
8141
|
source.original.routingInput?.operatorUserId === operator &&
|
|
8117
8142
|
message?.from?.id === operator &&
|
|
@@ -8142,6 +8167,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8142
8167
|
if (!result) return;
|
|
8143
8168
|
// Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
|
|
8144
8169
|
let movedIntoTab = false;
|
|
8170
|
+
let unissued = source.original.routingInput?.phase === "waiting" &&
|
|
8171
|
+
(!temporary || temporary.forwardProtocol === "one-shot-v1") &&
|
|
8172
|
+
![...(temporary?.forwardedInputs ?? []), ...(temporary?.completedInputs ?? [])].some(input =>
|
|
8173
|
+
input.journalBindingKey === source.journalBindingKey && input.updateIds.includes(source.original.updateId));
|
|
8174
|
+
const views = new Map<string, { chatId: number; threadId?: number; messageId: number }>();
|
|
8175
|
+
const chooser = source.original.routingInput?.chooser;
|
|
8176
|
+
if (chooser) views.set(`${chooser.chatId}:${chooser.messageId}`, { ...chooser });
|
|
8145
8177
|
for (const [id, pending] of pendingUnboundReroutes) {
|
|
8146
8178
|
if (
|
|
8147
8179
|
!Updates.collectTelegramAdmissionSourceUpdateIds(
|
|
@@ -8150,32 +8182,55 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8150
8182
|
)
|
|
8151
8183
|
continue;
|
|
8152
8184
|
movedIntoTab ||= pending.allTabCopiesForwarded !== undefined;
|
|
8185
|
+
unissued &&= isPendingRerouteUntouched(pending) || pending.phase.kind === "released";
|
|
8186
|
+
if (pending.chooserMessageId !== undefined)
|
|
8187
|
+
views.set(`${pending.sourceTarget.chatId}:${pending.chooserMessageId}`, {
|
|
8188
|
+
...pending.sourceTarget, messageId: pending.chooserMessageId,
|
|
8189
|
+
});
|
|
8153
8190
|
removePendingReroute(id);
|
|
8154
|
-
|
|
8155
|
-
|
|
8156
|
-
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
|
|
8160
|
-
)
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
|
|
8165
|
-
|
|
8191
|
+
}
|
|
8192
|
+
const notice = unissued
|
|
8193
|
+
? "<b>⌛ Routing expired; input was not delivered.</b>"
|
|
8194
|
+
: "<b>⌛ Routing expired; delivery is unconfirmed.</b>\nPreviously accepted work may continue.";
|
|
8195
|
+
const noticeKey = `${source.journalBindingKey}:${source.original.routingInput?.expiresAtMs}:${chooser ? `${chooser.chatId}:${chooser.messageId}` : source.original.updateId}`;
|
|
8196
|
+
if (runtimeCurrent() && !expiredRoutingViews.has(noticeKey)) {
|
|
8197
|
+
if (expiredRoutingViews.size >= 100) expiredRoutingViews.delete(expiredRoutingViews.values().next().value!);
|
|
8198
|
+
expiredRoutingViews.add(noticeKey);
|
|
8199
|
+
let visible = false;
|
|
8200
|
+
const assertNoticeAuthority = () => {
|
|
8201
|
+
if (!runtimeCurrent()) throw new Error("Routing expiry notice authority changed.");
|
|
8202
|
+
};
|
|
8203
|
+
for (const view of views.values()) {
|
|
8204
|
+
if (!runtimeCurrent() || !editInteractiveMessage || deps.editInteractiveMessage !== editInteractiveMessage) break;
|
|
8205
|
+
if (view.chatId !== message!.chat!.id || !Number.isSafeInteger(view.threadId) || view.threadId! <= 0) continue;
|
|
8166
8206
|
try {
|
|
8167
|
-
await
|
|
8168
|
-
|
|
8169
|
-
pending.chooserMessageId,
|
|
8170
|
-
"<b>⌛ Routing choice expired.</b>",
|
|
8171
|
-
"html",
|
|
8172
|
-
{ inline_keyboard: [] },
|
|
8173
|
-
);
|
|
8174
|
-
} catch (error) {
|
|
8175
|
-
deps.recordRuntimeEvent?.("routing", error, {
|
|
8176
|
-
phase: "routing-input-expiry-view",
|
|
8207
|
+
await editInteractiveMessage(view.chatId, view.messageId, notice, "html", { inline_keyboard: [] }, {
|
|
8208
|
+
target: { chatId: view.chatId, threadId: view.threadId }, assertAuthority: assertNoticeAuthority,
|
|
8177
8209
|
});
|
|
8210
|
+
visible = true;
|
|
8211
|
+
} catch (error) {
|
|
8212
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-view" });
|
|
8213
|
+
}
|
|
8214
|
+
}
|
|
8215
|
+
// A temporary tab is about to disappear; a cold/missing chooser also needs an independent owned destination.
|
|
8216
|
+
const leader = instanceId ? deps.threadStore?.getActiveByInstanceId(instanceId) : undefined;
|
|
8217
|
+
if ((!visible || temporary?.target) && runtimeCurrent() && leader &&
|
|
8218
|
+
leader.target.chatId === message!.chat!.id && Number.isSafeInteger(leader.target.threadId) && leader.target.threadId! > 0) {
|
|
8219
|
+
const target = { chatId: leader.target.chatId, threadId: leader.target.threadId };
|
|
8220
|
+
try {
|
|
8221
|
+
const assertFallback = () => {
|
|
8222
|
+
assertNoticeAuthority();
|
|
8223
|
+
const active = deps.threadStore?.getActiveByInstanceId(instanceId!);
|
|
8224
|
+
if (deps.sendTextReply !== sendTextReply || active?.target.chatId !== target.chatId || active.target.threadId !== target.threadId)
|
|
8225
|
+
throw new Error("Routing expiry notice destination changed.");
|
|
8226
|
+
};
|
|
8227
|
+
assertFallback();
|
|
8228
|
+
await sendTextReply(target.chatId, 0, notice, { parseMode: "HTML", target, assertAuthority: assertFallback });
|
|
8229
|
+
} catch (error) {
|
|
8230
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-notice" });
|
|
8178
8231
|
}
|
|
8232
|
+
} else if (!visible && runtimeCurrent()) {
|
|
8233
|
+
deps.recordRuntimeEvent?.("routing", "Routing expiry notice has no current owned Thread.", { phase: "routing-input-expiry-notice-unavailable" });
|
|
8179
8234
|
}
|
|
8180
8235
|
}
|
|
8181
8236
|
// The scheduled stage captures only context/epoch and a tab token. It reconstructs disposition from body-free proof,
|
|
@@ -8434,7 +8489,12 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8434
8489
|
)
|
|
8435
8490
|
continue;
|
|
8436
8491
|
try {
|
|
8437
|
-
await retireCancellationChooser(id, pending, isCurrent)
|
|
8492
|
+
if (await retireCancellationChooser(id, pending, isCurrent))
|
|
8493
|
+
await deleteCancelledOrphanTab(
|
|
8494
|
+
pending,
|
|
8495
|
+
pending.abandonment!,
|
|
8496
|
+
isCurrent,
|
|
8497
|
+
);
|
|
8438
8498
|
} catch (error) {
|
|
8439
8499
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
8440
8500
|
phase: "recovered-chooser-cleanup",
|
|
@@ -8463,6 +8523,60 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8463
8523
|
}
|
|
8464
8524
|
return true;
|
|
8465
8525
|
};
|
|
8526
|
+
/**
|
|
8527
|
+
* A cancelled input in an unbound (non-temporary) tab leaves nothing to route: once no chooser, queue, live owner,
|
|
8528
|
+
* Workspace/Restore record or retained journal arrival still names the tab, it is deleted like a routing tab. Any
|
|
8529
|
+
* missing proof or failure keeps the tab; this never retries and never blocks the Cancel outcome.
|
|
8530
|
+
*/
|
|
8531
|
+
const deleteCancelledOrphanTab = async (
|
|
8532
|
+
pending: PendingUnboundReroute,
|
|
8533
|
+
cancellation: NonNullable<PendingUnboundReroute["abandonment"]>,
|
|
8534
|
+
isAuthorityCurrent: () => boolean,
|
|
8535
|
+
): Promise<void> => {
|
|
8536
|
+
const { chatId, threadId } = pending.sourceTarget;
|
|
8537
|
+
if (
|
|
8538
|
+
threadId === undefined ||
|
|
8539
|
+
pending.temporaryThread ||
|
|
8540
|
+
isTemporaryTabTarget({ chatId, threadId }) ||
|
|
8541
|
+
hasPendingRerouteForTarget({ chatId, threadId }) ||
|
|
8542
|
+
!deps.inspectTemporaryThreadSources
|
|
8543
|
+
)
|
|
8544
|
+
return;
|
|
8545
|
+
const target = { chatId, threadId };
|
|
8546
|
+
try {
|
|
8547
|
+
const own = {
|
|
8548
|
+
journalBindingKey: cancellation.journalBindingKey,
|
|
8549
|
+
updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(
|
|
8550
|
+
pending.messages,
|
|
8551
|
+
),
|
|
8552
|
+
};
|
|
8553
|
+
const assertCurrent = (): void => {
|
|
8554
|
+
if (!isAuthorityCurrent())
|
|
8555
|
+
throw new Error("Unbound tab cleanup authority changed.");
|
|
8556
|
+
};
|
|
8557
|
+
assertCurrent();
|
|
8558
|
+
if (
|
|
8559
|
+
deps.inspectTemporaryThreadSources(
|
|
8560
|
+
target,
|
|
8561
|
+
[cancellation.journalBindingKey],
|
|
8562
|
+
[own],
|
|
8563
|
+
)?.length !== 0
|
|
8564
|
+
)
|
|
8565
|
+
return;
|
|
8566
|
+
assertCurrent();
|
|
8567
|
+
await closeReroutedUnboundTopic(
|
|
8568
|
+
target,
|
|
8569
|
+
pending.chooserMessageId,
|
|
8570
|
+
assertCurrent,
|
|
8571
|
+
);
|
|
8572
|
+
} catch (error) {
|
|
8573
|
+
deps.recordRuntimeEvent?.("routing", error, {
|
|
8574
|
+
phase: "unbound-cancel-tab-cleanup",
|
|
8575
|
+
chatId: target.chatId,
|
|
8576
|
+
threadId: target.threadId,
|
|
8577
|
+
});
|
|
8578
|
+
}
|
|
8579
|
+
};
|
|
8466
8580
|
const handleUnboundRerouteCancelCallback = async (
|
|
8467
8581
|
query: TCallbackQuery,
|
|
8468
8582
|
ctx: TContext,
|
|
@@ -8580,6 +8694,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8580
8694
|
await Promise.all([
|
|
8581
8695
|
deps.answerCallbackQuery(query.id, "Routing cancelled"),
|
|
8582
8696
|
deleteAllTabCopies(pending!),
|
|
8697
|
+
deleteCancelledOrphanTab(pending!, cancellation!, isAuthorityCurrent),
|
|
8583
8698
|
]);
|
|
8584
8699
|
} catch (error) {
|
|
8585
8700
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
@@ -10068,6 +10183,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10068
10183
|
appendControlItem: deps.queueMutationRuntime.append,
|
|
10069
10184
|
getAdmissionScope: deps.getAdmissionScope,
|
|
10070
10185
|
getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
|
|
10186
|
+
getReplyTarget: deps.getCommandReplyTarget,
|
|
10071
10187
|
onControlQueued: (message, receipt) =>
|
|
10072
10188
|
reportQueueAdmission([message], [receipt]),
|
|
10073
10189
|
showStatus: deps.menuActions.sendStatusMessage,
|
|
@@ -1049,6 +1049,24 @@ export class TelegramApiStaleTargetError extends Error {
|
|
|
1049
1049
|
}
|
|
1050
1050
|
}
|
|
1051
1051
|
|
|
1052
|
+
/** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
|
|
1053
|
+
export class TelegramApiFollowerNotAllowedError extends Error {
|
|
1054
|
+
readonly requestTarget: { chatId: number; threadId: number } | undefined;
|
|
1055
|
+
/** Present only after a structured pre-execution refusal matches the requested API method. */
|
|
1056
|
+
readonly rejectedRequestMethod: string | undefined;
|
|
1057
|
+
|
|
1058
|
+
constructor(
|
|
1059
|
+
message: string,
|
|
1060
|
+
requestTarget?: { chatId: number; threadId: number },
|
|
1061
|
+
rejectedRequestMethod?: string,
|
|
1062
|
+
) {
|
|
1063
|
+
super(message);
|
|
1064
|
+
this.name = "TelegramApiFollowerNotAllowedError";
|
|
1065
|
+
this.requestTarget = requestTarget ? { ...requestTarget } : undefined;
|
|
1066
|
+
this.rejectedRequestMethod = rejectedRequestMethod;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1052
1070
|
export function getTelegramApiErrorRequestTarget(
|
|
1053
1071
|
error: unknown,
|
|
1054
1072
|
): { chatId: number; threadId: number } | undefined {
|