/** * Telegram thread binding helpers * Zones: multi-instance bus, Telegram UI threads, volatile extension state * Owns current live instance-binding to Telegram UI thread mappings backed by Bot API ForumTopic/message_thread_id transport */ import { randomUUID } from "node:crypto"; import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync, } from "node:fs"; import { chmod, mkdir, readFile, rename, unlink, writeFile, } from "node:fs/promises"; import { dirname } from "node:path"; import { isTelegramApiCommitUnknownError, TelegramApiCommitUnknownError, type TelegramApiCallOptions, } from "./telegram-api.ts"; import type { TelegramTarget } from "./target.ts"; import { withTelegramFileTransaction } from "./locks.ts"; import * as ThreadReconciler from "./thread-reconciler.ts"; import { resolveAgentDir, resolveTelegramProfileTempFilePath, } from "./paths.ts"; export interface TelegramThreadNameInput { seed: string; cwd?: string; role?: "leader" | "follower"; peers?: readonly string[]; slot?: string; } export type TelegramTopicTargetStatus = | "active" | "offline" | "stale" | "pending" | "starting" | "probe-required" | "failed"; export type TelegramTopicSyncStatus = "open" | "closed" | "deleted" | "unknown"; export type TelegramThreadOwner = | { kind: "leader"; cwd?: string; instanceId?: string; telegramProfile?: string; } | { kind: "manual-follower"; instanceId: string; telegramProfile?: string } | { kind: "pending-topic"; chatId: number; threadId: number } | { kind: "legacy"; key: string }; const TELEGRAM_THREAD_RESERVATION_TTL_MS = 15 * 60 * 1000; export interface TelegramThreadReservation { target: TelegramTarget & { threadId: number }; slot: string; reason: string; createdAtMs: number; updatedAtMs: number; expiresAtMs?: number; instanceId?: string; lastReconcileAction?: string; } export interface TelegramThreadPendingProvision { id: string; owner: "leader" | "manual-follower"; instanceId: string; profileKey?: string; status?: "in-flight" | "ambiguous"; threadName?: string; slot?: string; target?: TelegramTarget & { threadId: number }; startedAtMs: number; expiresAtMs?: number; leaderEpoch?: number | string; } export type TelegramThreadCleanupIntent = ThreadReconciler.TelegramThreadCleanupIntent; type TelegramProvisionRecoveryFile = Record< string, { instanceId: string; profileKey?: string; leaderEpoch?: number | string; target: TelegramTarget & { threadId: number }; } >; export interface TelegramTopicSyncObservation { target: TelegramTarget & { threadId: number }; syncStatus: TelegramTopicSyncStatus; observedAtMs: number; instanceId?: string; slot?: string; lastSyncError?: string; lastReconcileAction?: string; } export interface TelegramTopicTargetRecord { /** Legacy string key derived from `owner`; always present in memory, never persisted. */ profileKey: string; owner?: TelegramThreadOwner; target: TelegramTarget & { threadId: number }; status: TelegramTopicTargetStatus; createdAtMs: number; updatedAtMs: number; threadName?: string; instanceId?: string; slot?: string; lastError?: string; syncStatus?: TelegramTopicSyncStatus; lastSyncObservedAtMs?: number; lastSyncProbeAtMs?: number; lastSyncError?: string; lastReconcileAction?: string; rerouteConfirmedAtMs?: number; } export interface TelegramThreadIdentityRecord { profileKey: string; threadName?: string; slot?: string; updatedAtMs: number; } export type TelegramBotThreadMode = "unknown" | "enabled" | "disabled"; export interface TelegramBotStateSnapshot { threadMode: TelegramBotThreadMode; updatedAtMs?: number; lastSlot?: string; lastReconcileAction?: string; } export interface TelegramTopicTargetFile { version: 1; source: "snapshot"; writtenAtMs: number; bot: TelegramBotStateSnapshot; runtime?: Record; liveRoster?: Record; diagnostics?: Record; threads: TelegramTopicTargetRecord[]; identities?: TelegramThreadIdentityRecord[]; reservations?: TelegramThreadReservation[]; pendingProvisions?: TelegramThreadPendingProvision[]; pendingCleanups?: TelegramThreadCleanupIntent[]; syncObservations?: TelegramTopicSyncObservation[]; } function getNextMonotonicSlot( records: Map, reservations: readonly TelegramThreadReservation[], pendingProvisions: readonly TelegramThreadPendingProvision[], nowMs: number, lastSlot?: string, ): string | undefined { let cursorCode: number | undefined; if (lastSlot && /^[A-Z]$/.test(lastSlot)) { cursorCode = lastSlot.charCodeAt(0); } else { cursorCode = "A".charCodeAt(0) - 1; for (const record of records.values()) { if (!record.slot || !isCurrentThreadRecord(record)) continue; cursorCode = Math.max(cursorCode, record.slot.charCodeAt(0)); } for (const reservation of reservations) { if ( reservation.expiresAtMs !== undefined && reservation.expiresAtMs <= nowMs ) continue; if (!reservation.slot) continue; cursorCode = Math.max(cursorCode, reservation.slot.charCodeAt(0)); } for (const provision of pendingProvisions) { if ( provision.status !== "ambiguous" && provision.expiresAtMs !== undefined && provision.expiresAtMs <= nowMs ) continue; if (!provision.slot) continue; cursorCode = Math.max(cursorCode, provision.slot.charCodeAt(0)); } } let code = cursorCode + 1; if (code > "Z".charCodeAt(0)) code = "A".charCodeAt(0); for (let attempt = 0; attempt < 26; attempt++) { const candidate = String.fromCharCode(code); if ( !isTelegramTopicTargetSlotOccupied( candidate, records, reservations, pendingProvisions, nowMs, ) ) { return candidate; } code += 1; if (code > "Z".charCodeAt(0)) code = "A".charCodeAt(0); } return undefined; } export interface TelegramTopicTargetStore { load: () => Promise; /** Discard process-local projections and reload owner-published state. */ refresh?: () => Promise; persist: () => Promise; list: () => TelegramTopicTargetRecord[]; getFollowerRecoveryHintByTarget?: ( target: TelegramTarget, ) => { slot?: string; threadName?: string } | undefined; listReservations: () => TelegramThreadReservation[]; listPendingProvisions: () => TelegramThreadPendingProvision[]; listPendingCleanups: () => TelegramThreadCleanupIntent[]; listSyncObservations: () => TelegramTopicSyncObservation[]; reserveThread: (reservation: TelegramThreadReservation) => void; upsertPendingProvision: (provision: TelegramThreadPendingProvision) => void; recordPendingProvisionTargetRecovery: ( provision: TelegramThreadPendingProvision, target: TelegramTarget & { threadId: number }, ) => Promise; removePendingProvision: (id: string) => boolean; upsertPendingCleanup: (intent: TelegramThreadCleanupIntent) => void; removePendingCleanup: (id: string) => boolean; getBotState: () => TelegramBotStateSnapshot; setBotState: (state: Partial) => void; setStatusSnapshot: (snapshot: { runtime?: Record; liveRoster?: Record; diagnostics?: Record; }) => void; getByProfileKey: ( profileKey: string, ) => TelegramTopicTargetRecord | undefined; getActiveByInstanceId: ( instanceId: string, ) => TelegramTopicTargetRecord | undefined; getIdentityByProfileKey: ( profileKey: string, ) => TelegramThreadIdentityRecord | undefined; forgetIdentityByProfileKey: (profileKey: string) => boolean; upsert: (record: TelegramTopicTargetRecord) => TelegramTopicTargetRecord; markOfflineByInstanceId: (instanceId: string) => number; markStaleByTarget: ( target: TelegramTarget, syncStatus?: TelegramTopicSyncStatus, lastSyncError?: string, ) => boolean; markActiveByTarget: (target: TelegramTarget) => boolean; renameByTarget: ( target: TelegramTarget, threadName: string, ) => TelegramTopicTargetRecord | undefined; allocateSlot: ( profileKey: string, preferredSlot?: string, ) => string | undefined; /** Claim the first reusable inactive thread for an instance, linking it to instanceId. */ claimReusableTarget: ( instanceId: string, threadName?: string, ) => TelegramTopicTargetRecord | undefined; } export function reconcileTelegramFreshAllocationCursor( store: Pick, nowMs = Date.now(), ): boolean { const currentCursor = store.getBotState().lastSlot; const slottedRecords = store .list() .filter((record) => !!record.slot && /^[A-Z]$/.test(record.slot)); if (slottedRecords.some((record) => record.slot === currentCursor)) { return false; } const latestLiveRecord = slottedRecords.reduce< TelegramTopicTargetRecord | undefined >((latest, record) => { if (!latest) return record; if (record.createdAtMs !== latest.createdAtMs) { return record.createdAtMs > latest.createdAtMs ? record : latest; } return record.updatedAtMs > latest.updatedAtMs ? record : latest; }, undefined); const nextCursor = latestLiveRecord?.slot; if (nextCursor === currentCursor) return false; store.setBotState({ lastSlot: nextCursor, updatedAtMs: nowMs, lastReconcileAction: "live-cursor-realignment", }); return true; } export interface TelegramTopicTargetStoreOptions { path: string | (() => string); getNowMs?: () => number; canPersist?: () => boolean; commitPersist?: (commit: () => void) => boolean; } export interface TelegramTopicTargetProvisionerDeps { topicChatId: number; store: Pick< TelegramTopicTargetStore, | "list" | "getByProfileKey" | "getActiveByInstanceId" | "getIdentityByProfileKey" | "forgetIdentityByProfileKey" | "upsert" | "markStaleByTarget" | "allocateSlot" | "claimReusableTarget" | "listPendingProvisions" | "upsertPendingProvision" | "recordPendingProvisionTargetRecovery" | "removePendingProvision" | "persist" >; callApi: ( method: string, body: Record, options?: TelegramApiCallOptions, ) => Promise; topicNameTemplate?: string; getNowMs?: () => number; getRandom?: () => number; getCurrentLeaderEpoch?: () => number | string | undefined; claimPendingTargets?: boolean; } export interface TelegramTopicTargetRenamerDeps { store: Pick; callApi: ( method: string, body: Record, ) => Promise; topicNameTemplate?: string; } export interface TelegramTopicTargetProvisionRequest { instanceId: string; owner?: TelegramThreadOwner; /** Legacy string key derived from `owner`; always present in memory. */ profileKey: string; threadName?: string; preferredSlot?: string; } export interface TelegramTopicTargetRenameRequest { target: TelegramTarget & { threadId: number }; threadName: string; slot?: string; } export interface TelegramTopicTargetProvisionResult { target: TelegramTarget & { threadId: number }; reused: boolean; record: TelegramTopicTargetRecord; } interface TelegramTopicResult { message_thread_id?: number; } function hashString(value: string): number { let hash = 2166136261; for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16777619); } return hash >>> 0; } function getWorkspaceHint(cwd: string | undefined): string | undefined { if (!cwd) return undefined; const parts = cwd.split("/").filter(Boolean); const last = parts.at(-1)?.trim(); if (!last) return undefined; return ( last .replace(/[^\p{L}\p{N}._-]+/gu, " ") .trim() .slice(0, 32) || undefined ); } export function createTelegramThreadName( input: TelegramThreadNameInput, ): string { const workspace = getWorkspaceHint(input.cwd); const roleMark = input.role === "leader" ? "Leader" : input.role === "follower" ? "Follower" : undefined; const slot = input.slot ? `Thread ${input.slot}` : undefined; const peerSalt = input.peers?.slice().sort().join("|") ?? ""; const fallback = `Instance ${hashString( `${input.seed}|${input.cwd ?? ""}|${input.role ?? ""}|${peerSalt}|${input.slot ?? ""}`, ) .toString(36) .slice(0, 4)}`; return ( [slot, workspace, roleMark].filter(Boolean).join(" ").slice(0, 96) || fallback ); } export function getTelegramStatePath( agentDir = resolveAgentDir(), profileName?: string, ): string { return resolveTelegramProfileTempFilePath( "state", "json", agentDir, profileName, ); } export function getTelegramTopicTargetsPath( agentDir = resolveAgentDir(), profileName?: string, ): string { return getTelegramStatePath(agentDir, profileName); } const TELEGRAM_LEADER_SESSION_HANDOFF_KEY = "__piTelegramLeaderSessionHandoff"; export const TELEGRAM_LEADER_SESSION_HANDOFF_TTL_MS = 30_000; export interface TelegramLeaderSessionHandoff { pid: number; instanceId: string; createdAtMs: number; profileKey: string; target: TelegramTarget & { threadId: number }; slot?: string; threadName?: string; } export function getTelegramLeaderSessionHandoff(): TelegramLeaderSessionHandoff | undefined { const value = (globalThis as Record)[ TELEGRAM_LEADER_SESSION_HANDOFF_KEY ]; if (!value || typeof value !== "object") return undefined; const handoff = value as Partial; if ( typeof handoff.pid !== "number" || typeof handoff.instanceId !== "string" || typeof handoff.createdAtMs !== "number" || typeof handoff.profileKey !== "string" || typeof handoff.target?.chatId !== "number" || typeof handoff.target.threadId !== "number" ) { return undefined; } return handoff as TelegramLeaderSessionHandoff; } export function setTelegramLeaderSessionHandoff( handoff: TelegramLeaderSessionHandoff | undefined, ): void { const store = globalThis as Record; if (!handoff) delete store[TELEGRAM_LEADER_SESSION_HANDOFF_KEY]; else store[TELEGRAM_LEADER_SESSION_HANDOFF_KEY] = handoff; } export function isTelegramLeaderSessionHandoffFresh( handoff: TelegramLeaderSessionHandoff | undefined, options: { pid?: number; nowMs?: number; ttlMs?: number } = {}, ): handoff is TelegramLeaderSessionHandoff { if (!handoff) return false; const pid = options.pid ?? process.pid; const nowMs = options.nowMs ?? Date.now(); const ttlMs = options.ttlMs ?? TELEGRAM_LEADER_SESSION_HANDOFF_TTL_MS; return handoff.pid === pid && nowMs - handoff.createdAtMs <= ttlMs; } export function getTelegramThreadOwnerKey(owner: TelegramThreadOwner): string { switch (owner.kind) { case "leader": { const base = owner.cwd ? `cwd:${owner.cwd}` : `leader:${owner.instanceId ?? "default"}`; return owner.telegramProfile ? `profile:${owner.telegramProfile}:${base}` : base; } case "manual-follower": return owner.telegramProfile ? `profile:${owner.telegramProfile}:manual:${owner.instanceId}` : `manual:${owner.instanceId}`; case "pending-topic": return `topic:${owner.chatId}:${owner.threadId}`; case "legacy": return `legacy:${owner.key}`; } } export function getTelegramThreadOwnerFromProfileKey( profileKey: string, ): TelegramThreadOwner { if (profileKey.startsWith("profile:")) { const [, telegramProfile, ownerKind, ...rest] = profileKey.split(":"); const value = rest.join(":"); if (ownerKind === "cwd") return { kind: "leader", cwd: value, telegramProfile }; if (ownerKind === "leader") return { kind: "leader", instanceId: value, telegramProfile }; if (ownerKind === "manual") return { kind: "manual-follower", instanceId: value, telegramProfile }; } if (profileKey.startsWith("cwd:")) return { kind: "leader", cwd: profileKey.slice(4) }; if (profileKey.startsWith("manual:")) { return { kind: "manual-follower", instanceId: profileKey.slice(7) }; } if (profileKey.startsWith("topic:")) { const [, chatIdText, threadIdText] = profileKey.split(":"); const chatId = Number(chatIdText); const threadId = Number(threadIdText); if (Number.isInteger(chatId) && Number.isInteger(threadId)) { return { kind: "pending-topic", chatId, threadId }; } } if (profileKey.startsWith("leader:")) { return { kind: "leader", instanceId: profileKey.slice(7) }; } return { kind: "legacy", key: profileKey }; } function parseThreadOwner(value: unknown): TelegramThreadOwner | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const record = value as Record; if (record.kind === "leader") { return { kind: "leader", cwd: typeof record.cwd === "string" ? record.cwd : undefined, instanceId: typeof record.instanceId === "string" ? record.instanceId : undefined, }; } if ( record.kind === "manual-follower" && typeof record.instanceId === "string" ) { return { kind: "manual-follower", instanceId: record.instanceId }; } if ( record.kind === "pending-topic" && typeof record.chatId === "number" && typeof record.threadId === "number" && Number.isInteger(record.threadId) ) { return { kind: "pending-topic", chatId: record.chatId, threadId: record.threadId, }; } if (record.kind === "legacy" && typeof record.key === "string") { return { kind: "legacy", key: record.key }; } return undefined; } function getRecordOwner( record: TelegramTopicTargetRecord, ): TelegramThreadOwner { return ( record.owner ?? getTelegramThreadOwnerFromProfileKey(record.profileKey) ); } function getRecordOwnerKey(record: TelegramTopicTargetRecord): string { return getTelegramThreadOwnerKey(getRecordOwner(record)); } function cloneRecord( record: TelegramTopicTargetRecord, ): TelegramTopicTargetRecord { const owner = getRecordOwner(record); return { ...record, owner: { ...owner }, profileKey: getTelegramThreadOwnerKey(owner), target: { ...record.target }, }; } function getPersistedThreadName( record: Record, ): string | undefined { const value = typeof record.threadName === "string" ? record.threadName : typeof record.displayName === "string" ? record.displayName : undefined; return value ? normalizeTelegramTopicTargetThreadName(value) : undefined; } function normalizeRecord( value: unknown, ): TelegramTopicTargetRecord | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const record = value as Record; const target = record.target; const owner = parseThreadOwner(record.owner) ?? (typeof record.profileKey === "string" && record.profileKey.length > 0 ? getTelegramThreadOwnerFromProfileKey(record.profileKey) : undefined); if (!owner) return undefined; if (!target || typeof target !== "object" || Array.isArray(target)) return undefined; const targetRecord = target as Record; if ( typeof targetRecord.chatId !== "number" || typeof targetRecord.threadId !== "number" || !Number.isInteger(targetRecord.threadId) ) { return undefined; } const status = record.status; if ( status !== "active" && status !== "offline" && status !== "stale" && status !== "pending" && status !== "starting" && status !== "probe-required" && status !== "failed" ) return undefined; if ( typeof record.createdAtMs !== "number" || typeof record.updatedAtMs !== "number" ) return undefined; const normalized: TelegramTopicTargetRecord = { profileKey: getTelegramThreadOwnerKey(owner), owner, target: { chatId: targetRecord.chatId, threadId: targetRecord.threadId }, status, createdAtMs: record.createdAtMs, updatedAtMs: record.updatedAtMs, threadName: getPersistedThreadName(record), instanceId: typeof record.instanceId === "string" ? record.instanceId : undefined, slot: typeof record.slot === "string" ? record.slot : undefined, }; const syncStatus = record.syncStatus ?? record.twinStatus; if ( syncStatus === "open" || syncStatus === "closed" || syncStatus === "deleted" || syncStatus === "unknown" ) { normalized.syncStatus = syncStatus; } if (typeof record.lastError === "string") normalized.lastError = record.lastError; const lastSyncObservedAtMs = record.lastSyncObservedAtMs ?? record.lastTwinObservedAtMs; if (typeof lastSyncObservedAtMs === "number") { normalized.lastSyncObservedAtMs = lastSyncObservedAtMs; } const lastSyncProbeAtMs = record.lastSyncProbeAtMs ?? record.lastTwinProbeAtMs; if (typeof lastSyncProbeAtMs === "number") { normalized.lastSyncProbeAtMs = lastSyncProbeAtMs; } const lastSyncError = record.lastSyncError ?? record.lastTwinError; if (typeof lastSyncError === "string") { normalized.lastSyncError = lastSyncError; } if (typeof record.lastReconcileAction === "string") { normalized.lastReconcileAction = record.lastReconcileAction; } if (typeof record.rerouteConfirmedAtMs === "number") { normalized.rerouteConfirmedAtMs = record.rerouteConfirmedAtMs; } return normalized; } function isCurrentThreadRecord(record: TelegramTopicTargetRecord): boolean { return ( record.status === "active" || record.status === "starting" || record.status === "pending" ); } function isPersistedThreadRecord(record: TelegramTopicTargetRecord): boolean { return isCurrentThreadRecord(record) || record.status === "probe-required"; } function normalizeIdentityRecord( value: unknown, ): TelegramThreadIdentityRecord | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const record = value as Record; if (typeof record.profileKey !== "string" || record.profileKey.length === 0) return undefined; if (typeof record.updatedAtMs !== "number") return undefined; const identity: TelegramThreadIdentityRecord = { profileKey: record.profileKey, updatedAtMs: record.updatedAtMs, }; const persistedThreadName = getPersistedThreadName(record); if (persistedThreadName) { const threadName = normalizeTelegramTopicTargetThreadName(persistedThreadName); if (threadName) identity.threadName = threadName; } if (typeof record.slot === "string" && /^[A-Z]$/.test(record.slot)) { identity.slot = record.slot; } return identity.threadName || identity.slot ? identity : undefined; } function cloneIdentityRecord( identity: TelegramThreadIdentityRecord, ): TelegramThreadIdentityRecord { return { ...identity }; } function normalizeBotStateSnapshot(value: unknown): TelegramBotStateSnapshot { if (!value || typeof value !== "object" || Array.isArray(value)) { return { threadMode: "unknown" }; } const record = value as Record; const threadMode = record.threadMode === "enabled" || record.threadMode === "disabled" ? record.threadMode : "unknown"; return { threadMode, updatedAtMs: typeof record.updatedAtMs === "number" ? record.updatedAtMs : undefined, lastSlot: typeof record.lastSlot === "string" && /^[A-Z]$/.test(record.lastSlot) ? record.lastSlot : undefined, lastReconcileAction: typeof record.lastReconcileAction === "string" ? record.lastReconcileAction : undefined, }; } function normalizeSyncObservation( value: unknown, ): TelegramTopicSyncObservation | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const record = value as Record; const targetValue = record.target; if ( !targetValue || typeof targetValue !== "object" || Array.isArray(targetValue) ) { return undefined; } const targetRecord = targetValue as Record; const target = typeof targetRecord.chatId === "number" && typeof targetRecord.threadId === "number" && Number.isInteger(targetRecord.threadId) ? { chatId: targetRecord.chatId, threadId: targetRecord.threadId } : undefined; const syncStatus = record.syncStatus; if ( !target || (syncStatus !== "open" && syncStatus !== "closed" && syncStatus !== "deleted" && syncStatus !== "unknown") ) { return undefined; } return { target, syncStatus, observedAtMs: typeof record.observedAtMs === "number" ? record.observedAtMs : 0, instanceId: typeof record.instanceId === "string" ? record.instanceId : undefined, slot: typeof record.slot === "string" ? record.slot : undefined, lastSyncError: typeof record.lastSyncError === "string" ? record.lastSyncError : undefined, lastReconcileAction: typeof record.lastReconcileAction === "string" ? record.lastReconcileAction : undefined, }; } function normalizePendingProvision( value: unknown, ): TelegramThreadPendingProvision | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const record = value as Record; const owner = record.owner; if (owner !== "leader" && owner !== "manual-follower") return undefined; if (typeof record.id !== "string" || record.id.length === 0) return undefined; if (typeof record.instanceId !== "string" || record.instanceId.length === 0) return undefined; if (typeof record.startedAtMs !== "number") return undefined; let target: (TelegramTarget & { threadId: number }) | undefined; const targetValue = record.target; if ( targetValue && typeof targetValue === "object" && !Array.isArray(targetValue) ) { const targetRecord = targetValue as Record; if ( typeof targetRecord.chatId === "number" && typeof targetRecord.threadId === "number" && Number.isInteger(targetRecord.threadId) ) { target = { chatId: targetRecord.chatId, threadId: targetRecord.threadId }; } } return { id: record.id, owner, instanceId: record.instanceId, ...(typeof record.profileKey === "string" ? { profileKey: record.profileKey } : {}), ...(record.status === "in-flight" || record.status === "ambiguous" ? { status: record.status } : {}), ...(typeof record.threadName === "string" ? { threadName: record.threadName } : {}), ...(typeof record.slot === "string" ? { slot: record.slot } : {}), ...(target ? { target } : {}), startedAtMs: record.startedAtMs, ...(typeof record.expiresAtMs === "number" ? { expiresAtMs: record.expiresAtMs } : {}), ...(typeof record.leaderEpoch === "number" || typeof record.leaderEpoch === "string" ? { leaderEpoch: record.leaderEpoch } : {}), }; } function normalizePendingCleanup( value: unknown, ): TelegramThreadCleanupIntent | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const record = value as Record; const targetValue = record.target; if ( !targetValue || typeof targetValue !== "object" || Array.isArray(targetValue) ) { return undefined; } const targetRecord = targetValue as Record; const owner = record.owner; if (owner !== "leader" && owner !== "manual-follower") return undefined; if (typeof record.id !== "string" || record.id.length === 0) return undefined; if (typeof record.instanceId !== "string" || record.instanceId.length === 0) return undefined; if ( typeof record.runtimeGeneration !== "string" || record.runtimeGeneration.length === 0 ) { return undefined; } if ( typeof targetRecord.chatId !== "number" || typeof targetRecord.threadId !== "number" || !Number.isInteger(targetRecord.threadId) || typeof record.requestedAtMs !== "number" ) { return undefined; } return { id: record.id, owner, instanceId: record.instanceId, runtimeGeneration: record.runtimeGeneration, ...(typeof record.profileKey === "string" ? { profileKey: record.profileKey } : {}), target: { chatId: targetRecord.chatId, threadId: targetRecord.threadId, }, requestedAtMs: record.requestedAtMs, }; } function normalizeReservation( value: unknown, ): TelegramThreadReservation | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const record = value as Record; const targetValue = record.target; if ( !targetValue || typeof targetValue !== "object" || Array.isArray(targetValue) ) { return undefined; } const targetRecord = targetValue as Record; const target = typeof targetRecord.chatId === "number" && typeof targetRecord.threadId === "number" && Number.isInteger(targetRecord.threadId) ? { chatId: targetRecord.chatId, threadId: targetRecord.threadId } : undefined; const slot = typeof record.slot === "string" ? record.slot : undefined; const reason = typeof record.reason === "string" ? record.reason : undefined; if (!target || !slot || !reason) return undefined; return { target, slot, reason, createdAtMs: typeof record.createdAtMs === "number" ? record.createdAtMs : 0, updatedAtMs: typeof record.updatedAtMs === "number" ? record.updatedAtMs : 0, expiresAtMs: typeof record.expiresAtMs === "number" ? record.expiresAtMs : undefined, instanceId: typeof record.instanceId === "string" ? record.instanceId : undefined, lastReconcileAction: typeof record.lastReconcileAction === "string" ? record.lastReconcileAction : undefined, }; } function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile { if (!value || typeof value !== "object" || Array.isArray(value)) { return { version: 1, source: "snapshot", writtenAtMs: 0, bot: { threadMode: "unknown" }, threads: [], }; } const file = value as Record; if (file.version !== 1) { return { version: 1, source: "snapshot", writtenAtMs: 0, bot: { threadMode: "unknown" }, threads: [], }; } const rawThreads = Array.isArray(file.threads) ? file.threads : []; const threads = rawThreads .map((record) => normalizeRecord(record)) .filter( (record): record is TelegramTopicTargetRecord => !!record && isPersistedThreadRecord(record), ); return { version: 1, source: "snapshot", writtenAtMs: typeof file.writtenAtMs === "number" ? file.writtenAtMs : 0, bot: normalizeBotStateSnapshot(file.bot), threads, identities: Array.isArray(file.identities) ? file.identities.flatMap((identity) => { const normalized = normalizeIdentityRecord(identity); return normalized ? [normalized] : []; }) : [], reservations: Array.isArray(file.reservations) ? file.reservations.flatMap((reservation) => { const normalized = normalizeReservation(reservation); return normalized ? [normalized] : []; }) : [], pendingProvisions: Array.isArray(file.pendingProvisions) ? file.pendingProvisions.flatMap((provision) => { const normalized = normalizePendingProvision(provision); return normalized ? [normalized] : []; }) : [], pendingCleanups: Array.isArray(file.pendingCleanups) ? file.pendingCleanups.flatMap((intent) => { const normalized = normalizePendingCleanup(intent); return normalized ? [normalized] : []; }) : [], syncObservations: Array.isArray(file.syncObservations) ? file.syncObservations.flatMap((observation) => { const normalized = normalizeSyncObservation(observation); return normalized ? [normalized] : []; }) : [], }; } function serializeTelegramStateSemanticSnapshot( value: unknown, ): string | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const { writtenAtMs: _writtenAtMs, ...semantic } = value as Record< string, unknown >; return JSON.stringify(semantic); } function targetMatches(left: TelegramTarget, right: TelegramTarget): boolean { return left.chatId === right.chatId && left.threadId === right.threadId; } function getTargetRecoveryHintKey(target: TelegramTarget): string { return `${target.chatId}:${target.threadId ?? "private"}`; } function parseFollowerRecoveryHints( value: unknown, ): Map { const hints = new Map(); if (!value || typeof value !== "object" || Array.isArray(value)) return hints; const liveRoster = (value as Record).liveRoster; if ( !liveRoster || typeof liveRoster !== "object" || Array.isArray(liveRoster) ) return hints; const followers = (liveRoster as Record).busFollowers; if (!Array.isArray(followers)) return hints; for (const follower of followers) { if (!follower || typeof follower !== "object" || Array.isArray(follower)) continue; const record = follower as Record; const target = record.target; if (!target || typeof target !== "object" || Array.isArray(target)) continue; const targetRecord = target as Record; if (typeof targetRecord.chatId !== "number") continue; const normalizedTarget: TelegramTarget = { chatId: targetRecord.chatId, ...(typeof targetRecord.threadId === "number" ? { threadId: targetRecord.threadId } : {}), }; const slot = typeof targetRecord.slot === "string" && /^[A-Z]$/.test(targetRecord.slot) ? targetRecord.slot : typeof record.slot === "string" && /^[A-Z]$/.test(record.slot) ? record.slot : undefined; const threadName = typeof targetRecord.threadName === "string" ? targetRecord.threadName : typeof record.threadName === "string" ? record.threadName : undefined; hints.set(getTargetRecoveryHintKey(normalizedTarget), { ...(slot ? { slot } : {}), ...(threadName ? { threadName } : {}), }); } return hints; } function getInstanceProcessKey( instanceId: string | undefined, ): string | undefined { if (!instanceId) return undefined; const [pid] = instanceId.split(":", 1); return pid && /^\d+$/.test(pid) ? pid : undefined; } function isSameProcessInstance( left: string | undefined, right: string | undefined, ): boolean { const leftProcess = getInstanceProcessKey(left); return !!leftProcess && leftProcess === getInstanceProcessKey(right); } function isPendingProvisionLiveOrTargeted( provision: TelegramThreadPendingProvision, nowMs: number, ): boolean { if (provision.status === "ambiguous") return true; if (provision.expiresAtMs === undefined || provision.expiresAtMs > nowMs) { return true; } return !!provision.target; } export function createTelegramTopicTargetStore( options: TelegramTopicTargetStoreOptions, ): TelegramTopicTargetStore { const getNowMs = options.getNowMs ?? Date.now; let botState: TelegramBotStateSnapshot = { threadMode: "unknown" }; let records = new Map(); let identities = new Map(); let reservations: TelegramThreadReservation[] = []; let pendingProvisions: TelegramThreadPendingProvision[] = []; let pendingCleanups: TelegramThreadCleanupIntent[] = []; let syncObservations: TelegramTopicSyncObservation[] = []; let followerRecoveryHints = new Map< string, { slot?: string; threadName?: string } >(); let loaded = false; let loadedPath: string | undefined; let dirty = false; let mutationRevision = 0; let statusRevision = 0; let persistQueue: Promise = Promise.resolve(); let statusSnapshot: { runtime?: Record; liveRoster?: Record; diagnostics?: Record; } = {}; const rememberSlot = (slot: string | undefined, nowMs = getNowMs()) => { if (!slot || !/^[A-Z]$/.test(slot)) return; botState = { ...botState, lastSlot: slot, updatedAtMs: nowMs }; }; const rememberIdentity = (record: TelegramTopicTargetRecord) => { const profileKey = getRecordOwnerKey(record); if (!record.threadName && !record.slot) return; identities.set(profileKey, { profileKey, ...(record.threadName ? { threadName: record.threadName } : {}), ...(record.slot ? { slot: record.slot } : {}), updatedAtMs: record.updatedAtMs, }); }; const getPath = () => typeof options.path === "function" ? options.path() : options.path; const getRecoveryPath = (path: string) => `${path}.provision-recovery.json`; const readProvisionRecoveries = ( path: string, ): TelegramProvisionRecoveryFile => { const recoveryPath = getRecoveryPath(path); if (!existsSync(recoveryPath)) return {}; try { const value = JSON.parse(readFileSync(recoveryPath, "utf8")) as unknown; return value && typeof value === "object" && !Array.isArray(value) ? (value as TelegramProvisionRecoveryFile) : {}; } catch { return {}; } }; const resetForPath = (path: string) => { if (loadedPath === path) return; botState = { threadMode: "unknown" }; records = new Map(); identities = new Map(); reservations = []; pendingProvisions = []; pendingCleanups = []; syncObservations = []; followerRecoveryHints = new Map(); statusSnapshot = {}; loaded = false; dirty = false; loadedPath = path; }; const loadFromDisk = async () => { const path = getPath(); resetForPath(path); if (!existsSync(path)) { botState = { threadMode: "unknown" }; records = new Map(); identities = new Map(); reservations = []; pendingProvisions = []; pendingCleanups = []; syncObservations = []; followerRecoveryHints = new Map(); loaded = true; return; } const content = await readFile(path, "utf8"); const rawFile: unknown = JSON.parse(content); const file = parseTopicTargetFile(rawFile); followerRecoveryHints = parseFollowerRecoveryHints(rawFile); botState = file.bot; records = new Map( file.threads.map((record) => [ getRecordOwnerKey(record), cloneRecord(record), ]), ); identities = new Map( (file.identities ?? []).map((identity) => [ identity.profileKey, cloneIdentityRecord(identity), ]), ); for (const record of records.values()) rememberIdentity(record); const nowMs = getNowMs(); reservations = (file.reservations ?? []) .filter( (reservation) => reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs, ) .map((reservation) => ({ ...reservation })); const recoveries = readProvisionRecoveries(path); pendingProvisions = (file.pendingProvisions ?? []) .filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs)) .map((provision) => { const recovery = recoveries[provision.id]; const recoveryMatches = recovery?.instanceId === provision.instanceId && recovery.profileKey === provision.profileKey && recovery.leaderEpoch === provision.leaderEpoch && Number.isInteger(recovery.target?.threadId); return { ...provision, ...(recoveryMatches ? { target: { ...recovery.target }, status: "ambiguous" as const } : provision.target ? { target: { ...provision.target } } : {}), }; }); pendingCleanups = (file.pendingCleanups ?? []).map((intent) => ({ ...intent, target: { ...intent.target }, })); syncObservations = (file.syncObservations ?? []).map((observation) => ({ ...observation, target: { ...observation.target }, })); loaded = true; dirty = false; }; const markDirty = (): void => { loaded = true; dirty = true; mutationRevision += 1; }; return { async load() { if (dirty) return; await loadFromDisk(); }, refresh() { const refresh = persistQueue.then(loadFromDisk); persistQueue = refresh.catch(() => undefined); return refresh; }, persist() { const persist = persistQueue.then(async () => { const path = getPath(); if (loadedPath !== path && !dirty) resetForPath(path); if (options.canPersist && !options.canPersist()) { await loadFromDisk(); return; } if (!dirty || !loaded) await loadFromDisk(); await mkdir(dirname(path), { recursive: true }); const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; const nowMs = getNowMs(); reservations = reservations.filter( (reservation) => reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs, ); pendingProvisions = pendingProvisions.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs), ); const currentRecords = Array.from(records.values()) .filter(isPersistedThreadRecord) .map(cloneRecord); records = new Map( currentRecords.map((record) => [ getRecordOwnerKey(record), cloneRecord(record), ]), ); const persistedRevision = mutationRevision; const persistedStatusRevision = statusRevision; const file = { version: 1, source: "snapshot", writtenAtMs: nowMs, bot: botState, ...statusSnapshot, identities: Array.from(identities.values()).map(cloneIdentityRecord), reservations: reservations.map((reservation) => ({ ...reservation })), pendingProvisions: pendingProvisions.map((provision) => ({ ...provision, ...(provision.target ? { target: { ...provision.target } } : {}), })), pendingCleanups: pendingCleanups.map((intent) => ({ ...intent, target: { ...intent.target }, })), syncObservations: syncObservations.map((observation) => ({ ...observation, target: { ...observation.target }, })), threads: currentRecords.map((record) => { const { profileKey: _profileKey, ...serialized } = record; return serialized; }), }; let persistedSemanticSnapshot: string | undefined; try { persistedSemanticSnapshot = serializeTelegramStateSemanticSnapshot( JSON.parse(await readFile(path, "utf8")), ); } catch { /* missing or invalid snapshots must be replaced */ } if ( persistedSemanticSnapshot === serializeTelegramStateSemanticSnapshot(file) && mutationRevision === persistedRevision && statusRevision === persistedStatusRevision ) { loaded = true; dirty = false; return; } await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, { encoding: "utf8", mode: 0o600, }); await chmod(tempPath, 0o600); try { if (options.commitPersist) { const committed = options.commitPersist(() => { renameSync(tempPath, path); chmodSync(path, 0o600); }); if (!committed) { await loadFromDisk(); throw new Error( "Telegram thread snapshot lost exact transport ownership before commit.", ); } } else { await rename(tempPath, path); await chmod(path, 0o600); } } catch (error) { await unlink(tempPath).catch(() => undefined); throw error; } loaded = true; if (mutationRevision === persistedRevision) dirty = false; }); persistQueue = persist.catch(() => undefined); return persist; }, list() { return Array.from(records.values()).map(cloneRecord); }, getFollowerRecoveryHintByTarget(target) { const hint = followerRecoveryHints.get(getTargetRecoveryHintKey(target)); return hint ? { ...hint } : undefined; }, listReservations() { const nowMs = getNowMs(); return reservations .filter( (reservation) => reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs, ) .map((reservation) => ({ ...reservation })); }, listPendingProvisions() { const nowMs = getNowMs(); return pendingProvisions .filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs), ) .map((provision) => ({ ...provision, ...(provision.target ? { target: { ...provision.target } } : {}), })); }, listPendingCleanups() { return pendingCleanups.map((intent) => ({ ...intent, target: { ...intent.target }, })); }, listSyncObservations() { return syncObservations.map((observation) => ({ ...observation, target: { ...observation.target }, })); }, reserveThread(reservation) { const next = { ...reservation }; reservations = reservations.filter( (existing) => existing.slot !== next.slot && !targetMatches(existing.target, next.target), ); reservations.push(next); markDirty(); }, upsertPendingProvision(provision) { const next = { ...provision, ...(provision.target ? { target: { ...provision.target } } : {}), }; pendingProvisions = pendingProvisions.filter( (existing) => existing.id !== next.id, ); pendingProvisions.push(next); markDirty(); }, async recordPendingProvisionTargetRecovery(provision, target) { const path = getPath(); const recoveryPath = getRecoveryPath(path); await mkdir(dirname(recoveryPath), { recursive: true }); withTelegramFileTransaction(`${recoveryPath}.transaction`, () => { const recoveries = readProvisionRecoveries(path); recoveries[provision.id] = { instanceId: provision.instanceId, ...(provision.profileKey ? { profileKey: provision.profileKey } : {}), ...(provision.leaderEpoch !== undefined ? { leaderEpoch: provision.leaderEpoch } : {}), target: { ...target }, }; const tempPath = `${recoveryPath}.${process.pid}.${randomUUID()}.tmp`; writeFileSync(tempPath, `${JSON.stringify(recoveries, null, 2)}\n`, { encoding: "utf8", mode: 0o600, }); renameSync(tempPath, recoveryPath); chmodSync(recoveryPath, 0o600); }); const current = pendingProvisions.find( (entry) => entry.id === provision.id && entry.instanceId === provision.instanceId && entry.profileKey === provision.profileKey && entry.leaderEpoch === provision.leaderEpoch, ); if (!current) return false; current.target = { ...target }; current.status = "ambiguous"; return true; }, removePendingProvision(id) { const before = pendingProvisions.length; pendingProvisions = pendingProvisions.filter( (provision) => provision.id !== id, ); const changed = pendingProvisions.length !== before; if (changed) markDirty(); return changed; }, upsertPendingCleanup(intent) { const next = { ...intent, target: { ...intent.target } }; pendingCleanups = pendingCleanups.filter( (existing) => existing.id !== next.id, ); pendingCleanups.push(next); markDirty(); }, removePendingCleanup(id) { const before = pendingCleanups.length; pendingCleanups = pendingCleanups.filter((intent) => intent.id !== id); const changed = pendingCleanups.length !== before; if (changed) markDirty(); return changed; }, getBotState() { return Object.fromEntries( Object.entries(botState).filter(([, value]) => value !== undefined), ) as TelegramBotStateSnapshot; }, setBotState(state) { botState = { ...botState, ...state }; markDirty(); }, setStatusSnapshot(snapshot) { if (!loadedPath) loadedPath = getPath(); statusSnapshot = { ...snapshot }; statusRevision += 1; }, getByProfileKey(profileKey) { const ownerKey = getTelegramThreadOwnerKey( getTelegramThreadOwnerFromProfileKey(profileKey), ); const record = records.get(ownerKey) ?? records.get(profileKey); return record ? cloneRecord(record) : undefined; }, getActiveByInstanceId(instanceId) { for (const record of records.values()) { if (record.instanceId !== instanceId) continue; if (record.status !== "active" && record.status !== "starting") continue; return cloneRecord(record); } return undefined; }, getIdentityByProfileKey(profileKey) { const ownerKey = getTelegramThreadOwnerKey( getTelegramThreadOwnerFromProfileKey(profileKey), ); const identity = identities.get(ownerKey) ?? identities.get(profileKey); return identity ? cloneIdentityRecord(identity) : undefined; }, forgetIdentityByProfileKey(profileKey) { const ownerKey = getTelegramThreadOwnerKey( getTelegramThreadOwnerFromProfileKey(profileKey), ); const removedOwner = identities.delete(ownerKey); const removedProfile = identities.delete(profileKey); if (!removedOwner && !removedProfile) return false; markDirty(); return true; }, upsert(record) { const next = cloneRecord(record); const nextOwnerKey = getRecordOwnerKey(next); const previousRecord = records.get(nextOwnerKey); if (isCurrentThreadRecord(next)) { for (const existing of Array.from(records.values())) { const existingOwnerKey = getRecordOwnerKey(existing); if (existingOwnerKey === nextOwnerKey) continue; if (!targetMatches(existing.target, next.target)) continue; records.delete(existingOwnerKey); } } if ( next.instanceId && (next.status === "active" || next.status === "starting") ) { for (const existing of records.values()) { if (existing.instanceId !== next.instanceId) continue; if (getRecordOwnerKey(existing) === nextOwnerKey) continue; if (targetMatches(existing.target, next.target)) continue; if (existing.status !== "active" && existing.status !== "starting") continue; records.delete(getRecordOwnerKey(existing)); } } if (!isPersistedThreadRecord(next)) { rememberIdentity(next); records.delete(nextOwnerKey); markDirty(); return cloneRecord(next); } records.set(nextOwnerKey, next); if ( !previousRecord || !targetMatches(previousRecord.target, next.target) ) { rememberSlot(next.slot, next.updatedAtMs); } rememberIdentity(next); markDirty(); return cloneRecord(next); }, markOfflineByInstanceId(instanceId) { let count = 0; for (const record of Array.from(records.values())) { if ( record.instanceId !== instanceId || (record.status !== "active" && record.status !== "starting") ) continue; records.delete(getRecordOwnerKey(record)); count += 1; } if (count > 0) markDirty(); return count; }, markStaleByTarget(target, syncStatus = "unknown", lastSyncError) { for (const record of Array.from(records.values())) { if (!targetMatches(record.target, target)) continue; const nowMs = getNowMs(); syncObservations = syncObservations.filter( (observation) => !targetMatches(observation.target, record.target), ); syncObservations.push({ target: record.target, syncStatus, observedAtMs: nowMs, ...(record.instanceId ? { instanceId: record.instanceId } : {}), ...(record.slot ? { slot: record.slot } : {}), ...(lastSyncError ? { lastSyncError } : {}), lastReconcileAction: "mark-stale", }); rememberIdentity(record); records.delete(getRecordOwnerKey(record)); markDirty(); return true; } return false; }, markActiveByTarget(target) { const nowMs = getNowMs(); for (const record of records.values()) { if (!targetMatches(record.target, target)) continue; record.status = "active"; record.updatedAtMs = nowMs; record.syncStatus = "open"; record.lastSyncObservedAtMs = nowMs; record.lastReconcileAction = "mark-active"; delete record.lastError; delete record.lastSyncError; markDirty(); return true; } return false; }, renameByTarget(target, threadName) { const nowMs = getNowMs(); const normalizedThreadName = normalizeTelegramTopicTargetThreadName(threadName); if (!normalizedThreadName) return undefined; for (const record of records.values()) { if (!targetMatches(record.target, target)) continue; record.threadName = normalizedThreadName; record.updatedAtMs = nowMs; rememberIdentity(record); markDirty(); return cloneRecord(record); } return undefined; }, claimReusableTarget(instanceId, threadName) { const nowMs = getNowMs(); const candidates = Array.from(records.values()) .filter((record) => { if (record.instanceId) return false; if (record.slot === "A") return false; if (record.status !== "pending") return false; if (!record.slot) return true; return !Array.from(records.values()).some( (other) => other !== record && other.slot === record.slot && (other.status === "active" || other.status === "starting"), ); }) .sort((left, right) => { const leftSlot = left.slot ?? "Z"; const rightSlot = right.slot ?? "Z"; if (leftSlot !== rightSlot) return leftSlot.localeCompare(rightSlot); return left.createdAtMs - right.createdAtMs; }); const record = candidates[0]; if (!record) return undefined; record.status = "active"; record.instanceId = instanceId; record.updatedAtMs = nowMs; if ( !record.threadName && threadName && isTelegramTopicThreadNameValidForSlot(threadName, record.slot) ) record.threadName = threadName; delete record.lastError; rememberIdentity(record); markDirty(); return cloneRecord(record); }, allocateSlot(profileKey, preferredSlot) { const ownerKey = getTelegramThreadOwnerKey( getTelegramThreadOwnerFromProfileKey(profileKey), ); const existing = records.get(ownerKey) ?? records.get(profileKey); if (existing?.slot && isCurrentThreadRecord(existing)) { return existing.slot; } const nowMs = getNowMs(); if ( preferredSlot && !isTelegramTopicTargetSlotOccupied( preferredSlot, records, reservations, pendingProvisions, nowMs, ) ) { return preferredSlot; } return getNextMonotonicSlot( records, reservations, pendingProvisions, nowMs, botState.lastSlot, ); }, }; } function isTelegramTopicTargetSlotOccupied( slot: string, records: Map, reservations: readonly TelegramThreadReservation[] = [], pendingProvisions: readonly TelegramThreadPendingProvision[] = [], nowMs = Date.now(), ): boolean { for (const record of records.values()) { if (record.slot === slot && isCurrentThreadRecord(record)) return true; } for (const reservation of reservations) { if ( reservation.expiresAtMs !== undefined && reservation.expiresAtMs <= nowMs ) continue; if (reservation.slot === slot) return true; } for (const provision of pendingProvisions) { if ( provision.status !== "ambiguous" && provision.expiresAtMs !== undefined && provision.expiresAtMs <= nowMs ) continue; if (provision.slot === slot) return true; } return false; } export function normalizeTelegramTopicTargetThreadName( threadName: string, ): string { return threadName.replace(/\s+/g, " ").trim().slice(0, 96); } function getGraphemeSegments(value: string): string[] { const segmenter = ( Intl as unknown as { Segmenter?: new ( locale?: string, options?: { granularity: "grapheme" }, ) => { segment(input: string): Iterable<{ segment: string }> }; } ).Segmenter; if (!segmenter) return Array.from(value); return Array.from( new segmenter(undefined, { granularity: "grapheme" }).segment(value), (part) => part.segment, ); } export function getTelegramTopicIdentityName(threadName: string): string { return getGraphemeSegments(normalizeTelegramTopicTargetThreadName(threadName)) .join("") .trim(); } const TELEGRAM_THREAD_NAME_PALETTE: Record = { A: ["Atlas", "Aster", "Aurora", "Anchor", "Ashen"], B: ["Beacon", "Briar", "Boreal", "Birch", "Bison"], C: ["Cedar", "Comet", "Cipher", "Coral", "Cinder"], D: ["Delta", "Dawn", "Drift", "Dune", "Dagger"], E: ["Ember", "Echo", "Eagle", "Eden", "Elder"], F: ["Falcon", "Fjord", "Flint", "Forest", "Fable"], G: ["Grove", "Glade", "Glyph", "Garnet", "Gale"], H: ["Harbor", "Hawk", "Hazel", "Helix", "Haven"], I: ["Iris", "Ivory", "Iron", "Isle", "Idea"], J: ["Jade", "Juno", "Jolt", "Jewel", "Jasper"], K: ["Kite", "Karma", "Kernel", "Kodiak", "Kelp"], L: ["Lumen", "Laurel", "Lynx", "Lotus", "Lagoon"], M: ["Maple", "Meteor", "Meadow", "Marble", "Moss"], N: ["Nimbus", "Nova", "Nectar", "North", "Noble"], O: ["Orion", "Onyx", "Opal", "Orbit", "Olive"], P: ["Pine", "Pulse", "Praxis", "Pebble", "Prism"], Q: ["Quartz", "Quill", "Quasar", "Quest", "Quiver"], R: ["River", "Raven", "Rune", "Reef", "Ridge"], S: ["Spruce", "Solar", "Signal", "Stone", "Sable"], T: ["Timber", "Talon", "Terra", "Torch", "Tide"], U: ["Umber", "Unity", "Ursa", "Uplink", "Ulmus"], V: ["Violet", "Vector", "Vista", "Vale", "Vortex"], W: ["Willow", "Warden", "Wave", "Winter", "Wisp"], X: ["Xenon", "Xylem", "Xavier", "Xylo", "Xerus"], Y: ["Yarrow", "Yonder", "Yukon", "Yale", "Yogi"], Z: ["Zenith", "Zephyr", "Zircon", "Zebra", "Zion"], }; export function chooseTelegramThreadName(input: { slot: string | undefined; entropy?: number | string; getRandom?: () => number; }): string | undefined { if (!input.slot || !/^[A-Z]$/.test(input.slot)) return undefined; const names = TELEGRAM_THREAD_NAME_PALETTE[input.slot]; if (!names || names.length === 0) return undefined; const index = input.getRandom ? Math.max( 0, Math.min( names.length - 1, Math.floor(input.getRandom() * names.length), ), ) : getTelegramThreadNameEntropyIndex(input.entropy, names.length); return names[index]; } function getTelegramThreadNameLeadingSlot( threadName: string | undefined, ): string | undefined { if (!threadName) return undefined; const first = getTelegramTopicIdentityName(threadName)[0]; return first && /^[A-Z]$/.test(first) ? first : undefined; } function isTelegramSlotOccupiedByOtherCurrentRecord( records: readonly TelegramTopicTargetRecord[], slot: string, currentRecord: TelegramTopicTargetRecord, ): boolean { return records.some( (record) => !targetMatches(record.target, currentRecord.target) && isCurrentThreadRecord(record) && record.slot === slot, ); } function normalizeCurrentThreadNameSlots( store: Pick, ): void { for (const record of store.list()) { if (!isCurrentThreadRecord(record)) continue; const slot = getTelegramThreadNameLeadingSlot(record.threadName); if (!slot || record.slot === slot) continue; if (isTelegramSlotOccupiedByOtherCurrentRecord(store.list(), slot, record)) continue; store.upsert({ ...record, slot }); } } function getNextTelegramThreadNamePaletteSlot( records: readonly TelegramTopicTargetRecord[], fallbackSlot: string | undefined, ): string | undefined { let maxCode = "A".charCodeAt(0) - 1; for (const record of records) { if (!isCurrentThreadRecord(record) || !record.threadName) continue; const identity = getTelegramTopicIdentityName(record.threadName); const first = identity[0]; if (!first || !/^[A-Z]$/.test(first)) continue; maxCode = Math.max(maxCode, first.charCodeAt(0)); } if (maxCode < "A".charCodeAt(0)) return fallbackSlot; let code = maxCode + 1; if (code > "Z".charCodeAt(0)) code = "A".charCodeAt(0); return String.fromCharCode(code); } function getTelegramThreadNameEntropyIndex( entropy: number | string | undefined, length: number, ): number { if (length <= 1) return 0; if (typeof entropy === "number" && entropy < 1_000_000_000_000) return 0; const value = entropy === undefined ? "0" : String(entropy); let hash = 2166136261; for (const char of value) { hash ^= char.codePointAt(0) ?? 0; hash = Math.imul(hash, 16777619) >>> 0; } return hash % length; } export function getTelegramTopicThreadNameValidationError( threadName: string, _slot: string | undefined, ): string | undefined { const identity = getTelegramTopicIdentityName(threadName); const reasons: string[] = []; if (!identity) reasons.push("it is empty after trimming"); if (/\s/.test(identity)) reasons.push("it contains spaces"); if (/[^A-Za-z]/.test(identity)) { reasons.push("it contains characters outside Latin A-Z letters"); } if (!/^[A-Z]/.test(identity)) { reasons.push("it does not start with an uppercase Latin letter"); } const genericLabels = new Set(["telegram", "leader", "follower"]); if (genericLabels.has(identity.toLowerCase())) { reasons.push("it is a generic role label"); } if (/^[A-Z]$/.test(identity)) { reasons.push("it is only a bare slot letter"); } if (reasons.length === 0) return undefined; return `Invalid Telegram instance name: ${reasons.join("; ")}. Use exactly one capitalized Latin word with no spaces, punctuation, emoji, non-Latin letters, or digits; it must not be a generic role label or only a bare slot letter.`; } export function isTelegramTopicThreadNameValidForSlot( threadName: string, slot: string | undefined, ): boolean { return !getTelegramTopicThreadNameValidationError(threadName, slot); } function applyTopicNameTemplate( template: string, request: TelegramTopicTargetProvisionRequest, slot?: string, ): string { const threadName = request.threadName?.replace(/\s+/g, " ").trim() || request.profileKey; let result = template .replaceAll("{threadName}", threadName) .replaceAll("{profileKey}", request.profileKey) .replaceAll("{instanceId}", request.instanceId); if (slot) result = result.replaceAll("{slot}", slot); return result; } export function getTelegramTopicName( request: TelegramTopicTargetProvisionRequest, template = "{slot}", slot?: string, ): string { const name = applyTopicNameTemplate(template, request, slot) .replace(/\s+/g, " ") .trim(); return (name || slot || "Pi").slice(0, 128); } function asInteger(value: unknown): number | undefined { if (typeof value === "number" && Number.isInteger(value)) return value; if (typeof value !== "string" || value.trim() === "") return undefined; const parsed = Number(value); return Number.isInteger(parsed) ? parsed : undefined; } export interface TelegramPromoteFollowerBindingToLeaderDeps { store: TelegramTopicTargetStore; instanceId: string; cwd?: string; telegramProfile?: string; target?: TelegramTarget; slot?: string; threadName?: string; nowMs?: number; } export async function promoteTelegramFollowerBindingToLeader( deps: TelegramPromoteFollowerBindingToLeaderDeps, ): Promise { const target = deps.target; if (typeof target?.threadId !== "number") return undefined; await deps.store.load(); const nowMs = deps.nowMs ?? Date.now(); const existing = deps.store .list() .find( (record) => record.target.chatId === target.chatId && record.target.threadId === target.threadId, ); const owner: TelegramThreadOwner = { kind: "leader", cwd: deps.cwd, instanceId: deps.instanceId, ...(deps.telegramProfile ? { telegramProfile: deps.telegramProfile } : {}), }; const record = deps.store.upsert({ profileKey: getTelegramThreadOwnerKey(owner), owner, target: { chatId: target.chatId, threadId: target.threadId }, status: "active", createdAtMs: existing?.createdAtMs ?? nowMs, updatedAtMs: nowMs, ...((existing?.threadName ?? deps.threadName) ? { threadName: existing?.threadName ?? deps.threadName } : {}), instanceId: deps.instanceId, ...((existing?.slot ?? deps.slot) ? { slot: existing?.slot ?? deps.slot } : {}), ...(existing?.syncStatus ? { syncStatus: existing.syncStatus } : {}), ...(existing?.lastSyncObservedAtMs !== undefined ? { lastSyncObservedAtMs: existing.lastSyncObservedAtMs } : {}), lastReconcileAction: "follower-promoted-to-leader", ...(existing?.rerouteConfirmedAtMs !== undefined ? { rerouteConfirmedAtMs: existing.rerouteConfirmedAtMs } : {}), }); await deps.store.persist(); return record; } export interface TelegramOwnTopicProvisionDeps { getAllowedUserId: () => number | undefined; instanceId: string; cwd?: string; telegramProfile?: string; getNowMs?: () => number; getRandom?: () => number; getCurrentLeaderEpoch?: () => number | string | undefined; getThreadReconciliationMachineState?: () => ThreadReconciler.ThreadReconciliationMachineState | undefined; recordThreadReconciliationPlan?: ( plan: ThreadReconciler.ThreadReconciliationPlan, ) => void; store: TelegramTopicTargetStore; callApi: ( method: string, body: Record, ) => Promise; recordEvent: ( category: string, message: string, details?: Record, ) => void; } export interface TelegramOwnTopicProvisionResult { target: TelegramTarget & { threadId: number }; slot: string; threadName?: string; reused: boolean; } /** * Provision a topic for the bus leader's own use (slot A). * This is a thread-binding primitive; sync policy decides when startup/connect * should call it to ensure the leader has a visible working thread. */ export async function provisionOwnBusTopic( deps: TelegramOwnTopicProvisionDeps, ): Promise { const chatId = deps.getAllowedUserId(); let profileKey = getTelegramThreadOwnerKey({ kind: "leader", cwd: deps.cwd, instanceId: deps.instanceId, telegramProfile: deps.telegramProfile, }); if (typeof chatId !== "number") return undefined; await deps.store.load(); const reservationCleanupPorts = { callApi: deps.callApi, markStaleByTarget: ( target: TelegramTarget & { threadId: number }, syncStatus?: "closed" | "deleted", lastSyncError?: string, ) => deps.store.markStaleByTarget(target, syncStatus, lastSyncError), removePendingProvisionById: (id: string) => deps.store.removePendingProvision(id), persist: () => deps.store.persist(), getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch, recordRuntimeEvent( category: string, error: unknown, details?: Record, ) { deps.recordEvent( category, error instanceof Error ? error.message : String(error), details, ); }, }; const reservationCleanupNowMs = Date.now(); const reservationsBeforeCleanup = deps.store.listReservations(); const reservationCleanupPlan = ThreadReconciler.planThreadReconciliation({ nowMs: reservationCleanupNowMs, currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(), previousState: deps.getThreadReconciliationMachineState?.(), records: deps.store.list(), reservations: reservationsBeforeCleanup, pendingProvisions: deps.store.listPendingProvisions(), proactiveReservationCleanup: true, }); deps.recordThreadReconciliationPlan?.(reservationCleanupPlan); const reservationCleanupApplyStartedAtMs = Date.now(); await ThreadReconciler.applyThreadReconciliationPlan( reservationCleanupPlan, reservationCleanupPorts, ); deps.recordEvent("bus", "Bus leader reservation cleanup reconciled", { phase: "leader-topic-reservation-cleanup-duration", durationMs: Date.now() - reservationCleanupApplyStartedAtMs, actions: reservationCleanupPlan.actions.length, }); const nowMs = Date.now(); const currentLeaderOwner: TelegramThreadOwner = { kind: "leader", cwd: deps.cwd, instanceId: deps.instanceId, ...(deps.telegramProfile ? { telegramProfile: deps.telegramProfile } : {}), }; const leaderSessionHandoff = getTelegramLeaderSessionHandoff(); if ( isTelegramLeaderSessionHandoffFresh(leaderSessionHandoff) && leaderSessionHandoff.profileKey === profileKey ) { const existingHandoffRecord = deps.store .list() .find((record) => targetMatches(record.target, leaderSessionHandoff.target), ); deps.store.upsert({ profileKey, owner: currentLeaderOwner, target: { ...leaderSessionHandoff.target }, status: "active", createdAtMs: existingHandoffRecord?.createdAtMs ?? leaderSessionHandoff.createdAtMs, updatedAtMs: nowMs, threadName: existingHandoffRecord?.threadName ?? leaderSessionHandoff.threadName, instanceId: deps.instanceId, slot: existingHandoffRecord?.slot ?? leaderSessionHandoff.slot, ...(existingHandoffRecord?.syncStatus ? { syncStatus: existingHandoffRecord.syncStatus } : {}), ...(existingHandoffRecord?.lastSyncObservedAtMs !== undefined ? { lastSyncObservedAtMs: existingHandoffRecord.lastSyncObservedAtMs, } : {}), lastReconcileAction: "leader-session-handoff-restored", }); await deps.store.persist(); setTelegramLeaderSessionHandoff(undefined); deps.recordEvent("bus", "Bus leader session handoff restored", { phase: "leader-session-handoff-restore", chatId: leaderSessionHandoff.target.chatId, threadId: leaderSessionHandoff.target.threadId, slot: existingHandoffRecord?.slot ?? leaderSessionHandoff.slot, threadName: existingHandoffRecord?.threadName ?? leaderSessionHandoff.threadName, previousInstanceId: leaderSessionHandoff.instanceId, instanceId: deps.instanceId, }); } else if (leaderSessionHandoff) { setTelegramLeaderSessionHandoff(undefined); } const recordsBeforePreviousLeaderCleanup = deps.store.list(); const previousLeaderCleanupPlan = ThreadReconciler.planThreadReconciliation({ nowMs, currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(), previousState: deps.getThreadReconciliationMachineState?.(), records: recordsBeforePreviousLeaderCleanup.map((record) => ({ ...record, ownerKind: record.owner?.kind, })), pendingProvisions: deps.store.listPendingProvisions(), previousLeaderCleanup: { currentInstanceId: deps.instanceId }, }); deps.recordThreadReconciliationPlan?.(previousLeaderCleanupPlan); for (const action of previousLeaderCleanupPlan.actions) { if (action.kind !== "close-delete-previous-leader-topic") continue; const record = recordsBeforePreviousLeaderCleanup.find((candidate) => targetMatches(candidate.target, action.target), ); if (!record) continue; const isSameProfile = record.profileKey === profileKey; if (isSameProfile) { deps.recordEvent("bus", "Bus leader same-profile topic preserved", { phase: "leader-topic-same-profile-preserve", chatId: record.target.chatId, threadId: record.target.threadId, slot: record.slot, previousInstanceId: record.instanceId, instanceId: deps.instanceId, profileKey, }); continue; } if (isSameProcessInstance(record.instanceId, deps.instanceId)) { deps.store.upsert({ ...record, profileKey, owner: currentLeaderOwner, status: "active", instanceId: deps.instanceId, updatedAtMs: nowMs, lastError: undefined, lastReconcileAction: "leader-topic-same-process-preserve", }); deps.recordEvent("bus", "Bus leader same-process topic preserved", { phase: "leader-topic-same-process-preserve", chatId: record.target.chatId, threadId: record.target.threadId, slot: record.slot, previousInstanceId: record.instanceId, instanceId: deps.instanceId, profileKey, }); continue; } const previousLeaderCleanupStartedAtMs = Date.now(); const cleanup = await ThreadReconciler.applyThreadReconciliationPlan( { actions: [action] }, { callApi: deps.callApi, markStaleByTarget: (target, syncStatus, lastSyncError) => deps.store.markStaleByTarget(target, syncStatus, lastSyncError), persist: () => deps.store.persist(), removePendingProvisionById: (id) => deps.store.removePendingProvision(id), getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch, recordRuntimeEvent(category, error, details) { deps.recordEvent( category, error instanceof Error ? error.message : String(error), details, ); }, }, ); deps.recordEvent("bus", "Bus leader previous-topic cleanup applied", { phase: "leader-topic-previous-cleanup-duration", durationMs: Date.now() - previousLeaderCleanupStartedAtMs, chatId: record.target.chatId, threadId: record.target.threadId, slot: record.slot, }); if ( deps.getCurrentLeaderEpoch && (action.leaderEpoch === undefined || deps.getCurrentLeaderEpoch() !== action.leaderEpoch) ) { deps.recordEvent( "bus", "Skipped previous-topic local cleanup after leader epoch loss", { phase: "leader-topic-previous-cleanup-stale-epoch-skip", actionLeaderEpoch: action.leaderEpoch, currentLeaderEpoch: deps.getCurrentLeaderEpoch(), chatId: record.target.chatId, threadId: record.target.threadId, }, ); throw new Error( "Telegram leader ownership changed during topic reconciliation.", ); } if (cleanup.incompleteActions?.length) { throw new Error( "Previous Telegram leader topic deletion was not confirmed.", ); } deps.store.markStaleByTarget(record.target); deps.store.reserveThread({ target: record.target, slot: record.slot ?? "A", reason: "previous-process-cleaned-without-visible-probe", createdAtMs: nowMs, updatedAtMs: nowMs, expiresAtMs: nowMs + TELEGRAM_THREAD_RESERVATION_TTL_MS, instanceId: record.instanceId, lastReconcileAction: "leader-topic-previous-instance-cleaned-no-probe", }); deps.store.setBotState({ threadMode: "enabled", updatedAtMs: nowMs, lastReconcileAction: "leader-topic-next-slot-after-unprobed-previous", }); deps.recordEvent( "bus", "Bus leader previous-process topic reserved after cleanup without visible probe", { phase: "leader-topic-previous-instance-reserve-no-probe", chatId: record.target.chatId, threadId: record.target.threadId, slot: record.slot, previousInstanceId: record.instanceId, instanceId: deps.instanceId, }, ); } const provision = createTelegramTopicTargetProvisioner({ topicChatId: chatId, store: deps.store, callApi: deps.callApi, getNowMs: deps.getNowMs, getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch, getRandom: deps.getRandom, claimPendingTargets: false, }); let result = await provision({ instanceId: deps.instanceId, owner: currentLeaderOwner, profileKey, }); if (result.reused) { // Reused topics may already have a human-chosen Telegram title. Do not edit // them during leader startup: startup reconciliation must not reset a named // topic back to its bare slot or create redundant "renamed the thread" service // messages. Also do not probe with Bot API chat actions: every chat action is // user-visible as native typing/activity, so reload would falsely signal that // the agent is working. Treat the reused binding as optimistically open; // ordinary target-scoped sends still detect stale topics and trigger the // stale-api-error reconciliation path when real delivery happens. const nowMs = Date.now(); deps.store.upsert({ ...result.record, syncStatus: "open", lastSyncObservedAtMs: nowMs, lastReconcileAction: "leader-startup-skip-probe", }); } deps.store.setBotState({ threadMode: "enabled", updatedAtMs: Date.now(), lastReconcileAction: result.reused ? "leader-startup-skip-probe" : "leader-topic-created", }); await deps.store.persist(); deps.recordEvent("bus", "Bus leader own topic assigned", { phase: "leader-topic", chatId: result.target.chatId, threadId: result.target.threadId, slot: result.record.slot, threadName: result.record.threadName, reused: result.reused, }); return { target: result.target, slot: result.record.slot ?? "A", ...(result.record.threadName ? { threadName: result.record.threadName } : {}), reused: result.reused, }; } export interface TelegramInstanceThreadIdentityCandidate { target?: TelegramTarget; slot?: string; threadName?: string; } export function resolveTelegramInstanceThreadIdentity(options: { target?: TelegramTarget; follower?: TelegramInstanceThreadIdentityCandidate; leader?: TelegramInstanceThreadIdentityCandidate; record?: TelegramTopicTargetRecord; }): TelegramInstanceThreadIdentityCandidate { const targetMatchesCandidate = ( candidate: TelegramInstanceThreadIdentityCandidate | undefined, ) => { if (!candidate) return false; if (!options.target) return true; return ( !!candidate.target && targetMatches(candidate.target, options.target) ); }; const local = targetMatchesCandidate(options.follower) ? options.follower : targetMatchesCandidate(options.leader) ? options.leader : undefined; const record = options.record && (!options.target || targetMatches(options.record.target, options.target)) ? options.record : undefined; return { ...((local?.target ?? record?.target) ? { target: local?.target ?? record?.target } : {}), ...((local?.slot ?? record?.slot) ? { slot: local?.slot ?? record?.slot } : {}), ...((local?.threadName ?? record?.threadName) ? { threadName: local?.threadName ?? record?.threadName } : {}), }; } export interface TelegramLeaderThreadStateRuntime { getTarget(): TelegramTarget | undefined; getIdentity(): TelegramInstanceThreadIdentityCandidate | undefined; set( input: TelegramInstanceThreadIdentityCandidate & { target: TelegramTarget }, ): void; clear(): void; } export function createTelegramLeaderThreadStateRuntime(): TelegramLeaderThreadStateRuntime { let identity: | (TelegramInstanceThreadIdentityCandidate & { target: TelegramTarget }) | undefined; return { getTarget: () => identity?.target, getIdentity: () => identity, set(input) { identity = { ...input, target: { ...input.target } }; }, clear() { identity = undefined; }, }; } export interface TelegramCurrentInstanceThreadRuntime { findRecord(): TelegramTopicTargetRecord | undefined; getRecord(): TelegramTopicTargetRecord | undefined; getIdentity(target?: TelegramTarget): TelegramInstanceThreadIdentityCandidate; getRestorationIdentity(): TelegramInstanceThreadIdentityCandidate; } export interface TelegramCurrentInstanceThreadRuntimeDeps { instanceId: string; listRecords(): readonly TelegramTopicTargetRecord[]; getPreferredTarget(): TelegramTarget | undefined; getFollower(): | (TelegramInstanceThreadIdentityCandidate & { registered: boolean }) | undefined; getLeader(): TelegramInstanceThreadIdentityCandidate | undefined; } export function createTelegramCurrentInstanceThreadRuntime( deps: TelegramCurrentInstanceThreadRuntimeDeps, ): TelegramCurrentInstanceThreadRuntime { const findRecord = function (): TelegramTopicTargetRecord | undefined { return findCurrentTelegramInstanceThreadRecord({ records: deps.listRecords(), instanceId: deps.instanceId, preferredTarget: deps.getPreferredTarget(), }); }; const getRecord = function (): TelegramTopicTargetRecord | undefined { const record = findRecord(); const follower = deps.getFollower(); if (record?.owner?.kind === "manual-follower" && !follower?.registered) { return undefined; } return record; }; return { findRecord, getRecord, getIdentity(target) { const follower = deps.getFollower(); const record = target ? findCurrentTelegramInstanceThreadRecord({ records: deps.listRecords(), instanceId: deps.instanceId, preferredTarget: target, }) : getRecord(); return resolveTelegramInstanceThreadIdentity({ target, follower: follower?.registered ? follower : undefined, leader: deps.getLeader(), record, }); }, getRestorationIdentity() { const follower = deps.getFollower(); return resolveTelegramInstanceThreadIdentity({ follower: follower?.registered ? follower : undefined, leader: deps.getLeader(), record: findRecord(), }); }, }; } export function findCurrentTelegramInstanceThreadRecord(options: { records: readonly TelegramTopicTargetRecord[]; instanceId: string; preferredTarget?: TelegramTarget; }): TelegramTopicTargetRecord | undefined { const target = options.preferredTarget; if (typeof target?.threadId === "number") { const targetRecord = options.records.find((record) => { return ( record.target.chatId === target.chatId && record.target.threadId === target.threadId ); }); if (targetRecord) return targetRecord; } return options.records.find((record) => { return ( record.instanceId === options.instanceId && record.status === "active" ); }); } export function resolveTelegramInstanceThreadTarget(options: { followerTarget?: TelegramTarget; leaderTarget?: TelegramTarget; currentRecord?: TelegramTopicTargetRecord; }): (TelegramTarget & { threadId: number }) | undefined { const raw = typeof options.followerTarget?.threadId === "number" ? options.followerTarget : (options.currentRecord?.target ?? options.leaderTarget); return raw && typeof raw.chatId === "number" && typeof raw.threadId === "number" ? { chatId: raw.chatId, threadId: raw.threadId } : undefined; } export interface TelegramThreadStatusProjectionRuntime { getBusRole(): "leader" | "follower" | undefined; getBusFollowers(): ReturnType; getLocalBus(): { leaderSocketPath: string; leaderTransport: "socket" | "pipe"; followerSocketPath: string; followerTransport: "socket" | "pipe"; followerRegistered: boolean; followerTarget?: TelegramTarget; followerSlot?: string; followerThreadName?: string; leaderProtocol?: { protocolVersion: number; runtimeBuild: string; capabilities: string[]; }; }; getTopicTargets(): ReturnType; getThreadReservations(): ReturnType< typeof listTelegramThreadStatusReservations >; getTopicSyncObservations(): ReturnType< typeof listTelegramThreadStatusObservations >; getInstanceSlot(): string | undefined; getInstanceThreadName(): string | undefined; } export interface TelegramThreadStatusProjectionRuntimeDeps { getThreadMode(): "unknown" | "enabled" | "disabled"; isBusPollingStarted(): boolean; isFollowerRegistered(): boolean; listFollowers(): readonly TelegramThreadStatusFollowerView[]; listRecords(): readonly TelegramTopicTargetRecord[]; listReservations(): readonly TelegramThreadReservation[]; listSyncObservations(): readonly TelegramTopicSyncObservation[]; getLeaderSocketPath(): string; getFollowerSocketPath(): string; getTransportKind(path: string): "socket" | "pipe"; getFollowerTarget(): TelegramTarget | undefined; getFollowerSlot(): string | undefined; getFollowerThreadName(): string | undefined; getLeaderProtocol?(): | { protocolVersion: number; runtimeBuild: string; capabilities: string[]; } | undefined; getCurrentIdentity(): TelegramInstanceThreadIdentityCandidate; } export function createTelegramThreadStatusProjectionRuntime( deps: TelegramThreadStatusProjectionRuntimeDeps, ): TelegramThreadStatusProjectionRuntime { return { getBusRole() { if (deps.getThreadMode() === "disabled") return undefined; if (deps.isBusPollingStarted()) return "leader"; return deps.isFollowerRegistered() ? "follower" : undefined; }, getBusFollowers() { return listTelegramThreadStatusFollowers({ followers: deps.listFollowers(), records: deps.listRecords(), }); }, getLocalBus() { const leaderSocketPath = deps.getLeaderSocketPath(); const followerSocketPath = deps.getFollowerSocketPath(); const leaderProtocol = deps.getLeaderProtocol?.(); return { leaderSocketPath, leaderTransport: deps.getTransportKind(leaderSocketPath), followerSocketPath, followerTransport: deps.getTransportKind(followerSocketPath), followerRegistered: deps.isFollowerRegistered(), followerTarget: deps.getFollowerTarget(), followerSlot: deps.getFollowerSlot(), followerThreadName: deps.getFollowerThreadName(), ...(leaderProtocol ? { leaderProtocol } : {}), }; }, getTopicTargets: () => listTelegramThreadStatusTargets(deps.listRecords()), getThreadReservations: () => listTelegramThreadStatusReservations(deps.listReservations()), getTopicSyncObservations: () => listTelegramThreadStatusObservations(deps.listSyncObservations()), getInstanceSlot() { if (deps.getThreadMode() === "disabled") return undefined; return deps.getCurrentIdentity().slot; }, getInstanceThreadName() { if (deps.getThreadMode() === "disabled") return undefined; return deps.getCurrentIdentity().threadName; }, }; } export interface TelegramCurrentThreadAssemblyDeps { instanceId: string; listRecords: TelegramCurrentInstanceThreadRuntimeDeps["listRecords"]; getActiveTurnTarget(): TelegramTarget | undefined; getFollowerTarget(): TelegramTarget | undefined; isFollowerRegistered(): boolean; getFollowerSlot(): string | undefined; getFollowerThreadName(): string | undefined; getLeaderIdentity(): TelegramInstanceThreadIdentityCandidate | undefined; getLeaderTarget(): TelegramTarget | undefined; getLeaderProtocol?: TelegramThreadStatusProjectionRuntimeDeps["getLeaderProtocol"]; status: Pick< TelegramThreadStatusProjectionRuntimeDeps, | "getThreadMode" | "isBusPollingStarted" | "listFollowers" | "listReservations" | "listSyncObservations" | "getLeaderSocketPath" | "getFollowerSocketPath" | "getTransportKind" >; } export interface TelegramCurrentThreadAssembly { current: TelegramCurrentInstanceThreadRuntime; status: TelegramThreadStatusProjectionRuntime; } /** Own current-thread preference and its matching status projection. */ export function createTelegramCurrentThreadAssembly( deps: TelegramCurrentThreadAssemblyDeps, ): TelegramCurrentThreadAssembly { const getFollower = () => { const target = deps.getFollowerTarget(); if (!target) return undefined; return { registered: deps.isFollowerRegistered(), target, slot: deps.getFollowerSlot(), threadName: deps.getFollowerThreadName(), }; }; const current = createTelegramCurrentInstanceThreadRuntime({ instanceId: deps.instanceId, listRecords: deps.listRecords, getPreferredTarget: () => deps.getActiveTurnTarget() ?? deps.getFollowerTarget() ?? deps.getLeaderTarget(), getFollower, getLeader: deps.getLeaderIdentity, }); return { current, status: createTelegramThreadStatusProjectionRuntime({ ...deps.status, isFollowerRegistered: deps.isFollowerRegistered, listRecords: deps.listRecords, getFollowerTarget: deps.getFollowerTarget, getFollowerSlot: deps.getFollowerSlot, getFollowerThreadName: deps.getFollowerThreadName, getLeaderProtocol: deps.getLeaderProtocol, getCurrentIdentity: current.getRestorationIdentity, }), }; } export interface TelegramThreadStatusFollowerView { instanceId: string; cwd?: string; lastHeartbeatMs: number; target?: TelegramTarget; protocol?: { protocolVersion: number; runtimeBuild: string; capabilities: string[]; }; } function getTelegramThreadStatusName( record: TelegramTopicTargetRecord | undefined, ): string | undefined { if (!record) return undefined; if ( record.threadName && isTelegramTopicThreadNameValidForSlot(record.threadName, record.slot) ) return record.threadName; return chooseTelegramThreadName({ slot: record.slot }); } export function listTelegramThreadStatusFollowers(options: { followers: readonly TelegramThreadStatusFollowerView[]; records: readonly TelegramTopicTargetRecord[]; }): Array<{ instanceId: string; cwd?: string; lastHeartbeatMs: number; target?: TelegramTarget; protocol?: { protocolVersion: number; runtimeBuild: string; capabilities: string[]; }; slot?: string; threadName?: string; status?: string; }> { return options.followers.map((follower) => { const record = options.records.find((record) => { return ( record.target.chatId === follower.target?.chatId && record.target.threadId === follower.target?.threadId ); }); return { instanceId: follower.instanceId, cwd: follower.cwd, lastHeartbeatMs: follower.lastHeartbeatMs, target: follower.target, ...(follower.protocol ? { protocol: follower.protocol } : {}), slot: record?.slot, threadName: getTelegramThreadStatusName(record), status: record?.status, }; }); } export function listTelegramThreadStatusTargets( records: readonly TelegramTopicTargetRecord[], ): Array<{ instanceId?: string; status: TelegramTopicTargetStatus; target: TelegramTarget & { threadId: number }; slot?: string; threadName?: string; syncStatus?: TelegramTopicSyncStatus; lastSyncObservedAtMs?: number; lastSyncProbeAtMs?: number; lastSyncError?: string; lastReconcileAction?: string; }> { return records.map((record) => { return { instanceId: record.instanceId, status: record.status, target: record.target, slot: record.slot, threadName: getTelegramThreadStatusName(record), syncStatus: record.syncStatus, lastSyncObservedAtMs: record.lastSyncObservedAtMs, lastSyncProbeAtMs: record.lastSyncProbeAtMs, lastSyncError: record.lastSyncError, lastReconcileAction: record.lastReconcileAction, }; }); } export function listTelegramThreadStatusReservations( reservations: readonly TelegramThreadReservation[], ): Array<{ target: TelegramTarget & { threadId: number }; slot: string; reason: string; instanceId?: string; expiresAtMs?: number; lastReconcileAction?: string; }> { return reservations.map((reservation) => { return { target: reservation.target, slot: reservation.slot, reason: reservation.reason, instanceId: reservation.instanceId, expiresAtMs: reservation.expiresAtMs, lastReconcileAction: reservation.lastReconcileAction, }; }); } export function listTelegramThreadStatusObservations( observations: readonly TelegramTopicSyncObservation[], ): Array<{ target: TelegramTarget & { threadId: number }; syncStatus: TelegramTopicSyncStatus; observedAtMs: number; instanceId?: string; slot?: string; lastSyncError?: string; lastReconcileAction?: string; }> { return observations.map((observation) => { return { target: observation.target, syncStatus: observation.syncStatus, observedAtMs: observation.observedAtMs, instanceId: observation.instanceId, slot: observation.slot, lastSyncError: observation.lastSyncError, lastReconcileAction: observation.lastReconcileAction, }; }); } export function getTelegramTargetFromApiBody( body: unknown, ): (TelegramTarget & { threadId: number }) | undefined { if (!body || typeof body !== "object" || Array.isArray(body)) return undefined; const record = body as Record; const chatId = asInteger(record.chat_id); const threadId = asInteger(record.message_thread_id); return chatId !== undefined && threadId !== undefined ? { chatId, threadId } : undefined; } export function isTelegramTopicTargetStaleError(error: unknown): boolean { if (!(error instanceof Error)) return false; const status = "status" in error && typeof error.status === "number" ? error.status : undefined; if (status !== undefined && status !== 400) return false; const message = error.message.toLowerCase(); return ( message.includes("topic_id_invalid") || message.includes("message thread not found") || message.includes("thread not found") || message.includes("topic not found") || message.includes("topic deleted") || message.includes("topic closed") || message.includes("thread closed") || message.includes("forum topic closed") || message.includes("message thread closed") ); } export function isTelegramTopicModeUnavailableError(error: unknown): boolean { if (!(error instanceof Error)) return false; const message = error.message.toLowerCase(); return ( message.includes("not a forum") || message.includes("forum topic") || message.includes("topics are disabled") || message.includes("threaded mode") || message.includes("method is available only for") ); } export function getTelegramTopicTitleForThreadName( threadName: string, slot: string, template = "{threadName}", ): string { return getTelegramTopicName( { instanceId: "", profileKey: normalizeTelegramTopicTargetThreadName(threadName) || "Pi", threadName, }, template, slot, ); } export function createTelegramTopicTargetRenamer( deps: TelegramTopicTargetRenamerDeps, ): ( request: TelegramTopicTargetRenameRequest, ) => Promise { return async (request) => { const threadName = normalizeTelegramTopicTargetThreadName( request.threadName, ); if ( !threadName || !isTelegramTopicThreadNameValidForSlot(threadName, request.slot) ) return undefined; const name = getTelegramTopicTitleForThreadName( threadName, request.slot ?? "", deps.topicNameTemplate, ); await deps.callApi("editForumTopic", { chat_id: request.target.chatId, message_thread_id: request.target.threadId, name, }); return deps.store.renameByTarget(request.target, threadName); }; } export function createTelegramTopicTargetProvisioner( deps: TelegramTopicTargetProvisionerDeps, ): ( request: TelegramTopicTargetProvisionRequest, ) => Promise { const getNowMs = deps.getNowMs ?? (() => 0); const getRandom = deps.getRandom; return async (request) => { const leaderEpoch = deps.getCurrentLeaderEpoch?.(); const assertLeaderEpoch = (phase: string): void => { if ( deps.getCurrentLeaderEpoch && (leaderEpoch === undefined || deps.getCurrentLeaderEpoch() !== leaderEpoch) ) { throw new Error( `Telegram topic provisioning lost leader ownership (${phase}).`, ); } }; assertLeaderEpoch("start"); normalizeCurrentThreadNameSlots(deps.store); const existing = deps.store.getByProfileKey(request.profileKey); const isManualFollowerRequest = request.owner?.kind === "manual-follower"; const identity = deps.store.getIdentityByProfileKey(request.profileKey); const nowMs = getNowMs(); if (existing && isCurrentThreadRecord(existing)) { const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey); const identityThreadName = identity?.threadName && isTelegramTopicThreadNameValidForSlot(identity.threadName, slot) ? identity.threadName : undefined; const bakedThreadName = chooseTelegramThreadName({ slot: getNextTelegramThreadNamePaletteSlot(deps.store.list(), slot), entropy: nowMs, getRandom, }); const record = deps.store.upsert({ ...existing, status: "active", updatedAtMs: nowMs, threadName: existing.threadName ?? identityThreadName ?? bakedThreadName, instanceId: request.instanceId, slot, owner: request.owner ?? existing.owner, lastError: undefined, }); return { target: record.target, reused: true, record }; } const pendingForRequest = deps.store .listPendingProvisions() .find( (pending) => pending.profileKey === request.profileKey || pending.instanceId === request.instanceId, ); if (pendingForRequest?.target) { const record = deps.store.upsert({ profileKey: request.profileKey, owner: request.owner, target: pendingForRequest.target, status: "active", createdAtMs: pendingForRequest.startedAtMs, updatedAtMs: nowMs, threadName: pendingForRequest.threadName, instanceId: request.instanceId, slot: pendingForRequest.slot, }); deps.store.removePendingProvision(pendingForRequest.id); await deps.store.persist(); assertLeaderEpoch("after-recovered-binding"); return { target: record.target, reused: true, record }; } if (pendingForRequest) { throw new Error( `Telegram topic provisioning remains ${pendingForRequest.status ?? "in-flight"} for this instance.`, ); } const activeForInstance = deps.store.getActiveByInstanceId( request.instanceId, ); if (activeForInstance) { return { target: activeForInstance.target, reused: true, record: activeForInstance, }; } // No profileKey match — try to claim an existing inactive thread before creating another Telegram tab. if (deps.claimPendingTargets !== false) { const claimed = deps.store.claimReusableTarget( request.instanceId, identity?.threadName, ); if (claimed) { return { target: claimed.target, reused: true, record: claimed }; } } const candidateThreadName = identity?.threadName; const preferredNameSlot = getTelegramThreadNameLeadingSlot(candidateThreadName) ?? getNextTelegramThreadNamePaletteSlot(deps.store.list(), undefined) ?? request.preferredSlot; const slot = existing?.slot ?? (candidateThreadName ? undefined : identity?.slot) ?? deps.store.allocateSlot( request.profileKey, isManualFollowerRequest ? request.preferredSlot : (request.preferredSlot ?? preferredNameSlot), ); const requestThreadName = candidateThreadName && isTelegramTopicThreadNameValidForSlot(candidateThreadName, slot) ? candidateThreadName : chooseTelegramThreadName({ slot, entropy: nowMs, getRandom }); const pendingId = `provision:${request.instanceId}:${slot}:${nowMs}`; const pendingOwner = request.owner?.kind === "leader" ? "leader" : "manual-follower"; assertLeaderEpoch("before-pending-intent"); const pendingBase: TelegramThreadPendingProvision = { id: pendingId, owner: pendingOwner, instanceId: request.instanceId, profileKey: request.profileKey, threadName: requestThreadName, slot, startedAtMs: nowMs, ...(leaderEpoch !== undefined ? { leaderEpoch } : {}), }; deps.store.upsertPendingProvision(pendingBase); await deps.store.persist(); assertLeaderEpoch("after-pending-intent"); let threadId: number | undefined; try { assertLeaderEpoch("before-createForumTopic"); const topic = await deps.callApi( "createForumTopic", { chat_id: deps.topicChatId, name: getTelegramTopicName( { ...request, ...(requestThreadName ? { threadName: requestThreadName } : {}), }, deps.topicNameTemplate ?? (requestThreadName ? "{threadName}" : "{slot}"), slot, ), }, { maxAttempts: 1 }, ); threadId = topic.message_thread_id; if (typeof threadId !== "number" || !Number.isInteger(threadId)) { throw new TelegramApiCommitUnknownError( "createForumTopic", new Error("Telegram createForumTopic returned no message_thread_id."), ); } assertLeaderEpoch("after-createForumTopic"); const target = { chatId: deps.topicChatId, threadId }; deps.store.upsertPendingProvision({ ...pendingBase, target }); await deps.store.persist(); assertLeaderEpoch("after-pending-target"); deps.store.upsert({ profileKey: request.profileKey, owner: request.owner, target, status: "starting", createdAtMs: existing?.createdAtMs ?? nowMs, updatedAtMs: nowMs, threadName: requestThreadName, instanceId: request.instanceId, slot, }); await deps.store.persist(); assertLeaderEpoch("after-starting-binding"); const record = deps.store.upsert({ profileKey: request.profileKey, owner: request.owner, target, status: "active", createdAtMs: existing?.createdAtMs ?? nowMs, updatedAtMs: nowMs, threadName: requestThreadName, instanceId: request.instanceId, slot, }); deps.store.removePendingProvision(pendingId); await deps.store.persist(); assertLeaderEpoch("after-active-binding"); return { target: record.target, reused: false, record }; } catch (error) { if ( threadId !== undefined && deps.getCurrentLeaderEpoch && deps.getCurrentLeaderEpoch() !== leaderEpoch ) { await deps.store.recordPendingProvisionTargetRecovery(pendingBase, { chatId: deps.topicChatId, threadId, }); throw error; } assertLeaderEpoch("failure-cleanup"); if (threadId === undefined) { if (isTelegramApiCommitUnknownError(error)) { deps.store.upsertPendingProvision({ ...pendingBase, status: "ambiguous", }); } else { deps.store.removePendingProvision(pendingId); } await deps.store.persist(); } else { try { await deps.store.persist(); } catch { // Keep the original post-create failure visible to the caller. } } throw error; } }; }