refactor!: rename chat providers to channels
This commit is contained in:
@@ -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"]);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" } } },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (0–5).",
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user