Files
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

1209 lines
35 KiB
TypeScript

/**
* Regression tests for Telegram synchronization helpers
* Covers demand-driven reconciliation triggers and pure sync-slice state transitions
*/
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
TELEGRAM_SYNC_SLICES,
createTelegramLeaderHealthRuntime,
createTelegramManualThreadDisconnectHandler,
createTelegramProvisioningActivityRuntime,
createTelegramSyncStateRuntime,
createTelegramTopicLifecycleSyncHandler,
createUnknownTelegramSyncState,
ensureTelegramLeaderThreadBinding,
markTelegramSyncSliceFresh,
markTelegramSyncSliceSuspect,
recoverStaleTelegramTopicApiError,
shouldReconcileTelegramSync,
} from "../lib/sync.ts";
import { createTelegramTopicTargetStore } from "../lib/threads.ts";
test("Telegram sync state runtime owns transitions and nested provisioning activity", () => {
const syncState = createTelegramSyncStateRuntime();
syncState.markConfigChange("profile-change");
assert.equal(syncState.getState()["bot-identity"]?.status, "fresh");
syncState.markSliceFresh("target-bindings", {
nowMs: 10,
action: "binding-restored",
});
assert.deepEqual(syncState.getState()["target-bindings"], {
status: "fresh",
updatedAtMs: 10,
lastReconcileAction: "binding-restored",
});
const provisioning = createTelegramProvisioningActivityRuntime();
provisioning.start();
provisioning.start();
provisioning.end();
assert.equal(provisioning.isActive(), true);
provisioning.end();
provisioning.end();
assert.equal(provisioning.isActive(), false);
});
test("Telegram sync reconciliation is demand-driven, not per ordinary action", () => {
for (const trigger of [
"startup",
"reload",
"topic-lifecycle",
"stale-api-error",
"setup-change",
"pairing-change",
"follower-register",
"follower-prune",
"status-request",
"leader-health-tick",
] as const) {
assert.equal(shouldReconcileTelegramSync(trigger), true);
}
assert.equal(shouldReconcileTelegramSync("ordinary-message"), false);
assert.equal(shouldReconcileTelegramSync("ordinary-send"), false);
});
function createTopicStore(
records: Array<{
profileKey?: string;
target: { chatId: number; threadId: number };
status?: "active" | "offline" | "stale" | "pending" | "starting" | "failed";
createdAtMs?: number;
updatedAtMs?: number;
}> = [],
) {
const normalizedRecords = records.map((record, index) => ({
profileKey: record.profileKey ?? `topic:${index}`,
target: record.target,
status: record.status ?? "active",
createdAtMs: record.createdAtMs ?? 1000,
updatedAtMs: record.updatedAtMs ?? 1000,
}));
return {
persisted: false,
records: normalizedRecords,
async load() {},
list() {
return this.records;
},
listReservations() {
return [];
},
listPendingProvisions() {
return [];
},
markStaleByTarget(target: { chatId: number; threadId?: number }) {
const record = this.records.find(
(item) =>
item.target.chatId === target.chatId &&
item.target.threadId === target.threadId,
);
if (!record) return false;
record.status = "stale";
return true;
},
removePendingProvision() {
return false;
},
markActiveByTarget(target: { chatId: number; threadId?: number }) {
const record = this.records.find(
(item) =>
item.target.chatId === target.chatId &&
item.target.threadId === target.threadId,
);
if (!record) return false;
record.status = "active";
return true;
},
async persist() {
this.persisted = true;
},
};
}
test("Telegram sync recovers stale topic API errors outside the entrypoint", async () => {
const store = createTopicStore([
{ target: { chatId: 7, threadId: 42 }, status: "active" },
]);
let state = createUnknownTelegramSyncState();
const events: Array<{
category: string;
phase?: unknown;
threadId?: unknown;
}> = [];
const recovered = await recoverStaleTelegramTopicApiError(
{ chat_id: 7, message_thread_id: 42 },
new Error("Telegram API sendMessage failed: TOPIC_ID_INVALID"),
{
topicTargetStore: store,
getNowMs: () => 1234,
getSyncState: () => state,
setSyncState: (nextState) => {
state = nextState;
},
recordEvent: (category, _message, details) => {
events.push({
category,
phase: details?.phase,
threadId: details?.threadId,
});
},
},
);
assert.equal(recovered, true);
assert.equal(store.records[0]?.status, "stale");
assert.equal(store.persisted, true);
assert.equal(state["topic-state"]?.status, "suspect");
assert.equal(
state["transport-health"]?.lastReconcileAction,
"topic-target-stale",
);
assert.deepEqual(events, [
{ category: "bus", phase: "topic-target-stale", threadId: 42 },
]);
});
test("Telegram sync ignores non-stale topic API errors", async () => {
const store = createTopicStore([
{ target: { chatId: 7, threadId: 42 }, status: "active" },
]);
let state = createUnknownTelegramSyncState();
const recovered = await recoverStaleTelegramTopicApiError(
{ chat_id: 7, message_thread_id: 42 },
new Error("network down"),
{
topicTargetStore: store,
getSyncState: () => state,
setSyncState: (nextState) => {
state = nextState;
},
recordEvent: () => undefined,
},
);
assert.equal(recovered, false);
assert.equal(store.records[0]?.status, "active");
assert.equal(store.persisted, false);
});
test("Telegram leader health runtime refreshes sync slices outside the entrypoint", async () => {
let state = createUnknownTelegramSyncState();
let calls = 0;
const runtime = createTelegramLeaderHealthRuntime({
intervalMs: 1,
getNowMs: () => 1234,
callGetMe: async () => {
calls += 1;
},
getSyncState: () => state,
setSyncState: (nextState) => {
state = nextState;
},
recordEvent: () => undefined,
});
runtime.start();
await new Promise((resolve) => setTimeout(resolve, 10));
runtime.stop();
assert.equal(calls > 0, true);
assert.equal(state["transport-health"]?.status, "fresh");
assert.equal(state["bot-identity"]?.status, "fresh");
assert.equal(
state["transport-health"]?.lastReconcileAction,
"leader-health-tick",
);
});
test("Telegram leader health runtime marks transport suspect on probe failure", async () => {
let state = createUnknownTelegramSyncState();
const events: Array<{ category: string; phase?: unknown }> = [];
const runtime = createTelegramLeaderHealthRuntime({
intervalMs: 1,
getNowMs: () => 1234,
callGetMe: async () => {
throw new Error("boom");
},
getSyncState: () => state,
setSyncState: (nextState) => {
state = nextState;
},
recordEvent: (category, _message, details) => {
events.push({ category, phase: details?.phase });
},
});
runtime.start();
await new Promise((resolve) => setTimeout(resolve, 10));
runtime.stop();
assert.equal(state["transport-health"]?.status, "suspect");
assert.equal(
state["transport-health"]?.lastReconcileAction,
"leader-health-tick",
);
assert.deepEqual(events.at(-1), {
category: "telegram",
phase: "leader-health-tick",
});
});
test("Telegram sync model includes every local assumption slice", () => {
assert.deepEqual(TELEGRAM_SYNC_SLICES, [
"bot-identity",
"bot-capabilities",
"pairing",
"allowed-user",
"topic-capability",
"topic-state",
"target-bindings",
"reservations",
"transport-health",
]);
assert.deepEqual(createUnknownTelegramSyncState()["reservations"], {
status: "unknown",
});
});
test("Telegram sync slice state records suspect and fresh transitions", () => {
const suspect = markTelegramSyncSliceSuspect({}, "topic-state", {
nowMs: 1000,
reason: "topic closed",
action: "lifecycle-closed",
});
assert.deepEqual(suspect["topic-state"], {
status: "suspect",
suspectAtMs: 1000,
reason: "topic closed",
lastReconcileAction: "lifecycle-closed",
});
const fresh = markTelegramSyncSliceFresh(suspect, "topic-state", {
nowMs: 2000,
action: "startup-probe",
});
assert.deepEqual(fresh["topic-state"], {
status: "fresh",
updatedAtMs: 2000,
lastReconcileAction: "startup-probe",
});
});
test("Leader thread sync creates a visible leader thread when no binding exists", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-leader-sync-"));
const store = createTelegramTopicTargetStore({
path: join(dir, "telegram-targets.json"),
getNowMs: () => 2000,
});
const calls: Array<{ method: string; body: Record<string, unknown> }> = [];
try {
const result = await ensureTelegramLeaderThreadBinding({
getAllowedUserId: () => 7,
instanceId: "leader-a",
cwd: "/repo",
topicTargetStore: store,
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return { message_thread_id: 11 } as TResponse;
},
recordEvent() {},
});
assert.deepEqual(result, {
target: { chatId: 7, threadId: 11 },
slot: "A",
threadName: "Atlas",
reused: false,
});
assert.deepEqual(calls, [
{ method: "createForumTopic", body: { chat_id: 7, name: "Atlas" } },
]);
assert.deepEqual(store.getByProfileKey("cwd:/repo")?.target, {
chatId: 7,
threadId: 11,
});
} finally {
await rm(dir, { force: true, recursive: true });
}
});
test("Leader thread sync reuses same-profile topic across reload", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-leader-sync-prev-"));
const store = createTelegramTopicTargetStore({
path: join(dir, "telegram-targets.json"),
getNowMs: () => 2000,
});
const calls: Array<{ method: string; body: Record<string, unknown> }> = [];
const events: Array<Record<string, unknown>> = [];
try {
store.upsert({
profileKey: "cwd:/repo",
target: { chatId: 7, threadId: 10 },
status: "active",
createdAtMs: 1000,
updatedAtMs: 1000,
threadName: "🧭 Axial",
instanceId: "previous-instance",
slot: "A",
});
await store.persist();
const result = await ensureTelegramLeaderThreadBinding({
getAllowedUserId: () => 7,
instanceId: "leader-a",
cwd: "/repo",
topicTargetStore: store,
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return { message_thread_id: 11 } as TResponse;
},
recordEvent(_category, _message, details) {
if (details) events.push(details);
},
});
assert.deepEqual(result, {
target: { chatId: 7, threadId: 10 },
slot: "A",
threadName: "🧭 Axial",
reused: true,
});
assert.deepEqual(calls, []);
const record = store.getByProfileKey("cwd:/repo");
assert.equal(record?.target.threadId, 10);
assert.equal(record?.instanceId, "leader-a");
assert.deepEqual(record?.owner, {
kind: "leader",
cwd: "/repo",
instanceId: "leader-a",
});
assert.equal(record?.threadName, "🧭 Axial");
assert.equal(store.listReservations().length, 0);
assert.equal(
events.some(
(event) => event.phase === "leader-topic-same-profile-preserve",
),
true,
);
} finally {
await rm(dir, { force: true, recursive: true });
}
});
test("Leader thread sync rejects reused binding after load loses epoch", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-leader-reuse-epoch-"));
const store = createTelegramTopicTargetStore({
path: join(dir, "state.json"),
getNowMs: () => 2000,
});
let leaderEpoch: number | undefined = 1;
try {
store.upsert({
profileKey: "cwd:/repo",
target: { chatId: 7, threadId: 10 },
status: "active",
createdAtMs: 1000,
updatedAtMs: 1000,
instanceId: "leader-a",
slot: "A",
});
await store.persist();
const epochLosingStore = {
...store,
async load() {
await store.load();
leaderEpoch = undefined;
},
};
await assert.rejects(
ensureTelegramLeaderThreadBinding({
getAllowedUserId: () => 7,
instanceId: "leader-a",
cwd: "/repo",
getCurrentLeaderEpoch: () => leaderEpoch,
topicTargetStore: epochLosingStore,
async callApi<TResponse>() {
return {} as TResponse;
},
recordEvent() {},
}),
/lost ownership \(after-load\)/,
);
} finally {
await rm(dir, { force: true, recursive: true });
}
});
test("Leader thread sync reuses same-process legacy leader topic across reload", async () => {
const dir = await mkdtemp(
join(tmpdir(), "pi-telegram-leader-sync-same-pid-"),
);
const store = createTelegramTopicTargetStore({
path: join(dir, "telegram-targets.json"),
getNowMs: () => 2000,
});
const calls: Array<{ method: string; body: Record<string, unknown> }> = [];
const events: Array<Record<string, unknown>> = [];
try {
store.upsert({
profileKey: "leader:3824869:1782087343414",
target: { chatId: 7, threadId: 10 },
status: "active",
createdAtMs: 1000,
updatedAtMs: 1000,
threadName: "Nexus",
instanceId: "3824869:1782087343414",
slot: "N",
});
await store.persist();
const result = await ensureTelegramLeaderThreadBinding({
getAllowedUserId: () => 7,
instanceId: "3824869:1782088512458",
cwd: "/repo",
topicTargetStore: store,
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return { message_thread_id: 11 } as TResponse;
},
recordEvent(_category, _message, details) {
if (details) events.push(details);
},
});
assert.deepEqual(result, {
target: { chatId: 7, threadId: 10 },
slot: "N",
threadName: "Nexus",
reused: true,
});
assert.deepEqual(calls, []);
const record = store.getByProfileKey("cwd:/repo");
assert.equal(record?.target.threadId, 10);
assert.equal(record?.instanceId, "3824869:1782088512458");
assert.equal(record?.threadName, "Nexus");
assert.equal(
store.getByProfileKey("leader:3824869:1782087343414"),
undefined,
);
assert.equal(store.listReservations().length, 0);
assert.equal(
events.some(
(event) => event.phase === "leader-topic-same-process-preserve",
),
true,
);
} finally {
await rm(dir, { force: true, recursive: true });
}
});
test("Leader thread sync does not visibly probe same-profile topic during reload", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-leader-sync-defer-"));
const store = createTelegramTopicTargetStore({
path: join(dir, "telegram-targets.json"),
getNowMs: () => 2000,
});
const calls: Array<{ method: string; body: Record<string, unknown> }> = [];
try {
store.upsert({
profileKey: "cwd:/repo",
target: { chatId: 7, threadId: 10 },
status: "active",
createdAtMs: 1000,
updatedAtMs: 1000,
instanceId: "previous-instance",
slot: "A",
});
await store.persist();
const result = await ensureTelegramLeaderThreadBinding({
getAllowedUserId: () => 7,
instanceId: "leader-a",
cwd: "/repo",
topicTargetStore: store,
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return { message_thread_id: 11 } as TResponse;
},
recordEvent() {},
});
assert.deepEqual(result, {
target: { chatId: 7, threadId: 10 },
slot: "A",
threadName: "Atlas",
reused: true,
});
assert.deepEqual(calls, []);
assert.equal(store.getByProfileKey("cwd:/repo")?.target.threadId, 10);
assert.equal(store.listReservations().length, 0);
} finally {
await rm(dir, { force: true, recursive: true });
}
});
test("Leader thread sync proactively deletes known reservations on startup", async () => {
const dir = await mkdtemp(
join(tmpdir(), "pi-telegram-leader-reservation-cleanup-"),
);
const store = createTelegramTopicTargetStore({
path: join(dir, "state.json"),
getNowMs: () => 2000,
});
const calls: Array<{ method: string; body: Record<string, unknown> }> = [];
try {
store.reserveThread({
target: { chatId: 7, threadId: 9 },
slot: "A",
reason: "previous-process-still-probes-alive",
createdAtMs: 1000,
updatedAtMs: 1000,
expiresAtMs: 10_000,
});
await store.persist();
await ensureTelegramLeaderThreadBinding({
getAllowedUserId: () => 7,
instanceId: "leader-a",
cwd: "/repo",
topicTargetStore: store,
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
if (method === "sendChatAction" && body.message_thread_id === 9) {
throw new Error(
"Telegram API sendChatAction failed: HTTP 400: Bad Request: message thread not found",
);
}
return { message_thread_id: 10 } as TResponse;
},
recordEvent() {},
});
assert.deepEqual(
calls.slice(0, 3).map((call) => call.method),
["closeForumTopic", "deleteForumTopic", "createForumTopic"],
);
assert.deepEqual(
store.listReservations().map((reservation) => reservation.target),
[{ chatId: 7, threadId: 9 }],
);
} finally {
await rm(dir, { force: true, recursive: true });
}
});
test("Leader thread sync preserves follower records from previous instances", async () => {
const dir = await mkdtemp(
join(tmpdir(), "pi-telegram-leader-preserve-followers-"),
);
const store = createTelegramTopicTargetStore({
path: join(dir, "state.json"),
getNowMs: () => 2000,
});
const calls: Array<{ method: string; body: Record<string, unknown> }> = [];
try {
store.upsert({
profileKey: "manual:follower-a",
owner: { kind: "manual-follower", instanceId: "follower-a" },
target: { chatId: 7, threadId: 10 },
status: "active",
createdAtMs: 1000,
updatedAtMs: 1000,
instanceId: "follower-a",
slot: "T",
});
await store.persist();
await ensureTelegramLeaderThreadBinding({
getAllowedUserId: () => 7,
instanceId: "leader-a",
cwd: "/repo",
topicTargetStore: store,
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return { message_thread_id: 11 } as TResponse;
},
recordEvent() {},
});
assert.deepEqual(store.getByProfileKey("manual:follower-a")?.target, {
chatId: 7,
threadId: 10,
});
assert.equal(store.getByProfileKey("manual:follower-a")?.status, "active");
assert.equal(
calls.some(
(call) =>
(call.method === "closeForumTopic" ||
call.method === "deleteForumTopic") &&
call.body.message_thread_id === 10,
),
false,
);
} finally {
await rm(dir, { force: true, recursive: true });
}
});
test("Leader thread sync cleans previous leader records regardless of profile key", async () => {
const dir = await mkdtemp(
join(tmpdir(), "pi-telegram-leader-cross-profile-"),
);
const store = createTelegramTopicTargetStore({
path: join(dir, "state.json"),
getNowMs: () => 2000,
});
try {
store.upsert({
profileKey: "leader:previous-id",
target: { chatId: 7, threadId: 10 },
status: "active",
createdAtMs: 1000,
updatedAtMs: 1000,
instanceId: "previous-instance",
slot: "B",
});
await store.persist();
await ensureTelegramLeaderThreadBinding({
getAllowedUserId: () => 7,
instanceId: "leader-a",
cwd: "/repo",
topicTargetStore: store,
async callApi<TResponse>() {
return { message_thread_id: 11 } as TResponse;
},
recordEvent() {},
});
assert.equal(store.getByProfileKey("leader:previous-id"), undefined);
const [reservation] = store.listReservations();
assert.deepEqual(reservation?.target, { chatId: 7, threadId: 10 });
assert.equal(reservation?.slot, "B");
assert.equal(
reservation?.reason,
"previous-process-cleaned-without-visible-probe",
);
} finally {
await rm(dir, { force: true, recursive: true });
}
});
test("Leader thread sync aborts local previous-leader cleanup after ownership loss", async () => {
const dir = await mkdtemp(
join(tmpdir(), "pi-telegram-leader-cleanup-epoch-loss-"),
);
const store = createTelegramTopicTargetStore({
path: join(dir, "state.json"),
getNowMs: () => 2000,
});
let currentEpoch: number | undefined = 1;
const calls: string[] = [];
try {
store.upsert({
profileKey: "leader:previous-id",
target: { chatId: 7, threadId: 10 },
status: "active",
createdAtMs: 1000,
updatedAtMs: 1000,
instanceId: "previous-instance",
slot: "B",
});
await store.persist();
await assert.rejects(
ensureTelegramLeaderThreadBinding({
getAllowedUserId: () => 7,
instanceId: "leader-a",
cwd: "/repo",
topicTargetStore: store,
getCurrentLeaderEpoch: () => currentEpoch,
async callApi<TResponse>(method: string) {
calls.push(method);
currentEpoch = undefined;
return {} as TResponse;
},
recordEvent() {},
}),
/ownership changed during topic reconciliation/,
);
assert.deepEqual(calls, ["closeForumTopic"]);
assert.equal(store.getByProfileKey("leader:previous-id")?.status, "active");
assert.deepEqual(store.listReservations(), []);
} finally {
await rm(dir, { force: true, recursive: true });
}
});
test("Leader thread sync gets next monotonic slot after D on reload", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-leader-sync-d-to-e-"));
const store = createTelegramTopicTargetStore({
path: join(dir, "state.json"),
getNowMs: () => 2000,
});
try {
store.upsert({
profileKey: "leader:old-a",
target: { chatId: 7, threadId: 10 },
status: "active",
createdAtMs: 1000,
updatedAtMs: 1000,
instanceId: "previous-instance",
slot: "D",
});
await store.persist();
await ensureTelegramLeaderThreadBinding({
getAllowedUserId: () => 7,
instanceId: "leader-a",
cwd: "/repo",
topicTargetStore: store,
async callApi<TResponse>() {
return { message_thread_id: 11 } as TResponse;
},
recordEvent() {},
});
const [reservation] = store.listReservations();
assert.equal(reservation?.slot, "D");
assert.equal(
reservation?.reason,
"previous-process-cleaned-without-visible-probe",
);
const newRecord = store.getByProfileKey("cwd:/repo");
assert.equal(newRecord?.slot, "E");
} finally {
await rm(dir, { force: true, recursive: true });
}
});
test("Leader thread sync can force-refresh unnamed stale-prone leader bindings", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-leader-sync-refresh-"));
const store = createTelegramTopicTargetStore({
path: join(dir, "telegram-targets.json"),
getNowMs: () => 2000,
});
const calls: Array<{ method: string; body: Record<string, unknown> }> = [];
try {
store.upsert({
profileKey: "cwd:/repo",
target: { chatId: 7, threadId: 10 },
status: "active",
createdAtMs: 1000,
updatedAtMs: 1000,
instanceId: "leader-a",
slot: "A",
});
await store.persist();
const result = await ensureTelegramLeaderThreadBinding({
getAllowedUserId: () => 7,
instanceId: "leader-a",
cwd: "/repo",
forceFreshUnnamed: true,
topicTargetStore: store,
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return { message_thread_id: 11 } as TResponse;
},
recordEvent() {},
});
assert.deepEqual(result, {
target: { chatId: 7, threadId: 11 },
slot: "A",
threadName: "Atlas",
reused: false,
});
assert.deepEqual(calls, [
{ method: "createForumTopic", body: { chat_id: 7, name: "Atlas" } },
{
method: "closeForumTopic",
body: { chat_id: 7, message_thread_id: 10 },
},
]);
assert.equal(store.getByProfileKey("cwd:/repo")?.target.threadId, 11);
} finally {
await rm(dir, { force: true, recursive: true });
}
});
test("Leader thread sync force-refresh preserves promoted follower bindings", async () => {
const dir = await mkdtemp(
join(tmpdir(), "pi-telegram-promoted-follower-refresh-"),
);
const store = createTelegramTopicTargetStore({
path: join(dir, "state.json"),
getNowMs: () => 2000,
});
const calls: Array<{ method: string; body: Record<string, unknown> }> = [];
try {
store.upsert({
profileKey: "manual:follower-b",
owner: { kind: "manual-follower", instanceId: "follower-b" },
target: { chatId: 7, threadId: 12 },
status: "active",
createdAtMs: 1000,
updatedAtMs: 1000,
instanceId: "follower-b",
slot: "B",
});
await store.persist();
const result = await ensureTelegramLeaderThreadBinding({
getAllowedUserId: () => 7,
instanceId: "follower-b",
cwd: "/repo",
forceFreshUnnamed: true,
topicTargetStore: store,
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return { message_thread_id: 99 } as TResponse;
},
recordEvent() {},
});
assert.deepEqual(result, {
target: { chatId: 7, threadId: 12 },
slot: "B",
reused: true,
});
assert.deepEqual(calls, []);
assert.equal(
store.getActiveByInstanceId("follower-b")?.target.threadId,
12,
);
} finally {
await rm(dir, { force: true, recursive: true });
}
});
test("Topic lifecycle sync marks known topics stale or active", async () => {
const store = createTopicStore([
{ target: { chatId: 7, threadId: 42 }, status: "active" },
]);
const events: Array<Record<string, unknown>> = [];
const handler = createTelegramTopicLifecycleSyncHandler({
topicTargetStore: store,
isBusEnabled: () => true,
async callApi() {
throw new Error("unexpected API call");
},
recordEvent(_category, _message, details) {
events.push(details ?? {});
},
});
await handler({
kind: "closed",
target: { chatId: 7, threadId: 42 },
message: {},
});
assert.equal(store.records[0]?.status, "stale");
assert.equal(store.persisted, true);
assert.equal(events[0]?.phase, "topic-lifecycle");
assert.equal(events[0]?.changed, true);
store.persisted = false;
await handler({
kind: "reopened",
target: { chatId: 7, threadId: 42 },
message: {},
});
assert.equal(store.records[0]?.status, "active");
assert.equal(store.persisted, true);
});
test("Topic lifecycle sync loads persisted bindings before deleting created topics", async () => {
const calls: Array<{ method: string; body: unknown }> = [];
let loaded = false;
const store = {
persisted: false,
records: [] as Array<{
profileKey: string;
target: { chatId: number; threadId: number };
status: "active";
createdAtMs: number;
updatedAtMs: number;
}>,
async load() {
loaded = true;
this.records = [
{
profileKey: "manual:follower",
target: { chatId: 7, threadId: 42 },
status: "active",
createdAtMs: 1000,
updatedAtMs: 1000,
},
];
},
list() {
return this.records;
},
listReservations() {
return [];
},
listPendingProvisions() {
return [];
},
markStaleByTarget() {
return false;
},
markActiveByTarget() {
return false;
},
removePendingProvision() {
return false;
},
async persist() {
this.persisted = true;
},
};
const handler = createTelegramTopicLifecycleSyncHandler({
topicTargetStore: store,
isBusEnabled: () => true,
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return {} as TResponse;
},
});
await handler({
kind: "created",
target: { chatId: 7, threadId: 42 },
message: {},
});
assert.equal(loaded, true);
assert.deepEqual(calls, []);
assert.equal(store.persisted, false);
});
test("Topic lifecycle sync observes unknown created topics without deleting them", async () => {
const store = createTopicStore();
const calls: Array<{ method: string; body: unknown }> = [];
const events: Array<Record<string, unknown>> = [];
const handler = createTelegramTopicLifecycleSyncHandler({
topicTargetStore: store,
isBusEnabled: () => true,
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return {} as TResponse;
},
recordEvent(_category, _message, details) {
events.push(details ?? {});
},
});
await handler({
kind: "created",
target: { chatId: 7, threadId: 42 },
message: {},
});
assert.deepEqual(calls, []);
assert.equal(store.persisted, false);
assert.equal(events[0]?.phase, "topic-lifecycle-unknown-created-observed");
const classicCalls: Array<{ method: string; body: unknown }> = [];
store.persisted = false;
const classicHandler = createTelegramTopicLifecycleSyncHandler({
topicTargetStore: store,
isBusEnabled: () => false,
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
classicCalls.push({ method, body });
return {} as TResponse;
},
});
await classicHandler({
kind: "created",
target: { chatId: 7, threadId: 43 },
message: {},
});
assert.deepEqual(classicCalls, []);
assert.equal(store.persisted, false);
});
test("Topic lifecycle sync does not delete unknown created topics during provisioning", async () => {
const store = createTopicStore();
const calls: Array<{ method: string; body: unknown }> = [];
const events: Array<Record<string, unknown>> = [];
const handler = createTelegramTopicLifecycleSyncHandler({
topicTargetStore: store,
isBusEnabled: () => true,
isTopicProvisioningActive: () => true,
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return {} as TResponse;
},
recordEvent(_category, _message, details) {
events.push(details ?? {});
},
});
await handler({
kind: "created",
target: { chatId: 7, threadId: 44 },
message: {},
});
assert.deepEqual(calls, []);
assert.equal(store.persisted, false);
assert.equal(events[0]?.phase, "topic-lifecycle-provisioning-skip");
});
test("Manual follower disconnect preserves its Telegram thread binding", async () => {
const calls: unknown[] = [];
let offlineCalls = 0;
let persisted = 0;
let syncState = createUnknownTelegramSyncState();
const disconnect = createTelegramManualThreadDisconnectHandler({
instanceId: "follower-runtime:1",
getCurrentThreadRecord: () => ({
owner: { kind: "manual-follower" },
instanceId: "follower-runtime:1",
target: { chatId: 7, threadId: 42 },
}),
topicTargetStore: {
markOfflineByInstanceId: () => {
offlineCalls += 1;
return 1;
},
persist: async () => {
persisted += 1;
},
},
callApi: async <TResponse>(method: string, body: Record<string, unknown>) => {
calls.push({ method, body });
return { ok: true } as TResponse;
},
getLeaderTarget: () => undefined,
clearLeaderTarget: () => undefined,
getSyncState: () => syncState,
setSyncState: (state) => {
syncState = state;
},
stopPolling: async () => "stopped",
recordRuntimeEvent: () => undefined,
getNowMs: () => 2000,
});
assert.equal(await disconnect(), "stopped");
assert.deepEqual(calls, []);
assert.equal(offlineCalls, 0);
assert.equal(persisted, 0);
});
test("Manual leader disconnect stops local teardown after epoch loss", async () => {
const trace: string[] = [];
let currentEpoch: number | undefined = 1;
let syncState = createUnknownTelegramSyncState();
const disconnect = createTelegramManualThreadDisconnectHandler({
instanceId: "leader-runtime:1",
getCurrentThreadRecord: () => ({
owner: { kind: "leader" },
instanceId: "leader-runtime:1",
target: { chatId: 7, threadId: 42 },
}),
topicTargetStore: {
markOfflineByInstanceId: () => {
trace.push("mark-offline");
return 1;
},
persist: async () => {
trace.push("persist");
},
},
callApi: async <TResponse>(method: string) => {
trace.push(method);
currentEpoch = undefined;
return { ok: true } as TResponse;
},
getCurrentLeaderEpoch: () => currentEpoch,
getLeaderTarget: () => {
trace.push("get-leader-target");
return { chatId: 7, threadId: 42 };
},
clearLeaderTarget: () => {
trace.push("clear-leader-target");
},
getSyncState: () => syncState,
setSyncState: (state) => {
trace.push("set-sync-state");
syncState = state;
},
stopPolling: async () => {
trace.push("stop-polling");
return "stopped";
},
recordRuntimeEvent: () => undefined,
});
assert.equal(await disconnect(), "stopped");
assert.deepEqual(trace, ["closeForumTopic", "stop-polling"]);
});
test("Manual leader disconnect rechecks epoch after zero-change offline mutation", async () => {
const trace: string[] = [];
let currentEpoch: number | undefined = 1;
let syncState = createUnknownTelegramSyncState();
const disconnect = createTelegramManualThreadDisconnectHandler({
instanceId: "leader-runtime:1",
getCurrentThreadRecord: () => ({
owner: { kind: "leader" },
instanceId: "leader-runtime:1",
target: { chatId: 7, threadId: 42 },
}),
topicTargetStore: {
markOfflineByInstanceId: () => {
trace.push("mark-offline");
currentEpoch = undefined;
return 0;
},
persist: async () => {
trace.push("persist");
},
},
callApi: async <TResponse>(method: string) => {
trace.push(method);
return { ok: true } as TResponse;
},
getCurrentLeaderEpoch: () => currentEpoch,
getLeaderTarget: () => {
trace.push("get-leader-target");
return { chatId: 7, threadId: 42 };
},
clearLeaderTarget: () => {
trace.push("clear-leader-target");
},
getSyncState: () => syncState,
setSyncState: (state) => {
trace.push("set-sync-state");
syncState = state;
},
stopPolling: async () => {
trace.push("stop-polling");
return "stopped";
},
recordRuntimeEvent: () => undefined,
});
assert.equal(await disconnect(), "stopped");
assert.deepEqual(trace, [
"closeForumTopic",
"deleteForumTopic",
"mark-offline",
"stop-polling",
]);
});