From ad8799522cda0f94f9d3394f5f3add80ae73e169 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 01:41:11 +0000 Subject: [PATCH] feat(config): gate channel config writes --- CHANGELOG.md | 1 + docs/channels/discord.md | 10 ++ docs/channels/imessage.md | 10 ++ docs/channels/msteams.md | 10 ++ docs/channels/signal.md | 10 ++ docs/channels/slack.md | 16 +++ docs/channels/telegram.md | 14 +++ docs/channels/whatsapp.md | 10 ++ docs/gateway/configuration.md | 3 + .../reply/commands-config-writes.test.ts | 57 +++++++++ src/auto-reply/reply/commands-config.ts | 24 ++++ src/channels/plugins/config-writes.test.ts | 47 ++++++++ src/channels/plugins/config-writes.ts | 35 ++++++ src/config/schema.ts | 14 +++ src/config/types.discord.ts | 2 + src/config/types.imessage.ts | 2 + src/config/types.msteams.ts | 2 + src/config/types.signal.ts | 2 + src/config/types.slack.ts | 2 + src/config/types.telegram.ts | 2 + src/config/types.whatsapp.ts | 4 + src/config/zod-schema.providers-core.ts | 6 + src/config/zod-schema.providers-whatsapp.ts | 2 + src/slack/channel-migration.test.ts | 113 ++++++++++++++++++ src/slack/channel-migration.ts | 84 +++++++++++++ src/slack/monitor/events/channels.ts | 81 ++++++++++++- src/slack/monitor/types.ts | 7 ++ src/telegram/bot-handlers.ts | 8 ++ 28 files changed, 576 insertions(+), 2 deletions(-) create mode 100644 src/auto-reply/reply/commands-config-writes.test.ts create mode 100644 src/channels/plugins/config-writes.test.ts create mode 100644 src/channels/plugins/config-writes.ts create mode 100644 src/slack/channel-migration.test.ts create mode 100644 src/slack/channel-migration.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a74cd731..1584a2fc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR. - Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915) - Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko. +- Config: add `channels..configWrites` gating for channel-initiated config writes; migrate Slack channel IDs. ### Fixes - Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index f25f0e623..eaa8dfa0a 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -61,6 +61,16 @@ Note: Discord does not provide a simple username → id lookup without extra gui Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`. Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy. +## Config writes +By default, Discord is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`). + +Disable with: +```json5 +{ + channels: { discord: { configWrites: false } } +} +``` + ## How to create your own bot This is the “Discord Developer Portal” setup for running Clawdbot in a server (guild) channel like `#help`. diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index a67c5b744..8c751f0e2 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -35,6 +35,16 @@ Minimal config: - DMs share the agent's main session; groups are isolated (`agent::imessage:group:`). - If a multi-participant thread arrives with `is_group=false`, you can still isolate it by `chat_id` using `channels.imessage.groups` (see “Group-ish threads” below). +## Config writes +By default, iMessage is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`). + +Disable with: +```json5 +{ + channels: { imessage: { configWrites: false } } +} +``` + ## Requirements - macOS with Messages signed in. - Full Disk Access for Clawdbot + `imsg` (Messages DB access). diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 223f3ef82..67573e6c2 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -39,6 +39,16 @@ Note: group chats are blocked by default (`channels.msteams.groupPolicy: "allowl - Keep routing deterministic: replies always go back to the channel they arrived on. - Default to safe channel behavior (mentions required unless configured otherwise). +## Config writes +By default, Microsoft Teams is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`). + +Disable with: +```json5 +{ + channels: { msteams: { configWrites: false } } +} +``` + ## Access control (DMs + groups) **DM access** diff --git a/docs/channels/signal.md b/docs/channels/signal.md index 2099bd644..41a098d50 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -36,6 +36,16 @@ Minimal config: - Deterministic routing: replies always go back to Signal. - DMs share the agent's main session; groups are isolated (`agent::signal:group:`). +## Config writes +By default, Signal is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`). + +Disable with: +```json5 +{ + channels: { signal: { configWrites: false } } +} +``` + ## The number model (important) - The gateway connects to a **Signal device** (the `signal-cli` account). - If you run the bot on **your personal Signal account**, it will ignore your own messages (loop protection). diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 87b0613e1..96fef0a63 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -32,6 +32,7 @@ Minimal config: - `app_mention` - `reaction_added`, `reaction_removed` - `member_joined_channel`, `member_left_channel` + - `channel_id_changed` - `channel_rename` - `pin_added`, `pin_removed` 5) Invite the bot to channels you want it to read. @@ -66,6 +67,20 @@ Or via config: - `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt. - Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). +## Config writes +By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`. + +This happens when: +- Slack emits `channel_id_changed` (e.g. Slack Connect channel ID changes). Clawdbot can migrate `channels.slack.channels` automatically. +- You run `/config set` or `/config unset` in Slack (requires `commands.config: true`). + +Disable with: +```json5 +{ + channels: { slack: { configWrites: false } } +} +``` + ## Manifest (optional) Use this Slack app manifest to create the app quickly (adjust the name/command if you want). @@ -133,6 +148,7 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i "reaction_removed", "member_joined_channel", "member_left_channel", + "channel_id_changed", "channel_rename", "pin_added", "pin_removed" diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index c2804fbcb..54f26c9ae 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -168,6 +168,20 @@ Forward any message from the group to `@userinfobot` or `@getidsbot` on Telegram **Privacy note:** `@userinfobot` is a third-party bot. If you prefer, use gateway logs (`clawdbot logs`) or Telegram developer tools to find user/chat IDs. +## Config writes +By default, Telegram is allowed to write config updates triggered by channel events or `/config set|unset`. + +This happens when: +- A group is upgraded to a supergroup and Telegram emits `migrate_to_chat_id` (chat ID changes). Clawdbot can migrate `channels.telegram.groups` automatically. +- You run `/config set` or `/config unset` in a Telegram chat (requires `commands.config: true`). + +Disable with: +```json5 +{ + channels: { telegram: { configWrites: false } } +} +``` + ## Topics (forum supergroups) Telegram forum topics include a `message_thread_id` per message. Clawdbot: - Appends `:topic:` to the Telegram group session key so each topic is isolated. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 1fc619c23..b7e398434 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -31,6 +31,16 @@ Minimal config: - Deterministic routing: replies return to WhatsApp, no model routing. - Model sees enough context to understand quoted replies. +## Config writes +By default, WhatsApp is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`). + +Disable with: +```json5 +{ + channels: { whatsapp: { configWrites: false } } +} +``` + ## Architecture (who owns what) - **Gateway** owns the Baileys socket and inbox loop. - **CLI / macOS app** talk to the gateway; no direct Baileys use. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index e273ca521..62056ec76 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -780,6 +780,7 @@ Notes: - `commands.bash: true` enables `! ` to run host shell commands (`/bash ` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.`. - `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! ` requests are rejected (one at a time). - `commands.config: true` enables `/config` (reads/writes `clawdbot.json`). +- `channels..configWrites` gates config mutations initiated by that channel (default: true). This applies to `/config set|unset` plus provider-specific auto-migrations (Telegram supergroup ID changes, Slack channel ID changes). - `commands.debug: true` enables `/debug` (runtime-only overrides). - `commands.restart: true` enables `/restart` and the gateway tool restart action. - `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies. @@ -810,6 +811,7 @@ Set `web.enabled: false` to keep it off by default. Clawdbot starts Telegram only when a `channels.telegram` config section exists. The bot token is resolved from `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken`. Set `channels.telegram.enabled: false` to disable automatic startup. Multi-account support lives under `channels.telegram.accounts` (see the multi-account section above). Env tokens only apply to the default account. +Set `channels.telegram.configWrites: false` to block Telegram-initiated config writes (including supergroup ID migrations and `/config set|unset`). ```json5 { @@ -1002,6 +1004,7 @@ Slack runs in Socket Mode and requires both a bot token and app token: Multi-account support lives under `channels.slack.accounts` (see the multi-account section above). Env tokens only apply to the default account. Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:` (DM) or `channel:` when specifying delivery targets for cron/CLI commands. +Set `channels.slack.configWrites: false` to block Slack-initiated config writes (including channel ID migrations and `/config set|unset`). Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels..allowBots`. diff --git a/src/auto-reply/reply/commands-config-writes.test.ts b/src/auto-reply/reply/commands-config-writes.test.ts new file mode 100644 index 000000000..5a9568408 --- /dev/null +++ b/src/auto-reply/reply/commands-config-writes.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import type { MsgContext } from "../templating.js"; +import { buildCommandContext, handleCommands } from "./commands.js"; +import { parseInlineDirectives } from "./directive-handling.js"; + +function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial) { + const ctx = { + Body: commandBody, + CommandBody: commandBody, + CommandSource: "text", + CommandAuthorized: true, + Provider: "whatsapp", + Surface: "whatsapp", + ...ctxOverrides, + } as MsgContext; + + const command = buildCommandContext({ + ctx, + cfg, + isGroup: false, + triggerBodyNormalized: commandBody.trim().toLowerCase(), + commandAuthorized: true, + }); + + return { + ctx, + cfg, + command, + directives: parseInlineDirectives(commandBody), + elevated: { enabled: true, allowed: true, failures: [] }, + sessionKey: "agent:main:main", + workspaceDir: "/tmp", + defaultGroupActivation: () => "mention", + resolvedVerboseLevel: "off" as const, + resolvedReasoningLevel: "off" as const, + resolveDefaultThinkingLevel: async () => undefined, + provider: "whatsapp", + model: "test-model", + contextTokens: 0, + isGroup: false, + }; +} + +describe("handleCommands /config configWrites gating", () => { + it("blocks /config set when channel config writes are disabled", async () => { + const cfg = { + commands: { config: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"], configWrites: false } }, + } as ClawdbotConfig; + const params = buildParams("/config set messages.ackReaction=\":)\"", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config writes are disabled"); + }); +}); diff --git a/src/auto-reply/reply/commands-config.ts b/src/auto-reply/reply/commands-config.ts index 88a2647db..1d68cb58f 100644 --- a/src/auto-reply/reply/commands-config.ts +++ b/src/auto-reply/reply/commands-config.ts @@ -15,6 +15,8 @@ import { setConfigOverride, unsetConfigOverride, } from "../../config/runtime-overrides.js"; +import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js"; +import { normalizeChannelId } from "../../channels/registry.js"; import { logVerbose } from "../../globals.js"; import type { CommandHandler } from "./commands-types.js"; import { parseConfigCommand } from "./config-commands.js"; @@ -44,6 +46,28 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma reply: { text: `⚠️ ${configCommand.message}` }, }; } + + if (configCommand.action === "set" || configCommand.action === "unset") { + const channelId = params.command.channelId ?? normalizeChannelId(params.command.channel); + const allowWrites = resolveChannelConfigWrites({ + cfg: params.cfg, + channelId, + accountId: params.ctx.AccountId, + }); + if (!allowWrites) { + const channelLabel = channelId ?? "this channel"; + const hint = channelId + ? `channels.${channelId}.configWrites=true` + : "channels..configWrites=true"; + return { + shouldContinue: false, + reply: { + text: `⚠️ Config writes are disabled for ${channelLabel}. Set ${hint} to enable.`, + }, + }; + } + } + const snapshot = await readConfigFileSnapshot(); if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") { return { diff --git a/src/channels/plugins/config-writes.test.ts b/src/channels/plugins/config-writes.test.ts new file mode 100644 index 000000000..f69acf0a4 --- /dev/null +++ b/src/channels/plugins/config-writes.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { resolveChannelConfigWrites } from "./config-writes.js"; + +describe("resolveChannelConfigWrites", () => { + it("defaults to allow when unset", () => { + const cfg = {}; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(true); + }); + + it("blocks when channel config disables writes", () => { + const cfg = { channels: { slack: { configWrites: false } } }; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(false); + }); + + it("account override wins over channel default", () => { + const cfg = { + channels: { + slack: { + configWrites: true, + accounts: { + work: { configWrites: false }, + }, + }, + }, + }; + expect( + resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" }), + ).toBe(false); + }); + + it("matches account ids case-insensitively", () => { + const cfg = { + channels: { + slack: { + configWrites: true, + accounts: { + Work: { configWrites: false }, + }, + }, + }, + }; + expect( + resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" }), + ).toBe(false); + }); +}); diff --git a/src/channels/plugins/config-writes.ts b/src/channels/plugins/config-writes.ts new file mode 100644 index 000000000..98d351c12 --- /dev/null +++ b/src/channels/plugins/config-writes.ts @@ -0,0 +1,35 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import type { ChannelId } from "./types.js"; +import { normalizeAccountId } from "../../routing/session-key.js"; + +type ChannelConfigWithAccounts = { + configWrites?: boolean; + accounts?: Record; +}; + +function resolveAccountConfig( + accounts: ChannelConfigWithAccounts["accounts"], + accountId: string, +) { + if (!accounts || typeof accounts !== "object") return undefined; + if (accountId in accounts) return accounts[accountId]; + const matchKey = Object.keys(accounts).find( + (key) => key.toLowerCase() === accountId.toLowerCase(), + ); + return matchKey ? accounts[matchKey] : undefined; +} + +export function resolveChannelConfigWrites(params: { + cfg: ClawdbotConfig; + channelId?: ChannelId | null; + accountId?: string | null; +}): boolean { + if (!params.channelId) return true; + const channels = params.cfg.channels as Record | undefined; + const channelConfig = channels?.[params.channelId]; + if (!channelConfig) return true; + const accountId = normalizeAccountId(params.accountId); + const accountConfig = resolveAccountConfig(channelConfig.accounts, accountId); + const value = accountConfig?.configWrites ?? channelConfig.configWrites; + return value !== false; +} diff --git a/src/config/schema.ts b/src/config/schema.ts index 01d53d3d3..a2555e4d4 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -269,6 +269,20 @@ const FIELD_HELP: Record = { "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", "commands.restart": "Allow /restart and gateway restart tool actions (default: false).", "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", + "channels.telegram.configWrites": + "Allow Telegram to write config in response to channel events/commands (default: true).", + "channels.slack.configWrites": + "Allow Slack to write config in response to channel events/commands (default: true).", + "channels.discord.configWrites": + "Allow Discord to write config in response to channel events/commands (default: true).", + "channels.whatsapp.configWrites": + "Allow WhatsApp to write config in response to channel events/commands (default: true).", + "channels.signal.configWrites": + "Allow Signal to write config in response to channel events/commands (default: true).", + "channels.imessage.configWrites": + "Allow iMessage to write config in response to channel events/commands (default: true).", + "channels.msteams.configWrites": + "Allow Microsoft Teams to write config in response to channel events/commands (default: true).", "channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").', "channels.telegram.commands.native": 'Override native commands for Telegram (bool or "auto").', "channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").', diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 34fe01d37..c8f0a38b3 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -72,6 +72,8 @@ export type DiscordAccountConfig = { capabilities?: string[]; /** Override native command registration for Discord (bool or "auto"). */ commands?: ProviderCommandsConfig; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; /** If false, do not start this Discord account. Default: true. */ enabled?: boolean; token?: string; diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index e5d31bb14..bc86ecdbe 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -6,6 +6,8 @@ export type IMessageAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; /** If false, do not start this iMessage account. Default: true. */ enabled?: boolean; /** imsg CLI binary path (default: imsg). */ diff --git a/src/config/types.msteams.ts b/src/config/types.msteams.ts index d00cbc279..e443225f3 100644 --- a/src/config/types.msteams.ts +++ b/src/config/types.msteams.ts @@ -34,6 +34,8 @@ export type MSTeamsConfig = { enabled?: boolean; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; /** Azure Bot App ID (from Azure Bot registration). */ appId?: string; /** Azure Bot App Password / Client Secret. */ diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index 2df1f7900..c71d97169 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -8,6 +8,8 @@ export type SignalAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; /** If false, do not start this Signal account. Default: true. */ enabled?: boolean; /** Optional explicit E.164 account for signal-cli. */ diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 51bbe3096..16f6c6d10 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -67,6 +67,8 @@ export type SlackAccountConfig = { capabilities?: string[]; /** Override native command registration for Slack (bool or "auto"). */ commands?: ProviderCommandsConfig; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; /** If false, do not start this Slack account. Default: true. */ enabled?: boolean; botToken?: string; diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index b52c4e937..a3c26d076 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -21,6 +21,8 @@ export type TelegramAccountConfig = { capabilities?: string[]; /** Override native command registration for Telegram (bool or "auto"). */ commands?: ProviderCommandsConfig; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; /** * Controls how Telegram direct chats (DMs) are handled: * - "pairing" (default): unknown senders get a pairing code; owner must approve diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 7c67d2820..82f467530 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -12,6 +12,8 @@ export type WhatsAppConfig = { accounts?: Record; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; /** * Inbound message prefix (WhatsApp only). * Default: `[{agents.list[].identity.name}]` (or `[clawdbot]`) when allowFrom is empty, else `""`. @@ -78,6 +80,8 @@ export type WhatsAppAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; /** If false, do not start this WhatsApp account provider. Default: true. */ enabled?: boolean; /** Inbound message prefix override for this account (WhatsApp only). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index ccbb2127f..64ad7286b 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -36,6 +36,7 @@ export const TelegramAccountSchemaBase = z.object({ capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), commands: ProviderCommandsSchema, + configWrites: z.boolean().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), botToken: z.string().optional(), tokenFile: z.string().optional(), @@ -132,6 +133,7 @@ export const DiscordAccountSchema = z.object({ capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), commands: ProviderCommandsSchema, + configWrites: z.boolean().optional(), token: z.string().optional(), allowBots: z.boolean().optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), @@ -206,6 +208,7 @@ export const SlackAccountSchema = z.object({ capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), commands: ProviderCommandsSchema, + configWrites: z.boolean().optional(), botToken: z.string().optional(), appToken: z.string().optional(), allowBots: z.boolean().optional(), @@ -252,6 +255,7 @@ export const SignalAccountSchemaBase = z.object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), + configWrites: z.boolean().optional(), account: z.string().optional(), httpUrl: z.string().optional(), httpHost: z.string().optional(), @@ -303,6 +307,7 @@ export const IMessageAccountSchemaBase = z.object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), + configWrites: z.boolean().optional(), cliPath: ExecutableTokenSchema.optional(), dbPath: z.string().optional(), service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(), @@ -370,6 +375,7 @@ export const MSTeamsConfigSchema = z .object({ enabled: z.boolean().optional(), capabilities: z.array(z.string()).optional(), + configWrites: z.boolean().optional(), appId: z.string().optional(), appPassword: z.string().optional(), tenantId: z.string().optional(), diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 601eb03ec..3636c302c 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -11,6 +11,7 @@ export const WhatsAppAccountSchema = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + configWrites: z.boolean().optional(), enabled: z.boolean().optional(), messagePrefix: z.string().optional(), /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ @@ -60,6 +61,7 @@ export const WhatsAppConfigSchema = z .object({ accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(), capabilities: z.array(z.string()).optional(), + configWrites: z.boolean().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), messagePrefix: z.string().optional(), selfChatMode: z.boolean().optional(), diff --git a/src/slack/channel-migration.test.ts b/src/slack/channel-migration.test.ts new file mode 100644 index 000000000..b2837b554 --- /dev/null +++ b/src/slack/channel-migration.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { migrateSlackChannelConfig } from "./channel-migration.js"; + +describe("migrateSlackChannelConfig", () => { + it("migrates global channel ids", () => { + const cfg = { + channels: { + slack: { + channels: { + C123: { requireMention: false }, + }, + }, + }, + }; + + const result = migrateSlackChannelConfig({ + cfg, + accountId: "default", + oldChannelId: "C123", + newChannelId: "C999", + }); + + expect(result.migrated).toBe(true); + expect(cfg.channels.slack.channels).toEqual({ + C999: { requireMention: false }, + }); + }); + + it("migrates account-scoped channels", () => { + const cfg = { + channels: { + slack: { + accounts: { + primary: { + channels: { + C123: { requireMention: true }, + }, + }, + }, + }, + }, + }; + + const result = migrateSlackChannelConfig({ + cfg, + accountId: "primary", + oldChannelId: "C123", + newChannelId: "C999", + }); + + expect(result.migrated).toBe(true); + expect(result.scopes).toEqual(["account"]); + expect(cfg.channels.slack.accounts.primary.channels).toEqual({ + C999: { requireMention: true }, + }); + }); + + it("matches account ids case-insensitively", () => { + const cfg = { + channels: { + slack: { + accounts: { + Primary: { + channels: { + C123: {}, + }, + }, + }, + }, + }, + }; + + const result = migrateSlackChannelConfig({ + cfg, + accountId: "primary", + oldChannelId: "C123", + newChannelId: "C999", + }); + + expect(result.migrated).toBe(true); + expect(cfg.channels.slack.accounts.Primary.channels).toEqual({ + C999: {}, + }); + }); + + it("skips migration when new id already exists", () => { + const cfg = { + channels: { + slack: { + channels: { + C123: { requireMention: true }, + C999: { requireMention: false }, + }, + }, + }, + }; + + const result = migrateSlackChannelConfig({ + cfg, + accountId: "default", + oldChannelId: "C123", + newChannelId: "C999", + }); + + expect(result.migrated).toBe(false); + expect(result.skippedExisting).toBe(true); + expect(cfg.channels.slack.channels).toEqual({ + C123: { requireMention: true }, + C999: { requireMention: false }, + }); + }); +}); diff --git a/src/slack/channel-migration.ts b/src/slack/channel-migration.ts new file mode 100644 index 000000000..bc68c278b --- /dev/null +++ b/src/slack/channel-migration.ts @@ -0,0 +1,84 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { SlackChannelConfig } from "../config/types.slack.js"; +import { normalizeAccountId } from "../routing/session-key.js"; + +type SlackChannels = Record; + +type MigrationScope = "account" | "global"; + +export type SlackChannelMigrationResult = { + migrated: boolean; + skippedExisting: boolean; + scopes: MigrationScope[]; +}; + +function resolveAccountChannels( + cfg: ClawdbotConfig, + accountId?: string | null, +): { channels?: SlackChannels } { + if (!accountId) return {}; + const normalized = normalizeAccountId(accountId); + const accounts = cfg.channels?.slack?.accounts; + if (!accounts || typeof accounts !== "object") return {}; + const exact = accounts[normalized]; + if (exact?.channels) return { channels: exact.channels }; + const matchKey = Object.keys(accounts).find( + (key) => key.toLowerCase() === normalized.toLowerCase(), + ); + return { channels: matchKey ? accounts[matchKey]?.channels : undefined }; +} + +export function migrateSlackChannelsInPlace( + channels: SlackChannels | undefined, + oldChannelId: string, + newChannelId: string, +): { migrated: boolean; skippedExisting: boolean } { + if (!channels) return { migrated: false, skippedExisting: false }; + if (oldChannelId === newChannelId) return { migrated: false, skippedExisting: false }; + if (!Object.hasOwn(channels, oldChannelId)) return { migrated: false, skippedExisting: false }; + if (Object.hasOwn(channels, newChannelId)) return { migrated: false, skippedExisting: true }; + channels[newChannelId] = channels[oldChannelId]; + delete channels[oldChannelId]; + return { migrated: true, skippedExisting: false }; +} + +export function migrateSlackChannelConfig(params: { + cfg: ClawdbotConfig; + accountId?: string | null; + oldChannelId: string; + newChannelId: string; +}): SlackChannelMigrationResult { + const scopes: MigrationScope[] = []; + let migrated = false; + let skippedExisting = false; + + const accountChannels = resolveAccountChannels(params.cfg, params.accountId).channels; + if (accountChannels) { + const result = migrateSlackChannelsInPlace( + accountChannels, + params.oldChannelId, + params.newChannelId, + ); + if (result.migrated) { + migrated = true; + scopes.push("account"); + } + if (result.skippedExisting) skippedExisting = true; + } + + const globalChannels = params.cfg.channels?.slack?.channels; + if (globalChannels) { + const result = migrateSlackChannelsInPlace( + globalChannels, + params.oldChannelId, + params.newChannelId, + ); + if (result.migrated) { + migrated = true; + scopes.push("global"); + } + if (result.skippedExisting) skippedExisting = true; + } + + return { migrated, skippedExisting, scopes }; +} diff --git a/src/slack/monitor/events/channels.ts b/src/slack/monitor/events/channels.ts index ec2e3b270..8d9fb9e59 100644 --- a/src/slack/monitor/events/channels.ts +++ b/src/slack/monitor/events/channels.ts @@ -1,11 +1,18 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger } from "../../../globals.js"; +import { loadConfig, writeConfigFile } from "../../../config/config.js"; +import { resolveChannelConfigWrites } from "../../../channels/plugins/config-writes.js"; +import { danger, warn } from "../../../globals.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; import { resolveSlackChannelLabel } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; -import type { SlackChannelCreatedEvent, SlackChannelRenamedEvent } from "../types.js"; +import { migrateSlackChannelConfig } from "../../channel-migration.js"; +import type { + SlackChannelCreatedEvent, + SlackChannelIdChangedEvent, + SlackChannelRenamedEvent, +} from "../types.js"; export function registerSlackChannelEvents(params: { ctx: SlackMonitorContext }) { const { ctx } = params; @@ -75,4 +82,74 @@ export function registerSlackChannelEvents(params: { ctx: SlackMonitorContext }) } }, ); + + ctx.app.event( + "channel_id_changed", + async ({ event, body }: SlackEventMiddlewareArgs<"channel_id_changed">) => { + try { + if (ctx.shouldDropMismatchedSlackEvent(body)) return; + + const payload = event as SlackChannelIdChangedEvent; + const oldChannelId = payload.old_channel_id; + const newChannelId = payload.new_channel_id; + if (!oldChannelId || !newChannelId) return; + + const channelInfo = await ctx.resolveChannelName(newChannelId); + const label = resolveSlackChannelLabel({ + channelId: newChannelId, + channelName: channelInfo?.name, + }); + + ctx.runtime.log?.( + warn(`[slack] Channel ID changed: ${oldChannelId} → ${newChannelId} (${label})`), + ); + + if ( + !resolveChannelConfigWrites({ + cfg: ctx.cfg, + channelId: "slack", + accountId: ctx.accountId, + }) + ) { + ctx.runtime.log?.( + warn("[slack] Config writes disabled; skipping channel config migration."), + ); + return; + } + + const currentConfig = loadConfig(); + const migration = migrateSlackChannelConfig({ + cfg: currentConfig, + accountId: ctx.accountId, + oldChannelId, + newChannelId, + }); + + if (migration.migrated) { + migrateSlackChannelConfig({ + cfg: ctx.cfg, + accountId: ctx.accountId, + oldChannelId, + newChannelId, + }); + await writeConfigFile(currentConfig); + ctx.runtime.log?.(warn("[slack] Channel config migrated and saved successfully.")); + } else if (migration.skippedExisting) { + ctx.runtime.log?.( + warn( + `[slack] Channel config already exists for ${newChannelId}; leaving ${oldChannelId} unchanged`, + ), + ); + } else { + ctx.runtime.log?.( + warn( + `[slack] No config found for old channel ID ${oldChannelId}; migration logged only`, + ), + ); + } + } catch (err) { + ctx.runtime.error?.(danger(`slack channel_id_changed handler failed: ${String(err)}`)); + } + }, + ); } diff --git a/src/slack/monitor/types.ts b/src/slack/monitor/types.ts index 8a65d11ad..e878f3fda 100644 --- a/src/slack/monitor/types.ts +++ b/src/slack/monitor/types.ts @@ -46,6 +46,13 @@ export type SlackChannelRenamedEvent = { event_ts?: string; }; +export type SlackChannelIdChangedEvent = { + type: "channel_id_changed"; + old_channel_id?: string; + new_channel_id?: string; + event_ts?: string; +}; + export type SlackPinEvent = { type: "pin_added" | "pin_removed"; channel_id?: string; diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 62edcc768..4603dda25 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -9,6 +9,7 @@ import { firstDefined, isSenderAllowed, normalizeAllowFrom } from "./bot-access. import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js"; import { migrateTelegramGroupConfig } from "./group-migration.js"; import { readTelegramAllowFromStore } from "./pairing-store.js"; +import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js"; export const registerTelegramHandlers = ({ cfg, @@ -93,6 +94,13 @@ export const registerTelegramHandlers = ({ runtime.log?.(warn(`[telegram] Group migrated: "${chatTitle}" ${oldChatId} → ${newChatId}`)); + if (!resolveChannelConfigWrites({ cfg, channelId: "telegram", accountId })) { + runtime.log?.( + warn("[telegram] Config writes disabled; skipping group config migration."), + ); + return; + } + // Check if old chat ID has config and migrate it const currentConfig = loadConfig(); const migration = migrateTelegramGroupConfig({