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)
1527 lines
42 KiB
TypeScript
1527 lines
42 KiB
TypeScript
/**
|
|
* Regression tests for Telegram multi-instance bus helpers
|
|
* Covers the serializable leader/follower IPC contract and live follower registry behavior
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
existsSync,
|
|
mkdtempSync,
|
|
rmSync,
|
|
statSync,
|
|
unlinkSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
classifyTelegramBusTransportError,
|
|
getTelegramBusFollowerEndpoint,
|
|
getTelegramBusLeaderEndpoint,
|
|
getTelegramBusTransportKind,
|
|
getTelegramBusTransportRetryPolicy,
|
|
probeTelegramBusEndpoint,
|
|
} from "../lib/bus-transport.ts";
|
|
import {
|
|
createTelegramBusFollowerRegistry,
|
|
createTelegramBusForeignOwnedUpdateForwarder,
|
|
createTelegramFollowerApiCallAuthorizer,
|
|
createTelegramBusLocalServer,
|
|
createTelegramBusProcessRuntime,
|
|
createTelegramBusRequestId,
|
|
createTelegramBusRequestIdFactory,
|
|
encodeTelegramBusEnvelope,
|
|
getTelegramBusFollowerSocketPath,
|
|
getTelegramBusSocketPath,
|
|
getTelegramFollowerTargetOwnership,
|
|
isTelegramFollowerApiCallAllowed,
|
|
markTelegramBusAggregateDelivery,
|
|
parseTelegramBusEnvelope,
|
|
stripTelegramBusApiMetadata,
|
|
sendTelegramBusLocalEnvelope,
|
|
} from "../lib/bus.ts";
|
|
|
|
test("Bus process runtime resolves live profile endpoints", () => {
|
|
let profileName: string | undefined;
|
|
const runtime = createTelegramBusProcessRuntime({
|
|
getActiveProfileName: () => profileName,
|
|
pid: 42,
|
|
parentPid: 7,
|
|
parentProcessIdentity: "7:start:test",
|
|
createdAtMs: 1000,
|
|
});
|
|
assert.equal(runtime.instanceId, "42:1000");
|
|
assert.equal(runtime.manualFollowerOwnerId, "7:start:test");
|
|
const defaultLeaderPath = runtime.getLeaderSocketPath();
|
|
const defaultFollowerPath = runtime.getFollowerSocketPath();
|
|
profileName = "work";
|
|
assert.notEqual(runtime.getLeaderSocketPath(), defaultLeaderPath);
|
|
assert.notEqual(runtime.getFollowerSocketPath(), defaultFollowerPath);
|
|
assert.match(runtime.getLeaderSocketPath(), /work/);
|
|
assert.match(runtime.getFollowerSocketPath(), /work/);
|
|
});
|
|
|
|
test("Bus process runtime falls back to pid without a parent pid", () => {
|
|
const runtime = createTelegramBusProcessRuntime({
|
|
getActiveProfileName: () => undefined,
|
|
pid: 42,
|
|
parentPid: 0,
|
|
parentProcessIdentity: "42:start:test",
|
|
createdAtMs: 1000,
|
|
});
|
|
assert.equal(runtime.manualFollowerOwnerId, "42:start:test");
|
|
});
|
|
|
|
test("Bus transport boundary derives socket and pipe endpoints", () => {
|
|
assert.equal(
|
|
getTelegramBusLeaderEndpoint({ agentDir: "/agent", platform: "linux" }),
|
|
join("/agent", "tmp", "telegram", "bus.sock"),
|
|
);
|
|
assert.equal(
|
|
getTelegramBusFollowerEndpoint({
|
|
agentDir: "/agent",
|
|
platform: "linux",
|
|
instanceId: "pid:123",
|
|
}),
|
|
join("/agent", "tmp", "telegram", "followers", "pid_123.sock"),
|
|
);
|
|
const pipe = getTelegramBusLeaderEndpoint({
|
|
agentDir: "C:\\Users\\Admin\\.pi\\agent",
|
|
platform: "win32",
|
|
});
|
|
assert.match(pipe, /^\\\\\.\\pipe\\pi-telegram-.+-bus$/);
|
|
assert.equal(getTelegramBusTransportKind(pipe), "pipe");
|
|
assert.equal(getTelegramBusTransportKind("/tmp/bus.sock"), "socket");
|
|
});
|
|
|
|
test("Bus transport retry policy is operation-aware", () => {
|
|
const pipe = getTelegramBusLeaderEndpoint({
|
|
agentDir: "C:\\Users\\Admin\\.pi\\agent",
|
|
platform: "win32",
|
|
});
|
|
assert.deepEqual(
|
|
getTelegramBusTransportRetryPolicy({
|
|
endpoint: pipe,
|
|
operation: "operation",
|
|
}),
|
|
{ attempts: 3, delayMs: 100 },
|
|
);
|
|
assert.deepEqual(
|
|
getTelegramBusTransportRetryPolicy({
|
|
endpoint: "/tmp/bus.sock",
|
|
operation: "registration",
|
|
}),
|
|
{ attempts: 10, delayMs: 150 },
|
|
);
|
|
assert.deepEqual(
|
|
getTelegramBusTransportRetryPolicy({
|
|
endpoint: "/tmp/bus.sock",
|
|
operation: "registration",
|
|
overrides: { attempts: 2, delayMs: 5 },
|
|
}),
|
|
{ attempts: 2, delayMs: 5 },
|
|
);
|
|
assert.equal(
|
|
getTelegramBusTransportRetryPolicy({
|
|
endpoint: "/tmp/bus.sock",
|
|
operation: "operation",
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test("Bus transport error classifier marks transient IPC failures retryable", () => {
|
|
const error = Object.assign(new Error("connect ENOENT"), {
|
|
code: "ENOENT",
|
|
syscall: "connect",
|
|
});
|
|
assert.deepEqual(classifyTelegramBusTransportError(error), {
|
|
message: "connect ENOENT",
|
|
code: "ENOENT",
|
|
syscall: "connect",
|
|
kind: "connect",
|
|
retryable: true,
|
|
});
|
|
assert.deepEqual(
|
|
classifyTelegramBusTransportError(
|
|
Object.assign(new Error("operation expired"), { code: "ETIMEDOUT" }),
|
|
),
|
|
{
|
|
message: "operation expired",
|
|
code: "ETIMEDOUT",
|
|
syscall: undefined,
|
|
kind: "timeout",
|
|
retryable: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
test("Bus socket path is scoped under the agent temp directory", () => {
|
|
assert.equal(
|
|
getTelegramBusSocketPath("/agent"),
|
|
join("/agent", "tmp", "telegram", "bus.sock"),
|
|
);
|
|
assert.equal(
|
|
getTelegramBusFollowerSocketPath("pid:123", "/agent"),
|
|
join("/agent", "tmp", "telegram", "followers", "pid_123.sock"),
|
|
);
|
|
});
|
|
|
|
test("Bus socket paths isolate named profiles and preserve default Unix paths", () => {
|
|
assert.equal(
|
|
getTelegramBusSocketPath("/agent", "linux", "work"),
|
|
join("/agent", "tmp", "telegram", "bus.work.sock"),
|
|
);
|
|
assert.equal(
|
|
getTelegramBusFollowerSocketPath("pid:123", "/agent", "linux", "work"),
|
|
join("/agent", "tmp", "telegram", "followers", "work", "pid_123.sock"),
|
|
);
|
|
assert.notEqual(
|
|
getTelegramBusSocketPath("/agent", "linux", "work"),
|
|
getTelegramBusSocketPath("/agent", "linux", "personal"),
|
|
);
|
|
});
|
|
|
|
test("Bus socket path uses profile-scoped Windows named pipes on win32", () => {
|
|
assert.match(
|
|
getTelegramBusSocketPath("C:\\Users\\me\\.pi\\agent", "win32"),
|
|
/^\\\\\.\\pipe\\pi-telegram-[A-Za-z0-9_-]{16}-bus$/,
|
|
);
|
|
assert.match(
|
|
getTelegramBusFollowerSocketPath(
|
|
"pid:123/unsafe",
|
|
"C:\\Users\\me\\.pi\\agent",
|
|
"win32",
|
|
),
|
|
/^\\\\\.\\pipe\\pi-telegram-[A-Za-z0-9_-]{16}-follower-pid_123_unsafe$/,
|
|
);
|
|
assert.match(
|
|
getTelegramBusSocketPath("C:\\Users\\me\\.pi\\agent", "win32", "work"),
|
|
/^\\\\\.\\pipe\\pi-telegram-[A-Za-z0-9_-]{16}-bus-work$/,
|
|
);
|
|
assert.match(
|
|
getTelegramBusFollowerSocketPath(
|
|
"pid:123/unsafe",
|
|
"C:\\Users\\me\\.pi\\agent",
|
|
"win32",
|
|
"work",
|
|
),
|
|
/^\\\\\.\\pipe\\pi-telegram-[A-Za-z0-9_-]{16}-follower-work-pid_123_unsafe$/,
|
|
);
|
|
});
|
|
|
|
test("Bus request id factory owns one monotonic instance sequence", () => {
|
|
const createRequestId = createTelegramBusRequestIdFactory("inst-a");
|
|
|
|
assert.equal(createRequestId(), "inst-a:1");
|
|
assert.equal(createRequestId(), "inst-a:2");
|
|
assert.equal(createRequestId(), "inst-a:3");
|
|
});
|
|
|
|
test("Bus contract encodes and parses follower registration envelopes", () => {
|
|
const envelope = {
|
|
kind: "follower.register" as const,
|
|
requestId: createTelegramBusRequestId({
|
|
instanceId: "inst-a",
|
|
sequence: 1,
|
|
}),
|
|
registration: {
|
|
instanceId: "inst-a",
|
|
profileKey: "repo:/work/project",
|
|
threadName: "Eagle",
|
|
slot: "E",
|
|
cwd: "/work/project",
|
|
pid: 123,
|
|
target: { chatId: -1007, threadId: 42 },
|
|
connectedAtMs: 1000,
|
|
},
|
|
};
|
|
|
|
assert.deepEqual(
|
|
parseTelegramBusEnvelope(encodeTelegramBusEnvelope(envelope).trimEnd()),
|
|
envelope,
|
|
);
|
|
});
|
|
|
|
test("Bus contract encodes and parses follower target replacement envelopes", () => {
|
|
assert.deepEqual(
|
|
parseTelegramBusEnvelope(
|
|
encodeTelegramBusEnvelope({
|
|
kind: "leader.replaceFollowerTarget",
|
|
requestId: "leader:6",
|
|
recipientInstanceId: "inst-b",
|
|
target: { chatId: 7, threadId: 42 },
|
|
oldTarget: { chatId: 7, threadId: 10 },
|
|
reason: "thread-restore",
|
|
sentAtMs: 6000,
|
|
}).trimEnd(),
|
|
),
|
|
{
|
|
kind: "leader.replaceFollowerTarget",
|
|
requestId: "leader:6",
|
|
recipientInstanceId: "inst-b",
|
|
target: { chatId: 7, threadId: 42 },
|
|
oldTarget: { chatId: 7, threadId: 10 },
|
|
reason: "thread-restore",
|
|
sentAtMs: 6000,
|
|
},
|
|
);
|
|
assert.equal(
|
|
parseTelegramBusEnvelope(
|
|
JSON.stringify({
|
|
kind: "leader.replaceFollowerTarget",
|
|
requestId: "leader:bad",
|
|
recipientInstanceId: "inst-b",
|
|
target: { chatId: 7 },
|
|
reason: "thread-restore",
|
|
sentAtMs: 6000,
|
|
}),
|
|
),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test("Bus contract encodes and parses follower API call envelopes", () => {
|
|
assert.deepEqual(
|
|
parseTelegramBusEnvelope(
|
|
encodeTelegramBusEnvelope({
|
|
kind: "follower.callApi",
|
|
requestId: "inst-a:4",
|
|
instanceId: "inst-a",
|
|
method: "sendRichMessage",
|
|
args: [{ chat_id: 1, rich_message: { markdown: "hi" } }],
|
|
sentAtMs: 4000,
|
|
}).trimEnd(),
|
|
),
|
|
{
|
|
kind: "follower.callApi",
|
|
requestId: "inst-a:4",
|
|
instanceId: "inst-a",
|
|
method: "sendRichMessage",
|
|
args: [{ chat_id: 1, rich_message: { markdown: "hi" } }],
|
|
sentAtMs: 4000,
|
|
},
|
|
);
|
|
});
|
|
|
|
test("Bus contract encodes and parses forwarded update envelopes", () => {
|
|
assert.deepEqual(
|
|
parseTelegramBusEnvelope(
|
|
encodeTelegramBusEnvelope({
|
|
kind: "leader.forwardCallback",
|
|
requestId: "leader:2",
|
|
recipientInstanceId: "inst-b",
|
|
query: { id: "cb-1", data: "continue" },
|
|
sentAtMs: 2000,
|
|
}).trimEnd(),
|
|
),
|
|
{
|
|
kind: "leader.forwardCallback",
|
|
requestId: "leader:2",
|
|
recipientInstanceId: "inst-b",
|
|
query: { id: "cb-1", data: "continue" },
|
|
sentAtMs: 2000,
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(
|
|
parseTelegramBusEnvelope(
|
|
encodeTelegramBusEnvelope({
|
|
kind: "leader.forwardReaction",
|
|
requestId: "leader:3",
|
|
recipientInstanceId: "inst-b",
|
|
reactionUpdate: { message_id: 9, new_reaction: [] },
|
|
sentAtMs: 3000,
|
|
}).trimEnd(),
|
|
),
|
|
{
|
|
kind: "leader.forwardReaction",
|
|
requestId: "leader:3",
|
|
recipientInstanceId: "inst-b",
|
|
reactionUpdate: { message_id: 9, new_reaction: [] },
|
|
sentAtMs: 3000,
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(
|
|
parseTelegramBusEnvelope(
|
|
encodeTelegramBusEnvelope({
|
|
kind: "leader.forwardMessage",
|
|
requestId: "leader:4",
|
|
recipientInstanceId: "inst-b",
|
|
message: { message_id: 10 },
|
|
sentAtMs: 4000,
|
|
}).trimEnd(),
|
|
),
|
|
{
|
|
kind: "leader.forwardMessage",
|
|
requestId: "leader:4",
|
|
recipientInstanceId: "inst-b",
|
|
message: { message_id: 10 },
|
|
sentAtMs: 4000,
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(
|
|
parseTelegramBusEnvelope(
|
|
encodeTelegramBusEnvelope({
|
|
kind: "leader.forwardEditedMessage",
|
|
requestId: "leader:5",
|
|
recipientInstanceId: "inst-b",
|
|
message: { message_id: 11 },
|
|
sentAtMs: 5000,
|
|
}).trimEnd(),
|
|
),
|
|
{
|
|
kind: "leader.forwardEditedMessage",
|
|
requestId: "leader:5",
|
|
recipientInstanceId: "inst-b",
|
|
message: { message_id: 11 },
|
|
sentAtMs: 5000,
|
|
},
|
|
);
|
|
});
|
|
|
|
test("Bus contract rejects malformed envelopes", () => {
|
|
assert.equal(parseTelegramBusEnvelope("not-json"), undefined);
|
|
assert.equal(
|
|
parseTelegramBusEnvelope(JSON.stringify({ kind: "unknown" })),
|
|
undefined,
|
|
);
|
|
assert.equal(
|
|
parseTelegramBusEnvelope(
|
|
JSON.stringify({
|
|
kind: "follower.register",
|
|
requestId: "bad:1",
|
|
registration: { instanceId: "inst", target: { chatId: "bad" } },
|
|
}),
|
|
),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test("Bus follower registry registers, heartbeats, and prunes live instances", () => {
|
|
const registry = createTelegramBusFollowerRegistry();
|
|
|
|
assert.deepEqual(
|
|
registry.register({
|
|
instanceId: "inst-a",
|
|
threadName: "alpha",
|
|
target: { chatId: 1 },
|
|
connectedAtMs: 1000,
|
|
}),
|
|
{
|
|
instanceId: "inst-a",
|
|
threadName: "alpha",
|
|
target: { chatId: 1 },
|
|
connectedAtMs: 1000,
|
|
lastHeartbeatMs: 1000,
|
|
},
|
|
);
|
|
registry.register({
|
|
instanceId: "inst-b",
|
|
threadName: "beta",
|
|
target: { chatId: 2, threadId: 20 },
|
|
connectedAtMs: 1500,
|
|
});
|
|
|
|
assert.equal(registry.heartbeat("missing", 2000), undefined);
|
|
assert.equal(registry.heartbeat("inst-a", 2200)?.lastHeartbeatMs, 2200);
|
|
assert.deepEqual(
|
|
registry.list().map((follower) => follower.instanceId),
|
|
["inst-a", "inst-b"],
|
|
);
|
|
assert.deepEqual(
|
|
registry.pruneStale(3000, 1000).map((follower) => follower.instanceId),
|
|
["inst-b"],
|
|
);
|
|
assert.deepEqual(
|
|
registry.list().map((follower) => follower.instanceId),
|
|
["inst-a"],
|
|
);
|
|
assert.equal(registry.remove("inst-a"), true);
|
|
registry.register({ instanceId: "inst-c", connectedAtMs: 4000 });
|
|
registry.clear();
|
|
assert.deepEqual(registry.list(), []);
|
|
});
|
|
|
|
test("Bus follower API authorizer delegates message ownership with follower identity", () => {
|
|
const calls: string[] = [];
|
|
const follower = {
|
|
instanceId: "follower-a",
|
|
connectedAtMs: 1,
|
|
lastHeartbeatMs: 2,
|
|
target: { chatId: 7, threadId: 11 },
|
|
};
|
|
const authorize = createTelegramFollowerApiCallAuthorizer({
|
|
isMessageOwned({ chatId, messageId, follower: owner }) {
|
|
calls.push(`${owner.instanceId}:${chatId}:${messageId}`);
|
|
return true;
|
|
},
|
|
});
|
|
|
|
assert.equal(
|
|
authorize({
|
|
follower,
|
|
method: "call",
|
|
args: [
|
|
"editMessageText",
|
|
{ chat_id: 7, message_thread_id: 11, message_id: 9 },
|
|
],
|
|
}),
|
|
true,
|
|
);
|
|
assert.deepEqual(calls, ["follower-a:7:9"]);
|
|
});
|
|
|
|
test("Bus follower API allowlist permits scoped own-thread voice uploads", () => {
|
|
const follower = {
|
|
instanceId: "inst-a",
|
|
connectedAtMs: 1000,
|
|
lastHeartbeatMs: 1000,
|
|
target: { chatId: 10, threadId: 42 },
|
|
};
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "callMultipart",
|
|
args: [
|
|
"sendVoice",
|
|
{ chat_id: 10, message_thread_id: 42 },
|
|
"voice",
|
|
"/tmp/voice.opus",
|
|
"voice.opus",
|
|
],
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "callMultipart",
|
|
args: [
|
|
"sendVoice",
|
|
{ chat_id: "10", message_thread_id: "42" },
|
|
"voice",
|
|
"/tmp/voice.opus",
|
|
"voice.opus",
|
|
],
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "callMultipart",
|
|
args: [
|
|
"sendAudio",
|
|
{ chat_id: 10, message_thread_id: 42 },
|
|
"audio",
|
|
"/tmp/audio.mp3",
|
|
"audio.mp3",
|
|
],
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "callMultipart",
|
|
args: [
|
|
"sendVoice",
|
|
{ chat_id: 10 },
|
|
"voice",
|
|
"/tmp/voice.opus",
|
|
"voice.opus",
|
|
],
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("Bus follower API allowlist permits chat message edit/delete operations", () => {
|
|
const follower = {
|
|
instanceId: "inst-a",
|
|
connectedAtMs: 1000,
|
|
lastHeartbeatMs: 1000,
|
|
target: { chatId: 100, threadId: 42 },
|
|
};
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: ["editMessageText", { chat_id: 100, message_id: 9, text: "Next" }],
|
|
isMessageOwned: (chatId, messageId) => chatId === 100 && messageId === 9,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: ["deleteMessage", { chat_id: "100", message_id: "9" }],
|
|
isMessageOwned: (chatId, messageId) => chatId === 100 && messageId === 9,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: ["deleteMessage", { chat_id: 100, message_id: 10 }],
|
|
isMessageOwned: () => false,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: ["deleteMessage", { chat_id: 101, message_id: 9 }],
|
|
isMessageOwned: () => true,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: ["deleteMessage", { chat_id: 100 }],
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("Bus follower API allowlist permits bot command registration", () => {
|
|
const follower = {
|
|
instanceId: "inst-a",
|
|
connectedAtMs: 1000,
|
|
lastHeartbeatMs: 1000,
|
|
target: { chatId: 100, threadId: 42 },
|
|
};
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: [
|
|
"setMyCommands",
|
|
{ commands: [{ command: "start", description: "Start" }] },
|
|
],
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: ["setMyCommands", { commands: [{ command: "start" }] }],
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("Bus follower API allowlist permits own-chat typing and safe identity reads", () => {
|
|
const follower = {
|
|
instanceId: "inst-a",
|
|
connectedAtMs: 1000,
|
|
lastHeartbeatMs: 1000,
|
|
target: { chatId: 100, threadId: 42 },
|
|
};
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: ["getMe", {}],
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: ["sendChatAction", { chat_id: 100, action: "typing" }],
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: ["sendChatAction", { chat_id: 101, action: "typing" }],
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("Bus follower API allowlist permits marked aggregate delivery only in its own chat", () => {
|
|
const follower = {
|
|
instanceId: "inst-a",
|
|
connectedAtMs: 1000,
|
|
lastHeartbeatMs: 1000,
|
|
target: { chatId: 100, threadId: 42 },
|
|
};
|
|
const marked = markTelegramBusAggregateDelivery({
|
|
chat_id: 100,
|
|
text: "Aggregate",
|
|
});
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: ["sendMessage", marked],
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: ["sendMessage", { chat_id: 100, text: "Unmarked" }],
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: [
|
|
"sendMessage",
|
|
markTelegramBusAggregateDelivery({ chat_id: 101, text: "Wrong chat" }),
|
|
],
|
|
}),
|
|
false,
|
|
);
|
|
assert.deepEqual(stripTelegramBusApiMetadata(marked), {
|
|
chat_id: 100,
|
|
text: "Aggregate",
|
|
});
|
|
});
|
|
|
|
test("Bus follower API allowlist permits scoped own-topic rename only", () => {
|
|
const follower = {
|
|
instanceId: "inst-a",
|
|
connectedAtMs: 1000,
|
|
lastHeartbeatMs: 1000,
|
|
target: { chatId: 100, threadId: 42 },
|
|
};
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: [
|
|
"editForumTopic",
|
|
{ chat_id: 100, message_thread_id: 42, name: "Qname" },
|
|
],
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: [
|
|
"editForumTopic",
|
|
{ chat_id: 100, message_thread_id: 99, name: "Wrong" },
|
|
],
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("Bus follower API allowlist permits scoped own-topic cleanup only", () => {
|
|
const follower = {
|
|
instanceId: "inst-a",
|
|
connectedAtMs: 1000,
|
|
lastHeartbeatMs: 1000,
|
|
target: { chatId: 100, threadId: 42 },
|
|
};
|
|
for (const methodName of ["closeForumTopic", "deleteForumTopic"]) {
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: [methodName, { chat_id: 100, message_thread_id: 42 }],
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isTelegramFollowerApiCallAllowed({
|
|
follower,
|
|
method: "call",
|
|
args: [methodName, { chat_id: 100, message_thread_id: 99 }],
|
|
}),
|
|
false,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("Bus transport probe reports reachable and unreachable endpoints", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-probe-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: () => ({ kind: "bus.ack", requestId: "probe", ok: true }),
|
|
});
|
|
try {
|
|
const missing = await probeTelegramBusEndpoint({
|
|
endpoint: socketPath,
|
|
timeoutMs: 50,
|
|
});
|
|
assert.equal(missing.reachable, false);
|
|
assert.equal(missing.transport, "socket");
|
|
await server.start();
|
|
const reachable = await probeTelegramBusEndpoint({
|
|
endpoint: socketPath,
|
|
timeoutMs: 50,
|
|
});
|
|
assert.deepEqual(reachable, {
|
|
endpoint: socketPath,
|
|
transport: "socket",
|
|
reachable: true,
|
|
});
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus local server resolves the active profile endpoint on each start", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-profile-switch-"));
|
|
let profileName = "work";
|
|
const getSocketPath = () =>
|
|
getTelegramBusSocketPath(dir, "linux", profileName);
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath: getSocketPath,
|
|
handleEnvelope: () => ({ kind: "bus.ack", requestId: "profile", ok: true }),
|
|
});
|
|
const workSocketPath = getSocketPath();
|
|
try {
|
|
await server.start();
|
|
assert.equal(existsSync(workSocketPath), true);
|
|
await server.stop();
|
|
assert.equal(existsSync(workSocketPath), false);
|
|
|
|
profileName = "personal";
|
|
const personalSocketPath = getSocketPath();
|
|
await server.start();
|
|
assert.notEqual(personalSocketPath, workSocketPath);
|
|
assert.equal(existsSync(personalSocketPath), true);
|
|
assert.equal(existsSync(workSocketPath), false);
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Old bus server stop cannot invalidate a replacement endpoint generation", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-generation-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const first = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: (envelope) => ({
|
|
kind: "bus.ack",
|
|
requestId: envelope.requestId,
|
|
ok: false,
|
|
message: "first",
|
|
}),
|
|
});
|
|
const replacement = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: (envelope) => ({
|
|
kind: "bus.ack",
|
|
requestId: envelope.requestId,
|
|
ok: true,
|
|
message: "replacement",
|
|
}),
|
|
});
|
|
try {
|
|
await first.start();
|
|
await replacement.start();
|
|
await first.stop();
|
|
|
|
assert.equal(
|
|
(
|
|
await probeTelegramBusEndpoint({
|
|
endpoint: socketPath,
|
|
timeoutMs: 50,
|
|
})
|
|
).reachable,
|
|
true,
|
|
);
|
|
const response = await sendTelegramBusLocalEnvelope({
|
|
socketPath,
|
|
envelope: {
|
|
kind: "follower.heartbeat",
|
|
requestId: "replacement-generation",
|
|
instanceId: "follower-a",
|
|
sentAtMs: 2000,
|
|
},
|
|
});
|
|
assert.deepEqual(response, {
|
|
kind: "bus.ack",
|
|
requestId: "replacement-generation",
|
|
ok: true,
|
|
message: "replacement",
|
|
});
|
|
} finally {
|
|
await first.stop();
|
|
await replacement.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Stale bus server cannot publish over a replacement endpoint generation", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-publish-fence-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
let releasePublication: (() => void) | undefined;
|
|
let signalPublicationReady: (() => void) | undefined;
|
|
const publicationReady = new Promise<void>((resolve) => {
|
|
signalPublicationReady = resolve;
|
|
});
|
|
const publicationRelease = new Promise<void>((resolve) => {
|
|
releasePublication = resolve;
|
|
});
|
|
const stale = createTelegramBusLocalServer({
|
|
socketPath,
|
|
beforeEndpointPublication: async () => {
|
|
signalPublicationReady?.();
|
|
await publicationRelease;
|
|
},
|
|
commitEndpointPublication: () => false,
|
|
handleEnvelope: () => undefined,
|
|
});
|
|
const replacement = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: (envelope) => ({
|
|
kind: "bus.ack",
|
|
requestId: envelope.requestId,
|
|
ok: true,
|
|
message: "replacement",
|
|
}),
|
|
});
|
|
try {
|
|
const staleStart = stale.start();
|
|
await publicationReady;
|
|
await replacement.start();
|
|
releasePublication?.();
|
|
await assert.rejects(staleStart, /lost transport ownership/);
|
|
|
|
const response = await sendTelegramBusLocalEnvelope({
|
|
socketPath,
|
|
envelope: {
|
|
kind: "follower.heartbeat",
|
|
requestId: "replacement-after-stale-publication",
|
|
instanceId: "follower-a",
|
|
sentAtMs: 2000,
|
|
},
|
|
});
|
|
assert.deepEqual(response, {
|
|
kind: "bus.ack",
|
|
requestId: "replacement-after-stale-publication",
|
|
ok: true,
|
|
message: "replacement",
|
|
});
|
|
} finally {
|
|
releasePublication?.();
|
|
await stale.stop();
|
|
await replacement.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus local server rebinds an externally unlinked Unix endpoint", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-rebind-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const phases: string[] = [];
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: () => ({ kind: "bus.ack", requestId: "rebind", ok: true }),
|
|
recordTransportEvent: (phase) => phases.push(phase),
|
|
});
|
|
try {
|
|
await server.start();
|
|
unlinkSync(socketPath);
|
|
assert.equal(existsSync(socketPath), false);
|
|
|
|
assert.equal(await server.ensureEndpoint(), true);
|
|
assert.equal(existsSync(socketPath), true);
|
|
assert.equal(await server.ensureEndpoint(), false);
|
|
assert.deepEqual(
|
|
phases.filter((phase) => phase.includes("endpoint")),
|
|
["server-endpoint-missing", "server-endpoint-recovered"],
|
|
);
|
|
assert.equal(
|
|
(
|
|
await probeTelegramBusEndpoint({
|
|
endpoint: socketPath,
|
|
timeoutMs: 50,
|
|
})
|
|
).reachable,
|
|
true,
|
|
);
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus local client transport events include request diagnostics", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-client-events-"));
|
|
const socketPath = join(dir, "missing.sock");
|
|
const events: Array<{ phase: string; details: Record<string, unknown> }> = [];
|
|
try {
|
|
await assert.rejects(
|
|
sendTelegramBusLocalEnvelope({
|
|
socketPath,
|
|
envelope: {
|
|
kind: "follower.heartbeat",
|
|
requestId: "inst-a:events",
|
|
instanceId: "inst-a",
|
|
sentAtMs: 2000,
|
|
},
|
|
recordTransportEvent: (phase, details) =>
|
|
events.push({ phase, details }),
|
|
}),
|
|
);
|
|
assert.equal(events.length, 1);
|
|
assert.equal(events[0].phase, "client-failed");
|
|
assert.equal(events[0].details.envelopeKind, "follower.heartbeat");
|
|
assert.equal(events[0].details.requestId, "inst-a:events");
|
|
assert.equal(events[0].details.transport, "socket");
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus local client classifies response timeouts as transport timeouts", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-client-timeout-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const events: Array<{ phase: string; details: Record<string, unknown> }> = [];
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: () => undefined,
|
|
});
|
|
try {
|
|
await server.start();
|
|
await assert.rejects(
|
|
sendTelegramBusLocalEnvelope({
|
|
socketPath,
|
|
timeoutMs: 5,
|
|
envelope: {
|
|
kind: "follower.heartbeat",
|
|
requestId: "inst-a:timeout",
|
|
instanceId: "inst-a",
|
|
sentAtMs: 2000,
|
|
},
|
|
recordTransportEvent: (phase, details) =>
|
|
events.push({ phase, details }),
|
|
}),
|
|
);
|
|
assert.equal(events[0].phase, "client-failed");
|
|
assert.equal(events[0].details.code, "ETIMEDOUT");
|
|
assert.equal(events[0].details.kind, "timeout");
|
|
assert.equal(events[0].details.retryable, true);
|
|
assert.equal(events[0].details.requestId, "inst-a:timeout");
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus local server memoizes completed and in-flight request results", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-ledger-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
let executions = 0;
|
|
let release: (() => void) | undefined;
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
async handleEnvelope(envelope) {
|
|
executions += 1;
|
|
await gate;
|
|
return {
|
|
kind: "bus.ack",
|
|
requestId: envelope.requestId,
|
|
ok: true,
|
|
result: { executions },
|
|
};
|
|
},
|
|
});
|
|
try {
|
|
await server.start();
|
|
const envelope = {
|
|
kind: "follower.heartbeat" as const,
|
|
requestId: "follower-a:ledger:1",
|
|
instanceId: "follower-a",
|
|
sentAtMs: 1000,
|
|
};
|
|
const first = sendTelegramBusLocalEnvelope({ socketPath, envelope });
|
|
const duplicate = sendTelegramBusLocalEnvelope({ socketPath, envelope });
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
assert.equal(executions, 1);
|
|
release?.();
|
|
const [firstResult, duplicateResult] = await Promise.all([first, duplicate]);
|
|
const replayResult = await sendTelegramBusLocalEnvelope({
|
|
socketPath,
|
|
envelope,
|
|
});
|
|
assert.deepEqual(firstResult, duplicateResult);
|
|
assert.deepEqual(replayResult, firstResult);
|
|
assert.equal(executions, 1);
|
|
} finally {
|
|
release?.();
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus retry returns the memoized result after the first acknowledgement is lost", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-ledger-ack-loss-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
let executions = 0;
|
|
let dropped = false;
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope(envelope) {
|
|
executions += 1;
|
|
return {
|
|
kind: "bus.ack",
|
|
requestId: envelope.requestId,
|
|
ok: true,
|
|
result: { message_id: 77 },
|
|
};
|
|
},
|
|
shouldDropResponse() {
|
|
if (dropped) return false;
|
|
dropped = true;
|
|
return true;
|
|
},
|
|
});
|
|
try {
|
|
await server.start();
|
|
const response = await sendTelegramBusLocalEnvelope({
|
|
socketPath,
|
|
timeoutMs: 10,
|
|
retry: { attempts: 2, delayMs: 0 },
|
|
envelope: {
|
|
kind: "follower.callApi",
|
|
requestId: "follower-a:send:1",
|
|
instanceId: "follower-a",
|
|
method: "call",
|
|
args: ["sendMessage", { chat_id: 1, text: "hello" }],
|
|
sentAtMs: 1000,
|
|
},
|
|
});
|
|
assert.deepEqual(response, {
|
|
kind: "bus.ack",
|
|
requestId: "follower-a:send:1",
|
|
ok: true,
|
|
message: undefined,
|
|
result: { message_id: 77 },
|
|
});
|
|
assert.equal(executions, 1);
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus local server rejects request-id reuse with a changed payload", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-ledger-collision-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
let executions = 0;
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope(envelope) {
|
|
executions += 1;
|
|
return {
|
|
kind: "bus.ack",
|
|
requestId: envelope.requestId,
|
|
ok: true,
|
|
};
|
|
},
|
|
});
|
|
try {
|
|
await server.start();
|
|
const first = {
|
|
kind: "follower.heartbeat" as const,
|
|
requestId: "follower-a:collision:1",
|
|
instanceId: "follower-a",
|
|
sentAtMs: 1000,
|
|
};
|
|
await sendTelegramBusLocalEnvelope({ socketPath, envelope: first });
|
|
const collision = await sendTelegramBusLocalEnvelope({
|
|
socketPath,
|
|
envelope: { ...first, sentAtMs: 1001 },
|
|
});
|
|
assert.equal(executions, 1);
|
|
assert.deepEqual(collision, {
|
|
kind: "bus.ack",
|
|
requestId: first.requestId,
|
|
ok: false,
|
|
message: "Telegram bus request id was reused with a different payload.",
|
|
error: { code: "request-id-collision" },
|
|
});
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus local IPC server reports handler failures as protocol acks", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-handler-failure-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const events: Array<{ phase: string; details: Record<string, unknown> }> = [];
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: () => {
|
|
throw new Error("boom");
|
|
},
|
|
recordTransportEvent: (phase, details) => events.push({ phase, details }),
|
|
});
|
|
try {
|
|
await server.start();
|
|
const response = await sendTelegramBusLocalEnvelope({
|
|
socketPath,
|
|
envelope: {
|
|
kind: "follower.heartbeat",
|
|
requestId: "inst-a:failed-handler",
|
|
instanceId: "inst-a",
|
|
sentAtMs: 2000,
|
|
},
|
|
});
|
|
assert.deepEqual(response, {
|
|
kind: "bus.ack",
|
|
requestId: "inst-a:failed-handler",
|
|
ok: false,
|
|
message: "Telegram bus handler failed.",
|
|
});
|
|
const failure = events.find(
|
|
(event) => event.phase === "server-handler-failed",
|
|
);
|
|
assert.equal(failure?.details.envelopeKind, "follower.heartbeat");
|
|
assert.equal(failure?.details.requestId, "inst-a:failed-handler");
|
|
assert.equal(failure?.details.transport, "socket");
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus local IPC server handles request/response envelopes over a private Unix socket", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const received: string[] = [];
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: (envelope) => {
|
|
received.push(envelope.kind);
|
|
return { kind: "bus.ack", requestId: envelope.requestId, ok: true };
|
|
},
|
|
});
|
|
try {
|
|
await server.start();
|
|
assert.equal(statSync(dir).mode & 0o777, 0o700);
|
|
assert.equal(statSync(socketPath).mode & 0o777, 0o600);
|
|
const response = await sendTelegramBusLocalEnvelope({
|
|
socketPath,
|
|
envelope: {
|
|
kind: "follower.heartbeat",
|
|
requestId: "inst-a:2",
|
|
instanceId: "inst-a",
|
|
sentAtMs: 2000,
|
|
},
|
|
});
|
|
assert.deepEqual(response, {
|
|
kind: "bus.ack",
|
|
requestId: "inst-a:2",
|
|
ok: true,
|
|
message: undefined,
|
|
});
|
|
assert.deepEqual(received, ["follower.heartbeat"]);
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test(
|
|
"Bus local IPC server roundtrips over a Windows named pipe",
|
|
{ skip: process.platform !== "win32" },
|
|
async () => {
|
|
const socketPath = getTelegramBusSocketPath(
|
|
join(tmpdir(), `pi-telegram-bus-win-${process.pid}`),
|
|
"win32",
|
|
);
|
|
const received: string[] = [];
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: (envelope) => {
|
|
received.push(envelope.kind);
|
|
return { kind: "bus.ack", requestId: envelope.requestId, ok: true };
|
|
},
|
|
});
|
|
try {
|
|
await server.start();
|
|
const response = await sendTelegramBusLocalEnvelope({
|
|
socketPath,
|
|
envelope: {
|
|
kind: "follower.heartbeat",
|
|
requestId: "inst-a:win",
|
|
instanceId: "inst-a",
|
|
sentAtMs: 2000,
|
|
},
|
|
});
|
|
assert.deepEqual(response, {
|
|
kind: "bus.ack",
|
|
requestId: "inst-a:win",
|
|
ok: true,
|
|
message: undefined,
|
|
});
|
|
assert.deepEqual(received, ["follower.heartbeat"]);
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
},
|
|
);
|
|
|
|
test("Bus foreign-owned update forwarder sends routed update envelopes", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-forwarder-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const received: unknown[] = [];
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: (envelope) => {
|
|
received.push(envelope);
|
|
return { kind: "bus.ack", requestId: envelope.requestId, ok: true };
|
|
},
|
|
});
|
|
let sequence = 0;
|
|
const forwarder = createTelegramBusForeignOwnedUpdateForwarder({
|
|
socketPath,
|
|
createRequestId: () => `leader:${++sequence}`,
|
|
getNowMs: () => 9000,
|
|
});
|
|
try {
|
|
await server.start();
|
|
assert.equal(
|
|
await forwarder.forwardCallback({
|
|
query: { id: "cb-1" },
|
|
ownership: { instanceId: "inst-b" },
|
|
ctx: "ctx",
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
await forwarder.forwardReaction({
|
|
reactionUpdate: { message_id: 7 },
|
|
ownership: { instanceId: "inst-b" },
|
|
ctx: "ctx",
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
await forwarder.forwardMessage({
|
|
message: { message_id: 8 },
|
|
ownership: { instanceId: "inst-b" },
|
|
ctx: "ctx",
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
await forwarder.forwardEditedMessage({
|
|
message: { message_id: 9 },
|
|
ownership: { instanceId: "inst-b" },
|
|
ctx: "ctx",
|
|
}),
|
|
true,
|
|
);
|
|
assert.deepEqual(received, [
|
|
{
|
|
kind: "leader.forwardCallback",
|
|
requestId: "leader:1",
|
|
recipientInstanceId: "inst-b",
|
|
query: { id: "cb-1" },
|
|
sentAtMs: 9000,
|
|
},
|
|
{
|
|
kind: "leader.forwardReaction",
|
|
requestId: "leader:2",
|
|
recipientInstanceId: "inst-b",
|
|
reactionUpdate: { message_id: 7 },
|
|
sentAtMs: 9000,
|
|
},
|
|
{
|
|
kind: "leader.forwardMessage",
|
|
requestId: "leader:3",
|
|
recipientInstanceId: "inst-b",
|
|
message: { message_id: 8 },
|
|
sentAtMs: 9000,
|
|
},
|
|
{
|
|
kind: "leader.forwardEditedMessage",
|
|
requestId: "leader:4",
|
|
recipientInstanceId: "inst-b",
|
|
message: { message_id: 9 },
|
|
sentAtMs: 9000,
|
|
},
|
|
]);
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus foreign-owned update forwarder supports tolerant timeouts", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-forwarder-slow-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
async handleEnvelope(envelope) {
|
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
return { kind: "bus.ack", requestId: envelope.requestId, ok: true };
|
|
},
|
|
});
|
|
const forwarder = createTelegramBusForeignOwnedUpdateForwarder({
|
|
socketPath,
|
|
createRequestId: () => "leader:slow",
|
|
timeoutMs: 120,
|
|
});
|
|
try {
|
|
await server.start();
|
|
assert.equal(
|
|
await forwarder.forwardMessage({
|
|
message: { message_id: 8 },
|
|
ownership: { instanceId: "inst-b" },
|
|
ctx: "ctx",
|
|
}),
|
|
true,
|
|
);
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower registry resolves followers by target", () => {
|
|
const registry = createTelegramBusFollowerRegistry();
|
|
registry.register({
|
|
instanceId: "private",
|
|
target: { chatId: 1 },
|
|
connectedAtMs: 1000,
|
|
});
|
|
registry.register({
|
|
instanceId: "thread",
|
|
target: { chatId: 1, threadId: 2 },
|
|
connectedAtMs: 1000,
|
|
});
|
|
|
|
assert.equal(registry.getByTarget({ chatId: 1 })?.instanceId, "private");
|
|
assert.equal(
|
|
registry.getByTarget({ chatId: 1, threadId: 2 })?.instanceId,
|
|
"thread",
|
|
);
|
|
assert.equal(registry.getByTarget({ chatId: 1, threadId: 3 }), undefined);
|
|
});
|
|
|
|
test("Bus follower registry replaces stale registrations by profile and target", () => {
|
|
const registry = createTelegramBusFollowerRegistry();
|
|
registry.register({
|
|
instanceId: "follower:old",
|
|
profileKey: "manual:follower",
|
|
target: { chatId: 1, threadId: 2 },
|
|
connectedAtMs: 1000,
|
|
});
|
|
registry.register({
|
|
instanceId: "follower:new",
|
|
profileKey: "manual:follower",
|
|
target: { chatId: 1, threadId: 2 },
|
|
connectedAtMs: 2000,
|
|
});
|
|
|
|
assert.equal(registry.get("follower:old"), undefined);
|
|
assert.equal(
|
|
registry.getByTarget({ chatId: 1, threadId: 2 })?.instanceId,
|
|
"follower:new",
|
|
);
|
|
assert.deepEqual(
|
|
registry.list().map((follower) => follower.instanceId),
|
|
["follower:new"],
|
|
);
|
|
});
|
|
|
|
test("Bus follower target ownership falls back only to follower thread records", () => {
|
|
assert.deepEqual(
|
|
getTelegramFollowerTargetOwnership({
|
|
target: { chatId: 1, threadId: 2 },
|
|
followers: [],
|
|
currentInstanceId: "leader",
|
|
activeThreadRecords: [
|
|
{
|
|
status: "active",
|
|
instanceId: "follower-a",
|
|
profileKey: "manual:follower-a",
|
|
owner: { kind: "manual-follower" },
|
|
target: { chatId: 1, threadId: 2 },
|
|
},
|
|
],
|
|
}),
|
|
{ instanceId: "follower-a" },
|
|
);
|
|
assert.equal(
|
|
getTelegramFollowerTargetOwnership({
|
|
target: { chatId: 1, threadId: 2 },
|
|
followers: [],
|
|
currentInstanceId: "leader-b",
|
|
activeThreadRecords: [
|
|
{
|
|
status: "active",
|
|
instanceId: "leader-a",
|
|
profileKey: "cwd:/repo",
|
|
owner: { kind: "leader" },
|
|
target: { chatId: 1, threadId: 2 },
|
|
},
|
|
],
|
|
}),
|
|
undefined,
|
|
);
|
|
assert.equal(
|
|
getTelegramFollowerTargetOwnership({
|
|
target: { chatId: 1, threadId: 2 },
|
|
followers: [],
|
|
currentInstanceId: "leader",
|
|
activeThreadRecords: [
|
|
{
|
|
status: "offline",
|
|
instanceId: "follower-a",
|
|
profileKey: "manual:follower-a",
|
|
owner: { kind: "manual-follower" },
|
|
target: { chatId: 1, threadId: 2 },
|
|
},
|
|
],
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test("Bus follower registry returns defensive copies", () => {
|
|
const registry = createTelegramBusFollowerRegistry();
|
|
const registered = registry.register({
|
|
instanceId: "inst-a",
|
|
target: { chatId: 1, threadId: 2 },
|
|
connectedAtMs: 1000,
|
|
});
|
|
registered.target = { chatId: 99 };
|
|
|
|
assert.deepEqual(registry.get("inst-a")?.target, { chatId: 1, threadId: 2 });
|
|
const byTarget = registry.getByTarget({ chatId: 1, threadId: 2 });
|
|
if (byTarget) byTarget.target = { chatId: 99 };
|
|
assert.deepEqual(registry.getByTarget({ chatId: 1, threadId: 2 })?.target, {
|
|
chatId: 1,
|
|
threadId: 2,
|
|
});
|
|
});
|