@llblab/pi-telegram 0.51.5 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -8
- package/BACKLOG.md +1 -8
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +16 -6
- package/dist/lib/activity-verbosity.d.ts +1 -5
- package/dist/lib/activity-verbosity.js +5 -7
- package/dist/lib/bindings.d.ts +9 -2
- package/dist/lib/bindings.js +22 -13
- package/dist/lib/bus-api.js +4 -2
- package/dist/lib/bus-follower.d.ts +55 -57
- package/dist/lib/bus-follower.js +213 -140
- package/dist/lib/bus-leader.d.ts +21 -2
- package/dist/lib/bus-leader.js +181 -25
- package/dist/lib/bus-transport.d.ts +4 -1
- package/dist/lib/bus-transport.js +28 -4
- package/dist/lib/bus.d.ts +52 -56
- package/dist/lib/bus.js +78 -246
- package/dist/lib/channel-posts.d.ts +1 -3
- package/dist/lib/channel-posts.js +23 -26
- package/dist/lib/command-templates.d.ts +0 -5
- package/dist/lib/command-templates.js +5 -5
- package/dist/lib/commands.d.ts +12 -26
- package/dist/lib/commands.js +152 -98
- package/dist/lib/config.d.ts +0 -17
- package/dist/lib/config.js +12 -14
- package/dist/lib/delivery.d.ts +0 -4
- package/dist/lib/delivery.js +2 -5
- package/dist/lib/extension.js +281 -61
- package/dist/lib/generative-apps.d.ts +0 -19
- package/dist/lib/inbound.d.ts +0 -2
- package/dist/lib/inbound.js +2 -2
- package/dist/lib/journal.d.ts +177 -16
- package/dist/lib/journal.js +1115 -249
- package/dist/lib/keyboard.d.ts +0 -2
- package/dist/lib/keyboard.js +2 -2
- package/dist/lib/lifecycle.d.ts +57 -2
- package/dist/lib/lifecycle.js +110 -5
- package/dist/lib/locks.d.ts +100 -12
- package/dist/lib/locks.js +380 -14
- package/dist/lib/logging.d.ts +30 -12
- package/dist/lib/logging.js +129 -72
- package/dist/lib/media.d.ts +28 -5
- package/dist/lib/media.js +26 -8
- package/dist/lib/menu-model.d.ts +0 -11
- package/dist/lib/menu-model.js +8 -8
- package/dist/lib/menu-settings.d.ts +0 -18
- package/dist/lib/menu-settings.js +18 -18
- package/dist/lib/menu-status.d.ts +4 -4
- package/dist/lib/menu-status.js +12 -7
- package/dist/lib/menu-thinking.d.ts +1 -1
- package/dist/lib/menu-thinking.js +1 -1
- package/dist/lib/menu.d.ts +1 -0
- package/dist/lib/menu.js +4 -3
- package/dist/lib/model.js +2 -4
- package/dist/lib/outbound-attachments.d.ts +0 -1
- package/dist/lib/outbound-attachments.js +1 -1
- package/dist/lib/outbound-buttons.js +1 -8
- package/dist/lib/outbound-markup.d.ts +1 -6
- package/dist/lib/outbound-markup.js +4 -21
- package/dist/lib/outbound.d.ts +0 -8
- package/dist/lib/outbound.js +5 -5
- package/dist/lib/paths.d.ts +50 -7
- package/dist/lib/paths.js +155 -17
- package/dist/lib/pi.d.ts +1 -0
- package/dist/lib/pi.js +2 -1
- package/dist/lib/polling.d.ts +0 -5
- package/dist/lib/polling.js +5 -5
- package/dist/lib/preview.d.ts +0 -3
- package/dist/lib/preview.js +3 -3
- package/dist/lib/prompt-templates.d.ts +0 -1
- package/dist/lib/prompt-templates.js +1 -1
- package/dist/lib/queue.d.ts +3 -15
- package/dist/lib/queue.js +69 -8
- package/dist/lib/recovery.d.ts +29 -9
- package/dist/lib/recovery.js +104 -33
- package/dist/lib/replies.d.ts +0 -4
- package/dist/lib/replies.js +1 -1
- package/dist/lib/routing.d.ts +73 -8
- package/dist/lib/routing.js +2206 -307
- package/dist/lib/sections.d.ts +0 -2
- package/dist/lib/sections.js +1 -1
- package/dist/lib/status.d.ts +46 -9
- package/dist/lib/status.js +179 -27
- package/dist/lib/sync.d.ts +0 -5
- package/dist/lib/sync.js +9 -8
- package/dist/lib/telegram-api.d.ts +11 -3
- package/dist/lib/telegram-api.js +34 -23
- package/dist/lib/thread-cleanup-manager.d.ts +1 -0
- package/dist/lib/thread-cleanup-manager.js +18 -21
- package/dist/lib/thread-naming.d.ts +32 -5
- package/dist/lib/thread-naming.js +191 -7
- package/dist/lib/thread-reconciler.d.ts +2 -0
- package/dist/lib/thread-reconciler.js +1 -1
- package/dist/lib/threads.d.ts +292 -59
- package/dist/lib/threads.js +1664 -373
- package/dist/lib/turns.d.ts +0 -1
- package/dist/lib/turns.js +1 -1
- package/dist/lib/updates.d.ts +184 -28
- package/dist/lib/updates.js +1032 -114
- package/dist/lib/wire.d.ts +12 -0
- package/dist/lib/wire.js +21 -0
- package/dist/lib/workspace-admission.d.ts +25 -17
- package/dist/lib/workspace-admission.js +95 -20
- package/dist/lib/workspace-identity.d.ts +20 -0
- package/dist/lib/workspace-identity.js +103 -0
- package/dist/lib/workspace-retirement.d.ts +45 -10
- package/dist/lib/workspace-retirement.js +196 -64
- package/dist/package.json +6 -6
- package/dist/skills/generated-control-surface/SKILL.md +3 -3
- package/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/docs/README.md +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +158 -39
- package/docs/callback-namespaces.md +1 -1
- package/docs/delivery.md +1 -1
- package/docs/generative-apps.md +2 -2
- package/docs/inbound.md +1 -1
- package/docs/multi-instance-bus.md +127 -19
- package/docs/public-api.md +5 -3
- package/docs/ui-style.md +26 -8
- package/docs/updates.md +1 -1
- package/lib/activity-verbosity.ts +5 -9
- package/lib/bindings.ts +23 -10
- package/lib/bus-api.ts +5 -2
- package/lib/bus-follower.ts +251 -226
- package/lib/bus-leader.ts +178 -31
- package/lib/bus-transport.ts +30 -8
- package/lib/bus.ts +110 -334
- package/lib/channel-posts.ts +29 -29
- package/lib/command-templates.ts +5 -5
- package/lib/commands.ts +150 -112
- package/lib/config.ts +12 -17
- package/lib/delivery.ts +2 -8
- package/lib/extension.ts +282 -72
- package/lib/generative-apps.ts +0 -22
- package/lib/inbound.ts +2 -2
- package/lib/journal.ts +1182 -326
- package/lib/keyboard.ts +2 -2
- package/lib/lifecycle.ts +141 -5
- package/lib/locks.ts +402 -23
- package/lib/logging.ts +151 -101
- package/lib/media.ts +51 -9
- package/lib/menu-model.ts +8 -8
- package/lib/menu-settings.ts +18 -18
- package/lib/menu-status.ts +11 -0
- package/lib/menu-thinking.ts +2 -2
- package/lib/menu.ts +5 -1
- package/lib/model.ts +2 -4
- package/lib/outbound-attachments.ts +1 -1
- package/lib/outbound-buttons.ts +1 -10
- package/lib/outbound-markup.ts +5 -24
- package/lib/outbound.ts +5 -5
- package/lib/paths.ts +177 -17
- package/lib/pi.ts +5 -2
- package/lib/polling.ts +5 -5
- package/lib/preview.ts +3 -3
- package/lib/prompt-templates.ts +1 -1
- package/lib/queue.ts +67 -9
- package/lib/recovery.ts +104 -44
- package/lib/replies.ts +1 -1
- package/lib/routing.ts +1955 -375
- package/lib/sections.ts +1 -1
- package/lib/status.ts +185 -36
- package/lib/sync.ts +8 -8
- package/lib/telegram-api.ts +48 -33
- package/lib/thread-cleanup-manager.ts +24 -21
- package/lib/thread-naming.ts +267 -9
- package/lib/thread-reconciler.ts +3 -1
- package/lib/threads.ts +1718 -491
- package/lib/turns.ts +1 -1
- package/lib/updates.ts +1072 -151
- package/lib/wire.ts +28 -0
- package/lib/workspace-admission.ts +97 -47
- package/lib/workspace-identity.ts +147 -0
- package/lib/workspace-retirement.ts +234 -102
- package/package.json +6 -6
- package/scripts/audit-exports.mjs +100 -0
- package/scripts/check-downgrade.mjs +80 -43
- package/skills/generated-control-surface/SKILL.md +3 -3
- package/skills/telegram-bridge/references/diagnosis.md +3 -3
package/lib/sections.ts
CHANGED
|
@@ -284,7 +284,7 @@ function buildTelegramSectionCallbackContext(
|
|
|
284
284
|
// --- GlobalThis Bridge ---
|
|
285
285
|
|
|
286
286
|
/** @internal */
|
|
287
|
-
|
|
287
|
+
function setGlobalTelegramSectionRegistry(
|
|
288
288
|
registry: TelegramSectionRegistry,
|
|
289
289
|
): void {
|
|
290
290
|
(globalThis as Record<string, unknown>)[SECTION_REGISTRY_KEY] = registry;
|
package/lib/status.ts
CHANGED
|
@@ -1,9 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Telegram status rendering helpers
|
|
3
3
|
* Zones: telegram ui, pi agent diagnostics, tui
|
|
4
|
-
*
|
|
4
|
+
* Owns status summaries, redacted diagnostics, projection lifecycle and compact connection-failure copy
|
|
5
|
+
* Excludes canonical state, transport admission and filesystem policy
|
|
5
6
|
*/
|
|
6
7
|
|
|
8
|
+
import { isDeepStrictEqual } from "node:util";
|
|
9
|
+
|
|
10
|
+
/** UI copy is allowlisted; raw exception text belongs only in redacted diagnostics. */
|
|
11
|
+
export function formatTelegramConnectionFailure(error: unknown): string {
|
|
12
|
+
const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
13
|
+
const status = error && typeof error === "object" && "status" in error ? error.status : undefined;
|
|
14
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
15
|
+
if (status === 401) return "Telegram token rejected. Run /telegram-setup.";
|
|
16
|
+
if (status === 403) return "Telegram access denied. Check /telegram-status --debug.";
|
|
17
|
+
if ((typeof code === "string" && ["ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "ECONNRESET"].includes(code)) ||
|
|
18
|
+
/network unavailable|fetch failed/i.test(message)) {
|
|
19
|
+
return "Telegram network unavailable. Retry /telegram-connect.";
|
|
20
|
+
}
|
|
21
|
+
if (/Workspace slots?.*(?:unavailable|exhausted)|no free.*slot/i.test(message)) {
|
|
22
|
+
return "No Telegram slot available. Check /telegram-status --debug.";
|
|
23
|
+
}
|
|
24
|
+
if (code === "incompatible-protocol" || /protocol.*(?:incompatible|mismatch)|incompatible.*protocol/i.test(message)) {
|
|
25
|
+
return "Telegram instances are incompatible. Update them together.";
|
|
26
|
+
}
|
|
27
|
+
if (/unsupported.*(?:journal|version)|(?:journal|version).*unsupported/i.test(message)) {
|
|
28
|
+
return "Telegram state version unsupported. Use a compatible runtime.";
|
|
29
|
+
}
|
|
30
|
+
if (/follower registration failed|active in another Pi instance/i.test(message)) {
|
|
31
|
+
return "Telegram leader is active but unavailable. Check /telegram-status --debug.";
|
|
32
|
+
}
|
|
33
|
+
if (/unfinished Thread creation does not match/i.test(message)) {
|
|
34
|
+
return "Telegram Thread creation is unresolved. Check /telegram-status --debug.";
|
|
35
|
+
}
|
|
36
|
+
return "Telegram connection failed. Check /telegram-status --debug.";
|
|
37
|
+
}
|
|
38
|
+
|
|
7
39
|
const TELEGRAM_STATUS_DEFAULT_PROFILE_NAME = "default";
|
|
8
40
|
|
|
9
41
|
export type TelegramStatusQueueLane = "control" | "priority" | "default";
|
|
@@ -434,7 +466,7 @@ function truncateTelegramRuntimeEventText(
|
|
|
434
466
|
return `${text.slice(0, maxLength).trimEnd()}… [truncated ${text.length - maxLength} chars]`;
|
|
435
467
|
}
|
|
436
468
|
|
|
437
|
-
|
|
469
|
+
function redactTelegramRuntimeMessage(
|
|
438
470
|
message: string,
|
|
439
471
|
botToken: string | undefined,
|
|
440
472
|
): string {
|
|
@@ -550,7 +582,7 @@ export function registerTelegramStatusLineProvider(
|
|
|
550
582
|
};
|
|
551
583
|
}
|
|
552
584
|
|
|
553
|
-
|
|
585
|
+
function getTelegramStatusLineProviderResults(
|
|
554
586
|
ctx: TelegramStatusLineProviderContext,
|
|
555
587
|
): TelegramStatusLineProviderResult[] {
|
|
556
588
|
const results: TelegramStatusLineProviderResult[] = [];
|
|
@@ -813,13 +845,19 @@ export function createTelegramRuntimeLogScope(input: {
|
|
|
813
845
|
};
|
|
814
846
|
}
|
|
815
847
|
|
|
816
|
-
export
|
|
817
|
-
state: TelegramBridgeStatusLineState,
|
|
818
|
-
): {
|
|
848
|
+
export interface TelegramStatusSnapshot {
|
|
819
849
|
runtime: Record<string, unknown>;
|
|
820
850
|
liveRoster: Record<string, unknown>;
|
|
821
851
|
diagnostics: Record<string, unknown>;
|
|
822
|
-
}
|
|
852
|
+
}
|
|
853
|
+
export interface TelegramStoredRuntimeProjection extends TelegramStatusSnapshot {
|
|
854
|
+
version: 1;
|
|
855
|
+
source: "snapshot";
|
|
856
|
+
writtenAtMs: number;
|
|
857
|
+
}
|
|
858
|
+
export function createTelegramStatusSnapshot(
|
|
859
|
+
state: TelegramBridgeStatusLineState,
|
|
860
|
+
): TelegramStatusSnapshot {
|
|
823
861
|
return {
|
|
824
862
|
runtime: {
|
|
825
863
|
busRole: state.busRole,
|
|
@@ -840,7 +878,6 @@ export function createTelegramStatusSnapshot(
|
|
|
840
878
|
...(state.localBus ? { localBus: state.localBus } : {}),
|
|
841
879
|
topicTargets: state.topicTargets ?? [],
|
|
842
880
|
reservations: state.threadReservations ?? [],
|
|
843
|
-
syncObservations: state.topicSyncObservations ?? [],
|
|
844
881
|
},
|
|
845
882
|
diagnostics: {
|
|
846
883
|
pendingDispatch: state.pendingDispatch,
|
|
@@ -854,17 +891,94 @@ export function createTelegramStatusSnapshot(
|
|
|
854
891
|
};
|
|
855
892
|
}
|
|
856
893
|
|
|
894
|
+
function isRuntimeProjection(value: unknown): value is TelegramStoredRuntimeProjection {
|
|
895
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
896
|
+
const record = value as Record<string, unknown>;
|
|
897
|
+
return record.version === 1 && record.source === "snapshot" && Number.isSafeInteger(record.writtenAtMs) && (record.writtenAtMs as number) >= 0 &&
|
|
898
|
+
Object.keys(record).every(key => ["version", "source", "writtenAtMs", "runtime", "liveRoster", "diagnostics"].includes(key)) &&
|
|
899
|
+
["runtime", "liveRoster", "diagnostics"].every(key => record[key] !== null && typeof record[key] === "object" && !Array.isArray(record[key]));
|
|
900
|
+
}
|
|
901
|
+
export interface TelegramRuntimeProjectionScope {
|
|
902
|
+
path: string;
|
|
903
|
+
profile: string;
|
|
904
|
+
}
|
|
905
|
+
export interface TelegramRuntimeProjectionStorage {
|
|
906
|
+
read: (scope: TelegramRuntimeProjectionScope) => unknown;
|
|
907
|
+
/** The adapter must enforce exact owner plus the supplied physical/logical scope in one transaction. */
|
|
908
|
+
publish: (
|
|
909
|
+
scope: TelegramRuntimeProjectionScope,
|
|
910
|
+
mutate: (current: unknown) => { value: unknown; changed: boolean },
|
|
911
|
+
isCurrent: () => boolean,
|
|
912
|
+
) => boolean;
|
|
913
|
+
}
|
|
914
|
+
export interface TelegramRuntimeProjectionStoreOptions {
|
|
915
|
+
getPath: () => string;
|
|
916
|
+
getProfile: () => string | undefined;
|
|
917
|
+
/** Capture exact owner/context/session authority at submission, never renew it after the queued await. */
|
|
918
|
+
captureAuthority: () => (() => boolean) | undefined;
|
|
919
|
+
storage: TelegramRuntimeProjectionStorage;
|
|
920
|
+
getNowMs?: () => number;
|
|
921
|
+
}
|
|
922
|
+
/** Observational runtime storage only; Workspace, admission, transport, logs and recovery remain with their owners. */
|
|
923
|
+
export function createTelegramRuntimeProjectionStore(options: TelegramRuntimeProjectionStoreOptions): {
|
|
924
|
+
read: () => TelegramStoredRuntimeProjection | undefined;
|
|
925
|
+
persist: (snapshot: TelegramStatusSnapshot) => Promise<boolean>;
|
|
926
|
+
} {
|
|
927
|
+
let publicationQueue = Promise.resolve();
|
|
928
|
+
const { getPath, getProfile: readProfile, captureAuthority } = options;
|
|
929
|
+
const { read, publish } = options.storage;
|
|
930
|
+
const getNowMs = options.getNowMs ?? Date.now;
|
|
931
|
+
const getProfile = (): string => readProfile() || TELEGRAM_STATUS_DEFAULT_PROFILE_NAME;
|
|
932
|
+
return {
|
|
933
|
+
read() {
|
|
934
|
+
try {
|
|
935
|
+
const path = getPath(), profile = getProfile(), value = read({ path, profile });
|
|
936
|
+
return getPath() === path && getProfile() === profile && isRuntimeProjection(value) ? value : undefined;
|
|
937
|
+
} catch { return undefined; }
|
|
938
|
+
},
|
|
939
|
+
async persist(snapshot) {
|
|
940
|
+
const path = getPath(), profile = getProfile(), authority = captureAuthority();
|
|
941
|
+
if (!authority) return false;
|
|
942
|
+
// Freeze wire content at submission, but do not duplicate the event log in persistent runtime state.
|
|
943
|
+
const semantic = JSON.parse(JSON.stringify({ version: 1, source: "snapshot", runtime: snapshot.runtime,
|
|
944
|
+
liveRoster: snapshot.liveRoster, diagnostics: snapshot.diagnostics })) as Omit<TelegramStoredRuntimeProjection, "writtenAtMs">;
|
|
945
|
+
if (!isRuntimeProjection({ ...semantic, writtenAtMs: 0 })) throw new Error("Telegram runtime projection is malformed.");
|
|
946
|
+
delete semantic.diagnostics.recentRuntimeEvents;
|
|
947
|
+
const isCurrent = (): boolean => getPath() === path && getProfile() === profile && authority() === true;
|
|
948
|
+
const publication = publicationQueue.then(() => {
|
|
949
|
+
if (!isCurrent()) return false;
|
|
950
|
+
return publish({ path, profile }, current => {
|
|
951
|
+
if (isRuntimeProjection(current)) {
|
|
952
|
+
const { writtenAtMs: _writtenAtMs, ...existing } = current;
|
|
953
|
+
if (isDeepStrictEqual(existing, semantic)) return { value: current, changed: false };
|
|
954
|
+
}
|
|
955
|
+
const writtenAtMs = getNowMs();
|
|
956
|
+
if (!Number.isSafeInteger(writtenAtMs) || writtenAtMs < 0) throw new Error("Telegram runtime projection timestamp is invalid.");
|
|
957
|
+
return { value: { ...semantic, writtenAtMs }, changed: true };
|
|
958
|
+
}, isCurrent) === true;
|
|
959
|
+
});
|
|
960
|
+
publicationQueue = publication.then(() => undefined, () => undefined);
|
|
961
|
+
return publication;
|
|
962
|
+
},
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
|
|
857
966
|
const TELEGRAM_DIAGNOSTICS_SNAPSHOT_COALESCE_MS = 100;
|
|
858
967
|
|
|
859
968
|
export function createTelegramRuntimeDiagnosticsSnapshotScheduler(deps: {
|
|
860
|
-
persistSnapshot: () => Promise<void>;
|
|
969
|
+
persistSnapshot: (isCurrent: () => boolean) => Promise<void>;
|
|
861
970
|
recordError: (error: unknown) => void;
|
|
862
|
-
|
|
863
|
-
|
|
971
|
+
captureScope?: () => (() => boolean) | undefined;
|
|
972
|
+
setTimer?: (callback: () => void, ms: number) => { unref?: () => void } | number;
|
|
973
|
+
clearTimer?: (timer: { unref?: () => void } | number) => void;
|
|
974
|
+
}): (() => void) & { resume(): void; suspend(): Promise<void> } {
|
|
864
975
|
const setTimer = deps.setTimer ?? setTimeout;
|
|
865
|
-
|
|
976
|
+
const clearTimer = deps.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
|
977
|
+
let generation = 0;
|
|
978
|
+
let enabled = true;
|
|
979
|
+
let timer: { token: object; handle: { unref?: () => void } | number } | undefined;
|
|
866
980
|
let persistPromise: Promise<void> | undefined;
|
|
867
|
-
let pending
|
|
981
|
+
let pending: (() => boolean) | undefined;
|
|
868
982
|
const recordError = (error: unknown): void => {
|
|
869
983
|
try {
|
|
870
984
|
deps.recordError(error);
|
|
@@ -872,30 +986,72 @@ export function createTelegramRuntimeDiagnosticsSnapshotScheduler(deps: {
|
|
|
872
986
|
// Snapshot diagnostics cannot create an unhandled scheduler rejection.
|
|
873
987
|
}
|
|
874
988
|
};
|
|
875
|
-
const
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
989
|
+
const cancelTimer = (): void => {
|
|
990
|
+
const previous = timer;
|
|
991
|
+
timer = undefined;
|
|
992
|
+
if (previous) {
|
|
993
|
+
try { clearTimer(previous.handle); } catch (error) { recordError(error); }
|
|
879
994
|
}
|
|
880
|
-
|
|
995
|
+
};
|
|
996
|
+
const capture = (): (() => boolean) | undefined => {
|
|
997
|
+
if (!enabled) return undefined;
|
|
998
|
+
const expectedGeneration = generation;
|
|
999
|
+
let scope: (() => boolean) | undefined;
|
|
1000
|
+
try { scope = deps.captureScope ? deps.captureScope() : () => true; }
|
|
1001
|
+
catch (error) { recordError(error); return undefined; }
|
|
1002
|
+
if (!scope) return undefined;
|
|
1003
|
+
return () => {
|
|
1004
|
+
if (!enabled || generation !== expectedGeneration) return false;
|
|
1005
|
+
try { return scope!(); } catch (error) { recordError(error); return false; }
|
|
1006
|
+
};
|
|
1007
|
+
};
|
|
1008
|
+
const arm = (): void => {
|
|
1009
|
+
if (timer || persistPromise || !pending) return;
|
|
1010
|
+
const authority = pending;
|
|
1011
|
+
if (!authority()) { pending = undefined; return; }
|
|
1012
|
+
const token = {};
|
|
1013
|
+
const handle = setTimer(() => {
|
|
1014
|
+
if (timer?.token !== token) return;
|
|
881
1015
|
timer = undefined;
|
|
1016
|
+
const publicationAuthority = pending;
|
|
1017
|
+
pending = undefined;
|
|
1018
|
+
if (!authority() || !publicationAuthority?.()) return;
|
|
882
1019
|
let tracked: Promise<void>;
|
|
883
1020
|
tracked = Promise.resolve()
|
|
884
|
-
.then(deps.persistSnapshot)
|
|
1021
|
+
.then(() => publicationAuthority() ? deps.persistSnapshot(publicationAuthority) : undefined)
|
|
885
1022
|
.catch(recordError)
|
|
886
1023
|
.finally(() => {
|
|
887
1024
|
if (persistPromise !== tracked) return;
|
|
888
1025
|
persistPromise = undefined;
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
request();
|
|
892
|
-
}
|
|
1026
|
+
// Only an explicitly captured new request can arm a successor publication.
|
|
1027
|
+
arm();
|
|
893
1028
|
});
|
|
894
1029
|
persistPromise = tracked;
|
|
895
1030
|
}, TELEGRAM_DIAGNOSTICS_SNAPSHOT_COALESCE_MS);
|
|
896
|
-
|
|
1031
|
+
timer = { token, handle };
|
|
1032
|
+
if (typeof handle !== "number") handle.unref?.();
|
|
1033
|
+
};
|
|
1034
|
+
const request = (): void => {
|
|
1035
|
+
const authority = capture();
|
|
1036
|
+
if (!authority) return;
|
|
1037
|
+
pending = authority;
|
|
1038
|
+
arm();
|
|
897
1039
|
};
|
|
898
|
-
return request
|
|
1040
|
+
return Object.assign(request, {
|
|
1041
|
+
resume() {
|
|
1042
|
+
generation += 1;
|
|
1043
|
+
cancelTimer();
|
|
1044
|
+
pending = undefined;
|
|
1045
|
+
enabled = true;
|
|
1046
|
+
},
|
|
1047
|
+
suspend() {
|
|
1048
|
+
enabled = false;
|
|
1049
|
+
generation += 1;
|
|
1050
|
+
cancelTimer();
|
|
1051
|
+
pending = undefined;
|
|
1052
|
+
return persistPromise ?? Promise.resolve();
|
|
1053
|
+
},
|
|
1054
|
+
});
|
|
899
1055
|
}
|
|
900
1056
|
|
|
901
1057
|
export function getTelegramStatusBarProcessingStatus(state: {
|
|
@@ -1353,16 +1509,9 @@ function buildTelegramBridgeCompactStatusLines(
|
|
|
1353
1509
|
: state.activeSourceMessageIds?.length
|
|
1354
1510
|
? "active"
|
|
1355
1511
|
: "idle";
|
|
1356
|
-
const diagnosticsProfileName =
|
|
1357
|
-
state.activeProfileName === TELEGRAM_STATUS_DEFAULT_PROFILE_NAME
|
|
1358
|
-
? undefined
|
|
1359
|
-
: state.activeProfileName;
|
|
1360
|
-
const profileSuffix = diagnosticsProfileName
|
|
1361
|
-
? `.${diagnosticsProfileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}`
|
|
1362
|
-
: "";
|
|
1363
1512
|
const diagnosticsPaths = state.diagnosticPaths ?? {
|
|
1364
|
-
state:
|
|
1365
|
-
logs:
|
|
1513
|
+
state: "~/.pi/agent/tmp/pi-telegram/state.json",
|
|
1514
|
+
logs: "~/.pi/agent/tmp/pi-telegram/logs.jsonl",
|
|
1366
1515
|
};
|
|
1367
1516
|
return [
|
|
1368
1517
|
"connection:",
|
|
@@ -1414,7 +1563,7 @@ export function buildTelegramBridgeStatusLines(
|
|
|
1414
1563
|
return buildTelegramBridgeCompactStatusLines(state);
|
|
1415
1564
|
}
|
|
1416
1565
|
|
|
1417
|
-
|
|
1566
|
+
function buildTelegramBridgeDiagnosticStatusLines(
|
|
1418
1567
|
state: TelegramBridgeStatusLineState,
|
|
1419
1568
|
): string[] {
|
|
1420
1569
|
const controlQueueCount = state.queuedItems.filter(
|
|
@@ -1591,7 +1740,7 @@ function buildTelegramStatusRoleSuffix(
|
|
|
1591
1740
|
return ` @${state.busRole}`;
|
|
1592
1741
|
}
|
|
1593
1742
|
|
|
1594
|
-
|
|
1743
|
+
function buildStatusHtml(
|
|
1595
1744
|
ctx: TelegramStatusContext,
|
|
1596
1745
|
activeModel: TelegramStatusActiveModel | undefined,
|
|
1597
1746
|
bridgeStatus?: TelegramBridgeStatusLineState,
|
package/lib/sync.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { getTelegramApiErrorRequestTarget, isTelegramStaleTargetHttpError } from
|
|
|
8
8
|
import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
|
|
9
9
|
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
10
10
|
import { TelegramWorkspaceSlotUnavailableError } from "./workspace-slots.ts";
|
|
11
|
+
import { normalizeTelegramWorkspacePath } from "./workspace-identity.ts";
|
|
11
12
|
import {
|
|
12
13
|
createTelegramWorkspaceAdmissionOperationId,
|
|
13
14
|
runWithTelegramWorkspaceAdmissionsAsync,
|
|
@@ -20,7 +21,6 @@ import {
|
|
|
20
21
|
getTelegramThreadOwnerKey,
|
|
21
22
|
isSameTelegramProcessInstance,
|
|
22
23
|
isTelegramTopicTargetStaleError,
|
|
23
|
-
normalizeTelegramWorkspacePath,
|
|
24
24
|
provisionOwnBusTopic,
|
|
25
25
|
type TelegramOwnTopicProvisionResult,
|
|
26
26
|
type TelegramTopicTargetStore,
|
|
@@ -179,7 +179,7 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
|
|
|
179
179
|
getNowMs?: () => number;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
|
|
182
|
+
function markTelegramConfigSyncChange<
|
|
183
183
|
TSyncState extends TelegramSyncState,
|
|
184
184
|
>(state: TSyncState, action: string, options?: { nowMs?: number }): TSyncState {
|
|
185
185
|
const nowMs = options?.nowMs ?? Date.now();
|
|
@@ -245,7 +245,7 @@ export interface TelegramSessionRestartThreadCleanupDeps<
|
|
|
245
245
|
suspendPolling: () => Promise<void>;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
|
|
248
|
+
function createTelegramSessionRestartThreadCleanupHandler<
|
|
249
249
|
TSyncState extends TelegramSyncState,
|
|
250
250
|
>(
|
|
251
251
|
deps: TelegramSessionRestartThreadCleanupDeps<TSyncState>,
|
|
@@ -773,6 +773,7 @@ export async function ensureTelegramLeaderThreadBinding(
|
|
|
773
773
|
: undefined;
|
|
774
774
|
const legacyWorkspaceBinding =
|
|
775
775
|
workspaceIdentity?.instanceSlot === "a" &&
|
|
776
|
+
deps.sessionId === undefined &&
|
|
776
777
|
!persistedWorkspaceBinding &&
|
|
777
778
|
typeof legacyLeaderRecord?.target.threadId === "number"
|
|
778
779
|
? {
|
|
@@ -885,10 +886,9 @@ export async function ensureTelegramLeaderThreadBinding(
|
|
|
885
886
|
(record.status === "active" || record.status === "starting")
|
|
886
887
|
);
|
|
887
888
|
});
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
|
|
891
|
-
if (!deps.forceFreshUnnamed && priorTargets.length > 0) {
|
|
889
|
+
// Legacy callers without session identity may reuse the active instance target.
|
|
890
|
+
// Session-aware callers must resolve their exact Workspace binding instead.
|
|
891
|
+
if (deps.sessionId === undefined && !deps.forceFreshUnnamed && priorTargets.length > 0) {
|
|
892
892
|
const record = priorTargets[0];
|
|
893
893
|
deps.recordEvent(
|
|
894
894
|
"telegram",
|
|
@@ -997,7 +997,7 @@ export async function ensureTelegramLeaderThreadBinding(
|
|
|
997
997
|
}
|
|
998
998
|
}
|
|
999
999
|
|
|
1000
|
-
|
|
1000
|
+
const TELEGRAM_SYNC_SLICE_TARGET_BINDINGS = "target-bindings";
|
|
1001
1001
|
|
|
1002
1002
|
export const TELEGRAM_SYNC_SLICES = [
|
|
1003
1003
|
"bot-identity",
|
package/lib/telegram-api.ts
CHANGED
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
import { createWriteStream, openAsBlob } from "node:fs";
|
|
11
|
-
import { mkdir, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
11
|
+
import { mkdir, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
12
12
|
import { request as requestHttps } from "node:https";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
-
import {
|
|
14
|
+
import type { TelegramAttachmentSource } from "./media.ts";
|
|
15
|
+
import { resolveTelegramAttachmentsDir } from "./paths.ts";
|
|
15
16
|
import { Readable, Transform } from "node:stream";
|
|
16
17
|
import { pipeline } from "node:stream/promises";
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
const TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
19
20
|
|
|
20
21
|
export const TELEGRAM_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
|
21
22
|
|
|
@@ -34,12 +35,10 @@ export function getTelegramInboundFileByteLimitFromEnv(
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
function getTelegramApiTempDir(): string {
|
|
37
|
-
return
|
|
38
|
+
return resolveTelegramAttachmentsDir();
|
|
38
39
|
}
|
|
39
40
|
const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
40
41
|
const activeTelegramApiWorkspaceAdmissionOperationIds = new Set<string>();
|
|
41
|
-
const TELEGRAM_TEMP_SCRATCH_FILE_PATTERN =
|
|
42
|
-
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-/u;
|
|
43
42
|
const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(
|
|
44
43
|
process.env,
|
|
45
44
|
["PI_TELEGRAM_INBOUND_FILE_MAX_BYTES", "TELEGRAM_MAX_FILE_SIZE_BYTES"],
|
|
@@ -750,6 +749,8 @@ export interface TelegramBridgeApiRuntimeDeps {
|
|
|
750
749
|
tempDir: string;
|
|
751
750
|
maxFileSizeBytes: number;
|
|
752
751
|
tempFileMaxAgeMs: number;
|
|
752
|
+
/** Bot `@username` (or numeric id) naming private-chat attachments. */
|
|
753
|
+
getBotScope?: () => string | undefined;
|
|
753
754
|
recordRuntimeEvent: (
|
|
754
755
|
kind: "api" | "multipart" | "download",
|
|
755
756
|
error: unknown,
|
|
@@ -774,7 +775,11 @@ export interface TelegramBridgeApiRuntime {
|
|
|
774
775
|
fileName: string,
|
|
775
776
|
options?: TelegramApiCallOptions,
|
|
776
777
|
) => Promise<TResponse>;
|
|
777
|
-
downloadFile: (
|
|
778
|
+
downloadFile: (
|
|
779
|
+
fileId: string,
|
|
780
|
+
suggestedName: string,
|
|
781
|
+
source?: TelegramAttachmentSource,
|
|
782
|
+
) => Promise<string>;
|
|
778
783
|
deleteWebhook: (signal?: AbortSignal) => Promise<boolean>;
|
|
779
784
|
getUpdates: (
|
|
780
785
|
body: Record<string, unknown>,
|
|
@@ -853,6 +858,28 @@ function sanitizeFileName(name: string): string {
|
|
|
853
858
|
return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
854
859
|
}
|
|
855
860
|
|
|
861
|
+
/** Names follow `<kind>-<scope>-<messageId>[-<index>][-<name>|.<ext>]`. */
|
|
862
|
+
/**
|
|
863
|
+
* `scope` is the bot `@username` (id fallback) in a private chat and the public chat `@username` (id fallback) elsewhere.
|
|
864
|
+
* A negative chat id loses its sign, so the name never contains two adjacent dashes.
|
|
865
|
+
*/
|
|
866
|
+
export function createTelegramAttachmentFileName(
|
|
867
|
+
source: TelegramAttachmentSource,
|
|
868
|
+
generatedName: string,
|
|
869
|
+
botScope?: string,
|
|
870
|
+
): string {
|
|
871
|
+
const chat = source.chat;
|
|
872
|
+
const clean = (value: string): string => sanitizeFileName(value.replace(/^@/u, "")).replace(/^[-.]+/u, "");
|
|
873
|
+
const scope = source.scope ? clean(source.scope) : chat?.type === "private" || !chat
|
|
874
|
+
? botScope ? clean(botScope) : chat ? String(Math.abs(chat.id)) : ""
|
|
875
|
+
: chat.username ? clean(chat.username) : String(Math.abs(chat.id));
|
|
876
|
+
const dot = generatedName.lastIndexOf(".");
|
|
877
|
+
const extension = dot > 0 ? sanitizeFileName(generatedName.slice(dot)) : "";
|
|
878
|
+
const tail = source.userFileName ? `-${clean(source.userFileName)}` : extension;
|
|
879
|
+
return [sanitizeFileName(source.kind), scope, String(source.messageId)].filter(Boolean).join("-") +
|
|
880
|
+
(source.index !== undefined ? `-${source.index}` : "") + tail;
|
|
881
|
+
}
|
|
882
|
+
|
|
856
883
|
class TelegramApiMalformedSuccessError extends Error {
|
|
857
884
|
constructor(method: string, detail: string) {
|
|
858
885
|
super(`Telegram API ${method} ${detail}`);
|
|
@@ -1490,9 +1517,8 @@ export async function cleanupTelegramTempFiles(
|
|
|
1490
1517
|
return 0;
|
|
1491
1518
|
}
|
|
1492
1519
|
for (const entry of entries) {
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
}
|
|
1520
|
+
// The directory holds only attachment scratch files, so age alone decides.
|
|
1521
|
+
if (!entry.isFile()) continue;
|
|
1496
1522
|
const path = join(tempDir, entry.name);
|
|
1497
1523
|
try {
|
|
1498
1524
|
const stats = await stat(path);
|
|
@@ -1643,10 +1669,9 @@ export async function downloadTelegramFile(
|
|
|
1643
1669
|
);
|
|
1644
1670
|
assertTelegramFileSizeWithinLimit(file.file_size, options?.maxFileSizeBytes);
|
|
1645
1671
|
await mkdir(tempDir, { recursive: true, mode: 0o700 });
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
);
|
|
1672
|
+
// Names carry scope and message id, so the same message maps to the same path; publish by rename.
|
|
1673
|
+
const targetPath = join(tempDir, sanitizeFileName(suggestedName));
|
|
1674
|
+
const partPath = `${targetPath}.${randomUUID()}.part`;
|
|
1650
1675
|
const response = await callTelegramTransportRequest((family) =>
|
|
1651
1676
|
telegramFetch(
|
|
1652
1677
|
`${TELEGRAM_API_BASE}/file/bot${configuredBotToken}/${file.file_path}`,
|
|
@@ -1665,11 +1690,12 @@ export async function downloadTelegramFile(
|
|
|
1665
1690
|
try {
|
|
1666
1691
|
await writeTelegramDownloadResponse(
|
|
1667
1692
|
response,
|
|
1668
|
-
|
|
1693
|
+
partPath,
|
|
1669
1694
|
options?.maxFileSizeBytes,
|
|
1670
1695
|
);
|
|
1696
|
+
await rename(partPath, targetPath);
|
|
1671
1697
|
} catch (error) {
|
|
1672
|
-
await removeTelegramPartialDownload(
|
|
1698
|
+
await removeTelegramPartialDownload(partPath);
|
|
1673
1699
|
throw error;
|
|
1674
1700
|
}
|
|
1675
1701
|
return targetPath;
|
|
@@ -1700,21 +1726,6 @@ export async function answerTelegramCallbackQuery(
|
|
|
1700
1726
|
}
|
|
1701
1727
|
}
|
|
1702
1728
|
|
|
1703
|
-
export async function deleteTelegramMessage(
|
|
1704
|
-
botToken: string | undefined,
|
|
1705
|
-
chatId: number,
|
|
1706
|
-
messageId: number,
|
|
1707
|
-
): Promise<void> {
|
|
1708
|
-
try {
|
|
1709
|
-
await callTelegram<boolean>(botToken, "deleteMessage", {
|
|
1710
|
-
chat_id: chatId,
|
|
1711
|
-
message_id: messageId,
|
|
1712
|
-
});
|
|
1713
|
-
} catch {
|
|
1714
|
-
// ignore
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
1729
|
export function createTelegramChatActionSender<TAction extends string>(
|
|
1719
1730
|
sendChatAction: (
|
|
1720
1731
|
chatId: number,
|
|
@@ -1802,6 +1813,7 @@ export type TelegramWorkspaceThreadDeletionTransport = (
|
|
|
1802
1813
|
|
|
1803
1814
|
export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|
1804
1815
|
getBotToken: () => string | undefined;
|
|
1816
|
+
getBotScope?: () => string | undefined;
|
|
1805
1817
|
recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
|
|
1806
1818
|
captureRequestErrorHandler?: TelegramBridgeApiRuntimeDeps["captureRequestErrorHandler"];
|
|
1807
1819
|
targetActivity?: TelegramApiTargetActivityRuntime;
|
|
@@ -1831,6 +1843,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|
|
1831
1843
|
tempDir: getTelegramApiTempDir(),
|
|
1832
1844
|
maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
|
|
1833
1845
|
tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
|
|
1846
|
+
...(deps.getBotScope ? { getBotScope: deps.getBotScope } : {}),
|
|
1834
1847
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1835
1848
|
captureRequestErrorHandler: deps.captureRequestErrorHandler,
|
|
1836
1849
|
});
|
|
@@ -2040,11 +2053,13 @@ export function createTelegramBridgeApiRuntime(
|
|
|
2040
2053
|
* Downloads a file from the Telegram servers into the local temp directory.
|
|
2041
2054
|
* Used for inbound voice messages, photos, documents, etc.
|
|
2042
2055
|
*/
|
|
2043
|
-
downloadFile: async (fileId, suggestedName) => {
|
|
2056
|
+
downloadFile: async (fileId, suggestedName, source) => {
|
|
2044
2057
|
try {
|
|
2045
2058
|
return await deps.client.downloadFile(
|
|
2046
2059
|
fileId,
|
|
2047
|
-
|
|
2060
|
+
source
|
|
2061
|
+
? createTelegramAttachmentFileName(source, suggestedName, deps.getBotScope?.())
|
|
2062
|
+
: suggestedName,
|
|
2048
2063
|
deps.tempDir,
|
|
2049
2064
|
{
|
|
2050
2065
|
maxFileSizeBytes: deps.maxFileSizeBytes,
|
|
@@ -10,6 +10,12 @@ import { dirname } from "node:path";
|
|
|
10
10
|
import { createHash, randomUUID } from "node:crypto";
|
|
11
11
|
|
|
12
12
|
import { renameTelegramPathWithRetry, withTelegramFileTransaction } from "./locks.ts";
|
|
13
|
+
import { getTelegramJournalPublicationPaths } from "./paths.ts";
|
|
14
|
+
import {
|
|
15
|
+
isWireRecord as isObject,
|
|
16
|
+
hasOnlyWireKeys as onlyKeys,
|
|
17
|
+
isNonNegativeWireInteger as safeTime,
|
|
18
|
+
} from "./wire.ts";
|
|
13
19
|
import type { TelegramWorkspaceDeletionPermit,
|
|
14
20
|
TelegramWorkspaceDestructiveFence,
|
|
15
21
|
TelegramWorkspaceRetirementFence } from "./workspace-admission.ts";
|
|
@@ -247,15 +253,6 @@ export interface TelegramThreadCleanupWorkStore {
|
|
|
247
253
|
list(): TelegramThreadCleanupWorkSet[];
|
|
248
254
|
}
|
|
249
255
|
|
|
250
|
-
function isObject(value: unknown): value is Record<string, unknown> {
|
|
251
|
-
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
252
|
-
}
|
|
253
|
-
function onlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
254
|
-
const allowed = new Set(keys); return Object.keys(value).every(key => allowed.has(key));
|
|
255
|
-
}
|
|
256
|
-
function safeTime(value: unknown): value is number {
|
|
257
|
-
return Number.isSafeInteger(value) && (value as number) >= 0;
|
|
258
|
-
}
|
|
259
256
|
function validateWorkSet(value: unknown, profileName: string): TelegramThreadCleanupWorkSet {
|
|
260
257
|
if (!isObject(value) || !onlyKeys(value, ["operationId", "createdAtMs", "entries"]) ||
|
|
261
258
|
typeof value.operationId !== "string" || !value.operationId || !safeTime(value.createdAtMs) ||
|
|
@@ -311,11 +308,23 @@ function sameCandidates(left: readonly TelegramThreadCleanupCandidate[], right:
|
|
|
311
308
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
312
309
|
}
|
|
313
310
|
|
|
311
|
+
function parseTelegramThreadCleanupWorkFile(value: unknown, profileName: string, tokenSha256: string): TelegramThreadCleanupWorkFile {
|
|
312
|
+
if (!isObject(value) || !onlyKeys(value, ["version", "profileName", "tokenSha256", "workSets"]) ||
|
|
313
|
+
value.version !== 1 || value.profileName !== profileName || value.tokenSha256 !== tokenSha256 || !Array.isArray(value.workSets))
|
|
314
|
+
throw new Error("Telegram Thread cleanup store identity or schema does not match.");
|
|
315
|
+
const workSets = value.workSets.map(workSet => validateWorkSet(workSet, profileName));
|
|
316
|
+
if (new Set(workSets.map(workSet => workSet.operationId)).size !== workSets.length)
|
|
317
|
+
throw new Error("Telegram Thread cleanup operation identity is ambiguous.");
|
|
318
|
+
return { version: 1, profileName, tokenSha256, workSets };
|
|
319
|
+
}
|
|
320
|
+
|
|
314
321
|
export function createTelegramThreadCleanupWorkStore(options: {
|
|
315
|
-
path: string; profileName: string; tokenSha256: string; maxWorkSets?: number;
|
|
322
|
+
path: string; runtimeDir?: string; profileName: string; tokenSha256: string; maxWorkSets?: number;
|
|
316
323
|
maxBytes?: number; getNowMs?: () => number;
|
|
317
324
|
onPublicationBoundary?: (boundary: "after-write-before-rename" | "after-rename") => void;
|
|
318
325
|
}): TelegramThreadCleanupWorkStore {
|
|
326
|
+
options = { ...options };
|
|
327
|
+
const publicationPaths = getTelegramJournalPublicationPaths(options.path, options.runtimeDir);
|
|
319
328
|
const maxWorkSets = options.maxWorkSets ?? 32;
|
|
320
329
|
const maxBytes = options.maxBytes ?? 1024 * 1024;
|
|
321
330
|
const now = options.getNowMs ?? Date.now;
|
|
@@ -344,21 +353,15 @@ export function createTelegramThreadCleanupWorkStore(options: {
|
|
|
344
353
|
opened.mtimeNs !== before.mtimeNs) throw new Error("Telegram Thread cleanup store changed during inspection.");
|
|
345
354
|
value = JSON.parse(readFileSync(fd, "utf8")) as unknown;
|
|
346
355
|
} finally { closeSync(fd); }
|
|
347
|
-
|
|
348
|
-
value.version !== 1 || value.profileName !== options.profileName ||
|
|
349
|
-
value.tokenSha256 !== options.tokenSha256 || !Array.isArray(value.workSets))
|
|
350
|
-
throw new Error("Telegram Thread cleanup store identity or schema does not match.");
|
|
351
|
-
const workSets = value.workSets.map(workSet => validateWorkSet(workSet, options.profileName));
|
|
352
|
-
if (new Set(workSets.map(workSet => workSet.operationId)).size !== workSets.length)
|
|
353
|
-
throw new Error("Telegram Thread cleanup operation identity is ambiguous.");
|
|
354
|
-
return { version: 1, profileName: options.profileName, tokenSha256: options.tokenSha256, workSets };
|
|
356
|
+
return parseTelegramThreadCleanupWorkFile(value, options.profileName, options.tokenSha256);
|
|
355
357
|
};
|
|
356
358
|
const publish = (file: TelegramThreadCleanupWorkFile): void => {
|
|
357
359
|
if (file.workSets.length > maxWorkSets) throw new Error("Telegram Thread cleanup work-set capacity reached.");
|
|
358
360
|
const serialized = `${JSON.stringify(file, null, 2)}\n`;
|
|
359
361
|
if (Buffer.byteLength(serialized) > maxBytes) throw new Error("Telegram Thread cleanup byte capacity reached.");
|
|
360
|
-
const temporaryPath = `${
|
|
362
|
+
const temporaryPath = `${publicationPaths.temporaryBasePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
361
363
|
mkdirSync(dirname(options.path), { recursive: true, mode: 0o700 });
|
|
364
|
+
mkdirSync(dirname(temporaryPath), { recursive: true, mode: 0o700 });
|
|
362
365
|
try {
|
|
363
366
|
writeFileSync(temporaryPath, serialized, { mode: 0o600 });
|
|
364
367
|
chmodSync(temporaryPath, 0o600);
|
|
@@ -370,7 +373,7 @@ export function createTelegramThreadCleanupWorkStore(options: {
|
|
|
370
373
|
} finally { try { unlinkSync(temporaryPath); } catch { /* rename consumed it */ } }
|
|
371
374
|
};
|
|
372
375
|
const mutate = <T>(operation: (file: TelegramThreadCleanupWorkFile) => T): T =>
|
|
373
|
-
withTelegramFileTransaction(
|
|
376
|
+
withTelegramFileTransaction(publicationPaths.transactionPath, () => operation(read()));
|
|
374
377
|
return {
|
|
375
378
|
prepare(operationId, candidates) {
|
|
376
379
|
if (!operationId || candidates.length === 0 || candidates.some(candidate => candidate.profileName !== options.profileName))
|
|
@@ -435,7 +438,7 @@ export function createTelegramThreadCleanupWorkStore(options: {
|
|
|
435
438
|
},
|
|
436
439
|
list() {
|
|
437
440
|
return structuredClone(withTelegramFileTransaction(
|
|
438
|
-
|
|
441
|
+
publicationPaths.transactionPath,
|
|
439
442
|
() => read().workSets,
|
|
440
443
|
));
|
|
441
444
|
},
|