From 6754a926ee227f72f894044a883b5fdaad047662 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 03:25:59 +0000 Subject: [PATCH] fix(pairing): support legacy telegram allowFrom migration --- .../doctor-state-migrations.e2e.test.ts | 36 +++++++++++ src/commands/doctor.e2e-harness.ts | 10 ++++ src/infra/state-migrations.ts | 59 ++++++++++++++++++- src/pairing/pairing-store.test.ts | 34 +++++++++++ src/pairing/pairing-store.ts | 44 ++++++++++++-- 5 files changed, 175 insertions(+), 8 deletions(-) diff --git a/src/commands/doctor-state-migrations.e2e.test.ts b/src/commands/doctor-state-migrations.e2e.test.ts index ce8474f5468..5d43859173c 100644 --- a/src/commands/doctor-state-migrations.e2e.test.ts +++ b/src/commands/doctor-state-migrations.e2e.test.ts @@ -178,6 +178,42 @@ describe("doctor legacy state migrations", () => { expect(fs.existsSync(path.join(oauthDir, "creds.json"))).toBe(false); }); + it("migrates legacy Telegram pairing allowFrom store to account-scoped default file", async () => { + const root = await makeTempRoot(); + const cfg: OpenClawConfig = {}; + + const oauthDir = path.join(root, "credentials"); + fs.mkdirSync(oauthDir, { recursive: true }); + fs.writeFileSync( + path.join(oauthDir, "telegram-allowFrom.json"), + JSON.stringify( + { + version: 1, + allowFrom: ["123456"], + }, + null, + 2, + ) + "\n", + "utf-8", + ); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + expect(detected.pairingAllowFrom.hasLegacyTelegram).toBe(true); + + const result = await runLegacyStateMigrations({ detected, now: () => 123 }); + expect(result.warnings).toEqual([]); + + const target = path.join(oauthDir, "telegram-default-allowFrom.json"); + expect(fs.existsSync(target)).toBe(true); + expect(JSON.parse(fs.readFileSync(target, "utf-8"))).toEqual({ + version: 1, + allowFrom: ["123456"], + }); + }); + it("no-ops when nothing detected", async () => { const root = await makeTempRoot(); const cfg: OpenClawConfig = {}; diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index beb20a8180d..126d912f486 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -116,6 +116,11 @@ export const detectLegacyStateMigrations = vi.fn().mockResolvedValue({ targetDir: "/tmp/oauth/whatsapp/default", hasLegacy: false, }, + pairingAllowFrom: { + legacyTelegramPath: "/tmp/oauth/telegram-allowFrom.json", + targetTelegramPath: "/tmp/oauth/telegram-default-allowFrom.json", + hasLegacyTelegram: false, + }, preview: [], }) as unknown as MockFn; @@ -306,6 +311,11 @@ export async function arrangeLegacyStateMigrationTest(): Promise<{ targetDir: "/tmp/oauth/whatsapp/default", hasLegacy: false, }, + pairingAllowFrom: { + legacyTelegramPath: "/tmp/oauth/telegram-allowFrom.json", + targetTelegramPath: "/tmp/oauth/telegram-default-allowFrom.json", + hasLegacyTelegram: false, + }, preview: ["- Legacy sessions detected"], }); runLegacyStateMigrations.mockResolvedValueOnce({ diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 0556b562de2..7b570935a1d 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -55,6 +55,11 @@ export type LegacyStateDetection = { targetDir: string; hasLegacy: boolean; }; + pairingAllowFrom: { + legacyTelegramPath: string; + targetTelegramPath: string; + hasLegacyTelegram: boolean; + }; preview: string[]; }; @@ -612,6 +617,13 @@ export async function detectLegacyStateMigrations(params: { const hasLegacyWhatsAppAuth = fileExists(path.join(oauthDir, "creds.json")) && !fileExists(path.join(targetWhatsAppAuthDir, "creds.json")); + const legacyTelegramAllowFromPath = path.join(oauthDir, "telegram-allowFrom.json"); + const targetTelegramAllowFromPath = path.join( + oauthDir, + `telegram-${DEFAULT_ACCOUNT_ID}-allowFrom.json`, + ); + const hasLegacyTelegramAllowFrom = + fileExists(legacyTelegramAllowFromPath) && !fileExists(targetTelegramAllowFromPath); const preview: string[] = []; if (hasLegacySessions) { @@ -626,6 +638,11 @@ export async function detectLegacyStateMigrations(params: { if (hasLegacyWhatsAppAuth) { preview.push(`- WhatsApp auth: ${oauthDir} → ${targetWhatsAppAuthDir} (keep oauth.json)`); } + if (hasLegacyTelegramAllowFrom) { + preview.push( + `- Telegram pairing allowFrom: ${legacyTelegramAllowFromPath} → ${targetTelegramAllowFromPath}`, + ); + } return { targetAgentId, @@ -651,6 +668,11 @@ export async function detectLegacyStateMigrations(params: { targetDir: targetWhatsAppAuthDir, hasLegacy: hasLegacyWhatsAppAuth, }, + pairingAllowFrom: { + legacyTelegramPath: legacyTelegramAllowFromPath, + targetTelegramPath: targetTelegramAllowFromPath, + hasLegacyTelegram: hasLegacyTelegramAllowFrom, + }, preview, }; } @@ -867,6 +889,28 @@ async function migrateLegacyWhatsAppAuth( return { changes, warnings }; } +async function migrateLegacyTelegramPairingAllowFrom( + detected: LegacyStateDetection, +): Promise<{ changes: string[]; warnings: string[] }> { + const changes: string[] = []; + const warnings: string[] = []; + if (!detected.pairingAllowFrom.hasLegacyTelegram) { + return { changes, warnings }; + } + + const legacyPath = detected.pairingAllowFrom.legacyTelegramPath; + const targetPath = detected.pairingAllowFrom.targetTelegramPath; + try { + ensureDir(path.dirname(targetPath)); + fs.copyFileSync(legacyPath, targetPath); + changes.push(`Copied Telegram pairing allowFrom → ${targetPath}`); + } catch (err) { + warnings.push(`Failed migrating Telegram pairing allowFrom (${legacyPath}): ${String(err)}`); + } + + return { changes, warnings }; +} + export async function runLegacyStateMigrations(params: { detected: LegacyStateDetection; now?: () => number; @@ -876,9 +920,20 @@ export async function runLegacyStateMigrations(params: { const sessions = await migrateLegacySessions(detected, now); const agentDir = await migrateLegacyAgentDir(detected, now); const whatsappAuth = await migrateLegacyWhatsAppAuth(detected); + const telegramPairingAllowFrom = await migrateLegacyTelegramPairingAllowFrom(detected); return { - changes: [...sessions.changes, ...agentDir.changes, ...whatsappAuth.changes], - warnings: [...sessions.warnings, ...agentDir.warnings, ...whatsappAuth.warnings], + changes: [ + ...sessions.changes, + ...agentDir.changes, + ...whatsappAuth.changes, + ...telegramPairingAllowFrom.changes, + ], + warnings: [ + ...sessions.warnings, + ...agentDir.warnings, + ...whatsappAuth.warnings, + ...telegramPairingAllowFrom.warnings, + ], }; } diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index cb6db0be5ef..163c99e0641 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -184,4 +184,38 @@ describe("pairing store", () => { expect(channelScoped).not.toContain("12345"); }); }); + + it("reads legacy channel-scoped allowFrom for default account", async () => { + await withTempStateDir(async (stateDir) => { + const oauthDir = resolveOAuthDir(process.env, stateDir); + await fs.mkdir(oauthDir, { recursive: true }); + await fs.writeFile( + path.join(oauthDir, "telegram-allowFrom.json"), + JSON.stringify( + { + version: 1, + allowFrom: ["1001"], + }, + null, + 2, + ) + "\n", + "utf8", + ); + await fs.writeFile( + path.join(oauthDir, "telegram-default-allowFrom.json"), + JSON.stringify( + { + version: 1, + allowFrom: ["1002"], + }, + null, + 2, + ) + "\n", + "utf8", + ); + + const scoped = await readChannelAllowFromStore("telegram", process.env, "default"); + expect(scoped).toEqual(["1002", "1001"]); + }); + }); }); diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index 86f2cbe85c5..428acc289d4 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -234,6 +234,31 @@ function normalizeAllowFromInput(channel: PairingChannel, entry: string | number return normalizeAllowEntry(channel, normalizeId(entry)); } +function dedupePreserveOrder(entries: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const entry of entries) { + const normalized = String(entry).trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + out.push(normalized); + } + return out; +} + +async function readAllowFromStateForPath( + channel: PairingChannel, + filePath: string, +): Promise { + const { value } = await readJsonFile(filePath, { + version: 1, + allowFrom: [], + }); + return normalizeAllowFromList(channel, value); +} + async function readAllowFromState(params: { channel: PairingChannel; entry: string | number; @@ -291,12 +316,19 @@ export async function readChannelAllowFromStore( env: NodeJS.ProcessEnv = process.env, accountId?: string, ): Promise { - const filePath = resolveAllowFromPath(channel, env, accountId); - const { value } = await readJsonFile(filePath, { - version: 1, - allowFrom: [], - }); - return normalizeAllowFromList(channel, value); + const normalizedAccountId = accountId?.trim().toLowerCase() ?? ""; + if (!normalizedAccountId) { + const filePath = resolveAllowFromPath(channel, env); + return await readAllowFromStateForPath(channel, filePath); + } + + const scopedPath = resolveAllowFromPath(channel, env, accountId); + const scopedEntries = await readAllowFromStateForPath(channel, scopedPath); + // Backward compatibility: legacy channel-level allowFrom store was unscoped. + // Keep honoring it alongside account-scoped files to prevent re-pair prompts after upgrades. + const legacyPath = resolveAllowFromPath(channel, env); + const legacyEntries = await readAllowFromStateForPath(channel, legacyPath); + return dedupePreserveOrder([...scopedEntries, ...legacyEntries]); } export async function addChannelAllowFromStoreEntry(params: {