fix(telegram): migrate group config on supergroup IDs (#906)

Thanks @sleontenko.

Co-authored-by: Stan <sleontenko@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-15 01:10:14 +00:00
parent 9b7c4b3884
commit 326d4049da
5 changed files with 218 additions and 12 deletions

View File

@@ -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.

View File

@@ -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?.(

View File

@@ -282,6 +282,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
});
registerTelegramHandlers({
cfg,
accountId: account.accountId,
bot,
opts,
runtime,

View File

@@ -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 },
});
});
});

View File

@@ -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<string, TelegramGroupConfig>;
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 };
}