@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
|
@@ -3741,6 +3741,86 @@ export function isTelegramThreadCleanupJournalNamespaceClear(
|
|
|
3741
3741
|
);
|
|
3742
3742
|
}
|
|
3743
3743
|
|
|
3744
|
+
/** Demand-driven recovery only; caller holds temporary-cleanup admission. Never a deletion grant. */
|
|
3745
|
+
export function recoverTelegramThreadCleanupDeadQueueOwners(
|
|
3746
|
+
input: Parameters<typeof isTelegramThreadCleanupJournalNamespaceClear>[0] & {
|
|
3747
|
+
recoveryOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
3748
|
+
isCurrent: () => boolean;
|
|
3749
|
+
withSourceSerialization: NonNullable<TelegramUpdateJournalStoreOptions["withSourceSerialization"]>;
|
|
3750
|
+
getQueueProcessLiveness?: TelegramUpdateJournalStoreOptions["getQueueProcessLiveness"];
|
|
3751
|
+
},
|
|
3752
|
+
): number {
|
|
3753
|
+
return input.withSourceSerialization(() => {
|
|
3754
|
+
const assertCurrent = () => {
|
|
3755
|
+
if (!input.isCurrent()) throw new Error("Temporary cleanup queue recovery lost authority.");
|
|
3756
|
+
};
|
|
3757
|
+
assertCurrent();
|
|
3758
|
+
if (!input.cleanup) return 0;
|
|
3759
|
+
// Validate exact references and complete namespace coverage before considering any mutation.
|
|
3760
|
+
if (isTelegramThreadCleanupJournalNamespaceClear(input)) return 0;
|
|
3761
|
+
const namespace = inspectTelegramSessionJournalNamespace(input);
|
|
3762
|
+
if (namespace.retainedInputs?.some(value => value.state !== "committed") ||
|
|
3763
|
+
input.requiredJournalBindingKeys.some(key => !namespace.sources.some(source =>
|
|
3764
|
+
source.path === getTelegramUpdateJournalBindingPath(key) && source.evidence.kind === "present"))) return 0;
|
|
3765
|
+
const plans: Array<{ path: string; recovery: TelegramUpdateJournalDeadQueueOwnerRecoveryInput }> = [];
|
|
3766
|
+
for (const source of namespace.sources) {
|
|
3767
|
+
if (source.evidence.kind !== "present") continue;
|
|
3768
|
+
const file = source.evidence.file;
|
|
3769
|
+
if (file.version !== TELEGRAM_UPDATE_JOURNAL_VERSION) continue;
|
|
3770
|
+
const groups = new Map<string, TelegramUpdateJournalEntry[]>();
|
|
3771
|
+
for (const entry of file.entries) {
|
|
3772
|
+
if (!entry.queueReceiptId) continue;
|
|
3773
|
+
const group = groups.get(entry.queueReceiptId) ?? [];
|
|
3774
|
+
group.push(entry);
|
|
3775
|
+
groups.set(entry.queueReceiptId, group);
|
|
3776
|
+
}
|
|
3777
|
+
for (const group of groups.values()) {
|
|
3778
|
+
const entry = group[0]!;
|
|
3779
|
+
if (entry.state !== "queued" || !entry.queueReceiptId || !entry.queueOwner ||
|
|
3780
|
+
!entry.queueKind || entry.queueHandoff || entry.inputClaim) continue;
|
|
3781
|
+
if (group.some(value => value.state !== "queued" || value.queueHandoff || value.inputClaim ||
|
|
3782
|
+
value.queueKind !== entry.queueKind || !isDeepStrictEqual(value.queueOwner, entry.queueOwner))) continue;
|
|
3783
|
+
plans.push({ path: source.path, recovery: {
|
|
3784
|
+
queueKind: entry.queueKind, receiptId: entry.queueReceiptId,
|
|
3785
|
+
sourceUpdateIds: group.map(value => value.updateId).sort((a, b) => a - b),
|
|
3786
|
+
deadOwner: cloneJournalQueueOwner(entry.queueOwner), recoveryOwner: { ...input.recoveryOwner },
|
|
3787
|
+
} });
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
const liveness = input.getQueueProcessLiveness ?? getTelegramProcessLiveness;
|
|
3791
|
+
const dead = plans.filter(plan => {
|
|
3792
|
+
assertCurrent();
|
|
3793
|
+
const result = liveness(plan.recovery.deadOwner);
|
|
3794
|
+
assertCurrent();
|
|
3795
|
+
return result === "dead";
|
|
3796
|
+
});
|
|
3797
|
+
let removed = 0;
|
|
3798
|
+
for (const plan of dead) {
|
|
3799
|
+
assertCurrent();
|
|
3800
|
+
removed += input.withSourceReference(plan.path, () => {
|
|
3801
|
+
const journal = createTelegramUpdateJournalStore({
|
|
3802
|
+
path: plan.path, profileName: input.profile, botIdentity: input.botIdentity,
|
|
3803
|
+
queueRuntimeIdentity: input.recoveryOwner,
|
|
3804
|
+
// This private synchronous store stays inside the cross-family serializer already held above.
|
|
3805
|
+
withSourceSerialization(operation) { assertCurrent(); return operation(); },
|
|
3806
|
+
sourceAccess: { directory: dirname(plan.path), limits: input.limits },
|
|
3807
|
+
getQueueProcessLiveness(owner) {
|
|
3808
|
+
assertCurrent();
|
|
3809
|
+
const result = liveness(owner);
|
|
3810
|
+
assertCurrent();
|
|
3811
|
+
return result;
|
|
3812
|
+
},
|
|
3813
|
+
withWriterAdmission(operation) { assertCurrent(); return operation(); },
|
|
3814
|
+
});
|
|
3815
|
+
// The journal transaction rechecks whole-group identity, absence of handoff and fresh process-death proof.
|
|
3816
|
+
return journal.recoverDeadQueueOwner(plan.recovery).recoveredUpdateIds.length;
|
|
3817
|
+
});
|
|
3818
|
+
}
|
|
3819
|
+
assertCurrent();
|
|
3820
|
+
return removed;
|
|
3821
|
+
});
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3744
3824
|
function cloneEntry(
|
|
3745
3825
|
entry: TelegramUpdateJournalEntry,
|
|
3746
3826
|
): TelegramUpdateJournalEntry {
|
|
@@ -23,6 +23,118 @@ import {
|
|
|
23
23
|
} from "./paths.ts";
|
|
24
24
|
import * as Status from "./status.ts";
|
|
25
25
|
|
|
26
|
+
/** Sink shape shared with the runtime event recorder this domain already owns. */
|
|
27
|
+
export type TelegramFailureRecordSink = (
|
|
28
|
+
category: string,
|
|
29
|
+
errorOrMessage: unknown,
|
|
30
|
+
details: Record<string, unknown>,
|
|
31
|
+
) => void;
|
|
32
|
+
|
|
33
|
+
export interface TelegramFailureRunSummary {
|
|
34
|
+
signature: string;
|
|
35
|
+
failedAttempts: number;
|
|
36
|
+
suppressedFailures: number;
|
|
37
|
+
failedForMs: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TelegramFailureRunLog {
|
|
41
|
+
/** Records one failure; a repeated identical failure only extends the current run and returns false. */
|
|
42
|
+
record: (error: unknown, details?: Record<string, unknown>) => boolean;
|
|
43
|
+
/** Closes the run after a success, emitting its single outcome record. */
|
|
44
|
+
settled: () => TelegramFailureRunSummary | undefined;
|
|
45
|
+
/** Ends the run without an outcome record, for a stop or replacement that reports its own outcome. */
|
|
46
|
+
reset: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TelegramFailureRunLogInput {
|
|
50
|
+
category: string;
|
|
51
|
+
failurePhase: string;
|
|
52
|
+
outcomePhase: string;
|
|
53
|
+
/** Human-readable subject for the outcome record, e.g. `follower heartbeat`. */
|
|
54
|
+
label: string;
|
|
55
|
+
record?: TelegramFailureRecordSink;
|
|
56
|
+
/** Static context reapplied to every record of this phase, e.g. a session or profile key. */
|
|
57
|
+
context?: () => Record<string, unknown>;
|
|
58
|
+
getNowMs?: () => number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Stable identity of one failure kind: errno when present, otherwise the failure text. */
|
|
62
|
+
export function getTelegramFailureSignature(error: unknown): string {
|
|
63
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
64
|
+
if (typeof code === "string" && code) return code;
|
|
65
|
+
return error instanceof Error ? error.message : String(error);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Consecutive identical failure runs for one diagnostic phase: a repeat extends the run instead of logging again,
|
|
70
|
+
* and a closing success or reset emits at most one outcome record. This never gates detection or recovery.
|
|
71
|
+
*/
|
|
72
|
+
export function createTelegramFailureRunLog(
|
|
73
|
+
input: TelegramFailureRunLogInput,
|
|
74
|
+
): TelegramFailureRunLog {
|
|
75
|
+
const getNowMs = input.getNowMs ?? Date.now;
|
|
76
|
+
let run: { signature: string; suppressed: number; startedAtMs: number; details: Record<string, unknown> } | undefined;
|
|
77
|
+
const emit = (errorOrMessage: unknown, details: Record<string, unknown>): void => {
|
|
78
|
+
try {
|
|
79
|
+
input.record?.(input.category, errorOrMessage, {
|
|
80
|
+
...(input.context?.() ?? {}),
|
|
81
|
+
...details,
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
// A diagnostic sink never converts a recorded failure into another failure.
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
record(error, details) {
|
|
89
|
+
const signature = getTelegramFailureSignature(error);
|
|
90
|
+
const admitted = { ...(details ?? {}) };
|
|
91
|
+
if (run?.signature === signature) {
|
|
92
|
+
run.suppressed += 1;
|
|
93
|
+
Object.assign(run.details, admitted);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const previous = run;
|
|
97
|
+
run = { signature, suppressed: 0, startedAtMs: getNowMs(), details: admitted };
|
|
98
|
+
emit(error, {
|
|
99
|
+
...admitted,
|
|
100
|
+
phase: input.failurePhase,
|
|
101
|
+
failureSignature: signature,
|
|
102
|
+
...(previous
|
|
103
|
+
? {
|
|
104
|
+
previousFailureSignature: previous.signature,
|
|
105
|
+
previousFailedAttempts: previous.suppressed + 1,
|
|
106
|
+
previousFailedForMs: getNowMs() - previous.startedAtMs,
|
|
107
|
+
}
|
|
108
|
+
: {}),
|
|
109
|
+
});
|
|
110
|
+
return true;
|
|
111
|
+
},
|
|
112
|
+
settled() {
|
|
113
|
+
const closed = run;
|
|
114
|
+
if (!closed) return undefined;
|
|
115
|
+
run = undefined;
|
|
116
|
+
const summary: TelegramFailureRunSummary = {
|
|
117
|
+
signature: closed.signature,
|
|
118
|
+
failedAttempts: closed.suppressed + 1,
|
|
119
|
+
suppressedFailures: closed.suppressed,
|
|
120
|
+
failedForMs: getNowMs() - closed.startedAtMs,
|
|
121
|
+
};
|
|
122
|
+
emit(`Telegram ${input.label} recovered after ${summary.failedAttempts} failed attempts.`, {
|
|
123
|
+
...closed.details,
|
|
124
|
+
phase: input.outcomePhase,
|
|
125
|
+
failureSignature: summary.signature,
|
|
126
|
+
failedAttempts: summary.failedAttempts,
|
|
127
|
+
suppressedFailures: summary.suppressedFailures,
|
|
128
|
+
failedForMs: summary.failedForMs,
|
|
129
|
+
});
|
|
130
|
+
return summary;
|
|
131
|
+
},
|
|
132
|
+
reset() {
|
|
133
|
+
run = undefined;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
26
138
|
type TelegramLogPathInput = string | (() => string);
|
|
27
139
|
|
|
28
140
|
interface TelegramRuntimeJsonlEvent {
|
|
@@ -391,6 +503,10 @@ interface TelegramRuntimeDiagnosticsRuntime<TContext> {
|
|
|
391
503
|
error: unknown,
|
|
392
504
|
details?: Record<string, unknown>,
|
|
393
505
|
): void;
|
|
506
|
+
/** A phase-scoped failure log whose records travel through this runtime's own event pipeline. */
|
|
507
|
+
createFailureLog(
|
|
508
|
+
input: Omit<TelegramFailureRunLogInput, "record">,
|
|
509
|
+
): TelegramFailureRunLog;
|
|
394
510
|
bindStorage(ports: {
|
|
395
511
|
getBotToken(): string | undefined;
|
|
396
512
|
getProfileName(): string | undefined;
|
|
@@ -498,6 +614,12 @@ export function createTelegramRuntimeDiagnosticsRuntime<TContext>(
|
|
|
498
614
|
};
|
|
499
615
|
return {
|
|
500
616
|
events,
|
|
617
|
+
createFailureLog: (input) =>
|
|
618
|
+
createTelegramFailureRunLog({
|
|
619
|
+
...input,
|
|
620
|
+
record: (category, errorOrMessage, details) =>
|
|
621
|
+
recordRuntimeEvent(category, errorOrMessage, details),
|
|
622
|
+
}),
|
|
501
623
|
recordRuntimeEvent,
|
|
502
624
|
bindStorage(ports) {
|
|
503
625
|
getBotToken = ports.getBotToken;
|
|
@@ -130,6 +130,7 @@ export interface TelegramQueuedOutboundAttachmentView {
|
|
|
130
130
|
export interface TelegramOutboundAttachmentQueueTargetView {
|
|
131
131
|
queuedAttachments: TelegramQueuedOutboundAttachmentView[];
|
|
132
132
|
guestQueryId?: string;
|
|
133
|
+
guestInlineMessageId?: string;
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutboundAttachmentQueueTargetView {
|
|
@@ -718,6 +719,11 @@ export async function queueTelegramOutboundAttachments(options: {
|
|
|
718
719
|
maxAttachmentSizeBytes: options.maxAttachmentSizeBytes,
|
|
719
720
|
statPath: options.statPath,
|
|
720
721
|
});
|
|
722
|
+
if (options.activeTurn.guestInlineMessageId && pendingAttachments.some(
|
|
723
|
+
(attachment) => getTelegramGuestAttachmentTransport(attachment.path).fileField === "voice",
|
|
724
|
+
)) {
|
|
725
|
+
throw new Error("Telegram Guest Mode cannot replace an inline placeholder with a voice note; no attachment was queued. Use MP3 audio or a normal bot chat.");
|
|
726
|
+
}
|
|
721
727
|
options.activeTurn.queuedAttachments.push(...pendingAttachments);
|
|
722
728
|
const added = pendingAttachments.map((attachment) => attachment.path);
|
|
723
729
|
return {
|
|
@@ -733,6 +739,13 @@ export async function queueTelegramOutboundAttachments(options: {
|
|
|
733
739
|
|
|
734
740
|
export async function deliverTelegramGuestCachedAttachment(options: {
|
|
735
741
|
guestQueryId: string;
|
|
742
|
+
guestInlineMessageId?: string;
|
|
743
|
+
editGuestInlineMessage?: (
|
|
744
|
+
inlineMessageId: string,
|
|
745
|
+
content: { media: { type: "photo" | "document" | "audio"; media: string; caption?: string } },
|
|
746
|
+
options: { assertAuthority: () => void },
|
|
747
|
+
) => Promise<void>;
|
|
748
|
+
isDeliveryActive?: () => boolean;
|
|
736
749
|
stagingChatId: number;
|
|
737
750
|
stagingTarget?: TelegramTarget;
|
|
738
751
|
attachment: TelegramQueuedOutboundAttachmentView;
|
|
@@ -750,6 +763,14 @@ export async function deliverTelegramGuestCachedAttachment(options: {
|
|
|
750
763
|
const transport = getTelegramGuestAttachmentTransport(
|
|
751
764
|
options.attachment.path,
|
|
752
765
|
);
|
|
766
|
+
const isActive = () => options.isDeliveryActive?.() !== false;
|
|
767
|
+
const assertActive = () => {
|
|
768
|
+
if (!isActive()) throw new Error("Guest attachment delivery lost authority.");
|
|
769
|
+
};
|
|
770
|
+
if (!isActive()) return;
|
|
771
|
+
if (options.guestInlineMessageId && (!options.editGuestInlineMessage || transport.fileField === "voice")) {
|
|
772
|
+
throw new Error("Guest inline attachment editing is unavailable for this media.");
|
|
773
|
+
}
|
|
753
774
|
let stagingMessageId: number | undefined;
|
|
754
775
|
let answerAttempted = false;
|
|
755
776
|
try {
|
|
@@ -764,6 +785,7 @@ export async function deliverTelegramGuestCachedAttachment(options: {
|
|
|
764
785
|
options.attachment.fileName,
|
|
765
786
|
)) as TelegramGuestStagingMessage;
|
|
766
787
|
stagingMessageId = message.message_id;
|
|
788
|
+
if (!isActive()) return;
|
|
767
789
|
const caption = options.caption
|
|
768
790
|
? Array.from(options.caption).slice(0, 1024).join("")
|
|
769
791
|
: undefined;
|
|
@@ -810,17 +832,28 @@ export async function deliverTelegramGuestCachedAttachment(options: {
|
|
|
810
832
|
...(caption ? { caption } : {}),
|
|
811
833
|
};
|
|
812
834
|
}
|
|
835
|
+
if (!isActive()) return;
|
|
813
836
|
answerAttempted = true;
|
|
814
|
-
|
|
837
|
+
if (options.guestInlineMessageId && options.editGuestInlineMessage) {
|
|
838
|
+
if (result.type === "voice") throw new Error("Voice notes cannot replace inline placeholders.");
|
|
839
|
+
const media = result.type === "photo" ? result.photo_file_id
|
|
840
|
+
: result.type === "audio" ? result.audio_file_id : result.document_file_id;
|
|
841
|
+
await options.editGuestInlineMessage(options.guestInlineMessageId, {
|
|
842
|
+
media: { type: result.type, media, ...(caption ? { caption } : {}) },
|
|
843
|
+
}, { assertAuthority: assertActive });
|
|
844
|
+
} else {
|
|
845
|
+
await options.answerGuestQuery(options.guestQueryId, result);
|
|
846
|
+
}
|
|
815
847
|
} catch (error) {
|
|
816
|
-
if (!
|
|
848
|
+
if (!isActive()) return;
|
|
849
|
+
if (!options.guestInlineMessageId && !answerAttempted && options.answerGuestText && options.fallbackText) {
|
|
817
850
|
answerAttempted = true;
|
|
818
851
|
await options.answerGuestText(options.guestQueryId, options.fallbackText);
|
|
819
852
|
} else {
|
|
820
853
|
throw error;
|
|
821
854
|
}
|
|
822
855
|
} finally {
|
|
823
|
-
if (stagingMessageId !== undefined) {
|
|
856
|
+
if (stagingMessageId !== undefined && isActive()) {
|
|
824
857
|
try {
|
|
825
858
|
await options.deleteMessage(options.stagingChatId, stagingMessageId);
|
|
826
859
|
} catch (error) {
|
|
@@ -167,13 +167,32 @@ export function resolveTelegramProfileTempFilePath(
|
|
|
167
167
|
);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
/** Shared state/log paths for every profile, under the resolved agent dir (honors `PI_CODING_AGENT_DIR`). */
|
|
171
|
+
export function getTelegramDiagnosticsDisplayPaths(
|
|
172
|
+
_profileName?: string,
|
|
173
|
+
agentDir = resolveAgentDir(),
|
|
174
|
+
): {
|
|
171
175
|
state: string;
|
|
172
176
|
logs: string;
|
|
173
177
|
} {
|
|
178
|
+
// Storage paths are canonical, so also match a canonical home (e.g. /home -> /var/home).
|
|
179
|
+
const homes = [homedir()];
|
|
180
|
+
try {
|
|
181
|
+
homes.push(realpathSync(homes[0]!));
|
|
182
|
+
} catch {
|
|
183
|
+
/* An unreadable home only loses the ~ abbreviation. */
|
|
184
|
+
}
|
|
185
|
+
const display = (path: string): string => {
|
|
186
|
+
const home = homes.find(
|
|
187
|
+
(candidate) => path === candidate || path.startsWith(`${candidate}${sep}`),
|
|
188
|
+
);
|
|
189
|
+
return home
|
|
190
|
+
? `~${path.slice(home.length).split(sep).join("/")}`
|
|
191
|
+
: path;
|
|
192
|
+
};
|
|
174
193
|
return {
|
|
175
|
-
state:
|
|
176
|
-
logs:
|
|
194
|
+
state: display(resolveTelegramStatePath(agentDir)),
|
|
195
|
+
logs: display(resolveTelegramRuntimeLogPath(agentDir)),
|
|
177
196
|
};
|
|
178
197
|
}
|
|
179
198
|
|
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
type MaybePromise<T> = T | Promise<T>;
|
|
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
|
export interface TelegramPollingConfig {
|
|
10
20
|
botToken?: string;
|
|
11
21
|
}
|
|
@@ -682,6 +692,8 @@ export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThread
|
|
|
682
692
|
export interface TelegramThreadCapabilityRuntimeDeps<
|
|
683
693
|
TContext,
|
|
684
694
|
> extends TelegramThreadCapabilityReaderDeps {
|
|
695
|
+
/** Collapses one sustained probe failure run; the caller supplies its owner. */
|
|
696
|
+
failureLog: TelegramRepeatedFailureLogPort;
|
|
685
697
|
lifecycle?: TelegramThreadCapabilityLifecycle;
|
|
686
698
|
topicTargetStore: TelegramThreadCapabilityStore;
|
|
687
699
|
ownsLock: (ctx: TContext) => boolean;
|
|
@@ -803,6 +815,8 @@ export interface TelegramThreadCapabilityOrchestrationDeps<TContext, TOwner>
|
|
|
803
815
|
extends
|
|
804
816
|
TelegramThreadCapabilityReaderDeps,
|
|
805
817
|
TelegramThreadAwarePollingControls<TContext, TOwner> {
|
|
818
|
+
/** Collapses one sustained probe failure run; the composition root supplies its owner. */
|
|
819
|
+
failureLog: TelegramRepeatedFailureLogPort;
|
|
806
820
|
state: TelegramThreadCapabilityStateRuntime;
|
|
807
821
|
topicTargetStore: TelegramThreadCapabilityStore;
|
|
808
822
|
ownsLock: (ctx: TContext) => boolean;
|
|
@@ -882,6 +896,7 @@ export function createTelegramThreadCapabilityOrchestration<TContext, TOwner>(
|
|
|
882
896
|
isTopicModeUnavailableError: deps.isTopicModeUnavailableError,
|
|
883
897
|
updateStatus: deps.updateStatus,
|
|
884
898
|
recordEvent: deps.recordEvent,
|
|
899
|
+
failureLog: deps.failureLog,
|
|
885
900
|
};
|
|
886
901
|
return {
|
|
887
902
|
monitor: createTelegramThreadCapabilityMonitor(capabilityDeps),
|
|
@@ -1269,8 +1284,34 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
|
|
|
1269
1284
|
let generation = 0;
|
|
1270
1285
|
let transitionPromise: Promise<void> | undefined;
|
|
1271
1286
|
let consecutiveDisabledProbes = 0;
|
|
1287
|
+
// An absent leader endpoint repeats one identical probe failure every interval; the port collapses the run.
|
|
1288
|
+
// Running it through one guard keeps a faulty diagnostics port from breaking probes or the interval callback.
|
|
1289
|
+
const capabilityFailureLog: TelegramRepeatedFailureLogPort = {
|
|
1290
|
+
record(error, details) {
|
|
1291
|
+
try {
|
|
1292
|
+
deps.failureLog.record(error, details);
|
|
1293
|
+
} catch {
|
|
1294
|
+
// Monitor diagnostics cannot create an uncaught interval exception.
|
|
1295
|
+
}
|
|
1296
|
+
},
|
|
1297
|
+
settled() {
|
|
1298
|
+
try {
|
|
1299
|
+
deps.failureLog.settled();
|
|
1300
|
+
} catch {
|
|
1301
|
+
// Monitor diagnostics cannot fail a completed probe.
|
|
1302
|
+
}
|
|
1303
|
+
},
|
|
1304
|
+
reset() {
|
|
1305
|
+
try {
|
|
1306
|
+
deps.failureLog.reset();
|
|
1307
|
+
} catch {
|
|
1308
|
+
// Monitor diagnostics cannot fail a monitor stop.
|
|
1309
|
+
}
|
|
1310
|
+
},
|
|
1311
|
+
};
|
|
1272
1312
|
const stop = (): void => {
|
|
1273
1313
|
generation += 1;
|
|
1314
|
+
capabilityFailureLog.reset();
|
|
1274
1315
|
if (interval) clearInterval(interval);
|
|
1275
1316
|
interval = undefined;
|
|
1276
1317
|
deps.lifecycle?.invalidate();
|
|
@@ -1287,6 +1328,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
|
|
|
1287
1328
|
tracked = readTelegramThreadCapability(deps)
|
|
1288
1329
|
.then(async (threadModeEnabled) => {
|
|
1289
1330
|
if (!isCurrent()) return;
|
|
1331
|
+
capabilityFailureLog.settled();
|
|
1290
1332
|
if (threadModeEnabled === undefined) {
|
|
1291
1333
|
if (
|
|
1292
1334
|
deps.topicTargetStore.getBotState().threadMode !== "enabled" &&
|
|
@@ -1357,11 +1399,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
|
|
|
1357
1399
|
})
|
|
1358
1400
|
.catch((error) => {
|
|
1359
1401
|
if (!isCurrent()) return;
|
|
1360
|
-
|
|
1361
|
-
deps.recordEvent("bus", error, { phase: "capability-monitor" });
|
|
1362
|
-
} catch {
|
|
1363
|
-
// Monitor diagnostics cannot create an unhandled interval rejection.
|
|
1364
|
-
}
|
|
1402
|
+
capabilityFailureLog.record(error);
|
|
1365
1403
|
})
|
|
1366
1404
|
.finally(() => {
|
|
1367
1405
|
if (transitionPromise === tracked) transitionPromise = undefined;
|
|
@@ -1384,11 +1422,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
|
|
|
1384
1422
|
} catch {
|
|
1385
1423
|
/* Timer shutdown must not escape the callback. */
|
|
1386
1424
|
}
|
|
1387
|
-
|
|
1388
|
-
deps.recordEvent("bus", error, { phase: "capability-monitor" });
|
|
1389
|
-
} catch {
|
|
1390
|
-
// Monitor diagnostics cannot create an uncaught interval exception.
|
|
1391
|
-
}
|
|
1425
|
+
capabilityFailureLog.record(error);
|
|
1392
1426
|
}
|
|
1393
1427
|
}, intervalMs);
|
|
1394
1428
|
interval.unref?.();
|
|
@@ -1983,13 +1983,18 @@ export interface TelegramAgentEndRuntimeDeps<
|
|
|
1983
1983
|
) => Promise<void>;
|
|
1984
1984
|
sendGuestReply?: (guestQueryId: string, markdown: string) => Promise<void>;
|
|
1985
1985
|
/** Replaces the early guest ACK with the final text. */
|
|
1986
|
-
editGuestReply?: (
|
|
1986
|
+
editGuestReply?: (
|
|
1987
|
+
inlineMessageId: string,
|
|
1988
|
+
markdown: string,
|
|
1989
|
+
options?: { assertAuthority?: () => void },
|
|
1990
|
+
) => Promise<void>;
|
|
1987
1991
|
/** Cancels the animated guest placeholder before the final edit. */
|
|
1988
1992
|
stopGuestPlaceholder?: (inlineMessageId: string) => Promise<void>;
|
|
1989
1993
|
sendGuestAttachment?: (
|
|
1990
1994
|
turn: TTurn,
|
|
1991
1995
|
attachment: QueuedAttachment,
|
|
1992
1996
|
caption?: string,
|
|
1997
|
+
options?: { isDeliveryActive?: () => boolean },
|
|
1993
1998
|
) => Promise<void>;
|
|
1994
1999
|
sendGuestVoiceReply?: (
|
|
1995
2000
|
turn: TTurn,
|
|
@@ -2350,13 +2355,46 @@ export async function handleTelegramAgentEndRuntime<
|
|
|
2350
2355
|
});
|
|
2351
2356
|
}
|
|
2352
2357
|
}
|
|
2353
|
-
if (
|
|
2354
|
-
|
|
2358
|
+
if (!isDeliveryActive()) return;
|
|
2359
|
+
if (turn.guestInlineMessageId) {
|
|
2360
|
+
let experimentText = assistant.errorMessage
|
|
2355
2361
|
? "Telegram bridge: Pi failed while processing the request."
|
|
2356
2362
|
: finalText;
|
|
2363
|
+
const [attachment] = turn.queuedAttachments;
|
|
2364
|
+
if (!assistant.errorMessage && attachment) {
|
|
2365
|
+
try {
|
|
2366
|
+
if (!deps.sendGuestAttachment) throw new Error("Guest attachment delivery is unavailable.");
|
|
2367
|
+
await deps.sendGuestAttachment(turn, attachment, finalText || undefined, { isDeliveryActive });
|
|
2368
|
+
if (isDeliveryActive() && endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
|
|
2369
|
+
return;
|
|
2370
|
+
} catch (error) {
|
|
2371
|
+
deps.recordRuntimeEvent?.("delivery", error, {
|
|
2372
|
+
phase: "guest-attachment", guestQueryId: turn.guestQueryId,
|
|
2373
|
+
});
|
|
2374
|
+
if (!isDeliveryActive()) return;
|
|
2375
|
+
if (isTelegramApiCommitUnknownError(error)) {
|
|
2376
|
+
if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
experimentText = "Telegram bridge could not deliver the requested attachment. Please retry in a normal bot chat.";
|
|
2380
|
+
}
|
|
2381
|
+
} else if (!assistant.errorMessage && hasOutboundArtifacts) {
|
|
2382
|
+
experimentText = [finalText, "Telegram Guest Mode cannot replace an inline placeholder with a voice note. Please request MP3 audio or use a normal bot chat."].filter(Boolean).join("\n\n");
|
|
2383
|
+
}
|
|
2384
|
+
if (!deps.editGuestReply) {
|
|
2385
|
+
deps.recordRuntimeEvent?.("delivery", new Error("Guest inline reply editing is unavailable."), {
|
|
2386
|
+
phase: "guest-ack-edit", guestQueryId: turn.guestQueryId,
|
|
2387
|
+
});
|
|
2388
|
+
if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2357
2391
|
if (experimentText) {
|
|
2358
2392
|
try {
|
|
2359
|
-
await deps.editGuestReply(turn.guestInlineMessageId, experimentText
|
|
2393
|
+
await deps.editGuestReply(turn.guestInlineMessageId, experimentText, {
|
|
2394
|
+
assertAuthority() {
|
|
2395
|
+
if (!isDeliveryActive()) throw new Error("Guest reply edit lost authority.");
|
|
2396
|
+
},
|
|
2397
|
+
});
|
|
2360
2398
|
deps.recordRuntimeEvent?.(
|
|
2361
2399
|
"guest",
|
|
2362
2400
|
new Error("Guest ACK edited the guest answer"),
|
|
@@ -2401,6 +2439,7 @@ export async function handleTelegramAgentEndRuntime<
|
|
|
2401
2439
|
turn,
|
|
2402
2440
|
guestAttachment,
|
|
2403
2441
|
finalText || undefined,
|
|
2442
|
+
{ isDeliveryActive },
|
|
2404
2443
|
);
|
|
2405
2444
|
} catch (error) {
|
|
2406
2445
|
deps.recordRuntimeEvent?.("delivery", error, {
|
|
@@ -1033,13 +1033,14 @@ export function createGuestMarkdownReplyEditor(deps: {
|
|
|
1033
1033
|
editGuestInlineMessage: (
|
|
1034
1034
|
inlineMessageId: string,
|
|
1035
1035
|
content: { richMessage?: TelegramInputRichMessage; text?: string },
|
|
1036
|
+
options?: { assertAuthority?: () => void },
|
|
1036
1037
|
) => Promise<void>;
|
|
1037
1038
|
}) {
|
|
1038
|
-
return async (inlineMessageId: string, markdown: string) => {
|
|
1039
|
+
return async (inlineMessageId: string, markdown: string, options?: { assertAuthority?: () => void }) => {
|
|
1039
1040
|
const [richMarkdown = markdown] = splitTelegramNativeMarkdown(markdown);
|
|
1040
1041
|
await deps.editGuestInlineMessage(inlineMessageId, {
|
|
1041
1042
|
richMessage: { markdown: richMarkdown },
|
|
1042
|
-
});
|
|
1043
|
+
}, options);
|
|
1043
1044
|
};
|
|
1044
1045
|
}
|
|
1045
1046
|
|