feat(config): gate channel config writes
This commit is contained in:
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,
|
||||
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.<channel>.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 {
|
||||
|
||||
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.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").',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,8 @@ export type WhatsAppConfig = {
|
||||
accounts?: Record<string, WhatsAppAccountConfig>;
|
||||
/** 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). */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
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 { 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)}`));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user