feat(config): gate channel config writes

This commit is contained in:
Peter Steinberger
2026-01-15 01:41:11 +00:00
parent f65668cb5f
commit ad8799522c
28 changed files with 576 additions and 2 deletions

View File

@@ -17,6 +17,7 @@
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR. - Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915) - 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. - Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
### Fixes ### Fixes
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor. - Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.

View File

@@ -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: 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. 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 ## How to create your own bot
This is the “Discord Developer Portal” setup for running Clawdbot in a server (guild) channel like `#help`. This is the “Discord Developer Portal” setup for running Clawdbot in a server (guild) channel like `#help`.

View File

@@ -35,6 +35,16 @@ Minimal config:
- DMs share the agent's main session; groups are isolated (`agent:<agentId>:imessage:group:<chat_id>`). - DMs share the agent's main session; groups are isolated (`agent:<agentId>:imessage:group:<chat_id>`).
- 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). - 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 ## Requirements
- macOS with Messages signed in. - macOS with Messages signed in.
- Full Disk Access for Clawdbot + `imsg` (Messages DB access). - Full Disk Access for Clawdbot + `imsg` (Messages DB access).

View File

@@ -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. - Keep routing deterministic: replies always go back to the channel they arrived on.
- Default to safe channel behavior (mentions required unless configured otherwise). - 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) ## Access control (DMs + groups)
**DM access** **DM access**

View File

@@ -36,6 +36,16 @@ Minimal config:
- Deterministic routing: replies always go back to Signal. - Deterministic routing: replies always go back to Signal.
- DMs share the agent's main session; groups are isolated (`agent:<agentId>:signal:group:<groupId>`). - DMs share the agent's main session; groups are isolated (`agent:<agentId>:signal:group:<groupId>`).
## 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 number model (important)
- The gateway connects to a **Signal device** (the `signal-cli` account). - 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). - If you run the bot on **your personal Signal account**, it will ignore your own messages (loop protection).

View File

@@ -32,6 +32,7 @@ Minimal config:
- `app_mention` - `app_mention`
- `reaction_added`, `reaction_removed` - `reaction_added`, `reaction_removed`
- `member_joined_channel`, `member_left_channel` - `member_joined_channel`, `member_left_channel`
- `channel_id_changed`
- `channel_rename` - `channel_rename`
- `pin_added`, `pin_removed` - `pin_added`, `pin_removed`
5) Invite the bot to channels you want it to read. 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. - `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). - 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) ## Manifest (optional)
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). 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", "reaction_removed",
"member_joined_channel", "member_joined_channel",
"member_left_channel", "member_left_channel",
"channel_id_changed",
"channel_rename", "channel_rename",
"pin_added", "pin_added",
"pin_removed" "pin_removed"

View File

@@ -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. **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) ## Topics (forum supergroups)
Telegram forum topics include a `message_thread_id` per message. Clawdbot: Telegram forum topics include a `message_thread_id` per message. Clawdbot:
- Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated. - Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated.

View File

@@ -31,6 +31,16 @@ Minimal config:
- Deterministic routing: replies return to WhatsApp, no model routing. - Deterministic routing: replies return to WhatsApp, no model routing.
- Model sees enough context to understand quoted replies. - 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) ## Architecture (who owns what)
- **Gateway** owns the Baileys socket and inbox loop. - **Gateway** owns the Baileys socket and inbox loop.
- **CLI / macOS app** talk to the gateway; no direct Baileys use. - **CLI / macOS app** talk to the gateway; no direct Baileys use.

View File

@@ -780,6 +780,7 @@ Notes:
- `commands.bash: true` enables `! <cmd>` to run host shell commands (`/bash <cmd>` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.<channel>`. - `commands.bash: true` enables `! <cmd>` to run host shell commands (`/bash <cmd>` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.<channel>`.
- `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! <cmd>` requests are rejected (one at a time). - `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! <cmd>` requests are rejected (one at a time).
- `commands.config: true` enables `/config` (reads/writes `clawdbot.json`). - `commands.config: true` enables `/config` (reads/writes `clawdbot.json`).
- `channels.<provider>.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.debug: true` enables `/debug` (runtime-only overrides).
- `commands.restart: true` enables `/restart` and the gateway tool restart action. - `commands.restart: true` enables `/restart` and the gateway tool restart action.
- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies. - `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`. 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. 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. 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 ```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. 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:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands. Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` 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.<id>.allowBots`. Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.

View File

@@ -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<MsgContext>) {
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");
});
});

View File

@@ -15,6 +15,8 @@ import {
setConfigOverride, setConfigOverride,
unsetConfigOverride, unsetConfigOverride,
} from "../../config/runtime-overrides.js"; } 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 { logVerbose } from "../../globals.js";
import type { CommandHandler } from "./commands-types.js"; import type { CommandHandler } from "./commands-types.js";
import { parseConfigCommand } from "./config-commands.js"; import { parseConfigCommand } from "./config-commands.js";
@@ -44,6 +46,28 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
reply: { text: `⚠️ ${configCommand.message}` }, 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.<channel>.configWrites=true";
return {
shouldContinue: false,
reply: {
text: `⚠️ Config writes are disabled for ${channelLabel}. Set ${hint} to enable.`,
},
};
}
}
const snapshot = await readConfigFileSnapshot(); const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") { if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") {
return { return {

View File

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

View File

@@ -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<string, { configWrites?: boolean }>;
};
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<string, ChannelConfigWithAccounts> | 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;
}

View File

@@ -269,6 +269,20 @@ const FIELD_HELP: Record<string, string> = {
"commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).",
"commands.restart": "Allow /restart and gateway restart tool actions (default: false).", "commands.restart": "Allow /restart and gateway restart tool actions (default: false).",
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", "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.discord.commands.native": 'Override native commands for Discord (bool or "auto").',
"channels.telegram.commands.native": 'Override native commands for Telegram (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").', "channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").',

View File

@@ -72,6 +72,8 @@ export type DiscordAccountConfig = {
capabilities?: string[]; capabilities?: string[];
/** Override native command registration for Discord (bool or "auto"). */ /** Override native command registration for Discord (bool or "auto"). */
commands?: ProviderCommandsConfig; commands?: ProviderCommandsConfig;
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this Discord account. Default: true. */ /** If false, do not start this Discord account. Default: true. */
enabled?: boolean; enabled?: boolean;
token?: string; token?: string;

View File

@@ -6,6 +6,8 @@ export type IMessageAccountConfig = {
name?: string; name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this iMessage account. Default: true. */ /** If false, do not start this iMessage account. Default: true. */
enabled?: boolean; enabled?: boolean;
/** imsg CLI binary path (default: imsg). */ /** imsg CLI binary path (default: imsg). */

View File

@@ -34,6 +34,8 @@ export type MSTeamsConfig = {
enabled?: boolean; enabled?: boolean;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** Azure Bot App ID (from Azure Bot registration). */ /** Azure Bot App ID (from Azure Bot registration). */
appId?: string; appId?: string;
/** Azure Bot App Password / Client Secret. */ /** Azure Bot App Password / Client Secret. */

View File

@@ -8,6 +8,8 @@ export type SignalAccountConfig = {
name?: string; name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this Signal account. Default: true. */ /** If false, do not start this Signal account. Default: true. */
enabled?: boolean; enabled?: boolean;
/** Optional explicit E.164 account for signal-cli. */ /** Optional explicit E.164 account for signal-cli. */

View File

@@ -67,6 +67,8 @@ export type SlackAccountConfig = {
capabilities?: string[]; capabilities?: string[];
/** Override native command registration for Slack (bool or "auto"). */ /** Override native command registration for Slack (bool or "auto"). */
commands?: ProviderCommandsConfig; commands?: ProviderCommandsConfig;
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this Slack account. Default: true. */ /** If false, do not start this Slack account. Default: true. */
enabled?: boolean; enabled?: boolean;
botToken?: string; botToken?: string;

View File

@@ -21,6 +21,8 @@ export type TelegramAccountConfig = {
capabilities?: string[]; capabilities?: string[];
/** Override native command registration for Telegram (bool or "auto"). */ /** Override native command registration for Telegram (bool or "auto"). */
commands?: ProviderCommandsConfig; commands?: ProviderCommandsConfig;
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** /**
* Controls how Telegram direct chats (DMs) are handled: * Controls how Telegram direct chats (DMs) are handled:
* - "pairing" (default): unknown senders get a pairing code; owner must approve * - "pairing" (default): unknown senders get a pairing code; owner must approve

View File

@@ -12,6 +12,8 @@ export type WhatsAppConfig = {
accounts?: Record<string, WhatsAppAccountConfig>; accounts?: Record<string, WhatsAppAccountConfig>;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** /**
* Inbound message prefix (WhatsApp only). * Inbound message prefix (WhatsApp only).
* Default: `[{agents.list[].identity.name}]` (or `[clawdbot]`) when allowFrom is empty, else `""`. * Default: `[{agents.list[].identity.name}]` (or `[clawdbot]`) when allowFrom is empty, else `""`.
@@ -78,6 +80,8 @@ export type WhatsAppAccountConfig = {
name?: string; name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this WhatsApp account provider. Default: true. */ /** If false, do not start this WhatsApp account provider. Default: true. */
enabled?: boolean; enabled?: boolean;
/** Inbound message prefix override for this account (WhatsApp only). */ /** Inbound message prefix override for this account (WhatsApp only). */

View File

@@ -36,6 +36,7 @@ export const TelegramAccountSchemaBase = z.object({
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
commands: ProviderCommandsSchema, commands: ProviderCommandsSchema,
configWrites: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"), dmPolicy: DmPolicySchema.optional().default("pairing"),
botToken: z.string().optional(), botToken: z.string().optional(),
tokenFile: z.string().optional(), tokenFile: z.string().optional(),
@@ -132,6 +133,7 @@ export const DiscordAccountSchema = z.object({
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
commands: ProviderCommandsSchema, commands: ProviderCommandsSchema,
configWrites: z.boolean().optional(),
token: z.string().optional(), token: z.string().optional(),
allowBots: z.boolean().optional(), allowBots: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"), groupPolicy: GroupPolicySchema.optional().default("allowlist"),
@@ -206,6 +208,7 @@ export const SlackAccountSchema = z.object({
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
commands: ProviderCommandsSchema, commands: ProviderCommandsSchema,
configWrites: z.boolean().optional(),
botToken: z.string().optional(), botToken: z.string().optional(),
appToken: z.string().optional(), appToken: z.string().optional(),
allowBots: z.boolean().optional(), allowBots: z.boolean().optional(),
@@ -252,6 +255,7 @@ export const SignalAccountSchemaBase = z.object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
account: z.string().optional(), account: z.string().optional(),
httpUrl: z.string().optional(), httpUrl: z.string().optional(),
httpHost: z.string().optional(), httpHost: z.string().optional(),
@@ -303,6 +307,7 @@ export const IMessageAccountSchemaBase = z.object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
cliPath: ExecutableTokenSchema.optional(), cliPath: ExecutableTokenSchema.optional(),
dbPath: z.string().optional(), dbPath: z.string().optional(),
service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(), service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(),
@@ -370,6 +375,7 @@ export const MSTeamsConfigSchema = z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
configWrites: z.boolean().optional(),
appId: z.string().optional(), appId: z.string().optional(),
appPassword: z.string().optional(), appPassword: z.string().optional(),
tenantId: z.string().optional(), tenantId: z.string().optional(),

View File

@@ -11,6 +11,7 @@ export const WhatsAppAccountSchema = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
configWrites: z.boolean().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
messagePrefix: z.string().optional(), messagePrefix: z.string().optional(),
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
@@ -60,6 +61,7 @@ export const WhatsAppConfigSchema = z
.object({ .object({
accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(), accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
configWrites: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"), dmPolicy: DmPolicySchema.optional().default("pairing"),
messagePrefix: z.string().optional(), messagePrefix: z.string().optional(),
selfChatMode: z.boolean().optional(), selfChatMode: z.boolean().optional(),

View File

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

View File

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

View File

@@ -1,11 +1,18 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt"; 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 { enqueueSystemEvent } from "../../../infra/system-events.js";
import { resolveSlackChannelLabel } from "../channel-config.js"; import { resolveSlackChannelLabel } from "../channel-config.js";
import type { SlackMonitorContext } from "../context.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 }) { export function registerSlackChannelEvents(params: { ctx: SlackMonitorContext }) {
const { ctx } = params; 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)}`));
}
},
);
} }

View File

@@ -46,6 +46,13 @@ export type SlackChannelRenamedEvent = {
event_ts?: string; event_ts?: string;
}; };
export type SlackChannelIdChangedEvent = {
type: "channel_id_changed";
old_channel_id?: string;
new_channel_id?: string;
event_ts?: string;
};
export type SlackPinEvent = { export type SlackPinEvent = {
type: "pin_added" | "pin_removed"; type: "pin_added" | "pin_removed";
channel_id?: string; channel_id?: string;

View File

@@ -9,6 +9,7 @@ import { firstDefined, isSenderAllowed, normalizeAllowFrom } from "./bot-access.
import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js"; import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js";
import { migrateTelegramGroupConfig } from "./group-migration.js"; import { migrateTelegramGroupConfig } from "./group-migration.js";
import { readTelegramAllowFromStore } from "./pairing-store.js"; import { readTelegramAllowFromStore } from "./pairing-store.js";
import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js";
export const registerTelegramHandlers = ({ export const registerTelegramHandlers = ({
cfg, cfg,
@@ -93,6 +94,13 @@ export const registerTelegramHandlers = ({
runtime.log?.(warn(`[telegram] Group migrated: "${chatTitle}" ${oldChatId}${newChatId}`)); 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 // Check if old chat ID has config and migrate it
const currentConfig = loadConfig(); const currentConfig = loadConfig();
const migration = migrateTelegramGroupConfig({ const migration = migrateTelegramGroupConfig({