refactor!: rename chat providers to channels

This commit is contained in:
Peter Steinberger
2026-01-13 06:16:43 +00:00
parent 0cd632ba84
commit 90342a4f3a
393 changed files with 8004 additions and 6737 deletions

View File

@@ -1,35 +1,36 @@
import { describe, expect, it } from "vitest";
import { resolveChannelCapabilities } from "./channel-capabilities.js";
import type { ClawdbotConfig } from "./config.js";
import { resolveProviderCapabilities } from "./provider-capabilities.js";
describe("resolveProviderCapabilities", () => {
describe("resolveChannelCapabilities", () => {
it("returns undefined for missing inputs", () => {
expect(resolveProviderCapabilities({})).toBeUndefined();
expect(resolveChannelCapabilities({})).toBeUndefined();
expect(
resolveProviderCapabilities({ cfg: {} as ClawdbotConfig }),
resolveChannelCapabilities({ cfg: {} as ClawdbotConfig }),
).toBeUndefined();
expect(
resolveProviderCapabilities({ cfg: {} as ClawdbotConfig, provider: "" }),
resolveChannelCapabilities({ cfg: {} as ClawdbotConfig, channel: "" }),
).toBeUndefined();
});
it("normalizes and prefers per-account capabilities", () => {
const cfg = {
telegram: {
capabilities: [" inlineButtons ", ""],
accounts: {
default: {
capabilities: [" perAccount ", " "],
channels: {
telegram: {
capabilities: [" inlineButtons ", ""],
accounts: {
default: {
capabilities: [" perAccount ", " "],
},
},
},
},
} satisfies Partial<ClawdbotConfig>;
expect(
resolveProviderCapabilities({
resolveChannelCapabilities({
cfg: cfg as ClawdbotConfig,
provider: "telegram",
channel: "telegram",
accountId: "default",
}),
).toEqual(["perAccount"]);
@@ -37,18 +38,20 @@ describe("resolveProviderCapabilities", () => {
it("falls back to provider capabilities when account capabilities are missing", () => {
const cfg = {
telegram: {
capabilities: ["inlineButtons"],
accounts: {
default: {},
channels: {
telegram: {
capabilities: ["inlineButtons"],
accounts: {
default: {},
},
},
},
} satisfies Partial<ClawdbotConfig>;
expect(
resolveProviderCapabilities({
resolveChannelCapabilities({
cfg: cfg as ClawdbotConfig,
provider: "telegram",
channel: "telegram",
accountId: "default",
}),
).toEqual(["inlineButtons"]);
@@ -56,17 +59,19 @@ describe("resolveProviderCapabilities", () => {
it("matches account keys case-insensitively", () => {
const cfg = {
slack: {
accounts: {
Family: { capabilities: ["threads"] },
channels: {
slack: {
accounts: {
Family: { capabilities: ["threads"] },
},
},
},
} satisfies Partial<ClawdbotConfig>;
expect(
resolveProviderCapabilities({
resolveChannelCapabilities({
cfg: cfg as ClawdbotConfig,
provider: "slack",
channel: "slack",
accountId: "family",
}),
).toEqual(["threads"]);
@@ -74,13 +79,13 @@ describe("resolveProviderCapabilities", () => {
it("supports msteams capabilities", () => {
const cfg = {
msteams: { capabilities: [" polls ", ""] },
channels: { msteams: { capabilities: [" polls ", ""] } },
} satisfies Partial<ClawdbotConfig>;
expect(
resolveProviderCapabilities({
resolveChannelCapabilities({
cfg: cfg as ClawdbotConfig,
provider: "msteams",
channel: "msteams",
}),
).toEqual(["polls"]);
});

View File

@@ -1,4 +1,4 @@
import { normalizeProviderId } from "../providers/registry.js";
import { normalizeChannelId } from "../channels/registry.js";
import { normalizeAccountId } from "../routing/session-key.js";
import type { ClawdbotConfig } from "./config.js";
@@ -44,23 +44,25 @@ function resolveAccountCapabilities(params: {
return normalizeCapabilities(cfg.capabilities);
}
export function resolveProviderCapabilities(params: {
export function resolveChannelCapabilities(params: {
cfg?: ClawdbotConfig;
provider?: string | null;
channel?: string | null;
accountId?: string | null;
}): string[] | undefined {
const cfg = params.cfg;
const provider = normalizeProviderId(params.provider);
if (!cfg || !provider) return undefined;
const channel = normalizeChannelId(params.channel);
if (!cfg || !channel) return undefined;
const providerConfig = (cfg as Record<string, unknown>)[provider] as
const channelsConfig = cfg.channels as Record<string, unknown> | undefined;
const channelConfig = (channelsConfig?.[channel] ??
(cfg as Record<string, unknown>)[channel]) as
| {
accounts?: Record<string, { capabilities?: string[] }>;
capabilities?: string[];
}
| undefined;
return resolveAccountCapabilities({
cfg: providerConfig,
cfg: channelConfig,
accountId: params.accountId,
});
}

View File

@@ -1,9 +1,9 @@
import type { ProviderId } from "../providers/plugins/types.js";
import { normalizeProviderId } from "../providers/registry.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { normalizeChannelId } from "../channels/registry.js";
import type { NativeCommandsSetting } from "./types.js";
function resolveAutoDefault(providerId?: ProviderId): boolean {
const id = normalizeProviderId(providerId);
function resolveAutoDefault(providerId?: ChannelId): boolean {
const id = normalizeChannelId(providerId);
if (!id) return false;
if (id === "discord" || id === "telegram") return true;
if (id === "slack") return false;
@@ -11,7 +11,7 @@ function resolveAutoDefault(providerId?: ProviderId): boolean {
}
export function resolveNativeCommandsEnabled(params: {
providerId: ProviderId;
providerId: ChannelId;
providerSetting?: NativeCommandsSetting;
globalSetting?: NativeCommandsSetting;
}): boolean {

View File

@@ -207,15 +207,17 @@ describe("config identity defaults", () => {
// legacy field should be ignored (moved to providers)
textChunkLimit: 9999,
},
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
telegram: { enabled: true, textChunkLimit: 3333 },
discord: {
enabled: true,
textChunkLimit: 1999,
maxLinesPerMessage: 17,
channels: {
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
telegram: { enabled: true, textChunkLimit: 3333 },
discord: {
enabled: true,
textChunkLimit: 1999,
maxLinesPerMessage: 17,
},
signal: { enabled: true, textChunkLimit: 2222 },
imessage: { enabled: true, textChunkLimit: 1111 },
},
signal: { enabled: true, textChunkLimit: 2222 },
imessage: { enabled: true, textChunkLimit: 1111 },
},
null,
2,
@@ -227,12 +229,12 @@ describe("config identity defaults", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.whatsapp?.textChunkLimit).toBe(4444);
expect(cfg.telegram?.textChunkLimit).toBe(3333);
expect(cfg.discord?.textChunkLimit).toBe(1999);
expect(cfg.discord?.maxLinesPerMessage).toBe(17);
expect(cfg.signal?.textChunkLimit).toBe(2222);
expect(cfg.imessage?.textChunkLimit).toBe(1111);
expect(cfg.channels?.whatsapp?.textChunkLimit).toBe(4444);
expect(cfg.channels?.telegram?.textChunkLimit).toBe(3333);
expect(cfg.channels?.discord?.textChunkLimit).toBe(1999);
expect(cfg.channels?.discord?.maxLinesPerMessage).toBe(17);
expect(cfg.channels?.signal?.textChunkLimit).toBe(2222);
expect(cfg.channels?.imessage?.textChunkLimit).toBe(1111);
const legacy = (cfg.messages as unknown as Record<string, unknown>)
.textChunkLimit;
@@ -581,21 +583,23 @@ describe("config discord", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
discord: {
enabled: true,
dm: {
channels: {
discord: {
enabled: true,
allowFrom: ["steipete"],
groupEnabled: true,
groupChannels: ["clawd-dm"],
},
guilds: {
"123": {
slug: "friends-of-clawd",
requireMention: false,
users: ["steipete"],
channels: {
general: { allow: true },
dm: {
enabled: true,
allowFrom: ["steipete"],
groupEnabled: true,
groupChannels: ["clawd-dm"],
},
guilds: {
"123": {
slug: "friends-of-clawd",
requireMention: false,
users: ["steipete"],
channels: {
general: { allow: true },
},
},
},
},
@@ -611,11 +615,15 @@ describe("config discord", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.discord?.enabled).toBe(true);
expect(cfg.discord?.dm?.groupEnabled).toBe(true);
expect(cfg.discord?.dm?.groupChannels).toEqual(["clawd-dm"]);
expect(cfg.discord?.guilds?.["123"]?.slug).toBe("friends-of-clawd");
expect(cfg.discord?.guilds?.["123"]?.channels?.general?.allow).toBe(true);
expect(cfg.channels?.discord?.enabled).toBe(true);
expect(cfg.channels?.discord?.dm?.groupEnabled).toBe(true);
expect(cfg.channels?.discord?.dm?.groupChannels).toEqual(["clawd-dm"]);
expect(cfg.channels?.discord?.guilds?.["123"]?.slug).toBe(
"friends-of-clawd",
);
expect(
cfg.channels?.discord?.guilds?.["123"]?.channels?.general?.allow,
).toBe(true);
});
});
});
@@ -625,13 +633,15 @@ describe("config msteams", () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
msteams: {
replyStyle: "top-level",
teams: {
team123: {
replyStyle: "thread",
channels: {
chan456: { replyStyle: "top-level" },
channels: {
msteams: {
replyStyle: "top-level",
teams: {
team123: {
replyStyle: "thread",
channels: {
chan456: { replyStyle: "top-level" },
},
},
},
},
@@ -639,10 +649,13 @@ describe("config msteams", () => {
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.msteams?.replyStyle).toBe("top-level");
expect(res.config.msteams?.teams?.team123?.replyStyle).toBe("thread");
expect(res.config.channels?.msteams?.replyStyle).toBe("top-level");
expect(res.config.channels?.msteams?.teams?.team123?.replyStyle).toBe(
"thread",
);
expect(
res.config.msteams?.teams?.team123?.channels?.chan456?.replyStyle,
res.config.channels?.msteams?.teams?.team123?.channels?.chan456
?.replyStyle,
).toBe("top-level");
}
});
@@ -651,7 +664,7 @@ describe("config msteams", () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
msteams: { replyStyle: "nope" },
channels: { msteams: { replyStyle: "nope" } },
});
expect(res.ok).toBe(false);
});
@@ -785,10 +798,12 @@ describe("Nix integration (U3, U5, U9)", () => {
},
],
},
whatsapp: {
accounts: {
personal: {
authDir: "~/.clawdbot/credentials/wa-personal",
channels: {
whatsapp: {
accounts: {
personal: {
authDir: "~/.clawdbot/credentials/wa-personal",
},
},
},
},
@@ -818,7 +833,7 @@ describe("Nix integration (U3, U5, U9)", () => {
expect(cfg.agents?.list?.[0]?.sandbox?.workspaceRoot).toBe(
path.join(home, "sandbox-root"),
);
expect(cfg.whatsapp?.accounts?.personal?.authDir).toBe(
expect(cfg.channels?.whatsapp?.accounts?.personal?.authDir).toBe(
path.join(home, ".clawdbot", "credentials", "wa-personal"),
);
});
@@ -858,7 +873,7 @@ describe("Nix integration (U3, U5, U9)", () => {
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({
telegram: { botToken: "123:ABC" },
channels: { telegram: { botToken: "123:ABC" } },
}),
"utf-8",
);
@@ -866,8 +881,8 @@ describe("Nix integration (U3, U5, U9)", () => {
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.telegram?.botToken).toBe("123:ABC");
expect(cfg.telegram?.tokenFile).toBeUndefined();
expect(cfg.channels?.telegram?.botToken).toBe("123:ABC");
expect(cfg.channels?.telegram?.tokenFile).toBeUndefined();
});
});
@@ -878,7 +893,7 @@ describe("Nix integration (U3, U5, U9)", () => {
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({
telegram: { tokenFile: "/run/agenix/telegram-token" },
channels: { telegram: { tokenFile: "/run/agenix/telegram-token" } },
}),
"utf-8",
);
@@ -886,8 +901,10 @@ describe("Nix integration (U3, U5, U9)", () => {
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.telegram?.tokenFile).toBe("/run/agenix/telegram-token");
expect(cfg.telegram?.botToken).toBeUndefined();
expect(cfg.channels?.telegram?.tokenFile).toBe(
"/run/agenix/telegram-token",
);
expect(cfg.channels?.telegram?.botToken).toBeUndefined();
});
});
@@ -898,9 +915,11 @@ describe("Nix integration (U3, U5, U9)", () => {
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({
telegram: {
botToken: "fallback:token",
tokenFile: "/run/agenix/telegram-token",
channels: {
telegram: {
botToken: "fallback:token",
tokenFile: "/run/agenix/telegram-token",
},
},
}),
"utf-8",
@@ -909,8 +928,10 @@ describe("Nix integration (U3, U5, U9)", () => {
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.telegram?.botToken).toBe("fallback:token");
expect(cfg.telegram?.tokenFile).toBe("/run/agenix/telegram-token");
expect(cfg.channels?.telegram?.botToken).toBe("fallback:token");
expect(cfg.channels?.telegram?.tokenFile).toBe(
"/run/agenix/telegram-token",
);
});
});
});
@@ -1052,37 +1073,43 @@ describe("legacy config detection", () => {
}
});
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
it("migrates routing.allowFrom to channels.whatsapp.allowFrom", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { allowFrom: ["+15555550123"] },
});
expect(res.changes).toContain(
"Moved routing.allowFrom → whatsapp.allowFrom.",
"Moved routing.allowFrom → channels.whatsapp.allowFrom.",
);
expect(res.config?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(res.config?.routing?.allowFrom).toBeUndefined();
});
it("migrates routing.groupChat.requireMention to whatsapp/telegram/imessage groups", async () => {
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { groupChat: { requireMention: false } },
});
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → whatsapp.groups."*".requireMention.',
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
);
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → telegram.groups."*".requireMention.',
'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.',
);
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → imessage.groups."*".requireMention.',
'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.',
);
expect(res.config?.channels?.whatsapp?.groups?.["*"]?.requireMention).toBe(
false,
);
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(
false,
);
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(
false,
);
expect(res.config?.whatsapp?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.telegram?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.imessage?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
});
@@ -1221,7 +1248,9 @@ describe("legacy config detection", () => {
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("telegram.requireMention");
expect(
res.issues.some((issue) => issue.path === "telegram.requireMention"),
).toBe(true);
}
});
@@ -1270,11 +1299,11 @@ describe("legacy config detection", () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
telegram: { dmPolicy: "open", allowFrom: ["123456789"] },
channels: { telegram: { dmPolicy: "open", allowFrom: ["123456789"] } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("telegram.allowFrom");
expect(res.issues[0]?.path).toBe("channels.telegram.allowFrom");
}
});
@@ -1282,41 +1311,41 @@ describe("legacy config detection", () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
telegram: { dmPolicy: "open", allowFrom: ["*"] },
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.telegram?.dmPolicy).toBe("open");
expect(res.config.channels?.telegram?.dmPolicy).toBe("open");
}
});
it("defaults telegram.dmPolicy to pairing when telegram section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ telegram: {} });
const res = validateConfigObject({ channels: { telegram: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.telegram?.dmPolicy).toBe("pairing");
expect(res.config.channels?.telegram?.dmPolicy).toBe("pairing");
}
});
it("defaults telegram.groupPolicy to allowlist when telegram section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ telegram: {} });
const res = validateConfigObject({ channels: { telegram: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.telegram?.groupPolicy).toBe("allowlist");
expect(res.config.channels?.telegram?.groupPolicy).toBe("allowlist");
}
});
it("defaults telegram.streamMode to partial when telegram section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ telegram: {} });
const res = validateConfigObject({ channels: { telegram: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.telegram?.streamMode).toBe("partial");
expect(res.config.channels?.telegram?.streamMode).toBe("partial");
}
});
@@ -1324,11 +1353,13 @@ describe("legacy config detection", () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
whatsapp: { dmPolicy: "open", allowFrom: ["+15555550123"] },
channels: {
whatsapp: { dmPolicy: "open", allowFrom: ["+15555550123"] },
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("whatsapp.allowFrom");
expect(res.issues[0]?.path).toBe("channels.whatsapp.allowFrom");
}
});
@@ -1336,31 +1367,31 @@ describe("legacy config detection", () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
whatsapp: { dmPolicy: "open", allowFrom: ["*"] },
channels: { whatsapp: { dmPolicy: "open", allowFrom: ["*"] } },
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.whatsapp?.dmPolicy).toBe("open");
expect(res.config.channels?.whatsapp?.dmPolicy).toBe("open");
}
});
it("defaults whatsapp.dmPolicy to pairing when whatsapp section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ whatsapp: {} });
const res = validateConfigObject({ channels: { whatsapp: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.whatsapp?.dmPolicy).toBe("pairing");
expect(res.config.channels?.whatsapp?.dmPolicy).toBe("pairing");
}
});
it("defaults whatsapp.groupPolicy to allowlist when whatsapp section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ whatsapp: {} });
const res = validateConfigObject({ channels: { whatsapp: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.whatsapp?.groupPolicy).toBe("allowlist");
expect(res.config.channels?.whatsapp?.groupPolicy).toBe("allowlist");
}
});
@@ -1368,11 +1399,11 @@ describe("legacy config detection", () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
signal: { dmPolicy: "open", allowFrom: ["+15555550123"] },
channels: { signal: { dmPolicy: "open", allowFrom: ["+15555550123"] } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("signal.allowFrom");
expect(res.issues[0]?.path).toBe("channels.signal.allowFrom");
}
});
@@ -1380,31 +1411,31 @@ describe("legacy config detection", () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
signal: { dmPolicy: "open", allowFrom: ["*"] },
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.signal?.dmPolicy).toBe("open");
expect(res.config.channels?.signal?.dmPolicy).toBe("open");
}
});
it("defaults signal.dmPolicy to pairing when signal section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ signal: {} });
const res = validateConfigObject({ channels: { signal: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.signal?.dmPolicy).toBe("pairing");
expect(res.config.channels?.signal?.dmPolicy).toBe("pairing");
}
});
it("defaults signal.groupPolicy to allowlist when signal section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ signal: {} });
const res = validateConfigObject({ channels: { signal: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.signal?.groupPolicy).toBe("allowlist");
expect(res.config.channels?.signal?.groupPolicy).toBe("allowlist");
}
});
@@ -1413,26 +1444,32 @@ describe("legacy config detection", () => {
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
messages: { groupChat: { historyLimit: 12 } },
whatsapp: { historyLimit: 9, accounts: { work: { historyLimit: 4 } } },
telegram: { historyLimit: 8, accounts: { ops: { historyLimit: 3 } } },
slack: { historyLimit: 7, accounts: { ops: { historyLimit: 2 } } },
signal: { historyLimit: 6 },
imessage: { historyLimit: 5 },
msteams: { historyLimit: 4 },
discord: { historyLimit: 3 },
channels: {
whatsapp: { historyLimit: 9, accounts: { work: { historyLimit: 4 } } },
telegram: { historyLimit: 8, accounts: { ops: { historyLimit: 3 } } },
slack: { historyLimit: 7, accounts: { ops: { historyLimit: 2 } } },
signal: { historyLimit: 6 },
imessage: { historyLimit: 5 },
msteams: { historyLimit: 4 },
discord: { historyLimit: 3 },
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.whatsapp?.historyLimit).toBe(9);
expect(res.config.whatsapp?.accounts?.work?.historyLimit).toBe(4);
expect(res.config.telegram?.historyLimit).toBe(8);
expect(res.config.telegram?.accounts?.ops?.historyLimit).toBe(3);
expect(res.config.slack?.historyLimit).toBe(7);
expect(res.config.slack?.accounts?.ops?.historyLimit).toBe(2);
expect(res.config.signal?.historyLimit).toBe(6);
expect(res.config.imessage?.historyLimit).toBe(5);
expect(res.config.msteams?.historyLimit).toBe(4);
expect(res.config.discord?.historyLimit).toBe(3);
expect(res.config.channels?.whatsapp?.historyLimit).toBe(9);
expect(res.config.channels?.whatsapp?.accounts?.work?.historyLimit).toBe(
4,
);
expect(res.config.channels?.telegram?.historyLimit).toBe(8);
expect(res.config.channels?.telegram?.accounts?.ops?.historyLimit).toBe(
3,
);
expect(res.config.channels?.slack?.historyLimit).toBe(7);
expect(res.config.channels?.slack?.accounts?.ops?.historyLimit).toBe(2);
expect(res.config.channels?.signal?.historyLimit).toBe(6);
expect(res.config.channels?.imessage?.historyLimit).toBe(5);
expect(res.config.channels?.msteams?.historyLimit).toBe(4);
expect(res.config.channels?.discord?.historyLimit).toBe(3);
}
});
@@ -1440,11 +1477,13 @@ describe("legacy config detection", () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
imessage: { dmPolicy: "open", allowFrom: ["+15555550123"] },
channels: {
imessage: { dmPolicy: "open", allowFrom: ["+15555550123"] },
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("imessage.allowFrom");
expect(res.issues[0]?.path).toBe("channels.imessage.allowFrom");
}
});
@@ -1452,61 +1491,61 @@ describe("legacy config detection", () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
imessage: { dmPolicy: "open", allowFrom: ["*"] },
channels: { imessage: { dmPolicy: "open", allowFrom: ["*"] } },
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.imessage?.dmPolicy).toBe("open");
expect(res.config.channels?.imessage?.dmPolicy).toBe("open");
}
});
it("defaults imessage.dmPolicy to pairing when imessage section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ imessage: {} });
const res = validateConfigObject({ channels: { imessage: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.imessage?.dmPolicy).toBe("pairing");
expect(res.config.channels?.imessage?.dmPolicy).toBe("pairing");
}
});
it("defaults imessage.groupPolicy to allowlist when imessage section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ imessage: {} });
const res = validateConfigObject({ channels: { imessage: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.imessage?.groupPolicy).toBe("allowlist");
expect(res.config.channels?.imessage?.groupPolicy).toBe("allowlist");
}
});
it("defaults discord.groupPolicy to allowlist when discord section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ discord: {} });
const res = validateConfigObject({ channels: { discord: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.discord?.groupPolicy).toBe("allowlist");
expect(res.config.channels?.discord?.groupPolicy).toBe("allowlist");
}
});
it("defaults slack.groupPolicy to allowlist when slack section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ slack: {} });
const res = validateConfigObject({ channels: { slack: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.slack?.groupPolicy).toBe("allowlist");
expect(res.config.channels?.slack?.groupPolicy).toBe("allowlist");
}
});
it("defaults msteams.groupPolicy to allowlist when msteams section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ msteams: {} });
const res = validateConfigObject({ channels: { msteams: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.msteams?.groupPolicy).toBe("allowlist");
expect(res.config.channels?.msteams?.groupPolicy).toBe("allowlist");
}
});
@@ -1514,12 +1553,14 @@ describe("legacy config detection", () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
imessage: { cliPath: "imsg; rm -rf /" },
channels: { imessage: { cliPath: "imsg; rm -rf /" } },
tools: { audio: { transcription: { args: ["--model", "base"] } } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((i) => i.path === "imessage.cliPath")).toBe(true);
expect(
res.issues.some((i) => i.path === "channels.imessage.cliPath"),
).toBe(true);
}
});
@@ -1536,7 +1577,7 @@ describe("legacy config detection", () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
imessage: { cliPath: "/Applications/Imsg Tools/imsg" },
channels: { imessage: { cliPath: "/Applications/Imsg Tools/imsg" } },
tools: {
audio: {
transcription: {
@@ -1552,11 +1593,11 @@ describe("legacy config detection", () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
discord: { dm: { policy: "open", allowFrom: ["123"] } },
channels: { discord: { dm: { policy: "open", allowFrom: ["123"] } } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("discord.dm.allowFrom");
expect(res.issues[0]?.path).toBe("channels.discord.dm.allowFrom");
}
});
@@ -1564,11 +1605,11 @@ describe("legacy config detection", () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
slack: { dm: { policy: "open", allowFrom: ["U123"] } },
channels: { slack: { dm: { policy: "open", allowFrom: ["U123"] } } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("slack.dm.allowFrom");
expect(res.issues[0]?.path).toBe("channels.slack.dm.allowFrom");
}
});
@@ -1584,17 +1625,19 @@ describe("legacy config detection", () => {
}
});
it("migrates telegram.requireMention to telegram.groups.*.requireMention", async () => {
it("migrates telegram.requireMention to channels.telegram.groups.*.requireMention", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
telegram: { requireMention: false },
});
expect(res.changes).toContain(
'Moved telegram.requireMention → telegram.groups."*".requireMention.',
'Moved telegram.requireMention → channels.telegram.groups."*".requireMention.',
);
expect(res.config?.telegram?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.telegram?.requireMention).toBeUndefined();
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(
false,
);
expect(res.config?.channels?.telegram?.requireMention).toBeUndefined();
});
it("migrates legacy model config to agent.models + model lists", async () => {
@@ -1632,7 +1675,7 @@ describe("legacy config detection", () => {
expect(res.config?.agent).toBeUndefined();
});
it("surfaces legacy issues in snapshot", async () => {
it("auto-migrates legacy config in snapshot (no legacyIssues)", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
@@ -1642,13 +1685,234 @@ describe("legacy config detection", () => {
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules();
const { readConfigFileSnapshot } = await import("./config.js");
const snap = await readConfigFileSnapshot();
try {
const { readConfigFileSnapshot } = await import("./config.js");
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false);
expect(snap.legacyIssues.length).toBe(1);
expect(snap.legacyIssues[0]?.path).toBe("routing.allowFrom");
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.length).toBe(0);
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
channels?: { whatsapp?: { allowFrom?: string[] } };
routing?: unknown;
};
expect(parsed.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(parsed.routing).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
});
it("auto-migrates legacy provider sections on load and writes back", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ whatsapp: { allowFrom: ["+1555"] } }, null, 2),
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules();
try {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.channels?.whatsapp?.allowFrom).toEqual(["+1555"]);
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
channels?: { whatsapp?: { allowFrom?: string[] } };
whatsapp?: unknown;
};
expect(parsed.channels?.whatsapp?.allowFrom).toEqual(["+1555"]);
expect(parsed.whatsapp).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
});
it("auto-migrates routing.allowFrom on load and writes back", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ routing: { allowFrom: ["+1666"] } }, null, 2),
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules();
try {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.channels?.whatsapp?.allowFrom).toEqual(["+1666"]);
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
channels?: { whatsapp?: { allowFrom?: string[] } };
routing?: unknown;
};
expect(parsed.channels?.whatsapp?.allowFrom).toEqual(["+1666"]);
expect(parsed.routing).toBeUndefined();
} finally {
warnSpy.mockRestore();
}
});
});
it("auto-migrates bindings[].match.provider on load and writes back", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
bindings: [{ agentId: "main", match: { provider: "slack" } }],
},
null,
2,
),
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules();
try {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.bindings?.[0]?.match?.channel).toBe("slack");
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
bindings?: Array<{ match?: { channel?: string; provider?: string } }>;
};
expect(parsed.bindings?.[0]?.match?.channel).toBe("slack");
expect(parsed.bindings?.[0]?.match?.provider).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
});
it("auto-migrates session.sendPolicy.rules[].match.provider on load and writes back", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
session: {
sendPolicy: {
rules: [{ action: "deny", match: { provider: "telegram" } }],
},
},
},
null,
2,
),
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules();
try {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.session?.sendPolicy?.rules?.[0]?.match?.channel).toBe(
"telegram",
);
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
session?: {
sendPolicy?: {
rules?: Array<{
match?: { channel?: string; provider?: string };
}>;
};
};
};
expect(parsed.session?.sendPolicy?.rules?.[0]?.match?.channel).toBe(
"telegram",
);
expect(
parsed.session?.sendPolicy?.rules?.[0]?.match?.provider,
).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
});
it("auto-migrates messages.queue.byProvider on load and writes back", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{ messages: { queue: { byProvider: { whatsapp: "queue" } } } },
null,
2,
),
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules();
try {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.queue?.byChannel?.whatsapp).toBe("queue");
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
messages?: {
queue?: {
byChannel?: Record<string, unknown>;
byProvider?: unknown;
};
};
};
expect(parsed.messages?.queue?.byChannel?.whatsapp).toBe("queue");
expect(parsed.messages?.queue?.byProvider).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
});
});

View File

@@ -1,52 +1,52 @@
import { normalizeAccountId } from "../routing/session-key.js";
import type { ClawdbotConfig } from "./config.js";
export type GroupPolicyProvider = "whatsapp" | "telegram" | "imessage";
export type GroupPolicyChannel = "whatsapp" | "telegram" | "imessage";
export type ProviderGroupConfig = {
export type ChannelGroupConfig = {
requireMention?: boolean;
};
export type ProviderGroupPolicy = {
export type ChannelGroupPolicy = {
allowlistEnabled: boolean;
allowed: boolean;
groupConfig?: ProviderGroupConfig;
defaultConfig?: ProviderGroupConfig;
groupConfig?: ChannelGroupConfig;
defaultConfig?: ChannelGroupConfig;
};
type ProviderGroups = Record<string, ProviderGroupConfig>;
type ChannelGroups = Record<string, ChannelGroupConfig>;
function resolveProviderGroups(
function resolveChannelGroups(
cfg: ClawdbotConfig,
provider: GroupPolicyProvider,
channel: GroupPolicyChannel,
accountId?: string | null,
): ProviderGroups | undefined {
): ChannelGroups | undefined {
const normalizedAccountId = normalizeAccountId(accountId);
const providerConfig = (cfg as Record<string, unknown>)[provider] as
const channelConfig = cfg.channels?.[channel] as
| {
accounts?: Record<string, { groups?: ProviderGroups }>;
groups?: ProviderGroups;
accounts?: Record<string, { groups?: ChannelGroups }>;
groups?: ChannelGroups;
}
| undefined;
if (!providerConfig) return undefined;
if (!channelConfig) return undefined;
const accountGroups =
providerConfig.accounts?.[normalizedAccountId]?.groups ??
providerConfig.accounts?.[
Object.keys(providerConfig.accounts ?? {}).find(
channelConfig.accounts?.[normalizedAccountId]?.groups ??
channelConfig.accounts?.[
Object.keys(channelConfig.accounts ?? {}).find(
(key) => key.toLowerCase() === normalizedAccountId.toLowerCase(),
) ?? ""
]?.groups;
return accountGroups ?? providerConfig.groups;
return accountGroups ?? channelConfig.groups;
}
export function resolveProviderGroupPolicy(params: {
export function resolveChannelGroupPolicy(params: {
cfg: ClawdbotConfig;
provider: GroupPolicyProvider;
channel: GroupPolicyChannel;
groupId?: string | null;
accountId?: string | null;
}): ProviderGroupPolicy {
const { cfg, provider } = params;
const groups = resolveProviderGroups(cfg, provider, params.accountId);
}): ChannelGroupPolicy {
const { cfg, channel } = params;
const groups = resolveChannelGroups(cfg, channel, params.accountId);
const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0);
const normalizedId = params.groupId?.trim();
const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined;
@@ -67,16 +67,16 @@ export function resolveProviderGroupPolicy(params: {
};
}
export function resolveProviderGroupRequireMention(params: {
export function resolveChannelGroupRequireMention(params: {
cfg: ClawdbotConfig;
provider: GroupPolicyProvider;
channel: GroupPolicyChannel;
groupId?: string | null;
accountId?: string | null;
requireMentionOverride?: boolean;
overrideOrder?: "before-config" | "after-config";
}): boolean {
const { requireMentionOverride, overrideOrder = "after-config" } = params;
const { groupConfig, defaultConfig } = resolveProviderGroupPolicy(params);
const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params);
const configMention =
typeof groupConfig?.requireMention === "boolean"
? groupConfig.requireMention

View File

@@ -352,8 +352,8 @@ describe("real-world config patterns", () => {
[configPath("gateway.json")]: {
gateway: { port: 18789, bind: "loopback" },
},
[configPath("providers", "whatsapp.json")]: {
whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] },
[configPath("channels", "whatsapp.json")]: {
channels: { whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] } },
},
[configPath("agents", "defaults.json")]: {
agents: { defaults: { sandbox: { mode: "all" } } },
@@ -363,14 +363,14 @@ describe("real-world config patterns", () => {
const obj = {
$include: [
"./gateway.json",
"./providers/whatsapp.json",
"./channels/whatsapp.json",
"./agents/defaults.json",
],
};
expect(resolve(obj, files)).toEqual({
gateway: { port: 18789, bind: "loopback" },
whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] },
channels: { whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] } },
agents: { defaults: { sandbox: { mode: "all" } } },
});
});

View File

@@ -23,7 +23,7 @@ import {
applyTalkApiKey,
} from "./defaults.js";
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
import { findLegacyConfigIssues } from "./legacy.js";
import { applyLegacyMigrations, findLegacyConfigIssues } from "./legacy.js";
import { normalizeConfigPaths } from "./normalize-paths.js";
import { resolveConfigPath, resolveStateDir } from "./paths.js";
import { applyConfigOverrides } from "./runtime-overrides.js";
@@ -86,6 +86,10 @@ function warnOnConfigMiskeys(
}
}
function formatLegacyMigrationLog(changes: string[]): string {
return `Auto-migrated config:\n${changes.map((entry) => `- ${entry}`).join("\n")}`;
}
function applyConfigEnv(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): void {
const envConfig = cfg.env;
if (!envConfig) return;
@@ -142,6 +146,53 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const deps = normalizeDeps(overrides);
const configPath = resolveConfigPathForDeps(deps);
const writeConfigFileSync = (cfg: ClawdbotConfig) => {
const dir = path.dirname(configPath);
deps.fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const json = JSON.stringify(applyModelDefaults(cfg), null, 2)
.trimEnd()
.concat("\n");
const tmp = path.join(
dir,
`${path.basename(configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
);
deps.fs.writeFileSync(tmp, json, { encoding: "utf-8", mode: 0o600 });
try {
deps.fs.copyFileSync(configPath, `${configPath}.bak`);
} catch {
// best-effort
}
try {
deps.fs.renameSync(tmp, configPath);
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "EPERM" || code === "EEXIST") {
deps.fs.copyFileSync(tmp, configPath);
try {
deps.fs.chmodSync(configPath, 0o600);
} catch {
// best-effort
}
try {
deps.fs.unlinkSync(tmp);
} catch {
// best-effort
}
return;
}
try {
deps.fs.unlinkSync(tmp);
} catch {
// best-effort
}
throw err;
}
};
function loadConfig(): ClawdbotConfig {
try {
if (!deps.fs.existsSync(configPath)) {
@@ -165,9 +216,12 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
parseJson: (raw) => deps.json5.parse(raw),
});
warnOnConfigMiskeys(resolved, deps.logger);
if (typeof resolved !== "object" || resolved === null) return {};
const validated = ClawdbotSchema.safeParse(resolved);
const migrated = applyLegacyMigrations(resolved);
const resolvedConfig = migrated.next ?? resolved;
warnOnConfigMiskeys(resolvedConfig, deps.logger);
if (typeof resolvedConfig !== "object" || resolvedConfig === null)
return {};
const validated = ClawdbotSchema.safeParse(resolvedConfig);
if (!validated.success) {
deps.logger.error("Invalid config:");
for (const iss of validated.error.issues) {
@@ -175,6 +229,16 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}
return {};
}
if (migrated.next && migrated.changes.length > 0) {
deps.logger.warn(formatLegacyMigrationLog(migrated.changes));
try {
writeConfigFileSync(resolvedConfig as ClawdbotConfig);
} catch (err) {
deps.logger.warn(
`Failed to write migrated config at ${configPath}: ${String(err)}`,
);
}
}
const cfg = applyModelDefaults(
applyContextPruningDefaults(
applySessionDefaults(
@@ -287,13 +351,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
};
}
const legacyIssues = findLegacyConfigIssues(resolved);
const resolvedConfig =
typeof resolved === "object" && resolved !== null
? (resolved as ClawdbotConfig)
: {};
const migrated = applyLegacyMigrations(resolved);
const resolvedConfig = migrated.next ?? resolved;
const legacyIssues = findLegacyConfigIssues(resolvedConfig);
const validated = validateConfigObject(resolved);
const validated = validateConfigObject(resolvedConfig);
if (!validated.ok) {
return {
path: configPath,
@@ -307,6 +369,15 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
};
}
if (migrated.next && migrated.changes.length > 0) {
deps.logger.warn(formatLegacyMigrationLog(migrated.changes));
await writeConfigFile(validated.config).catch((err) => {
deps.logger.warn(
`Failed to write migrated config at ${configPath}: ${String(err)}`,
);
});
}
return {
path: configPath,
exists: true,

View File

@@ -121,126 +121,284 @@ const ensureAgentEntry = (
};
const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
{
path: ["whatsapp"],
message:
"whatsapp config moved to channels.whatsapp (auto-migrated on load).",
},
{
path: ["telegram"],
message:
"telegram config moved to channels.telegram (auto-migrated on load).",
},
{
path: ["discord"],
message:
"discord config moved to channels.discord (auto-migrated on load).",
},
{
path: ["slack"],
message: "slack config moved to channels.slack (auto-migrated on load).",
},
{
path: ["signal"],
message: "signal config moved to channels.signal (auto-migrated on load).",
},
{
path: ["imessage"],
message:
"imessage config moved to channels.imessage (auto-migrated on load).",
},
{
path: ["msteams"],
message:
"msteams config moved to channels.msteams (auto-migrated on load).",
},
{
path: ["routing", "allowFrom"],
message:
"routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdbot doctor` to migrate).",
"routing.allowFrom was removed; use channels.whatsapp.allowFrom instead (auto-migrated on load).",
},
{
path: ["routing", "bindings"],
message:
"routing.bindings was moved; use top-level bindings instead (run `clawdbot doctor` to migrate).",
"routing.bindings was moved; use top-level bindings instead (auto-migrated on load).",
},
{
path: ["routing", "agents"],
message:
"routing.agents was moved; use agents.list instead (run `clawdbot doctor` to migrate).",
"routing.agents was moved; use agents.list instead (auto-migrated on load).",
},
{
path: ["routing", "defaultAgentId"],
message:
"routing.defaultAgentId was moved; use agents.list[].default instead (run `clawdbot doctor` to migrate).",
"routing.defaultAgentId was moved; use agents.list[].default instead (auto-migrated on load).",
},
{
path: ["routing", "agentToAgent"],
message:
"routing.agentToAgent was moved; use tools.agentToAgent instead (run `clawdbot doctor` to migrate).",
"routing.agentToAgent was moved; use tools.agentToAgent instead (auto-migrated on load).",
},
{
path: ["routing", "groupChat", "requireMention"],
message:
'routing.groupChat.requireMention was removed; use whatsapp/telegram/imessage groups defaults (e.g. whatsapp.groups."*".requireMention) instead (run `clawdbot doctor` to migrate).',
'routing.groupChat.requireMention was removed; use channels.whatsapp/telegram/imessage groups defaults (e.g. channels.whatsapp.groups."*".requireMention) instead (auto-migrated on load).',
},
{
path: ["routing", "groupChat", "mentionPatterns"],
message:
"routing.groupChat.mentionPatterns was moved; use agents.list[].groupChat.mentionPatterns or messages.groupChat.mentionPatterns instead (run `clawdbot doctor` to migrate).",
"routing.groupChat.mentionPatterns was moved; use agents.list[].groupChat.mentionPatterns or messages.groupChat.mentionPatterns instead (auto-migrated on load).",
},
{
path: ["routing", "queue"],
message:
"routing.queue was moved; use messages.queue instead (run `clawdbot doctor` to migrate).",
"routing.queue was moved; use messages.queue instead (auto-migrated on load).",
},
{
path: ["routing", "transcribeAudio"],
message:
"routing.transcribeAudio was moved; use tools.audio.transcription instead (run `clawdbot doctor` to migrate).",
"routing.transcribeAudio was moved; use tools.audio.transcription instead (auto-migrated on load).",
},
{
path: ["telegram", "requireMention"],
message:
'telegram.requireMention was removed; use telegram.groups."*".requireMention instead (run `clawdbot doctor` to migrate).',
'telegram.requireMention was removed; use channels.telegram.groups."*".requireMention instead (auto-migrated on load).',
},
{
path: ["identity"],
message:
"identity was moved; use agents.list[].identity instead (run `clawdbot doctor` to migrate).",
"identity was moved; use agents.list[].identity instead (auto-migrated on load).",
},
{
path: ["agent"],
message:
"agent.* was moved; use agents.defaults (and tools.* for tool/elevated/exec settings) instead (run `clawdbot doctor` to migrate).",
"agent.* was moved; use agents.defaults (and tools.* for tool/elevated/exec settings) instead (auto-migrated on load).",
},
{
path: ["agent", "model"],
message:
"agent.model string was replaced by agents.defaults.model.primary/fallbacks and agents.defaults.models (run `clawdbot doctor` to migrate).",
"agent.model string was replaced by agents.defaults.model.primary/fallbacks and agents.defaults.models (auto-migrated on load).",
match: (value) => typeof value === "string",
},
{
path: ["agent", "imageModel"],
message:
"agent.imageModel string was replaced by agents.defaults.imageModel.primary/fallbacks (run `clawdbot doctor` to migrate).",
"agent.imageModel string was replaced by agents.defaults.imageModel.primary/fallbacks (auto-migrated on load).",
match: (value) => typeof value === "string",
},
{
path: ["agent", "allowedModels"],
message:
"agent.allowedModels was replaced by agents.defaults.models (run `clawdbot doctor` to migrate).",
"agent.allowedModels was replaced by agents.defaults.models (auto-migrated on load).",
},
{
path: ["agent", "modelAliases"],
message:
"agent.modelAliases was replaced by agents.defaults.models.*.alias (run `clawdbot doctor` to migrate).",
"agent.modelAliases was replaced by agents.defaults.models.*.alias (auto-migrated on load).",
},
{
path: ["agent", "modelFallbacks"],
message:
"agent.modelFallbacks was replaced by agents.defaults.model.fallbacks (run `clawdbot doctor` to migrate).",
"agent.modelFallbacks was replaced by agents.defaults.model.fallbacks (auto-migrated on load).",
},
{
path: ["agent", "imageModelFallbacks"],
message:
"agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (run `clawdbot doctor` to migrate).",
"agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (auto-migrated on load).",
},
{
path: ["gateway", "token"],
message:
"gateway.token is ignored; use gateway.auth.token instead (run `clawdbot doctor` to migrate).",
"gateway.token is ignored; use gateway.auth.token instead (auto-migrated on load).",
},
];
const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
{
id: "routing.allowFrom->whatsapp.allowFrom",
describe: "Move routing.allowFrom to whatsapp.allowFrom",
id: "bindings.match.provider->bindings.match.channel",
describe: "Move bindings[].match.provider to bindings[].match.channel",
apply: (raw, changes) => {
const bindings = Array.isArray(raw.bindings) ? raw.bindings : null;
if (!bindings) return;
let touched = false;
for (const entry of bindings) {
if (!isRecord(entry)) continue;
const match = getRecord(entry.match);
if (!match) continue;
if (typeof match.channel === "string" && match.channel.trim()) continue;
const provider =
typeof match.provider === "string" ? match.provider.trim() : "";
if (!provider) continue;
match.channel = provider;
delete match.provider;
entry.match = match;
touched = true;
}
if (touched) {
raw.bindings = bindings;
changes.push(
"Moved bindings[].match.provider → bindings[].match.channel.",
);
}
},
},
{
id: "session.sendPolicy.rules.match.provider->match.channel",
describe: "Move session.sendPolicy.rules[].match.provider to match.channel",
apply: (raw, changes) => {
const session = getRecord(raw.session);
if (!session) return;
const sendPolicy = getRecord(session.sendPolicy);
if (!sendPolicy) return;
const rules = Array.isArray(sendPolicy.rules) ? sendPolicy.rules : null;
if (!rules) return;
let touched = false;
for (const rule of rules) {
if (!isRecord(rule)) continue;
const match = getRecord(rule.match);
if (!match) continue;
if (typeof match.channel === "string" && match.channel.trim()) continue;
const provider =
typeof match.provider === "string" ? match.provider.trim() : "";
if (!provider) continue;
match.channel = provider;
delete match.provider;
rule.match = match;
touched = true;
}
if (touched) {
sendPolicy.rules = rules;
session.sendPolicy = sendPolicy;
raw.session = session;
changes.push(
"Moved session.sendPolicy.rules[].match.provider → match.channel.",
);
}
},
},
{
id: "messages.queue.byProvider->byChannel",
describe: "Move messages.queue.byProvider to messages.queue.byChannel",
apply: (raw, changes) => {
const messages = getRecord(raw.messages);
if (!messages) return;
const queue = getRecord(messages.queue);
if (!queue) return;
if (queue.byProvider === undefined) return;
if (queue.byChannel === undefined) {
queue.byChannel = queue.byProvider;
changes.push(
"Moved messages.queue.byProvider → messages.queue.byChannel.",
);
} else {
changes.push(
"Removed messages.queue.byProvider (messages.queue.byChannel already set).",
);
}
delete queue.byProvider;
messages.queue = queue;
raw.messages = messages;
},
},
{
id: "providers->channels",
describe: "Move provider config sections to channels.*",
apply: (raw, changes) => {
const legacyKeys = [
"whatsapp",
"telegram",
"discord",
"slack",
"signal",
"imessage",
"msteams",
];
const legacyEntries = legacyKeys.filter((key) => isRecord(raw[key]));
if (legacyEntries.length === 0) return;
const channels = ensureRecord(raw, "channels");
for (const key of legacyEntries) {
const legacy = getRecord(raw[key]);
if (!legacy) continue;
const channelEntry = ensureRecord(channels, key);
const hadEntries = Object.keys(channelEntry).length > 0;
mergeMissing(channelEntry, legacy);
channels[key] = channelEntry;
delete raw[key];
changes.push(
hadEntries
? `Merged ${key} → channels.${key}.`
: `Moved ${key} → channels.${key}.`,
);
}
raw.channels = channels;
},
},
{
id: "routing.allowFrom->channels.whatsapp.allowFrom",
describe: "Move routing.allowFrom to channels.whatsapp.allowFrom",
apply: (raw, changes) => {
const routing = raw.routing;
if (!routing || typeof routing !== "object") return;
const allowFrom = (routing as Record<string, unknown>).allowFrom;
if (allowFrom === undefined) return;
const channels = ensureRecord(raw, "channels");
const whatsapp =
raw.whatsapp && typeof raw.whatsapp === "object"
? (raw.whatsapp as Record<string, unknown>)
channels.whatsapp && typeof channels.whatsapp === "object"
? (channels.whatsapp as Record<string, unknown>)
: {};
if (whatsapp.allowFrom === undefined) {
whatsapp.allowFrom = allowFrom;
changes.push("Moved routing.allowFrom → whatsapp.allowFrom.");
changes.push("Moved routing.allowFrom → channels.whatsapp.allowFrom.");
} else {
changes.push(
"Removed routing.allowFrom (whatsapp.allowFrom already set).",
"Removed routing.allowFrom (channels.whatsapp.allowFrom already set).",
);
}
@@ -248,13 +406,14 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
if (Object.keys(routing as Record<string, unknown>).length === 0) {
delete raw.routing;
}
raw.whatsapp = whatsapp;
channels.whatsapp = whatsapp;
raw.channels = channels;
},
},
{
id: "routing.groupChat.requireMention->groups.*.requireMention",
describe:
"Move routing.groupChat.requireMention to whatsapp/telegram/imessage groups",
"Move routing.groupChat.requireMention to channels.whatsapp/telegram/imessage groups",
apply: (raw, changes) => {
const routing = raw.routing;
if (!routing || typeof routing !== "object") return;
@@ -270,10 +429,11 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
const requireMention = groupChat.requireMention;
if (requireMention === undefined) return;
const channels = ensureRecord(raw, "channels");
const applyTo = (key: "whatsapp" | "telegram" | "imessage") => {
const section =
raw[key] && typeof raw[key] === "object"
? (raw[key] as Record<string, unknown>)
channels[key] && typeof channels[key] === "object"
? (channels[key] as Record<string, unknown>)
: {};
const groups =
section.groups && typeof section.groups === "object"
@@ -288,13 +448,13 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
entry.requireMention = requireMention;
groups[defaultKey] = entry;
section.groups = groups;
raw[key] = section;
channels[key] = section;
changes.push(
`Moved routing.groupChat.requireMention → ${key}.groups."*".requireMention.`,
`Moved routing.groupChat.requireMention → channels.${key}.groups."*".requireMention.`,
);
} else {
changes.push(
`Removed routing.groupChat.requireMention (${key}.groups."*" already set).`,
`Removed routing.groupChat.requireMention (channels.${key}.groups."*" already set).`,
);
}
};
@@ -310,6 +470,7 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
if (Object.keys(routing as Record<string, unknown>).length === 0) {
delete raw.routing;
}
raw.channels = channels;
},
},
{
@@ -341,11 +502,12 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
},
},
{
id: "telegram.requireMention->telegram.groups.*.requireMention",
id: "telegram.requireMention->channels.telegram.groups.*.requireMention",
describe:
"Move telegram.requireMention to telegram.groups.*.requireMention",
"Move telegram.requireMention to channels.telegram.groups.*.requireMention",
apply: (raw, changes) => {
const telegram = raw.telegram;
const channels = ensureRecord(raw, "channels");
const telegram = channels.telegram;
if (!telegram || typeof telegram !== "object") return;
const requireMention = (telegram as Record<string, unknown>)
.requireMention;
@@ -370,18 +532,17 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
groups[defaultKey] = entry;
(telegram as Record<string, unknown>).groups = groups;
changes.push(
'Moved telegram.requireMention → telegram.groups."*".requireMention.',
'Moved telegram.requireMention → channels.telegram.groups."*".requireMention.',
);
} else {
changes.push(
'Removed telegram.requireMention (telegram.groups."*" already set).',
'Removed telegram.requireMention (channels.telegram.groups."*" already set).',
);
}
delete (telegram as Record<string, unknown>).requireMention;
if (Object.keys(telegram as Record<string, unknown>).length === 0) {
delete raw.telegram;
}
channels.telegram = telegram as Record<string, unknown>;
raw.channels = channels;
},
},
{

View File

@@ -30,6 +30,9 @@ export function mergeWhatsAppConfig(
): ClawdbotConfig {
return {
...cfg,
whatsapp: mergeConfigSection(cfg.whatsapp, patch, options),
channels: {
...cfg.channels,
whatsapp: mergeConfigSection(cfg.channels?.whatsapp, patch, options),
},
};
}

View File

@@ -17,15 +17,17 @@ describe("normalizeConfigPaths", () => {
path: "~/.clawdbot/hooks.json5",
transformsDir: "~/hooks-xform",
},
telegram: {
accounts: {
personal: {
tokenFile: "~/.clawdbot/telegram.token",
channels: {
telegram: {
accounts: {
personal: {
tokenFile: "~/.clawdbot/telegram.token",
},
},
},
},
imessage: {
accounts: { personal: { dbPath: "~/Library/Messages/chat.db" } },
imessage: {
accounts: { personal: { dbPath: "~/Library/Messages/chat.db" } },
},
},
agents: {
defaults: { workspace: "~/ws-default" },
@@ -51,10 +53,10 @@ describe("normalizeConfigPaths", () => {
);
expect(cfg.hooks?.path).toBe(path.join(home, ".clawdbot", "hooks.json5"));
expect(cfg.hooks?.transformsDir).toBe(path.join(home, "hooks-xform"));
expect(cfg.telegram?.accounts?.personal?.tokenFile).toBe(
expect(cfg.channels?.telegram?.accounts?.personal?.tokenFile).toBe(
path.join(home, ".clawdbot", "telegram.token"),
);
expect(cfg.imessage?.accounts?.personal?.dbPath).toBe(
expect(cfg.channels?.imessage?.accounts?.personal?.dbPath).toBe(
path.join(home, "Library", "Messages", "chat.db"),
);
expect(cfg.agents?.defaults?.workspace).toBe(

View File

@@ -24,17 +24,17 @@ describe("runtime overrides", () => {
it("merges object overrides without clobbering siblings", () => {
const cfg = {
whatsapp: { dmPolicy: "pairing", allowFrom: ["+1"] },
channels: { whatsapp: { dmPolicy: "pairing", allowFrom: ["+1"] } },
} as ClawdbotConfig;
setConfigOverride("whatsapp.dmPolicy", "open");
setConfigOverride("channels.whatsapp.dmPolicy", "open");
const next = applyConfigOverrides(cfg);
expect(next.whatsapp?.dmPolicy).toBe("open");
expect(next.whatsapp?.allowFrom).toEqual(["+1"]);
expect(next.channels?.whatsapp?.dmPolicy).toBe("open");
expect(next.channels?.whatsapp?.allowFrom).toEqual(["+1"]);
});
it("unsets overrides and prunes empty branches", () => {
setConfigOverride("whatsapp.dmPolicy", "open");
const removed = unsetConfigOverride("whatsapp.dmPolicy");
setConfigOverride("channels.whatsapp.dmPolicy", "open");
const removed = unsetConfigOverride("channels.whatsapp.dmPolicy");
expect(removed.ok).toBe(true);
expect(removed.removed).toBe(true);
expect(Object.keys(getConfigOverrides()).length).toBe(0);

View File

@@ -53,12 +53,7 @@ const GROUP_LABELS: Record<string, string> = {
ui: "UI",
browser: "Browser",
talk: "Talk",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
signal: "Signal",
imessage: "iMessage",
whatsapp: "WhatsApp",
channels: "Messaging Channels",
skills: "Skills",
plugins: "Plugins",
discovery: "Discovery",
@@ -82,12 +77,7 @@ const GROUP_ORDER: Record<string, number> = {
ui: 120,
browser: 130,
talk: 140,
telegram: 150,
discord: 160,
slack: 165,
signal: 170,
imessage: 180,
whatsapp: 190,
channels: 150,
skills: 200,
plugins: 205,
discovery: 210,
@@ -167,34 +157,41 @@ const FIELD_LABELS: Record<string, string> = {
"messages.ackReaction": "Ack Reaction Emoji",
"messages.ackReactionScope": "Ack Reaction Scope",
"talk.apiKey": "Talk API Key",
"telegram.botToken": "Telegram Bot Token",
"telegram.dmPolicy": "Telegram DM Policy",
"telegram.streamMode": "Telegram Draft Stream Mode",
"telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars",
"telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars",
"telegram.draftChunk.breakPreference":
"channels.whatsapp": "WhatsApp",
"channels.telegram": "Telegram",
"channels.discord": "Discord",
"channels.slack": "Slack",
"channels.signal": "Signal",
"channels.imessage": "iMessage",
"channels.msteams": "MS Teams",
"channels.telegram.botToken": "Telegram Bot Token",
"channels.telegram.dmPolicy": "Telegram DM Policy",
"channels.telegram.streamMode": "Telegram Draft Stream Mode",
"channels.telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars",
"channels.telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars",
"channels.telegram.draftChunk.breakPreference":
"Telegram Draft Chunk Break Preference",
"telegram.retry.attempts": "Telegram Retry Attempts",
"telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
"telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
"telegram.retry.jitter": "Telegram Retry Jitter",
"whatsapp.dmPolicy": "WhatsApp DM Policy",
"whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
"signal.dmPolicy": "Signal DM Policy",
"imessage.dmPolicy": "iMessage DM Policy",
"discord.dm.policy": "Discord DM Policy",
"discord.retry.attempts": "Discord Retry Attempts",
"discord.retry.minDelayMs": "Discord Retry Min Delay (ms)",
"discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)",
"discord.retry.jitter": "Discord Retry Jitter",
"discord.maxLinesPerMessage": "Discord Max Lines Per Message",
"slack.dm.policy": "Slack DM Policy",
"slack.allowBots": "Slack Allow Bot Messages",
"discord.token": "Discord Bot Token",
"slack.botToken": "Slack Bot Token",
"slack.appToken": "Slack App Token",
"signal.account": "Signal Account",
"imessage.cliPath": "iMessage CLI Path",
"channels.telegram.retry.attempts": "Telegram Retry Attempts",
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
"channels.telegram.retry.jitter": "Telegram Retry Jitter",
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
"channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
"channels.signal.dmPolicy": "Signal DM Policy",
"channels.imessage.dmPolicy": "iMessage DM Policy",
"channels.discord.dm.policy": "Discord DM Policy",
"channels.discord.retry.attempts": "Discord Retry Attempts",
"channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)",
"channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)",
"channels.discord.retry.jitter": "Discord Retry Jitter",
"channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message",
"channels.slack.dm.policy": "Slack DM Policy",
"channels.slack.allowBots": "Slack Allow Bot Messages",
"channels.discord.token": "Discord Bot Token",
"channels.slack.botToken": "Slack Bot Token",
"channels.slack.appToken": "Slack App Token",
"channels.signal.account": "Signal Account",
"channels.imessage.cliPath": "iMessage CLI Path",
"plugins.enabled": "Enable Plugins",
"plugins.allow": "Plugin Allowlist",
"plugins.deny": "Plugin Denylist",
@@ -225,7 +222,7 @@ const FIELD_HELP: Record<string, string> = {
"Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
"tools.exec.applyPatch.allowModels":
'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").',
"slack.allowBots":
"channels.slack.allowBots":
"Allow bot-authored messages to trigger Slack replies (default: false).",
"auth.profiles": "Named auth profiles (provider + mode + optional email).",
"auth.order":
@@ -289,7 +286,7 @@ const FIELD_HELP: Record<string, string> = {
"agents.defaults.humanDelay.maxMs":
"Maximum delay in ms for custom humanDelay (default: 2500).",
"commands.native":
"Register native commands with connectors that support it (Discord/Slack/Telegram).",
"Register native commands with channels that support it (Discord/Slack/Telegram).",
"commands.text": "Allow text command parsing (slash commands only).",
"commands.bash":
"Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).",
@@ -303,11 +300,11 @@ const FIELD_HELP: Record<string, string> = {
"Allow /restart and gateway restart tool actions (default: false).",
"commands.useAccessGroups":
"Enforce access-group allowlists/policies for commands.",
"discord.commands.native":
"channels.discord.commands.native":
'Override native commands for Discord (bool or "auto").',
"telegram.commands.native":
"channels.telegram.commands.native":
'Override native commands for Telegram (bool or "auto").',
"slack.commands.native":
"channels.slack.commands.native":
'Override native commands for Slack (bool or "auto").',
"session.agentToAgent.maxPingPongTurns":
"Max reply-back turns between requester and target (05).",
@@ -315,46 +312,46 @@ const FIELD_HELP: Record<string, string> = {
"Emoji reaction used to acknowledge inbound messages (empty disables).",
"messages.ackReactionScope":
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
"telegram.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires telegram.allowFrom=["*"].',
"telegram.streamMode":
"channels.telegram.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].',
"channels.telegram.streamMode":
"Draft streaming mode for Telegram replies (off | partial | block). Separate from block streaming; requires private topics + sendMessageDraft.",
"telegram.draftChunk.minChars":
'Minimum chars before emitting a Telegram draft update when telegram.streamMode="block" (default: 200).',
"telegram.draftChunk.maxChars":
'Target max size for a Telegram draft update chunk when telegram.streamMode="block" (default: 800; clamped to telegram.textChunkLimit).',
"telegram.draftChunk.breakPreference":
"channels.telegram.draftChunk.minChars":
'Minimum chars before emitting a Telegram draft update when channels.telegram.streamMode="block" (default: 200).',
"channels.telegram.draftChunk.maxChars":
'Target max size for a Telegram draft update chunk when channels.telegram.streamMode="block" (default: 800; clamped to channels.telegram.textChunkLimit).',
"channels.telegram.draftChunk.breakPreference":
"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.",
"telegram.retry.attempts":
"channels.telegram.retry.attempts":
"Max retry attempts for outbound Telegram API calls (default: 3).",
"telegram.retry.minDelayMs":
"channels.telegram.retry.minDelayMs":
"Minimum retry delay in ms for Telegram outbound calls.",
"telegram.retry.maxDelayMs":
"channels.telegram.retry.maxDelayMs":
"Maximum retry delay cap in ms for Telegram outbound calls.",
"telegram.retry.jitter":
"channels.telegram.retry.jitter":
"Jitter factor (0-1) applied to Telegram retry delays.",
"whatsapp.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires whatsapp.allowFrom=["*"].',
"whatsapp.selfChatMode":
"channels.whatsapp.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].',
"channels.whatsapp.selfChatMode":
"Same-phone setup (bot uses your personal WhatsApp number).",
"signal.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires signal.allowFrom=["*"].',
"imessage.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires imessage.allowFrom=["*"].',
"discord.dm.policy":
'Direct message access control ("pairing" recommended). "open" requires discord.dm.allowFrom=["*"].',
"discord.retry.attempts":
"channels.signal.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].',
"channels.imessage.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].',
"channels.discord.dm.policy":
'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].',
"channels.discord.retry.attempts":
"Max retry attempts for outbound Discord API calls (default: 3).",
"discord.retry.minDelayMs":
"channels.discord.retry.minDelayMs":
"Minimum retry delay in ms for Discord outbound calls.",
"discord.retry.maxDelayMs":
"channels.discord.retry.maxDelayMs":
"Maximum retry delay cap in ms for Discord outbound calls.",
"discord.retry.jitter":
"channels.discord.retry.jitter":
"Jitter factor (0-1) applied to Discord retry delays.",
"discord.maxLinesPerMessage":
"channels.discord.maxLinesPerMessage":
"Soft max line count per Discord message (default: 17).",
"slack.dm.policy":
'Direct message access control ("pairing" recommended). "open" requires slack.dm.allowFrom=["*"].',
"channels.slack.dm.policy":
'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].',
};
const FIELD_PLACEHOLDERS: Record<string, string> = {

View File

@@ -100,7 +100,7 @@ describe("sessions", () => {
).toBe("agent:main:group:12345-678@g.us");
});
it("updateLastRoute persists provider and target", async () => {
it("updateLastRoute persists channel and target", async () => {
const mainSessionKey = "agent:main:main";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
const storePath = path.join(dir, "sessions.json");
@@ -130,14 +130,14 @@ describe("sessions", () => {
await updateLastRoute({
storePath,
sessionKey: mainSessionKey,
provider: "telegram",
channel: "telegram",
to: " 12345 ",
});
const store = loadSessionStore(storePath);
expect(store[mainSessionKey]?.sessionId).toBe("sess-1");
expect(store[mainSessionKey]?.updatedAt).toBeGreaterThanOrEqual(123);
expect(store[mainSessionKey]?.lastProvider).toBe("telegram");
expect(store[mainSessionKey]?.lastChannel).toBe("telegram");
expect(store[mainSessionKey]?.lastTo).toBe("12345");
expect(store[mainSessionKey]?.responseUsage).toBe("on");
expect(store[mainSessionKey]?.queueDebounceMs).toBe(1234);
@@ -147,6 +147,39 @@ describe("sessions", () => {
expect(store[mainSessionKey]?.compactionCount).toBe(2);
});
it("loadSessionStore auto-migrates legacy provider keys to channel keys", async () => {
const mainSessionKey = "agent:main:main";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(
storePath,
JSON.stringify(
{
[mainSessionKey]: {
sessionId: "sess-legacy",
updatedAt: 123,
provider: "slack",
lastProvider: "telegram",
lastTo: "user:U123",
},
},
null,
2,
),
"utf-8",
);
const store = loadSessionStore(storePath) as unknown as Record<
string,
Record<string, unknown>
>;
const entry = store[mainSessionKey] ?? {};
expect(entry.channel).toBe("slack");
expect(entry.provider).toBeUndefined();
expect(entry.lastChannel).toBe("telegram");
expect(entry.lastProvider).toBeUndefined();
});
it("derives session transcripts dir from CLAWDBOT_STATE_DIR", () => {
const dir = resolveSessionTranscriptsDir(
{ CLAWDBOT_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv,

View File

@@ -6,8 +6,8 @@ import path from "node:path";
import type { Skill } from "@mariozechner/pi-coding-agent";
import JSON5 from "json5";
import type { MsgContext } from "../auto-reply/templating.js";
import type { ProviderId } from "../providers/plugins/types.js";
import { PROVIDER_IDS } from "../providers/registry.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { CHANNEL_IDS } from "../channels/registry.js";
import {
buildAgentMainSessionKey,
DEFAULT_AGENT_ID,
@@ -65,9 +65,9 @@ export function clearSessionStoreCacheForTest(): void {
export type SessionScope = "per-sender" | "global";
export type SessionProviderId = ProviderId | "webchat";
export type SessionChannelId = ChannelId | "webchat";
const GROUP_SURFACES = new Set<string>([...PROVIDER_IDS, "webchat"]);
const GROUP_SURFACES = new Set<string>([...CHANNEL_IDS, "webchat"]);
export type SessionChatType = "direct" | "group" | "room";
@@ -115,11 +115,11 @@ export type SessionEntry = {
claudeCliSessionId?: string;
label?: string;
displayName?: string;
provider?: string;
channel?: string;
subject?: string;
room?: string;
space?: string;
lastProvider?: SessionProviderId;
lastChannel?: SessionChannelId;
lastTo?: string;
lastAccountId?: string;
skillsSnapshot?: SessionSkillSnapshot;
@@ -142,7 +142,7 @@ export function mergeSessionEntry(
export type GroupKeyResolution = {
key: string;
legacyKey?: string;
provider?: string;
channel?: string;
id?: string;
chatType?: SessionChatType;
};
@@ -418,7 +418,7 @@ export function resolveGroupSessionKey(
return {
key,
legacyKey,
provider: resolvedProvider,
channel: resolvedProvider,
id: id || raw || from,
chatType: resolvedKind === "channel" ? "room" : "group",
};
@@ -454,6 +454,23 @@ export function loadSessionStore(
// ignore missing/invalid store; we'll recreate it
}
// Best-effort migration: message provider → channel naming.
for (const entry of Object.values(store)) {
if (!entry || typeof entry !== "object") continue;
const rec = entry as unknown as Record<string, unknown>;
if (typeof rec.channel !== "string" && typeof rec.provider === "string") {
rec.channel = rec.provider;
delete rec.provider;
}
if (
typeof rec.lastChannel !== "string" &&
typeof rec.lastProvider === "string"
) {
rec.lastChannel = rec.lastProvider;
delete rec.lastProvider;
}
}
// Cache the result if caching is enabled
if (isSessionStoreCacheEnabled()) {
SESSION_STORE_CACHE.set(storePath, {
@@ -633,18 +650,18 @@ export async function updateSessionStoreEntry(params: {
export async function updateLastRoute(params: {
storePath: string;
sessionKey: string;
provider: SessionEntry["lastProvider"];
channel: SessionEntry["lastChannel"];
to?: string;
accountId?: string;
}) {
const { storePath, sessionKey, provider, to, accountId } = params;
const { storePath, sessionKey, channel, to, accountId } = params;
return await withSessionStoreLock(storePath, async () => {
const store = loadSessionStore(storePath);
const existing = store[sessionKey];
const now = Date.now();
const next = mergeSessionEntry(existing, {
updatedAt: Math.max(existing?.updatedAt ?? 0, now),
lastProvider: provider,
lastChannel: channel,
lastTo: to?.trim() ? to.trim() : undefined,
lastAccountId: accountId?.trim()
? accountId.trim()

View File

@@ -39,7 +39,7 @@ export type HumanDelayConfig = {
export type SessionSendPolicyAction = "allow" | "deny";
export type SessionSendPolicyMatch = {
provider?: string;
channel?: string;
chatType?: "direct" | "group" | "room";
keyPrefix?: string;
};
@@ -292,7 +292,7 @@ export type HookMappingConfig = {
messageTemplate?: string;
textTemplate?: string;
deliver?: boolean;
provider?:
channel?:
| "last"
| "whatsapp"
| "telegram"
@@ -637,7 +637,7 @@ export type SlackAccountConfig = {
* Controls how channel messages are handled:
* - "open": channels bypass allowlists; mention-gating applies
* - "disabled": block all channel messages
* - "allowlist": only allow channels present in slack.channels
* - "allowlist": only allow channels present in channels.slack.channels
*/
groupPolicy?: GroupPolicy;
/** Max channel messages to keep as history context (0 disables). */
@@ -803,6 +803,16 @@ export type MSTeamsConfig = {
teams?: Record<string, MSTeamsTeamConfig>;
};
export type ChannelsConfig = {
whatsapp?: WhatsAppConfig;
telegram?: TelegramConfig;
discord?: DiscordConfig;
slack?: SlackConfig;
signal?: SignalConfig;
imessage?: IMessageConfig;
msteams?: MSTeamsConfig;
};
export type IMessageAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
@@ -982,7 +992,7 @@ export type DmConfig = {
export type QueueConfig = {
mode?: QueueMode;
byProvider?: QueueModeByProvider;
byChannel?: QueueModeByProvider;
debounceMs?: number;
cap?: number;
drop?: QueueDropPolicy;
@@ -1187,7 +1197,7 @@ export type AgentsConfig = {
export type AgentBinding = {
agentId: string;
match: {
provider: string;
channel: string;
accountId?: string;
peer?: { kind: "dm" | "group" | "channel"; id: string };
guildId?: string;
@@ -1800,13 +1810,7 @@ export type ClawdbotConfig = {
commands?: CommandsConfig;
session?: SessionConfig;
web?: WebConfig;
whatsapp?: WhatsAppConfig;
telegram?: TelegramConfig;
discord?: DiscordConfig;
slack?: SlackConfig;
signal?: SignalConfig;
imessage?: IMessageConfig;
msteams?: MSTeamsConfig;
channels?: ChannelsConfig;
cron?: CronConfig;
hooks?: HooksConfig;
bridge?: BridgeConfig;

View File

@@ -210,7 +210,7 @@ const QueueModeBySurfaceSchema = z
const QueueSchema = z
.object({
mode: QueueModeSchema.optional(),
byProvider: QueueModeBySurfaceSchema,
byChannel: QueueModeBySurfaceSchema,
debounceMs: z.number().int().nonnegative().optional(),
cap: z.number().int().positive().optional(),
drop: QueueDropSchema.optional(),
@@ -315,7 +315,7 @@ const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine(
ctx,
path: ["allowFrom"],
message:
'telegram.dmPolicy="open" requires telegram.allowFrom to include "*"',
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
});
},
);
@@ -329,7 +329,7 @@ const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
ctx,
path: ["allowFrom"],
message:
'telegram.dmPolicy="open" requires telegram.allowFrom to include "*"',
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
});
});
@@ -348,7 +348,7 @@ const DiscordDmSchema = z
ctx,
path: ["allowFrom"],
message:
'discord.dm.policy="open" requires discord.dm.allowFrom to include "*"',
'channels.discord.dm.policy="open" requires channels.discord.dm.allowFrom to include "*"',
});
});
@@ -432,7 +432,7 @@ const SlackDmSchema = z
ctx,
path: ["allowFrom"],
message:
'slack.dm.policy="open" requires slack.dm.allowFrom to include "*"',
'channels.slack.dm.policy="open" requires channels.slack.dm.allowFrom to include "*"',
});
});
@@ -530,7 +530,7 @@ const SignalAccountSchema = SignalAccountSchemaBase.superRefine(
ctx,
path: ["allowFrom"],
message:
'signal.dmPolicy="open" requires signal.allowFrom to include "*"',
'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"',
});
},
);
@@ -543,7 +543,8 @@ const SignalConfigSchema = SignalAccountSchemaBase.extend({
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message: 'signal.dmPolicy="open" requires signal.allowFrom to include "*"',
message:
'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"',
});
});
@@ -589,7 +590,7 @@ const IMessageAccountSchema = IMessageAccountSchemaBase.superRefine(
ctx,
path: ["allowFrom"],
message:
'imessage.dmPolicy="open" requires imessage.allowFrom to include "*"',
'channels.imessage.dmPolicy="open" requires channels.imessage.allowFrom to include "*"',
});
},
);
@@ -603,7 +604,7 @@ const IMessageConfigSchema = IMessageAccountSchemaBase.extend({
ctx,
path: ["allowFrom"],
message:
'imessage.dmPolicy="open" requires imessage.allowFrom to include "*"',
'channels.imessage.dmPolicy="open" requires channels.imessage.allowFrom to include "*"',
});
});
@@ -652,10 +653,136 @@ const MSTeamsConfigSchema = z
ctx,
path: ["allowFrom"],
message:
'msteams.dmPolicy="open" requires msteams.allowFrom to include "*"',
'channels.msteams.dmPolicy="open" requires channels.msteams.allowFrom to include "*"',
});
});
const WhatsAppAccountSchema = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
messagePrefix: z.string().optional(),
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
authDir: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
selfChatMode: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
groups: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
})
.optional(),
)
.optional(),
ackReaction: z
.object({
emoji: z.string().optional(),
direct: z.boolean().optional().default(true),
group: z
.enum(["always", "mentions", "never"])
.optional()
.default("mentions"),
})
.optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'channels.whatsapp.accounts.*.dmPolicy="open" requires allowFrom to include "*"',
});
});
const WhatsAppConfigSchema = z
.object({
accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(),
capabilities: z.array(z.string()).optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
messagePrefix: z.string().optional(),
selfChatMode: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().int().positive().optional().default(50),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
actions: z
.object({
reactions: z.boolean().optional(),
sendMessage: z.boolean().optional(),
polls: z.boolean().optional(),
})
.optional(),
groups: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
})
.optional(),
)
.optional(),
ackReaction: z
.object({
emoji: z.string().optional(),
direct: z.boolean().optional().default(true),
group: z
.enum(["always", "mentions", "never"])
.optional()
.default("mentions"),
})
.optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'channels.whatsapp.dmPolicy="open" requires channels.whatsapp.allowFrom to include "*"',
});
});
const ChannelsSchema = z
.object({
whatsapp: WhatsAppConfigSchema.optional(),
telegram: TelegramConfigSchema.optional(),
discord: DiscordConfigSchema.optional(),
slack: SlackConfigSchema.optional(),
signal: SignalConfigSchema.optional(),
imessage: IMessageConfigSchema.optional(),
msteams: MSTeamsConfigSchema.optional(),
})
.optional();
const SessionSchema = z
.object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
@@ -682,7 +809,7 @@ const SessionSchema = z
action: z.union([z.literal("allow"), z.literal("deny")]),
match: z
.object({
provider: z.string().optional(),
channel: z.string().optional(),
chatType: z
.union([
z.literal("direct"),
@@ -1045,7 +1172,7 @@ const BindingsSchema = z
z.object({
agentId: z.string(),
match: z.object({
provider: z.string(),
channel: z.string(),
accountId: z.string().optional(),
peer: z
.object({
@@ -1097,7 +1224,7 @@ const HookMappingSchema = z
messageTemplate: z.string().optional(),
textTemplate: z.string().optional(),
deliver: z.boolean().optional(),
provider: z
channel: z
.union([
z.literal("last"),
z.literal("whatsapp"),
@@ -1487,130 +1614,7 @@ export const ClawdbotSchema = z
.optional(),
})
.optional(),
whatsapp: z
.object({
accounts: z
.record(
z.string(),
z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
messagePrefix: z.string().optional(),
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
authDir: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
selfChatMode: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
groups: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
})
.optional(),
)
.optional(),
ackReaction: z
.object({
emoji: z.string().optional(),
direct: z.boolean().optional().default(true),
group: z
.enum(["always", "mentions", "never"])
.optional()
.default("mentions"),
})
.optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'whatsapp.accounts.*.dmPolicy="open" requires allowFrom to include "*"',
});
})
.optional(),
)
.optional(),
capabilities: z.array(z.string()).optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
messagePrefix: z.string().optional(),
selfChatMode: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().int().positive().optional().default(50),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
actions: z
.object({
reactions: z.boolean().optional(),
sendMessage: z.boolean().optional(),
polls: z.boolean().optional(),
})
.optional(),
groups: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
})
.optional(),
)
.optional(),
ackReaction: z
.object({
emoji: z.string().optional(),
direct: z.boolean().optional().default(true),
group: z
.enum(["always", "mentions", "never"])
.optional()
.default("mentions"),
})
.optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'whatsapp.dmPolicy="open" requires whatsapp.allowFrom to include "*"',
});
})
.optional(),
telegram: TelegramConfigSchema.optional(),
discord: DiscordConfigSchema.optional(),
slack: SlackConfigSchema.optional(),
signal: SignalConfigSchema.optional(),
imessage: IMessageConfigSchema.optional(),
msteams: MSTeamsConfigSchema.optional(),
channels: ChannelsSchema,
bridge: z
.object({
enabled: z.boolean().optional(),