@llblab/pi-kit 0.35.0 → 0.35.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BACKLOG.md +2 -0
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +3 -2
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -6
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +3 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +11 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +22 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +75 -16
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +11 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +30 -19
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +21 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +61 -46
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +7 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +80 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +36 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +80 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +12 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +33 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +2 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +18 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +13 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +33 -12
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +6 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +46 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +5 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +137 -13
- package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +11 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +31 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +12 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +21 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +5 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +5 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +26 -13
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +6 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +57 -12
- package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +6 -2
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +4 -2
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +13 -2
- package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +2 -1
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +111 -15
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -0
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +30 -21
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +21 -8
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +64 -56
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +80 -0
- package/node_modules/@llblab/pi-telegram/lib/logging.ts +122 -0
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +36 -3
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +22 -3
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +44 -10
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +43 -4
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +3 -2
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +140 -18
- package/node_modules/@llblab/pi-telegram/lib/runtime.ts +40 -7
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -10
- package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +6 -1
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +37 -20
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +69 -17
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/package.json +2 -2
|
@@ -1568,6 +1568,13 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
1568
1568
|
requiredJournalBindingKeys: readonly string[],
|
|
1569
1569
|
ownInputs?: readonly Threads.TelegramTemporaryThreadInput[],
|
|
1570
1570
|
) => readonly number[] | undefined;
|
|
1571
|
+
/** Explicit cleanup-only recovery; never called by a read-only protection observer. */
|
|
1572
|
+
recoverTemporaryThreadDeadQueueOwners?: (
|
|
1573
|
+
target: Queue.TelegramQueueTarget,
|
|
1574
|
+
requiredJournalBindingKeys: readonly string[],
|
|
1575
|
+
ownInputs: readonly Threads.TelegramTemporaryThreadInput[],
|
|
1576
|
+
isCurrent: () => boolean,
|
|
1577
|
+
) => number;
|
|
1571
1578
|
/** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
|
|
1572
1579
|
temporaryThreadCleanupDelayMs?: number;
|
|
1573
1580
|
/** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
|
|
@@ -6837,6 +6844,93 @@ export function createTelegramInboundRouteRuntime<
|
|
|
6837
6844
|
}
|
|
6838
6845
|
await promptEnqueue(messages, ctx);
|
|
6839
6846
|
};
|
|
6847
|
+
/** Local menu acceptance owns a one-shot effect; source removal and cleanup precede its release, never its replay. */
|
|
6848
|
+
const stageTemporaryRerouteMenu = async (
|
|
6849
|
+
pending: PendingUnboundReroute,
|
|
6850
|
+
command: Commands.ParsedTelegramCommand,
|
|
6851
|
+
messages: TMessage[],
|
|
6852
|
+
ctx: TContext,
|
|
6853
|
+
assertRecipientCurrent: () => void,
|
|
6854
|
+
completion: NonNullable<ReturnType<typeof Updates.prepareTelegramDeferredSourceCompletion>>,
|
|
6855
|
+
): Promise<(() => Promise<void>) | undefined> => {
|
|
6856
|
+
const original = pending.messages[0];
|
|
6857
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
6858
|
+
const temporary = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
|
|
6859
|
+
if (!cap?.isCurrent() || !temporary) return undefined;
|
|
6860
|
+
let accept!: (value: boolean) => void, resume!: (value: boolean) => void;
|
|
6861
|
+
const accepted = new Promise<boolean>(resolve => { accept = resolve; });
|
|
6862
|
+
const released = new Promise<boolean>(resolve => { resume = resolve; });
|
|
6863
|
+
const admission: Commands.TelegramSelectedCommandAdmission = {
|
|
6864
|
+
assertSourceCurrent() {
|
|
6865
|
+
if (!completion.isCurrent()) throw new Error("Rerouted menu source authority changed.");
|
|
6866
|
+
},
|
|
6867
|
+
assertRecipientCurrent,
|
|
6868
|
+
reportCompleted() {
|
|
6869
|
+
const result = completion.reportCompleted();
|
|
6870
|
+
accept(result);
|
|
6871
|
+
return result;
|
|
6872
|
+
},
|
|
6873
|
+
async beforeEffects() {
|
|
6874
|
+
if (!(await released)) throw new Error("Rerouted menu execution was not released.");
|
|
6875
|
+
},
|
|
6876
|
+
};
|
|
6877
|
+
const prepare = command.name === "start" ? commandHandler.prepareSelectedStartCommand
|
|
6878
|
+
: command.name === "help" ? commandHandler.prepareSelectedHelpCommand : commandHandler.prepareSelectedMenuCommand;
|
|
6879
|
+
const dispatch = prepare(command, messages, ctx, admission);
|
|
6880
|
+
if (!dispatch) return undefined;
|
|
6881
|
+
const execution = dispatch().catch(error => {
|
|
6882
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-execution" });
|
|
6883
|
+
return false;
|
|
6884
|
+
});
|
|
6885
|
+
if (!(await Promise.race([accepted, execution.then(() => false)]))) {
|
|
6886
|
+
resume(false);
|
|
6887
|
+
await execution;
|
|
6888
|
+
return undefined;
|
|
6889
|
+
}
|
|
6890
|
+
return async () => {
|
|
6891
|
+
let allowEffects = false;
|
|
6892
|
+
try {
|
|
6893
|
+
const confirmed = await new Promise<boolean>(resolve => {
|
|
6894
|
+
let done = false, unsubscribe: (() => void) | undefined;
|
|
6895
|
+
const signal = Updates.getTelegramUpdateExecutionFence(original)?.signal;
|
|
6896
|
+
const finish = (result: boolean) => {
|
|
6897
|
+
if (done) return;
|
|
6898
|
+
done = true;
|
|
6899
|
+
clearTimeout(timer);
|
|
6900
|
+
unsubscribe?.();
|
|
6901
|
+
signal?.removeEventListener("abort", abort);
|
|
6902
|
+
resolve(result);
|
|
6903
|
+
};
|
|
6904
|
+
const abort = () => finish(false);
|
|
6905
|
+
const observe = () => {
|
|
6906
|
+
if (!cap.isCurrent() || !completion.isCurrent()) finish(false);
|
|
6907
|
+
else if (completion.inspectCompletion()) finish(true);
|
|
6908
|
+
};
|
|
6909
|
+
const timer = setTimeout(abort, deps.liveRebindCommandCompletionTimeoutMs ?? 8_000);
|
|
6910
|
+
timer.unref?.();
|
|
6911
|
+
unsubscribe = Updates.subscribeTelegramDeferredSourceCompletion(original, observe);
|
|
6912
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
6913
|
+
if (signal?.aborted) abort(); else observe();
|
|
6914
|
+
});
|
|
6915
|
+
if (!confirmed) throw new Error("Rerouted menu source removal is unconfirmed; execution remains suppressed.");
|
|
6916
|
+
assertRecipientCurrent();
|
|
6917
|
+
cancelTemporaryThreadCleanup(temporary.token);
|
|
6918
|
+
try {
|
|
6919
|
+
await runTemporaryThreadCleanup(temporary.token, cap, false);
|
|
6920
|
+
} catch (error) {
|
|
6921
|
+
// Cleanup uncertainty retains its canonical grant, not command execution authority or a retry license.
|
|
6922
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-cleanup" });
|
|
6923
|
+
}
|
|
6924
|
+
assertRecipientCurrent();
|
|
6925
|
+
allowEffects = true;
|
|
6926
|
+
} catch (error) {
|
|
6927
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-release" });
|
|
6928
|
+
} finally {
|
|
6929
|
+
resume(allowEffects);
|
|
6930
|
+
await execution;
|
|
6931
|
+
}
|
|
6932
|
+
};
|
|
6933
|
+
};
|
|
6840
6934
|
const finalizePendingReroute = async (
|
|
6841
6935
|
rerouteId: string,
|
|
6842
6936
|
pending: PendingUnboundReroute,
|
|
@@ -7258,12 +7352,20 @@ export function createTelegramInboundRouteRuntime<
|
|
|
7258
7352
|
});
|
|
7259
7353
|
if (!cancelledCurrent()) return;
|
|
7260
7354
|
entry = cap.adopt(entry);
|
|
7261
|
-
if (
|
|
7262
|
-
|
|
7263
|
-
|
|
7264
|
-
|
|
7265
|
-
|
|
7266
|
-
|
|
7355
|
+
if (!entry || !cap.isCurrent()) return;
|
|
7356
|
+
if (isRerouteTargetProtected(target, undefined, entry) && deps.recoverTemporaryThreadDeadQueueOwners &&
|
|
7357
|
+
store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary") {
|
|
7358
|
+
const inputs = Threads.getTelegramTemporaryThreadInputs(entry);
|
|
7359
|
+
const removed = deps.recoverTemporaryThreadDeadQueueOwners(
|
|
7360
|
+
target, [cap.journalBindingKey, ...inputs.map(input => input.journalBindingKey)],
|
|
7361
|
+
inputs, cap.isCurrent,
|
|
7362
|
+
);
|
|
7363
|
+
if (removed > 0) deps.recordRuntimeEvent?.("routing",
|
|
7364
|
+
"Discarded historical queue receipts from confirmed-dead owners before temporary Thread cleanup.",
|
|
7365
|
+
{ phase: "temporary-thread-dead-owner-recovery", removedUpdateCount: removed });
|
|
7366
|
+
}
|
|
7367
|
+
// Recovery is not deletion authority: recapture every ordinary protection after the journal CAS.
|
|
7368
|
+
if (!cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry)) return;
|
|
7267
7369
|
const issued = store.issueTemporaryThreadCleanup(entry, cap.authority);
|
|
7268
7370
|
if (!issued || !cap.isCurrent()) return;
|
|
7269
7371
|
entry = issued.entry;
|
|
@@ -8658,6 +8760,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8658
8760
|
const executeUnboundRerouteCallbackOperation = async (
|
|
8659
8761
|
query: TCallbackQuery,
|
|
8660
8762
|
ctx: TContext,
|
|
8763
|
+
afterAdmission: (release: () => Promise<void>) => void,
|
|
8661
8764
|
): Promise<boolean> => {
|
|
8662
8765
|
const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
|
|
8663
8766
|
if (!parsed) return false;
|
|
@@ -9349,7 +9452,20 @@ export function createTelegramInboundRouteRuntime<
|
|
|
9349
9452
|
const localMessages = pending.rerouteCopies
|
|
9350
9453
|
? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
|
|
9351
9454
|
: reroutedMessages;
|
|
9352
|
-
|
|
9455
|
+
const command = pending.dispatchKind === "command"
|
|
9456
|
+
? getLiveRebindMenu(localMessages) ?? getLiveRebindHelp(localMessages) : undefined;
|
|
9457
|
+
const completion = pending.rerouteCopies && command
|
|
9458
|
+
? Updates.prepareTelegramDeferredSourceCompletion(pending.messages[0]) : undefined;
|
|
9459
|
+
if (command && completion) {
|
|
9460
|
+
const release = await stageTemporaryRerouteMenu(pending, command, localMessages, ctx, assertRerouteCurrent, completion);
|
|
9461
|
+
if (!release) {
|
|
9462
|
+
await deps.answerCallbackQuery(query.id, "Command acceptance unconfirmed; not resending");
|
|
9463
|
+
return true;
|
|
9464
|
+
}
|
|
9465
|
+
afterAdmission(release);
|
|
9466
|
+
} else {
|
|
9467
|
+
await dispatchPendingRerouteMessages(pending, localMessages, ctx);
|
|
9468
|
+
}
|
|
9353
9469
|
assertRerouteCurrent();
|
|
9354
9470
|
pending.messages = [];
|
|
9355
9471
|
return completeRoutedReroute();
|
|
@@ -9360,17 +9476,23 @@ export function createTelegramInboundRouteRuntime<
|
|
|
9360
9476
|
): Promise<boolean> => {
|
|
9361
9477
|
const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
|
|
9362
9478
|
if (!parsed) return false;
|
|
9363
|
-
return withPendingRerouteSelection(parsed.rerouteId, query, () => {
|
|
9364
|
-
|
|
9365
|
-
|
|
9366
|
-
|
|
9367
|
-
|
|
9368
|
-
|
|
9369
|
-
|
|
9370
|
-
|
|
9371
|
-
|
|
9372
|
-
|
|
9373
|
-
|
|
9479
|
+
return withPendingRerouteSelection(parsed.rerouteId, query, async () => {
|
|
9480
|
+
let release: (() => Promise<void>) | undefined;
|
|
9481
|
+
const operation = () => executeUnboundRerouteCallbackOperation(query, ctx, value => { release = value; });
|
|
9482
|
+
try {
|
|
9483
|
+
if (!deps.runWorkspaceOperation) return await operation();
|
|
9484
|
+
return await deps.runWorkspaceOperation(
|
|
9485
|
+
{
|
|
9486
|
+
operationId: `workspace-reroute:${query.id}`,
|
|
9487
|
+
operationKind: "workspace.route-unbound-thread",
|
|
9488
|
+
scopes: [{ kind: "profile" }],
|
|
9489
|
+
},
|
|
9490
|
+
operation,
|
|
9491
|
+
);
|
|
9492
|
+
} finally {
|
|
9493
|
+
// Keep the exact source references, but release the non-reentrant Workspace gate before cleanup.
|
|
9494
|
+
await release?.();
|
|
9495
|
+
}
|
|
9374
9496
|
});
|
|
9375
9497
|
};
|
|
9376
9498
|
const handleUnboundRerouteCallback = async (
|
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
import * as Machine from "./machine.ts";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Structural port for run-collapsing diagnostics: a repeated identical failure extends its run instead of logging
|
|
11
|
+
* again, and the closing success records one outcome. The composition root owns the collapsed implementation.
|
|
12
|
+
*/
|
|
13
|
+
export interface TelegramRepeatedFailureLogPort {
|
|
14
|
+
record: (error: unknown, details?: Record<string, unknown>) => void;
|
|
15
|
+
settled: () => void;
|
|
16
|
+
reset: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 3_000;
|
|
10
20
|
const TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS = 250;
|
|
11
21
|
|
|
@@ -344,6 +354,8 @@ function updateTelegramRuntimeStatusSafely<TContext>(
|
|
|
344
354
|
export interface TelegramTypingLoopStarterDeps<
|
|
345
355
|
TContext,
|
|
346
356
|
> extends TelegramRuntimeEventRecorderPort {
|
|
357
|
+
/** Collapses one sustained presence failure run; the caller supplies its owner. */
|
|
358
|
+
failureLog: TelegramRepeatedFailureLogPort;
|
|
347
359
|
typing: TelegramRuntimeTypingPort;
|
|
348
360
|
getDefaultChatId: () => number | undefined;
|
|
349
361
|
sendTypingAction: (
|
|
@@ -374,6 +386,31 @@ export function createTelegramTypingLoopStarter<TContext>(
|
|
|
374
386
|
threadId: number | undefined;
|
|
375
387
|
canContinue: () => boolean;
|
|
376
388
|
} | undefined;
|
|
389
|
+
// An unreachable transport repeats one identical presence failure every tick; the port collapses the run.
|
|
390
|
+
// Running it through one guard keeps a faulty diagnostics port from escaping the in-flight action owner.
|
|
391
|
+
const typingFailureLog: TelegramRepeatedFailureLogPort = {
|
|
392
|
+
record(error, details) {
|
|
393
|
+
try {
|
|
394
|
+
deps.failureLog.record(error, details);
|
|
395
|
+
} catch {
|
|
396
|
+
// Typing diagnostics cannot escape the in-flight action owner.
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
settled() {
|
|
400
|
+
try {
|
|
401
|
+
deps.failureLog.settled();
|
|
402
|
+
} catch {
|
|
403
|
+
// A successful presence outranks its own diagnostic.
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
reset() {
|
|
407
|
+
try {
|
|
408
|
+
deps.failureLog.reset();
|
|
409
|
+
} catch {
|
|
410
|
+
// Typing diagnostics cannot fail a loop stop.
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
};
|
|
377
414
|
return (ctx, chatId, options) => {
|
|
378
415
|
const sessionGeneration = deps.getSessionGeneration?.();
|
|
379
416
|
const transportAuthority = deps.getTransportAuthority?.();
|
|
@@ -422,24 +459,20 @@ export function createTelegramTypingLoopStarter<TContext>(
|
|
|
422
459
|
if (!active || !canContinue()) throw new Error("Typing context authority changed.");
|
|
423
460
|
},
|
|
424
461
|
});
|
|
462
|
+
typingFailureLog.settled();
|
|
425
463
|
} catch (error) {
|
|
426
464
|
if (!active) return;
|
|
427
465
|
if (!canContinue()) {
|
|
428
466
|
deps.typing.stop();
|
|
429
467
|
return;
|
|
430
468
|
}
|
|
431
|
-
|
|
432
|
-
deps.recordRuntimeEvent?.("typing", error, {
|
|
433
|
-
chatId: targetChatId,
|
|
434
|
-
});
|
|
435
|
-
} catch {
|
|
436
|
-
// Typing diagnostics cannot escape the in-flight action owner.
|
|
437
|
-
}
|
|
469
|
+
typingFailureLog.record(error, { chatId: targetChatId });
|
|
438
470
|
}
|
|
439
471
|
},
|
|
440
472
|
shouldContinue: canContinue,
|
|
441
473
|
onStopped: () => {
|
|
442
474
|
active = false;
|
|
475
|
+
typingFailureLog.reset();
|
|
443
476
|
if (currentOwner === owner) currentOwner = undefined;
|
|
444
477
|
},
|
|
445
478
|
});
|
|
@@ -401,6 +401,8 @@ export interface TelegramApiCallOptions {
|
|
|
401
401
|
sleep?: (ms: number) => Promise<void>;
|
|
402
402
|
/** Observability hook fired before a 429 retry wait, never for 5xx waits. */
|
|
403
403
|
onRetryWait?: (wait: TelegramApiRetryWait) => void;
|
|
404
|
+
/** Caller-owned expected failure: still thrown, but the caller records the outcome instead of the API log. */
|
|
405
|
+
isExpectedError?: (error: unknown) => boolean;
|
|
404
406
|
}
|
|
405
407
|
|
|
406
408
|
export class TelegramApiAuthorityError extends Error {
|
|
@@ -473,6 +475,8 @@ export interface TelegramAnswerGuestQueryOptions {
|
|
|
473
475
|
}
|
|
474
476
|
|
|
475
477
|
export interface TelegramEditGuestInlineMessageContent {
|
|
478
|
+
/** Cached media only: inline edits cannot upload a new local file. */
|
|
479
|
+
media?: { type: "photo" | "document" | "audio"; media: string; caption?: string };
|
|
476
480
|
text?: string;
|
|
477
481
|
richMessage?: TelegramInputRichMessage;
|
|
478
482
|
parseMode?: "HTML";
|
|
@@ -917,10 +921,9 @@ export interface TelegramBridgeApiRuntime extends Pick<
|
|
|
917
921
|
options?: TelegramAnswerGuestQueryOptions,
|
|
918
922
|
) => Promise<void>;
|
|
919
923
|
/**
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
* guest answers, so this exists only to falsify that behavior live.
|
|
924
|
+
* Answers the guest query and retains the sent inline message id for final
|
|
925
|
+
* text or cached-media replacement. Requires direct transport ownership;
|
|
926
|
+
* actual guest-media rendering remains live-client gated.
|
|
924
927
|
*/
|
|
925
928
|
answerGuestQueryForInlineMessage: (
|
|
926
929
|
guestQueryId: string,
|
|
@@ -930,6 +933,7 @@ export interface TelegramBridgeApiRuntime extends Pick<
|
|
|
930
933
|
editGuestInlineMessage: (
|
|
931
934
|
inlineMessageId: string,
|
|
932
935
|
content: TelegramEditGuestInlineMessageContent,
|
|
936
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
933
937
|
) => Promise<void>;
|
|
934
938
|
deleteMessage: (chatId: number, messageId: number) => Promise<void>;
|
|
935
939
|
prepareTempDir: () => Promise<number>;
|
|
@@ -2195,11 +2199,12 @@ export function createTelegramBridgeApiRuntime(
|
|
|
2195
2199
|
) {
|
|
2196
2200
|
return true as TResponse;
|
|
2197
2201
|
}
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2202
|
+
if (!options?.isExpectedError?.(error))
|
|
2203
|
+
deps.recordRuntimeEvent(
|
|
2204
|
+
"api",
|
|
2205
|
+
error,
|
|
2206
|
+
withTelegramTransportDiagnostics(error, { method }),
|
|
2207
|
+
);
|
|
2203
2208
|
throw error;
|
|
2204
2209
|
}
|
|
2205
2210
|
};
|
|
@@ -2404,14 +2409,29 @@ export function createTelegramBridgeApiRuntime(
|
|
|
2404
2409
|
editGuestInlineMessage: async (
|
|
2405
2410
|
inlineMessageId: string,
|
|
2406
2411
|
content: TelegramEditGuestInlineMessageContent,
|
|
2412
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
2407
2413
|
) => {
|
|
2414
|
+
if (content.media) {
|
|
2415
|
+
try {
|
|
2416
|
+
const result = await callRecorded("editMessageMedia", {
|
|
2417
|
+
inline_message_id: inlineMessageId,
|
|
2418
|
+
media: content.media,
|
|
2419
|
+
}, { ...options, maxAttempts: 1, retryRateLimit: false });
|
|
2420
|
+
if (result !== true) throw new Error("Missing inline edit acknowledgement.");
|
|
2421
|
+
} catch (error) {
|
|
2422
|
+
// Only a parsed rejection permits replacing this attempt with a failure notice.
|
|
2423
|
+
if (isTelegramApiRequestRejected(error, "editMessageMedia")) throw error;
|
|
2424
|
+
throw new TelegramApiCommitUnknownError("editMessageMedia", error);
|
|
2425
|
+
}
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2408
2428
|
await callRecorded("editMessageText", {
|
|
2409
2429
|
inline_message_id: inlineMessageId,
|
|
2410
2430
|
...(content.richMessage
|
|
2411
2431
|
? { rich_message: content.richMessage }
|
|
2412
2432
|
: { text: content.text }),
|
|
2413
2433
|
...(content.parseMode ? { parse_mode: content.parseMode } : {}),
|
|
2414
|
-
});
|
|
2434
|
+
}, options);
|
|
2415
2435
|
},
|
|
2416
2436
|
prepareTempDir: () =>
|
|
2417
2437
|
prepareTelegramTempDir(deps.tempDir, deps.tempFileMaxAgeMs),
|
|
@@ -719,7 +719,12 @@ export async function applyThreadReconciliationPlan(
|
|
|
719
719
|
}
|
|
720
720
|
let deleteConfirmed = false;
|
|
721
721
|
let superseded = false;
|
|
722
|
-
|
|
722
|
+
// Private-chat topics (positive chat ids) cannot be closed; Telegram only supports closeForumTopic in forums.
|
|
723
|
+
const skipClose =
|
|
724
|
+
ports.skipCloseBeforeDelete ||
|
|
725
|
+
(action.kind === "close-delete-graceful-shutdown-topic" &&
|
|
726
|
+
action.target.chatId > 0);
|
|
727
|
+
for (const method of skipClose
|
|
723
728
|
? ["deleteForumTopic"]
|
|
724
729
|
: ["closeForumTopic", "deleteForumTopic"]) {
|
|
725
730
|
if (ports.isCleanupTargetProtected?.(action.target, action)) {
|
|
@@ -252,6 +252,13 @@ export interface TelegramWorkspaceExternalProtectionEvidence {
|
|
|
252
252
|
deliveryAuthority: TelegramWorkspaceProtectionState;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/** Per-binding probe; `withSnapshot` lets one occupancy capture share binding-independent evidence reads. */
|
|
256
|
+
export type TelegramWorkspaceExternalProtectionCapture = ((
|
|
257
|
+
binding: TelegramWorkspaceThreadBinding,
|
|
258
|
+
) => TelegramWorkspaceExternalProtectionEvidence) & {
|
|
259
|
+
withSnapshot?: <T>(capture: () => T) => T;
|
|
260
|
+
};
|
|
261
|
+
|
|
255
262
|
export interface TelegramWorkspaceSlotOccupancySnapshot {
|
|
256
263
|
bindings: TelegramWorkspaceSlotOccupancy[];
|
|
257
264
|
reservedSlots: string[];
|
|
@@ -876,9 +883,7 @@ export interface TelegramTopicTargetStore {
|
|
|
876
883
|
) => Promise<boolean>;
|
|
877
884
|
/** Caller must separately prove no external live owner, accepted work, or delivery authority. */
|
|
878
885
|
captureWorkspaceSlotOccupancy: (
|
|
879
|
-
getExternalProtection:
|
|
880
|
-
binding: TelegramWorkspaceThreadBinding,
|
|
881
|
-
) => TelegramWorkspaceExternalProtectionEvidence,
|
|
886
|
+
getExternalProtection: TelegramWorkspaceExternalProtectionCapture,
|
|
882
887
|
options?: { expectedRetirement?: TelegramWorkspaceRetirementIntent },
|
|
883
888
|
) => TelegramWorkspaceSlotOccupancySnapshot;
|
|
884
889
|
hasWorkspaceBinding: (cwd: string, sessionId?: string) => boolean;
|
|
@@ -2723,10 +2728,16 @@ function liveRebindOldTargetUnknown(value: TelegramWorkspaceLiveRebindIntent): b
|
|
|
2723
2728
|
function retainLiveRebind(value: TelegramWorkspaceLiveRebindIntent): boolean {
|
|
2724
2729
|
return liveRebindActive(value) || liveRebindOldTargetUnknown(value) || value.oldTargetProtected === true;
|
|
2725
2730
|
}
|
|
2726
|
-
function
|
|
2731
|
+
function provisioningProtectionIntents(snapshot: TelegramWorkspaceRestoreSnapshot): readonly Pick<TelegramWorkspaceRestoreIntent, "request">[] {
|
|
2732
|
+
return [...snapshot.operations, ...(snapshot.liveRebindings ?? []).filter(retainLiveRebind)];
|
|
2733
|
+
}
|
|
2734
|
+
function protectedLiveRebindOldTargetIntents(snapshot: TelegramWorkspaceRestoreSnapshot | undefined): TelegramWorkspaceLiveRebindIntent[] {
|
|
2727
2735
|
return (snapshot?.liveRebindings ?? []).filter(value =>
|
|
2728
2736
|
value.oldTargetProtected === true || liveRebindOldTargetUnknown(value) || value.inputRelease !== undefined && value.cleanup === "issued",
|
|
2729
|
-
)
|
|
2737
|
+
);
|
|
2738
|
+
}
|
|
2739
|
+
function protectedLiveRebindOldTargets(snapshot: TelegramWorkspaceRestoreSnapshot | undefined): TelegramTarget[] {
|
|
2740
|
+
return protectedLiveRebindOldTargetIntents(snapshot).map(value => ({ ...value.request.binding.target }));
|
|
2730
2741
|
}
|
|
2731
2742
|
function pendingWorkspaceRelocationRequests(
|
|
2732
2743
|
snapshot: TelegramWorkspaceRestoreSnapshot | undefined,
|
|
@@ -2821,10 +2832,11 @@ function assertWorkspaceRestoreBindingProtection(
|
|
|
2821
2832
|
throw new Error("Protected Workspace Restore owner target changed.");
|
|
2822
2833
|
}
|
|
2823
2834
|
}
|
|
2824
|
-
for (const
|
|
2835
|
+
for (const { request } of protectedLiveRebindOldTargetIntents(file.workspaceRestore)) {
|
|
2836
|
+
const old = request.binding.target;
|
|
2825
2837
|
if (file.workspaceBindings?.some(value => targetMatches(value.target, old)) ||
|
|
2826
2838
|
file.threads?.some(value => targetMatches(value.target, old) && ["active", "starting", "pending", "probe-required"].includes(value.status)) ||
|
|
2827
|
-
file.pendingProvisions?.some(value =>
|
|
2839
|
+
file.pendingProvisions?.some(value => conflictsWithWorkspaceRestoreProvision(request, value)) ||
|
|
2828
2840
|
file.reservations?.some(value => targetMatches(value.target, old)))
|
|
2829
2841
|
throw new Error("Unknown live cleanup target remains protected.");
|
|
2830
2842
|
}
|
|
@@ -3840,7 +3852,7 @@ export function createTelegramTopicTargetStore(
|
|
|
3840
3852
|
};
|
|
3841
3853
|
const assertRestoreRecoveryProtection = (
|
|
3842
3854
|
path: string,
|
|
3843
|
-
operations: readonly TelegramWorkspaceRestoreIntent[],
|
|
3855
|
+
operations: readonly Pick<TelegramWorkspaceRestoreIntent, "request">[],
|
|
3844
3856
|
provisions: readonly TelegramThreadPendingProvision[],
|
|
3845
3857
|
requireKnownTargets = false,
|
|
3846
3858
|
recoveryEvidence?: TelegramProvisionRecoveryFile,
|
|
@@ -5061,7 +5073,7 @@ export function createTelegramTopicTargetStore(
|
|
|
5061
5073
|
assertWorkspaceRestoreBindingProtection(file);
|
|
5062
5074
|
assertRestoreRecoveryProtection(
|
|
5063
5075
|
path,
|
|
5064
|
-
file.workspaceRestore
|
|
5076
|
+
provisioningProtectionIntents(file.workspaceRestore!),
|
|
5065
5077
|
file.pendingProvisions ?? [],
|
|
5066
5078
|
);
|
|
5067
5079
|
observe({
|
|
@@ -5117,7 +5129,7 @@ export function createTelegramTopicTargetStore(
|
|
|
5117
5129
|
assertWorkspaceRestoreBindingProtection(file);
|
|
5118
5130
|
assertRestoreRecoveryProtection(
|
|
5119
5131
|
path,
|
|
5120
|
-
file.workspaceRestore
|
|
5132
|
+
provisioningProtectionIntents(file.workspaceRestore!),
|
|
5121
5133
|
file.pendingProvisions ?? [],
|
|
5122
5134
|
);
|
|
5123
5135
|
const canonical = file.workspaceBindings ?? [],
|
|
@@ -5175,9 +5187,9 @@ export function createTelegramTopicTargetStore(
|
|
|
5175
5187
|
(row) => currentRow(row) && targetMatches(row.target, old),
|
|
5176
5188
|
) ||
|
|
5177
5189
|
reserved.some((value) => targetMatches(value.target, old)) ||
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
) ||
|
|
5190
|
+
// Use the same binding/slot/owner/target conflicts as rebind admission. An unrelated unlocated
|
|
5191
|
+
// create must not globally veto cleanup of this exact former binding (nor its issued marker).
|
|
5192
|
+
provisions.some((value) => conflictsWithWorkspaceRestoreProvision(request, value)) ||
|
|
5181
5193
|
cleanups.some((value) => targetMatches(value.target, old));
|
|
5182
5194
|
if (
|
|
5183
5195
|
protectedTarget(
|
|
@@ -5390,8 +5402,11 @@ export function createTelegramTopicTargetStore(
|
|
|
5390
5402
|
file.reservations?.some((value) =>
|
|
5391
5403
|
targetMatches(value.target, target),
|
|
5392
5404
|
) ||
|
|
5405
|
+
// This exact created frame already proves a separately created routing tab (API ACK or authenticated
|
|
5406
|
+
// implicit creation). An unlocated Workspace create is not its owner; keep that intent unresolved,
|
|
5407
|
+
// but veto a concrete target collision. Generic unknown-target/Restore cleanup keeps its own guards.
|
|
5393
5408
|
file.pendingProvisions?.some(
|
|
5394
|
-
(value) =>
|
|
5409
|
+
(value) => !!value.target && targetMatches(value.target, target),
|
|
5395
5410
|
) ||
|
|
5396
5411
|
file.pendingCleanups?.some((value) =>
|
|
5397
5412
|
targetMatches(value.target, target),
|
|
@@ -5557,7 +5572,7 @@ export function createTelegramTopicTargetStore(
|
|
|
5557
5572
|
parseTopicTargetFile(before.file).pendingProvisions ?? [];
|
|
5558
5573
|
assertRestoreRecoveryProtection(
|
|
5559
5574
|
path,
|
|
5560
|
-
before.state
|
|
5575
|
+
provisioningProtectionIntents(before.state),
|
|
5561
5576
|
provisions,
|
|
5562
5577
|
);
|
|
5563
5578
|
const after = { ...before.file, workspaceRestore: next };
|
|
@@ -5573,7 +5588,7 @@ export function createTelegramTopicTargetStore(
|
|
|
5573
5588
|
return false;
|
|
5574
5589
|
assertRestoreRecoveryProtection(
|
|
5575
5590
|
path,
|
|
5576
|
-
[...before.state
|
|
5591
|
+
[...provisioningProtectionIntents(before.state), ...provisioningProtectionIntents(next)],
|
|
5577
5592
|
provisions,
|
|
5578
5593
|
);
|
|
5579
5594
|
committedBefore = before;
|
|
@@ -5598,7 +5613,7 @@ export function createTelegramTopicTargetStore(
|
|
|
5598
5613
|
);
|
|
5599
5614
|
assertRestoreRecoveryProtection(
|
|
5600
5615
|
path,
|
|
5601
|
-
[...before.state
|
|
5616
|
+
[...provisioningProtectionIntents(before.state), ...provisioningProtectionIntents(next)],
|
|
5602
5617
|
provisions,
|
|
5603
5618
|
);
|
|
5604
5619
|
if (!currentScope() || !isCurrent()) return;
|
|
@@ -6775,7 +6790,7 @@ export function createTelegramTopicTargetStore(
|
|
|
6775
6790
|
conflictsWithWorkspaceRestoreProvision(request, provision),
|
|
6776
6791
|
) ||
|
|
6777
6792
|
conflictsWithTemporaryThread(workspaceRestore, provision.target) ||
|
|
6778
|
-
|
|
6793
|
+
protectedLiveRebindOldTargetIntents(workspaceRestore).some(({ request }) => conflictsWithWorkspaceRestoreProvision(request, provision))
|
|
6779
6794
|
) {
|
|
6780
6795
|
throw new Error("Protected Workspace Restore provisioning conflict.");
|
|
6781
6796
|
}
|
|
@@ -7349,7 +7364,9 @@ export function createTelegramTopicTargetStore(
|
|
|
7349
7364
|
},
|
|
7350
7365
|
captureWorkspaceSlotOccupancy(getExternalProtection, options) {
|
|
7351
7366
|
const nowMs = getNowMs();
|
|
7352
|
-
const
|
|
7367
|
+
const inSnapshot =
|
|
7368
|
+
getExternalProtection.withSnapshot ?? ((capture) => capture());
|
|
7369
|
+
const bindings = inSnapshot(() => Array.from(workspaceBindings.values()).map((binding) => {
|
|
7353
7370
|
const slot = binding.slot?.toLowerCase() ?? "";
|
|
7354
7371
|
const locallyProtected = isWorkspaceBindingLocallyProtected(
|
|
7355
7372
|
binding,
|
|
@@ -7388,7 +7405,7 @@ export function createTelegramTopicTargetStore(
|
|
|
7388
7405
|
: {}),
|
|
7389
7406
|
protection,
|
|
7390
7407
|
};
|
|
7391
|
-
});
|
|
7408
|
+
}));
|
|
7392
7409
|
const localReservedSlots = [
|
|
7393
7410
|
...Array.from(workspaceClaims.values()).map(
|
|
7394
7411
|
(claim) => claim.identity.slot,
|