From 326d4049da7163ef397267750d9e6b7982160654 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 01:10:14 +0000 Subject: [PATCH] fix(telegram): migrate group config on supergroup IDs (#906) Thanks @sleontenko. Co-authored-by: Stan --- CHANGELOG.md | 1 + src/telegram/bot-handlers.ts | 30 ++++--- src/telegram/bot.ts | 2 + src/telegram/group-migration.test.ts | 113 +++++++++++++++++++++++++++ src/telegram/group-migration.ts | 84 ++++++++++++++++++++ 5 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 src/telegram/group-migration.test.ts create mode 100644 src/telegram/group-migration.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 38fb0bba8..89a899a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis. - Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver. - Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman. +- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko. - Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose. - Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4. - Discord: isolate autoThread thread context. (#856) — thanks @davidguttman. diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index ada96d4fd..04e1d4568 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -7,9 +7,12 @@ import { resolveTelegramForumThreadId } from "./bot/helpers.js"; import type { TelegramMessage } from "./bot/types.js"; import { firstDefined, isSenderAllowed, normalizeAllowFrom } from "./bot-access.js"; import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js"; +import { migrateTelegramGroupConfig } from "./group-migration.js"; import { readTelegramAllowFromStore } from "./pairing-store.js"; export const registerTelegramHandlers = ({ + cfg, + accountId, bot, opts, runtime, @@ -82,6 +85,7 @@ export const registerTelegramHandlers = ({ try { const msg = ctx.message; if (!msg?.migrate_to_chat_id) return; + if (shouldSkipUpdate(ctx)) return; const oldChatId = String(msg.chat.id); const newChatId = String(msg.migrate_to_chat_id); @@ -94,26 +98,28 @@ export const registerTelegramHandlers = ({ ); // Check if old chat ID has config and migrate it - const currentConfig = await loadConfig(); - const telegramGroups = currentConfig.channels?.telegram?.groups; + const currentConfig = loadConfig(); + const migration = migrateTelegramGroupConfig({ + cfg: currentConfig, + accountId, + oldChatId, + newChatId, + }); - if (telegramGroups && telegramGroups[oldChatId]) { - const groupConfig = telegramGroups[oldChatId]; + if (migration.migrated) { runtime.log?.( warn( `[telegram] Migrating group config from ${oldChatId} to ${newChatId}`, ), ); - - // Copy config to new ID - telegramGroups[newChatId] = groupConfig; - // Remove old ID - delete telegramGroups[oldChatId]; - - // Save updated config + migrateTelegramGroupConfig({ cfg, accountId, oldChatId, newChatId }); await writeConfigFile(currentConfig); + runtime.log?.(warn(`[telegram] Group config migrated and saved successfully`)); + } else if (migration.skippedExisting) { runtime.log?.( - warn(`[telegram] Group config migrated and saved successfully`), + warn( + `[telegram] Group config already exists for ${newChatId}; leaving ${oldChatId} unchanged`, + ), ); } else { runtime.log?.( diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index ce2abebfb..b122a1777 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -282,6 +282,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { }); registerTelegramHandlers({ + cfg, + accountId: account.accountId, bot, opts, runtime, diff --git a/src/telegram/group-migration.test.ts b/src/telegram/group-migration.test.ts new file mode 100644 index 000000000..f6cc03360 --- /dev/null +++ b/src/telegram/group-migration.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { migrateTelegramGroupConfig } from "./group-migration.js"; + +describe("migrateTelegramGroupConfig", () => { + it("migrates global group ids", () => { + const cfg = { + channels: { + telegram: { + groups: { + "-123": { requireMention: false }, + }, + }, + }, + }; + + const result = migrateTelegramGroupConfig({ + cfg, + accountId: "default", + oldChatId: "-123", + newChatId: "-100123", + }); + + expect(result.migrated).toBe(true); + expect(cfg.channels.telegram.groups).toEqual({ + "-100123": { requireMention: false }, + }); + }); + + it("migrates account-scoped groups", () => { + const cfg = { + channels: { + telegram: { + accounts: { + primary: { + groups: { + "-123": { requireMention: true }, + }, + }, + }, + }, + }, + }; + + const result = migrateTelegramGroupConfig({ + cfg, + accountId: "primary", + oldChatId: "-123", + newChatId: "-100123", + }); + + expect(result.migrated).toBe(true); + expect(result.scopes).toEqual(["account"]); + expect(cfg.channels.telegram.accounts.primary.groups).toEqual({ + "-100123": { requireMention: true }, + }); + }); + + it("matches account ids case-insensitively", () => { + const cfg = { + channels: { + telegram: { + accounts: { + Primary: { + groups: { + "-123": {}, + }, + }, + }, + }, + }, + }; + + const result = migrateTelegramGroupConfig({ + cfg, + accountId: "primary", + oldChatId: "-123", + newChatId: "-100123", + }); + + expect(result.migrated).toBe(true); + expect(cfg.channels.telegram.accounts.Primary.groups).toEqual({ + "-100123": {}, + }); + }); + + it("skips migration when new id already exists", () => { + const cfg = { + channels: { + telegram: { + groups: { + "-123": { requireMention: true }, + "-100123": { requireMention: false }, + }, + }, + }, + }; + + const result = migrateTelegramGroupConfig({ + cfg, + accountId: "default", + oldChatId: "-123", + newChatId: "-100123", + }); + + expect(result.migrated).toBe(false); + expect(result.skippedExisting).toBe(true); + expect(cfg.channels.telegram.groups).toEqual({ + "-123": { requireMention: true }, + "-100123": { requireMention: false }, + }); + }); +}); diff --git a/src/telegram/group-migration.ts b/src/telegram/group-migration.ts new file mode 100644 index 000000000..77a59c66b --- /dev/null +++ b/src/telegram/group-migration.ts @@ -0,0 +1,84 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { TelegramGroupConfig } from "../config/types.telegram.js"; +import { normalizeAccountId } from "../routing/session-key.js"; + +type TelegramGroups = Record; + +type MigrationScope = "account" | "global"; + +export type TelegramGroupMigrationResult = { + migrated: boolean; + skippedExisting: boolean; + scopes: MigrationScope[]; +}; + +function resolveAccountGroups( + cfg: ClawdbotConfig, + accountId?: string | null, +): { groups?: TelegramGroups } { + if (!accountId) return {}; + const normalized = normalizeAccountId(accountId); + const accounts = cfg.channels?.telegram?.accounts; + if (!accounts || typeof accounts !== "object") return {}; + const exact = accounts[normalized]; + if (exact?.groups) return { groups: exact.groups }; + const matchKey = Object.keys(accounts).find( + (key) => key.toLowerCase() === normalized.toLowerCase(), + ); + return { groups: matchKey ? accounts[matchKey]?.groups : undefined }; +} + +export function migrateTelegramGroupsInPlace( + groups: TelegramGroups | undefined, + oldChatId: string, + newChatId: string, +): { migrated: boolean; skippedExisting: boolean } { + if (!groups) return { migrated: false, skippedExisting: false }; + if (oldChatId === newChatId) return { migrated: false, skippedExisting: false }; + if (!Object.hasOwn(groups, oldChatId)) return { migrated: false, skippedExisting: false }; + if (Object.hasOwn(groups, newChatId)) return { migrated: false, skippedExisting: true }; + groups[newChatId] = groups[oldChatId]; + delete groups[oldChatId]; + return { migrated: true, skippedExisting: false }; +} + +export function migrateTelegramGroupConfig(params: { + cfg: ClawdbotConfig; + accountId?: string | null; + oldChatId: string; + newChatId: string; +}): TelegramGroupMigrationResult { + const scopes: MigrationScope[] = []; + let migrated = false; + let skippedExisting = false; + + const accountGroups = resolveAccountGroups(params.cfg, params.accountId).groups; + if (accountGroups) { + const result = migrateTelegramGroupsInPlace( + accountGroups, + params.oldChatId, + params.newChatId, + ); + if (result.migrated) { + migrated = true; + scopes.push("account"); + } + if (result.skippedExisting) skippedExisting = true; + } + + const globalGroups = params.cfg.channels?.telegram?.groups; + if (globalGroups) { + const result = migrateTelegramGroupsInPlace( + globalGroups, + params.oldChatId, + params.newChatId, + ); + if (result.migrated) { + migrated = true; + scopes.push("global"); + } + if (result.skippedExisting) skippedExisting = true; + } + + return { migrated, skippedExisting, scopes }; +}