Files
oh-my-pi-telegram/lib/threads.ts
Evandro Fonseca Junior d1d04001d0
Release / release (push) Failing after 5s
Validate / validate (push) Failing after 5m21s
chore: fork @llblab/[email protected] as @evandrodevbr/oh-my-pi-telegram
Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a
drop-in extension for can1357/oh-my-pi (omp >= 16.5.2).

Changes vs upstream:
- Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram
- Bump version: 0.22.0 -> 0.22.0-evandro.1
- Update repository/homepage/bugs URLs to evandrodevbr
- Add omp field in package.json (displayName + compatibleWith)
- README: prepend 'Oh My Pi (omp) Compatibility' section with install
  commands, verified-components table, upstream lineage, and rationale
- CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased
- docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram
- tests/public-api.test.ts: rename package import paths

Gates re-run on the fork:
- npm run typecheck  -> tsc --noEmit (success)
- npm test           -> 1229 pass / 0 fail / 1 platform-only skip
- npm run audit      -> found 0 vulnerabilities
- npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz

Verified:
- omp install --link ./pi-telegram -> Linked OK
- omp plugin list -> @evandrodevbr/[email protected].0-evandro.1

Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
2026-07-15 14:18:16 -03:00

2883 lines
94 KiB
TypeScript

/**
* 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" | "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;
}
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<string, unknown>;
liveRoster?: Record<string, unknown>;
diagnostics?: Record<string, unknown>;
threads: TelegramTopicTargetRecord[];
identities?: TelegramThreadIdentityRecord[];
reservations?: TelegramThreadReservation[];
pendingProvisions?: TelegramThreadPendingProvision[];
syncObservations?: TelegramTopicSyncObservation[];
}
function getNextMonotonicSlot(
records: Map<string, TelegramTopicTargetRecord>,
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<void>;
persist: () => Promise<void>;
list: () => TelegramTopicTargetRecord[];
getFollowerRecoveryHintByTarget?: (
target: TelegramTarget,
) => { slot?: string; threadName?: string } | undefined;
listReservations: () => TelegramThreadReservation[];
listPendingProvisions: () => TelegramThreadPendingProvision[];
listSyncObservations: () => TelegramTopicSyncObservation[];
reserveThread: (reservation: TelegramThreadReservation) => void;
upsertPendingProvision: (provision: TelegramThreadPendingProvision) => void;
recordPendingProvisionTargetRecovery: (
provision: TelegramThreadPendingProvision,
target: TelegramTarget & { threadId: number },
) => Promise<boolean>;
removePendingProvision: (id: string) => boolean;
removeReservationByTarget: (target: TelegramTarget) => boolean;
getBotState: () => TelegramBotStateSnapshot;
setBotState: (state: Partial<TelegramBotStateSnapshot>) => void;
setStatusSnapshot: (snapshot: {
runtime?: Record<string, unknown>;
liveRoster?: Record<string, unknown>;
diagnostics?: Record<string, unknown>;
}) => 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<TelegramTopicTargetStore, "getBotState" | "list" | "setBotState">,
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: <TResponse>(
method: string,
body: Record<string, unknown>,
options?: TelegramApiCallOptions,
) => Promise<TResponse>;
topicNameTemplate?: string;
getNowMs?: () => number;
getRandom?: () => number;
getCurrentLeaderEpoch?: () => number | string | undefined;
claimPendingTargets?: boolean;
}
export interface TelegramTopicTargetRenamerDeps {
store: Pick<TelegramTopicTargetStore, "renameByTarget">;
callApi: <TResponse>(
method: string,
body: Record<string, unknown>,
) => Promise<TResponse>;
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);
}
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<string, unknown>;
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, unknown>,
): 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<string, unknown>;
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<string, unknown>;
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 !== "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 normalizeIdentityRecord(
value: unknown,
): TelegramThreadIdentityRecord | undefined {
if (!value || typeof value !== "object" || Array.isArray(value))
return undefined;
const record = value as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const targetValue = record.target;
if (
!targetValue ||
typeof targetValue !== "object" ||
Array.isArray(targetValue)
) {
return undefined;
}
const targetRecord = targetValue as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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 normalizeReservation(
value: unknown,
): TelegramThreadReservation | undefined {
if (!value || typeof value !== "object" || Array.isArray(value))
return undefined;
const record = value as Record<string, unknown>;
const targetValue = record.target;
if (
!targetValue ||
typeof targetValue !== "object" ||
Array.isArray(targetValue)
) {
return undefined;
}
const targetRecord = targetValue as Record<string, unknown>;
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<string, unknown>;
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 && isCurrentThreadRecord(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] : [];
})
: [],
syncObservations: Array.isArray(file.syncObservations)
? file.syncObservations.flatMap((observation) => {
const normalized = normalizeSyncObservation(observation);
return normalized ? [normalized] : [];
})
: [],
};
}
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<string, { slot?: string; threadName?: string }> {
const hints = new Map<string, { slot?: string; threadName?: string }>();
if (!value || typeof value !== "object" || Array.isArray(value)) return hints;
const liveRoster = (value as Record<string, unknown>).liveRoster;
if (
!liveRoster ||
typeof liveRoster !== "object" ||
Array.isArray(liveRoster)
)
return hints;
const followers = (liveRoster as Record<string, unknown>).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<string, unknown>;
const target = record.target;
if (!target || typeof target !== "object" || Array.isArray(target))
continue;
const targetRecord = target as Record<string, unknown>;
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<string, TelegramTopicTargetRecord>();
let identities = new Map<string, TelegramThreadIdentityRecord>();
let reservations: TelegramThreadReservation[] = [];
let pendingProvisions: TelegramThreadPendingProvision[] = [];
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 persistQueue: Promise<void> = Promise.resolve();
let statusSnapshot: {
runtime?: Record<string, unknown>;
liveRoster?: Record<string, unknown>;
diagnostics?: Record<string, unknown>;
} = {};
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 = [];
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 = [];
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 } }
: {}),
};
});
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();
},
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(isCurrentThreadRecord)
.map(cloneRecord);
records = new Map(
currentRecords.map((record) => [
getRecordOwnerKey(record),
cloneRecord(record),
]),
);
const persistedRevision = mutationRevision;
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 } } : {}),
})),
syncObservations: syncObservations.map((observation) => ({
...observation,
target: { ...observation.target },
})),
threads: currentRecords.map((record) => {
const { profileKey: _profileKey, ...serialized } = record;
return serialized;
}),
};
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 } } : {}),
}));
},
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;
},
removeReservationByTarget(target) {
const before = reservations.length;
reservations = reservations.filter(
(reservation) => !targetMatches(reservation.target, target),
);
const changed = reservations.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 };
},
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 (!isCurrentThreadRecord(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<string, TelegramTopicTargetRecord>,
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<string, readonly string[]> = {
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<TelegramTopicTargetStore, "list" | "upsert">,
): 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<TelegramTopicTargetRecord | undefined> {
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: <TResponse>(
method: string,
body: Record<string, unknown>,
) => Promise<TResponse>;
recordEvent: (
category: string,
message: string,
details?: Record<string, unknown>,
) => 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<TelegramOwnTopicProvisionResult | undefined> {
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),
removeReservationByTarget: (
target: TelegramTarget & { threadId: number },
) => deps.store.removeReservationByTarget(target),
removePendingProvisionById: (id: string) =>
deps.store.removePendingProvision(id),
persist: () => deps.store.persist(),
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
recordRuntimeEvent(
category: string,
error: unknown,
details?: Record<string, unknown>,
) {
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 reservationProbeResults: ThreadReconciler.ThreadReservationProbeResult[] =
[];
deps.recordEvent("bus", "Bus leader reservation probes skipped", {
phase: "leader-topic-reservation-probe-skipped",
reservations: reservationsBeforeCleanup.length,
});
const reservationProbePlan = ThreadReconciler.planThreadReconciliation({
nowMs: Date.now(),
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
previousState: deps.getThreadReconciliationMachineState?.(),
records: deps.store.list(),
reservations: deps.store.listReservations(),
pendingProvisions: deps.store.listPendingProvisions(),
reservationProbeResults,
});
deps.recordThreadReconciliationPlan?.(reservationProbePlan);
const reservationProbeApplyStartedAtMs = Date.now();
await ThreadReconciler.applyThreadReconciliationPlan(
reservationProbePlan,
reservationCleanupPorts,
);
deps.recordEvent(
"bus",
"Bus leader reservation probe reconciliation applied",
{
phase: "leader-topic-reservation-probe-apply-duration",
durationMs: Date.now() - reservationProbeApplyStartedAtMs,
actions: reservationProbePlan.actions.length,
},
);
const nowMs = Date.now();
const currentLeaderOwner: TelegramThreadOwner = {
kind: "leader",
cwd: deps.cwd,
instanceId: deps.instanceId,
...(deps.telegramProfile ? { telegramProfile: deps.telegramProfile } : {}),
};
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();
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.",
);
}
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;
}
export function createTelegramCurrentInstanceThreadRuntime(deps: {
instanceId: string;
listRecords(): readonly TelegramTopicTargetRecord[];
getPreferredTarget(): TelegramTarget | undefined;
getFollower():
| (TelegramInstanceThreadIdentityCandidate & { registered: boolean })
| undefined;
getLeader(): TelegramInstanceThreadIdentityCandidate | undefined;
}): 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,
});
},
};
}
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<typeof listTelegramThreadStatusFollowers>;
getLocalBus(): {
leaderSocketPath: string;
leaderTransport: "socket" | "pipe";
followerSocketPath: string;
followerTransport: "socket" | "pipe";
followerRegistered: boolean;
followerTarget?: TelegramTarget;
followerSlot?: string;
followerThreadName?: string;
};
getTopicTargets(): ReturnType<typeof listTelegramThreadStatusTargets>;
getThreadReservations(): ReturnType<
typeof listTelegramThreadStatusReservations
>;
getTopicSyncObservations(): ReturnType<
typeof listTelegramThreadStatusObservations
>;
getInstanceSlot(): string | undefined;
getInstanceThreadName(): string | undefined;
}
export function createTelegramThreadStatusProjectionRuntime(deps: {
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;
getCurrentIdentity(): TelegramInstanceThreadIdentityCandidate;
}): 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();
return {
leaderSocketPath,
leaderTransport: deps.getTransportKind(leaderSocketPath),
followerSocketPath,
followerTransport: deps.getTransportKind(followerSocketPath),
followerRegistered: deps.isFollowerRegistered(),
followerTarget: deps.getFollowerTarget(),
followerSlot: deps.getFollowerSlot(),
followerThreadName: deps.getFollowerThreadName(),
};
},
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 TelegramThreadStatusFollowerView {
instanceId: string;
cwd?: string;
lastHeartbeatMs: number;
target?: TelegramTarget;
}
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;
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,
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<string, unknown>;
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 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<TelegramTopicTargetRecord | undefined> {
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<TelegramTopicTargetProvisionResult> {
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);
let existing = deps.store.getByProfileKey(request.profileKey);
const isManualFollowerRequest = request.owner?.kind === "manual-follower";
if (
isManualFollowerRequest &&
existing &&
isCurrentThreadRecord(existing)
) {
deps.store.markStaleByTarget(
existing.target,
"unknown",
"Manual follower runtime was replaced before reconnect.",
);
deps.store.forgetIdentityByProfileKey(request.profileKey);
existing = undefined;
}
const identity =
isManualFollowerRequest && !existing
? undefined
: 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<TelegramTopicResult>(
"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;
}
};
}