feat(config): gate channel config writes
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
57
src/auto-reply/reply/commands-config-writes.test.ts
Normal file
57
src/auto-reply/reply/commands-config-writes.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
47
src/channels/plugins/config-writes.test.ts
Normal file
47
src/channels/plugins/config-writes.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
35
src/channels/plugins/config-writes.ts
Normal file
35
src/channels/plugins/config-writes.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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").',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
113
src/slack/channel-migration.test.ts
Normal file
113
src/slack/channel-migration.test.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
84
src/slack/channel-migration.ts
Normal file
84
src/slack/channel-migration.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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)}`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user