diff --git a/src/commands/doctor-legacy-config.e2e.test.ts b/src/commands/doctor-legacy-config.e2e.test.ts index 87db2a508b0..6d7b04f4c49 100644 --- a/src/commands/doctor-legacy-config.e2e.test.ts +++ b/src/commands/doctor-legacy-config.e2e.test.ts @@ -111,4 +111,44 @@ describe("normalizeLegacyConfigValues", () => { fs.rmSync(customDir, { recursive: true, force: true }); } }); + + it("migrates Slack dm.policy/dm.allowFrom to dmPolicy/allowFrom aliases", () => { + const res = normalizeLegacyConfigValues({ + channels: { + slack: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + }, + }, + }); + + expect(res.config.channels?.slack?.dmPolicy).toBe("open"); + expect(res.config.channels?.slack?.allowFrom).toEqual(["*"]); + expect(res.config.channels?.slack?.dm).toEqual({ enabled: true }); + expect(res.changes).toEqual([ + "Moved channels.slack.dm.policy → channels.slack.dmPolicy.", + "Moved channels.slack.dm.allowFrom → channels.slack.allowFrom.", + ]); + }); + + it("migrates Discord account dm.policy/dm.allowFrom to dmPolicy/allowFrom aliases", () => { + const res = normalizeLegacyConfigValues({ + channels: { + discord: { + accounts: { + work: { + dm: { policy: "allowlist", allowFrom: ["123"], groupEnabled: true }, + }, + }, + }, + }, + }); + + expect(res.config.channels?.discord?.accounts?.work?.dmPolicy).toBe("allowlist"); + expect(res.config.channels?.discord?.accounts?.work?.allowFrom).toEqual(["123"]); + expect(res.config.channels?.discord?.accounts?.work?.dm).toEqual({ groupEnabled: true }); + expect(res.changes).toEqual([ + "Moved channels.discord.accounts.work.dm.policy → channels.discord.accounts.work.dmPolicy.", + "Moved channels.discord.accounts.work.dm.allowFrom → channels.discord.accounts.work.allowFrom.", + ]); + }); }); diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index c6f9badf3ae..416e7dda585 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -6,6 +6,151 @@ export function normalizeLegacyConfigValues(cfg: OpenClawConfig): { const changes: string[] = []; let next: OpenClawConfig = cfg; + const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + + const normalizeDmAliases = (params: { + provider: "slack" | "discord"; + entry: Record; + pathPrefix: string; + }): { entry: Record; changed: boolean } => { + let changed = false; + let updated: Record = params.entry; + const rawDm = updated.dm; + const dm = isRecord(rawDm) ? structuredClone(rawDm) : null; + let dmChanged = false; + + const allowFromEqual = (a: unknown, b: unknown): boolean => { + if (!Array.isArray(a) || !Array.isArray(b)) { + return false; + } + const na = a.map((v) => String(v).trim()).filter(Boolean); + const nb = b.map((v) => String(v).trim()).filter(Boolean); + if (na.length !== nb.length) { + return false; + } + return na.every((v, i) => v === nb[i]); + }; + + const topDmPolicy = updated.dmPolicy; + const legacyDmPolicy = dm?.policy; + if (topDmPolicy === undefined && legacyDmPolicy !== undefined) { + updated = { ...updated, dmPolicy: legacyDmPolicy }; + changed = true; + if (dm) { + delete dm.policy; + dmChanged = true; + } + changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`); + } else if (topDmPolicy !== undefined && legacyDmPolicy !== undefined) { + if (topDmPolicy === legacyDmPolicy) { + if (dm) { + delete dm.policy; + dmChanged = true; + changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`); + } + } else { + changes.push( + `Kept ${params.pathPrefix}.dm.policy (conflicts with ${params.pathPrefix}.dmPolicy).`, + ); + } + } + + const topAllowFrom = updated.allowFrom; + const legacyAllowFrom = dm?.allowFrom; + if (topAllowFrom === undefined && legacyAllowFrom !== undefined) { + updated = { ...updated, allowFrom: legacyAllowFrom }; + changed = true; + if (dm) { + delete dm.allowFrom; + dmChanged = true; + } + changes.push(`Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`); + } else if (topAllowFrom !== undefined && legacyAllowFrom !== undefined) { + if (allowFromEqual(topAllowFrom, legacyAllowFrom)) { + if (dm) { + delete dm.allowFrom; + dmChanged = true; + changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`); + } + } else { + changes.push( + `Kept ${params.pathPrefix}.dm.allowFrom (conflicts with ${params.pathPrefix}.allowFrom).`, + ); + } + } + + if (dm && isRecord(rawDm) && dmChanged) { + const keys = Object.keys(dm); + if (keys.length === 0) { + if (updated.dm !== undefined) { + const { dm: _ignored, ...rest } = updated; + updated = rest; + changed = true; + changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`); + } + } else { + updated = { ...updated, dm }; + changed = true; + } + } + + return { entry: updated, changed }; + }; + + const normalizeProvider = (provider: "slack" | "discord") => { + const channels = next.channels as Record | undefined; + const rawEntry = channels?.[provider]; + if (!isRecord(rawEntry)) { + return; + } + + const base = normalizeDmAliases({ + provider, + entry: rawEntry, + pathPrefix: `channels.${provider}`, + }); + let updated = base.entry; + let changed = base.changed; + + const rawAccounts = updated.accounts; + if (isRecord(rawAccounts)) { + let accountsChanged = false; + const accounts = { ...rawAccounts }; + for (const [accountId, rawAccount] of Object.entries(rawAccounts)) { + if (!isRecord(rawAccount)) { + continue; + } + const res = normalizeDmAliases({ + provider, + entry: rawAccount, + pathPrefix: `channels.${provider}.accounts.${accountId}`, + }); + if (res.changed) { + accounts[accountId] = res.entry; + accountsChanged = true; + } + } + if (accountsChanged) { + updated = { ...updated, accounts }; + changed = true; + } + } + + if (changed) { + next = { + ...next, + channels: { + ...next.channels, + [provider]: updated as unknown, + }, + }; + } + }; + + normalizeProvider("slack"); + normalizeProvider("discord"); + const legacyAckReaction = cfg.messages?.ackReaction?.trim(); const hasWhatsAppConfig = cfg.channels?.whatsapp !== undefined; if (legacyAckReaction && hasWhatsAppConfig) {