mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
c2d8b3be4d
* fix(telegram): resolve legacy state owners explicitly * docs(changelog): note Telegram migration ownership fix * Revert "docs(changelog): note Telegram migration ownership fix" This reverts commit e02962eb4b2e2475854adf8c2a2915bcfd1cf65a.
854 lines
32 KiB
TypeScript
854 lines
32 KiB
TypeScript
// Telegram tests cover state migrations plugin behavior.
|
|
import { createHash } from "node:crypto";
|
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { Message } from "grammy/types";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
|
import { buildLegacyMigrationPreview } from "openclaw/plugin-sdk/runtime-doctor-migrations";
|
|
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { stateMigrations } from "../doctor-contract-api.js";
|
|
import { resolveTelegramBotInfoCachePath } from "./bot-info-cache.js";
|
|
import {
|
|
resolveTelegramMessageCachePath,
|
|
resolveTelegramMessageCachePersistentScopeKey,
|
|
} from "./message-cache-persistence.js";
|
|
import { detectTelegramLegacyStateMigrations } from "./state-migrations.js";
|
|
import {
|
|
resolveTopicNameCacheNamespace,
|
|
resolveTopicNameCachePath,
|
|
resolveTopicNameCacheScope,
|
|
} from "./topic-name-cache.js";
|
|
|
|
type PersistedCacheEntry = {
|
|
key: string;
|
|
node: {
|
|
sourceMessage: Message;
|
|
threadId?: string;
|
|
};
|
|
};
|
|
|
|
function persistedCacheEntry(messageId: number, text: string): PersistedCacheEntry {
|
|
return {
|
|
key: `default:7:${messageId}`,
|
|
node: {
|
|
sourceMessage: {
|
|
chat: { id: 7, type: "group", title: "Ops" },
|
|
message_id: messageId,
|
|
date: 1736380000 + messageId,
|
|
text,
|
|
from: { id: messageId, is_bot: false, first_name: `User ${messageId}` },
|
|
} as Message,
|
|
},
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
resetPluginStateStoreForTests();
|
|
});
|
|
|
|
describe("telegram state migrations", () => {
|
|
it("does not require a migration owner when multi-agent startup has no legacy artifacts", async () => {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
|
try {
|
|
const cfg = {
|
|
agents: {
|
|
ownership: "explicit",
|
|
entries: { main: {}, ops: {}, research: {} },
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
await expect(detectTelegramLegacyStateMigrations({ cfg, env })).resolves.toEqual([]);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("uses the materialized Telegram binding as legacy-state owner after H2 normalization", async () => {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
|
const legacyStorePath = path.join(dir, "sessions", "sessions.json");
|
|
const messageCachePath = resolveTelegramMessageCachePath(legacyStorePath);
|
|
const sentMessagePath = `${legacyStorePath}.telegram-sent-messages.json`;
|
|
const topicNamePath = resolveTopicNameCachePath(legacyStorePath);
|
|
const ownerStorePath = resolveStorePath(undefined, { env, agentId: "main" });
|
|
const ownerMessagePath = resolveTelegramMessageCachePath(ownerStorePath);
|
|
const ownerTopicNamespace = resolveTopicNameCacheNamespace(
|
|
resolveTopicNameCacheScope(ownerStorePath),
|
|
);
|
|
try {
|
|
await mkdir(path.dirname(legacyStorePath), { recursive: true });
|
|
await writeFile(messageCachePath, JSON.stringify([persistedCacheEntry(51, "bound owner")]));
|
|
await writeFile(sentMessagePath, JSON.stringify({ 7: { 51: Date.now() } }));
|
|
await writeFile(
|
|
topicNamePath,
|
|
JSON.stringify({ "7:51": { name: "Bound owner", updatedAt: Date.now() } }),
|
|
);
|
|
|
|
const cfg = {
|
|
agents: {
|
|
ownership: "explicit",
|
|
entries: { main: {}, ops: {}, research: {} },
|
|
},
|
|
bindings: [{ agentId: "main", match: { channel: "telegram", accountId: "*" } }],
|
|
} as OpenClawConfig;
|
|
const plans = await detectTelegramLegacyStateMigrations({ cfg, env });
|
|
const messagePlan = plans.find((plan) => plan.sourcePath === messageCachePath);
|
|
const sentPlan = plans.find((plan) => plan.sourcePath === sentMessagePath);
|
|
const topicPlan = plans.find((plan) => plan.sourcePath === topicNamePath);
|
|
|
|
expect(messagePlan).toMatchObject({
|
|
kind: "plugin-state-import",
|
|
scopeKey: resolveTelegramMessageCachePersistentScopeKey(ownerMessagePath),
|
|
});
|
|
expect(topicPlan).toMatchObject({
|
|
kind: "plugin-state-import",
|
|
namespace: ownerTopicNamespace,
|
|
});
|
|
if (!sentPlan || sentPlan.kind !== "plugin-state-import") {
|
|
throw new Error("expected Telegram sent-message import plan");
|
|
}
|
|
expect((await sentPlan.readEntries())[0]?.value).toMatchObject({
|
|
scopeKey: createHash("sha256").update(ownerStorePath, "utf8").digest("hex").slice(0, 24),
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("retains raw legacy default-marker ownership during rollback compatibility", async () => {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
|
const legacyStorePath = path.join(dir, "sessions", "sessions.json");
|
|
const sentMessagePath = `${legacyStorePath}.telegram-sent-messages.json`;
|
|
const ownerStorePath = resolveStorePath(undefined, { env, agentId: "ops" });
|
|
try {
|
|
await mkdir(path.dirname(legacyStorePath), { recursive: true });
|
|
await writeFile(sentMessagePath, JSON.stringify({ 7: { 52: Date.now() } }));
|
|
|
|
const cfg = {
|
|
agents: { list: [{ id: "main" }, { id: "ops", default: true }] },
|
|
} as OpenClawConfig;
|
|
const plans = await detectTelegramLegacyStateMigrations({ cfg, env });
|
|
const sentPlan = plans.find((plan) => plan.sourcePath === sentMessagePath);
|
|
if (!sentPlan || sentPlan.kind !== "plugin-state-import") {
|
|
throw new Error("expected Telegram sent-message import plan");
|
|
}
|
|
expect((await sentPlan.readEntries())[0]?.value).toMatchObject({
|
|
scopeKey: createHash("sha256").update(ownerStorePath, "utf8").digest("hex").slice(0, 24),
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("fails closed when legacy Telegram state has no explicit multi-agent owner", async () => {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
|
const legacyStorePath = path.join(dir, "sessions", "sessions.json");
|
|
const sentMessagePath = `${legacyStorePath}.telegram-sent-messages.json`;
|
|
try {
|
|
await mkdir(path.dirname(legacyStorePath), { recursive: true });
|
|
await writeFile(sentMessagePath, JSON.stringify({ 7: { 53: Date.now() } }));
|
|
|
|
const cfg = {
|
|
agents: { ownership: "explicit", entries: { main: {}, ops: {}, research: {} } },
|
|
} as OpenClawConfig;
|
|
await expect(detectTelegramLegacyStateMigrations({ cfg, env })).rejects.toMatchObject({
|
|
name: "AgentSelectionRequiredError",
|
|
code: "AGENT_SELECTION_REQUIRED",
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("fails closed when global legacy state spans multiple Telegram route owners", async () => {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
|
const legacyStorePath = path.join(dir, "sessions", "sessions.json");
|
|
const sentMessagePath = `${legacyStorePath}.telegram-sent-messages.json`;
|
|
try {
|
|
await mkdir(path.dirname(legacyStorePath), { recursive: true });
|
|
await writeFile(sentMessagePath, JSON.stringify({ 7: { 54: Date.now() } }));
|
|
|
|
const cfg = {
|
|
agents: { ownership: "explicit", entries: { main: {}, ops: {} } },
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
primary: { botToken: "123456:primary" },
|
|
alerts: { botToken: "123456:alerts" },
|
|
},
|
|
},
|
|
},
|
|
bindings: [
|
|
{ agentId: "main", match: { channel: "telegram", accountId: "primary" } },
|
|
{ agentId: "ops", match: { channel: "telegram", accountId: "alerts" } },
|
|
],
|
|
} as OpenClawConfig;
|
|
await expect(detectTelegramLegacyStateMigrations({ cfg, env })).rejects.toThrow(
|
|
/^Legacy Telegram state has multiple routed owners \((?:main, ops|ops, main)\)/,
|
|
);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("imports an account-scoped topic cache without requiring a global migration owner", async () => {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
|
const opsStorePath = resolveStorePath(undefined, { env, agentId: "ops" });
|
|
const topicNamePath = resolveTopicNameCachePath(opsStorePath);
|
|
try {
|
|
await mkdir(path.dirname(topicNamePath), { recursive: true });
|
|
await writeFile(
|
|
topicNamePath,
|
|
JSON.stringify({ "7:55": { name: "Ops", updatedAt: Date.now() } }),
|
|
);
|
|
|
|
const cfg = {
|
|
agents: { ownership: "explicit", entries: { main: {}, ops: {} } },
|
|
channels: { telegram: { accounts: { ops: { botToken: "123456:ops" } } } },
|
|
} as OpenClawConfig;
|
|
const plans = await detectTelegramLegacyStateMigrations({ cfg, env });
|
|
|
|
expect(plans.find((plan) => plan.sourcePath === topicNamePath)).toMatchObject({
|
|
kind: "plugin-state-import",
|
|
namespace: resolveTopicNameCacheNamespace(resolveTopicNameCacheScope(opsStorePath)),
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("detects legacy bot-info cache import", async () => {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
|
const persistedPath = resolveTelegramBotInfoCachePath("ops", env);
|
|
try {
|
|
await mkdir(path.dirname(persistedPath), { recursive: true });
|
|
await writeFile(
|
|
persistedPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
tokenFingerprint: "token:fingerprint",
|
|
fetchedAt: "2026-05-24T11:00:00.000Z",
|
|
botInfo: {
|
|
id: 123456,
|
|
is_bot: true,
|
|
first_name: "OpenClaw",
|
|
username: "openclaw_bot",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
ops: {
|
|
botToken: "123456:secret",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const plans = await detectTelegramLegacyStateMigrations({ cfg, env });
|
|
const botInfoPlan = plans.find(
|
|
(plan) =>
|
|
plan.kind === "plugin-state-import" && plan.label === "Telegram startup bot info cache",
|
|
);
|
|
|
|
expect(botInfoPlan).toMatchObject({
|
|
kind: "plugin-state-import",
|
|
sourcePath: persistedPath,
|
|
targetPath: "plugin state:telegram.bot-info-cache",
|
|
pluginId: "telegram",
|
|
namespace: "telegram.bot-info-cache",
|
|
scopeKey: "",
|
|
});
|
|
if (!botInfoPlan || botInfoPlan.kind !== "plugin-state-import") {
|
|
throw new Error("expected Telegram bot-info plugin-state import plan");
|
|
}
|
|
|
|
const entries = await botInfoPlan.readEntries();
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0]).toMatchObject({
|
|
key: "ops",
|
|
value: {
|
|
tokenFingerprint: "token:fingerprint",
|
|
fetchedAt: "2026-05-24T11:00:00.000Z",
|
|
botInfo: {
|
|
id: 123456,
|
|
username: "openclaw_bot",
|
|
},
|
|
},
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("detects legacy message-cache import for the runtime sidecar path", async () => {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
|
const storePath = resolveStorePath(undefined, { env, agentId: "main" });
|
|
const persistedPath = resolveTelegramMessageCachePath(storePath);
|
|
try {
|
|
await mkdir(path.dirname(persistedPath), { recursive: true });
|
|
const arrayEntry = persistedCacheEntry(9201, 'doctor preserves ]{"key": text');
|
|
arrayEntry.key = "default:7:9999";
|
|
arrayEntry.node.sourceMessage = {
|
|
...arrayEntry.node.sourceMessage,
|
|
openclaw_prompt_context_projection: {
|
|
transcriptMessageId: "must-not-be-inferred",
|
|
partIndex: 0,
|
|
finalPart: true,
|
|
},
|
|
} as Message;
|
|
const appendedEntry = persistedCacheEntry(9202, "doctor imports appended JSONL");
|
|
appendedEntry.node.threadId = "42";
|
|
const invalidKeyEntry = persistedCacheEntry(9203, "invalid key");
|
|
invalidKeyEntry.key = "orphan";
|
|
const invalidDateEntry = persistedCacheEntry(9204, "invalid date");
|
|
invalidDateEntry.node.sourceMessage = {
|
|
...invalidDateEntry.node.sourceMessage,
|
|
date: Number.NaN,
|
|
} as Message;
|
|
await writeFile(
|
|
persistedPath,
|
|
[
|
|
`${JSON.stringify([arrayEntry])}${JSON.stringify(appendedEntry)}`,
|
|
JSON.stringify(invalidKeyEntry),
|
|
JSON.stringify(invalidDateEntry),
|
|
'{"key":"default:7:9205","node":',
|
|
].join("\n"),
|
|
);
|
|
|
|
const cfg = {
|
|
agents: {
|
|
list: [{ id: "ops", default: true }],
|
|
},
|
|
} as OpenClawConfig;
|
|
const plans = await detectTelegramLegacyStateMigrations({ cfg, env });
|
|
const messageCachePlan = plans.find(
|
|
(plan) =>
|
|
plan.kind === "plugin-state-import" &&
|
|
plan.label === "Telegram prompt-context message cache",
|
|
);
|
|
|
|
expect(messageCachePlan).toMatchObject({
|
|
kind: "plugin-state-import",
|
|
sourcePath: persistedPath,
|
|
targetPath: "plugin state:telegram.message-cache",
|
|
pluginId: "telegram",
|
|
namespace: "telegram.message-cache",
|
|
});
|
|
if (!messageCachePlan || messageCachePlan.kind !== "plugin-state-import") {
|
|
throw new Error("expected Telegram message-cache plugin-state import plan");
|
|
}
|
|
const entries = await messageCachePlan.readEntries();
|
|
expect(entries).toHaveLength(2);
|
|
expect(entries[0]).toMatchObject({
|
|
key: "default:7:9201",
|
|
value: { version: 1, sourceMessage: { text: 'doctor preserves ]{"key": text' } },
|
|
});
|
|
expect(entries[1]).toMatchObject({
|
|
key: "default:7:9202",
|
|
value: {
|
|
version: 1,
|
|
sourceMessage: { text: "doctor imports appended JSONL" },
|
|
threadId: "42",
|
|
},
|
|
});
|
|
expect(entries[0]?.value).not.toHaveProperty(
|
|
"sourceMessage.openclaw_prompt_context_projection",
|
|
);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("detects legacy topic-name cache import for an account-scoped runtime sidecar path", async () => {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
|
const storePath = resolveStorePath(undefined, { env, agentId: "ops" });
|
|
const persistedPath = resolveTopicNameCachePath(storePath);
|
|
const namespace = resolveTopicNameCacheNamespace(resolveTopicNameCacheScope(storePath));
|
|
try {
|
|
await mkdir(path.dirname(persistedPath), { recursive: true });
|
|
await writeFile(
|
|
persistedPath,
|
|
JSON.stringify({
|
|
"7:42": {
|
|
name: "Deployments",
|
|
iconColor: 0x6fb9f0,
|
|
updatedAt: 1736380000,
|
|
},
|
|
}),
|
|
);
|
|
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
ops: {
|
|
botToken: "123456:secret",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const plans = await detectTelegramLegacyStateMigrations({ cfg, env });
|
|
const topicNamePlan = plans.find(
|
|
(plan) =>
|
|
plan.kind === "plugin-state-import" && plan.label === "Telegram forum topic-name cache",
|
|
);
|
|
|
|
expect(topicNamePlan).toMatchObject({
|
|
kind: "plugin-state-import",
|
|
sourcePath: persistedPath,
|
|
targetPath: `plugin state:${namespace}`,
|
|
pluginId: "telegram",
|
|
namespace,
|
|
scopeKey: "",
|
|
});
|
|
if (!topicNamePlan || topicNamePlan.kind !== "plugin-state-import") {
|
|
throw new Error("expected Telegram topic-name plugin-state import plan");
|
|
}
|
|
|
|
const entries = await topicNamePlan.readEntries();
|
|
expect(entries).toStrictEqual([
|
|
{
|
|
key: "7:42",
|
|
value: {
|
|
name: "Deployments",
|
|
iconColor: 0x6fb9f0,
|
|
updatedAt: 1736380000,
|
|
},
|
|
},
|
|
]);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("detects legacy topic-name cache import for the global sidecar path", async () => {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
|
const legacyStorePath = path.join(dir, "sessions", "sessions.json");
|
|
const persistedPath = resolveTopicNameCachePath(legacyStorePath);
|
|
const defaultAccountStorePath = resolveStorePath(undefined, { env, agentId: "ops" });
|
|
const namespace = resolveTopicNameCacheNamespace(
|
|
resolveTopicNameCacheScope(defaultAccountStorePath),
|
|
);
|
|
try {
|
|
await mkdir(path.dirname(persistedPath), { recursive: true });
|
|
await writeFile(
|
|
persistedPath,
|
|
JSON.stringify({
|
|
"7:43": {
|
|
name: "Legacy Deployments",
|
|
iconColor: 0x6fb9f1,
|
|
updatedAt: 1736380001,
|
|
},
|
|
}),
|
|
);
|
|
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
ops: {
|
|
botToken: "123456:secret",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const plans = await detectTelegramLegacyStateMigrations({ cfg, env });
|
|
const topicNamePlan = plans.find(
|
|
(plan) =>
|
|
plan.kind === "plugin-state-import" && plan.label === "Telegram forum topic-name cache",
|
|
);
|
|
|
|
expect(topicNamePlan).toMatchObject({
|
|
kind: "plugin-state-import",
|
|
sourcePath: persistedPath,
|
|
targetPath: `plugin state:${namespace}`,
|
|
pluginId: "telegram",
|
|
namespace,
|
|
scopeKey: "",
|
|
});
|
|
if (!topicNamePlan || topicNamePlan.kind !== "plugin-state-import") {
|
|
throw new Error("expected Telegram topic-name plugin-state import plan");
|
|
}
|
|
|
|
const entries = await topicNamePlan.readEntries();
|
|
expect(entries).toStrictEqual([
|
|
{
|
|
key: "7:43",
|
|
value: {
|
|
name: "Legacy Deployments",
|
|
iconColor: 0x6fb9f1,
|
|
updatedAt: 1736380001,
|
|
},
|
|
},
|
|
]);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("detects remaining Telegram JSON sidecars for plugin-state import", async () => {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
|
const storePath = resolveStorePath(undefined, { env, agentId: "main" });
|
|
const now = Date.now();
|
|
const updateOffsetPath = path.join(dir, "telegram", "update-offset-ops.json");
|
|
const botInfoPath = resolveTelegramBotInfoCachePath("ops", env);
|
|
const stickerCachePath = path.join(dir, "telegram", "sticker-cache.json");
|
|
const messageCachePath = resolveTelegramMessageCachePath(storePath);
|
|
const sentMessagePath = `${storePath}.telegram-sent-messages.json`;
|
|
const topicNamePath = resolveTopicNameCachePath(storePath);
|
|
const threadBindingsPath = path.join(dir, "telegram", "thread-bindings-ops.json");
|
|
try {
|
|
await mkdir(path.dirname(updateOffsetPath), { recursive: true });
|
|
await mkdir(path.dirname(sentMessagePath), { recursive: true });
|
|
await writeFile(
|
|
updateOffsetPath,
|
|
JSON.stringify({
|
|
version: 3,
|
|
lastUpdateId: 12345,
|
|
botId: "123456",
|
|
tokenFingerprint: "token:fingerprint",
|
|
}),
|
|
);
|
|
await writeFile(
|
|
botInfoPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
tokenFingerprint: "token:fingerprint",
|
|
fetchedAt: "2026-05-24T11:00:00.000Z",
|
|
botInfo: { id: 123456, is_bot: true, first_name: "OpenClaw" },
|
|
}),
|
|
);
|
|
await writeFile(
|
|
stickerCachePath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
stickers: {
|
|
unique_sticker: {
|
|
fileId: "file-1",
|
|
fileUniqueId: "unique_sticker",
|
|
description: "Deploy sticker",
|
|
cachedAt: "2026-05-24T12:00:00.000Z",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
await writeFile(messageCachePath, JSON.stringify([persistedCacheEntry(42, "hello")]));
|
|
await writeFile(sentMessagePath, JSON.stringify({ 7: { 42: now } }));
|
|
await writeFile(
|
|
topicNamePath,
|
|
JSON.stringify({ "7:42": { name: "Deployments", updatedAt: now } }),
|
|
);
|
|
await writeFile(
|
|
threadBindingsPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
bindings: [
|
|
{
|
|
accountId: "ops",
|
|
conversationId: "-100:topic:7",
|
|
targetKind: "subagent",
|
|
targetSessionKey: "agent:main:subagent:child",
|
|
boundAt: now,
|
|
lastActivityAt: now,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
ops: {
|
|
botToken: "123456:secret",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const plans = await detectTelegramLegacyStateMigrations({ cfg, env });
|
|
|
|
expect(
|
|
plans.map((plan) => ({
|
|
kind: plan.kind,
|
|
label: plan.label,
|
|
sourcePath: plan.sourcePath,
|
|
targetPath: plan.targetPath,
|
|
namespace: plan.kind === "plugin-state-import" ? plan.namespace : null,
|
|
})),
|
|
).toEqual([
|
|
{
|
|
kind: "plugin-state-import",
|
|
label: "Telegram update offset",
|
|
sourcePath: updateOffsetPath,
|
|
targetPath: "plugin state:telegram.update-offsets",
|
|
namespace: "telegram.update-offsets",
|
|
},
|
|
{
|
|
kind: "plugin-state-import",
|
|
label: "Telegram startup bot info cache",
|
|
sourcePath: botInfoPath,
|
|
targetPath: "plugin state:telegram.bot-info-cache",
|
|
namespace: "telegram.bot-info-cache",
|
|
},
|
|
{
|
|
kind: "plugin-state-import",
|
|
label: "Telegram sticker cache",
|
|
sourcePath: stickerCachePath,
|
|
targetPath: "plugin state:telegram.sticker-cache",
|
|
namespace: "telegram.sticker-cache",
|
|
},
|
|
{
|
|
kind: "plugin-state-import",
|
|
label: "Telegram prompt-context message cache",
|
|
sourcePath: messageCachePath,
|
|
targetPath: "plugin state:telegram.message-cache",
|
|
namespace: "telegram.message-cache",
|
|
},
|
|
{
|
|
kind: "plugin-state-import",
|
|
label: "Telegram sent-message cache",
|
|
sourcePath: sentMessagePath,
|
|
targetPath: "plugin state:telegram.sent-messages",
|
|
namespace: "telegram.sent-messages",
|
|
},
|
|
{
|
|
kind: "plugin-state-import",
|
|
label: "Telegram forum topic-name cache",
|
|
sourcePath: topicNamePath,
|
|
targetPath: `plugin state:${resolveTopicNameCacheNamespace(resolveTopicNameCacheScope(storePath))}`,
|
|
namespace: resolveTopicNameCacheNamespace(resolveTopicNameCacheScope(storePath)),
|
|
},
|
|
{
|
|
kind: "plugin-state-import",
|
|
label: "Telegram thread bindings",
|
|
sourcePath: threadBindingsPath,
|
|
targetPath: "plugin state:telegram.thread-bindings",
|
|
namespace: "telegram.thread-bindings",
|
|
},
|
|
]);
|
|
await expect(
|
|
stateMigrations[0]?.detectLegacyState({
|
|
config: cfg,
|
|
env,
|
|
stateDir: dir,
|
|
oauthDir: path.join(dir, "credentials"),
|
|
context: { openPluginStateKeyedStore: vi.fn() } as never,
|
|
}),
|
|
).resolves.toEqual({ preview: plans.map(buildLegacyMigrationPreview) });
|
|
|
|
const byLabel = new Map(plans.map((plan) => [plan.label, plan]));
|
|
expect(byLabel.get("Telegram update offset")).toMatchObject({
|
|
kind: "plugin-state-import",
|
|
sourcePath: updateOffsetPath,
|
|
namespace: "telegram.update-offsets",
|
|
});
|
|
expect(byLabel.get("Telegram sticker cache")).toMatchObject({
|
|
kind: "plugin-state-import",
|
|
sourcePath: stickerCachePath,
|
|
namespace: "telegram.sticker-cache",
|
|
});
|
|
expect(byLabel.get("Telegram sent-message cache")).toMatchObject({
|
|
kind: "plugin-state-import",
|
|
sourcePath: sentMessagePath,
|
|
namespace: "telegram.sent-messages",
|
|
cleanupWhenEmpty: true,
|
|
});
|
|
expect(byLabel.get("Telegram thread bindings")).toMatchObject({
|
|
kind: "plugin-state-import",
|
|
sourcePath: threadBindingsPath,
|
|
namespace: "telegram.thread-bindings",
|
|
});
|
|
|
|
for (const label of [
|
|
"Telegram update offset",
|
|
"Telegram sticker cache",
|
|
"Telegram sent-message cache",
|
|
"Telegram thread bindings",
|
|
]) {
|
|
const plan = byLabel.get(label);
|
|
if (!plan || plan.kind !== "plugin-state-import") {
|
|
throw new Error(`expected plugin-state import plan: ${label}`);
|
|
}
|
|
expect(await plan.readEntries()).toHaveLength(1);
|
|
}
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("cleans up expired and boundary Telegram sent-message cache sidecars", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-07-15T12:00:00.000Z"));
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
|
const storePath = resolveStorePath(undefined, { env, agentId: "main" });
|
|
const sentMessagePath = `${storePath}.telegram-sent-messages.json`;
|
|
const expiredAt = Date.now() - 8 * 24 * 60 * 60 * 1000;
|
|
const boundaryAt = Date.now() - 24 * 60 * 60 * 1000;
|
|
try {
|
|
await mkdir(path.dirname(sentMessagePath), { recursive: true });
|
|
await writeFile(sentMessagePath, JSON.stringify({ 7: { 42: expiredAt, 43: boundaryAt } }));
|
|
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
ops: {
|
|
botToken: "test",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const plans = await detectTelegramLegacyStateMigrations({ cfg, env });
|
|
const expiredPlans = plans.filter(
|
|
(plan) => plan.kind === "plugin-state-import" && plan.sourcePath === sentMessagePath,
|
|
);
|
|
|
|
expect(expiredPlans).toHaveLength(1);
|
|
for (const plan of expiredPlans) {
|
|
expect(plan).toMatchObject({ cleanupSource: "rename", cleanupWhenEmpty: true });
|
|
if (plan.kind !== "plugin-state-import") {
|
|
throw new Error("expected Telegram TTL cache import plan");
|
|
}
|
|
expect(await plan.readEntries()).toStrictEqual([]);
|
|
}
|
|
} finally {
|
|
vi.useRealTimers();
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("detects Telegram account sidecars even after the account was removed from config", async () => {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
|
const updateOffsetPath = path.join(dir, "telegram", "update-offset-oldbot.json");
|
|
const threadBindingsPath = path.join(dir, "telegram", "thread-bindings-oldbot.json");
|
|
const now = Date.now();
|
|
try {
|
|
await mkdir(path.dirname(updateOffsetPath), { recursive: true });
|
|
await writeFile(
|
|
updateOffsetPath,
|
|
JSON.stringify({
|
|
version: 3,
|
|
lastUpdateId: 12345,
|
|
botId: "123456",
|
|
tokenFingerprint: "token:fingerprint",
|
|
}),
|
|
);
|
|
await writeFile(
|
|
threadBindingsPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
bindings: [
|
|
{
|
|
accountId: "oldbot",
|
|
conversationId: "-100:topic:7",
|
|
targetKind: "subagent",
|
|
targetSessionKey: "agent:main:subagent:child",
|
|
boundAt: now,
|
|
lastActivityAt: now,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const plans = await detectTelegramLegacyStateMigrations({ cfg: {}, env });
|
|
const updateOffsetPlan = plans.find((plan) => plan.sourcePath === updateOffsetPath);
|
|
const threadBindingsPlan = plans.find((plan) => plan.sourcePath === threadBindingsPath);
|
|
|
|
expect(updateOffsetPlan).toMatchObject({
|
|
kind: "plugin-state-import",
|
|
label: "Telegram update offset",
|
|
namespace: "telegram.update-offsets",
|
|
});
|
|
expect(threadBindingsPlan).toMatchObject({
|
|
kind: "plugin-state-import",
|
|
label: "Telegram thread bindings",
|
|
namespace: "telegram.thread-bindings",
|
|
});
|
|
if (!updateOffsetPlan || updateOffsetPlan.kind !== "plugin-state-import") {
|
|
throw new Error("expected orphaned update offset import plan");
|
|
}
|
|
if (!threadBindingsPlan || threadBindingsPlan.kind !== "plugin-state-import") {
|
|
throw new Error("expected orphaned thread bindings import plan");
|
|
}
|
|
expect(await updateOffsetPlan.readEntries()).toHaveLength(1);
|
|
expect(await threadBindingsPlan.readEntries()).toHaveLength(1);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("imports legacy sent-message sidecars into the current runtime scope", async () => {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
|
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
|
const storePath = resolveStorePath(undefined, { env, agentId: "main" });
|
|
const legacyStorePath = path.join(dir, "sessions", "sessions.json");
|
|
const currentSentPath = `${storePath}.telegram-sent-messages.json`;
|
|
const legacySentPath = `${legacyStorePath}.telegram-sent-messages.json`;
|
|
const now = Date.now();
|
|
try {
|
|
await mkdir(path.dirname(currentSentPath), { recursive: true });
|
|
await mkdir(path.dirname(legacySentPath), { recursive: true });
|
|
const sentPayload = JSON.stringify({ 7: { 42: now } });
|
|
await writeFile(currentSentPath, sentPayload);
|
|
await writeFile(legacySentPath, sentPayload);
|
|
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
ops: {
|
|
botToken: "123456:secret",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const plans = await detectTelegramLegacyStateMigrations({ cfg, env });
|
|
const importPlans = plans.filter((plan) => plan.kind === "plugin-state-import");
|
|
const currentSentPlan = importPlans.find(
|
|
(plan) =>
|
|
plan.label === "Telegram sent-message cache" && plan.sourcePath === currentSentPath,
|
|
);
|
|
const legacySentPlan = importPlans.find(
|
|
(plan) =>
|
|
plan.label === "Telegram sent-message cache" && plan.sourcePath === legacySentPath,
|
|
);
|
|
if (!currentSentPlan || !legacySentPlan) {
|
|
throw new Error("expected current and legacy session-store import plans");
|
|
}
|
|
|
|
const stripTtl = (entries: Awaited<ReturnType<typeof currentSentPlan.readEntries>>) =>
|
|
entries.map(({ ttlMs: _ttlMs, ...entry }) => entry);
|
|
const currentSentEntries = stripTtl(await currentSentPlan.readEntries());
|
|
expect(stripTtl(await legacySentPlan.readEntries())).toStrictEqual(currentSentEntries);
|
|
expect(currentSentEntries[0]?.value).toMatchObject({
|
|
scopeKey: createHash("sha256").update(storePath, "utf8").digest("hex").slice(0, 24),
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|