mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 22:09:57 +00:00
refactor(src): split oversized modules
This commit is contained in:
BIN
src/config/.DS_Store
vendored
Normal file
BIN
src/config/.DS_Store
vendored
Normal file
Binary file not shown.
36
src/config/config.broadcast.test.ts
Normal file
36
src/config/config.broadcast.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("broadcast", () => {
|
||||
it("accepts a broadcast peer map with strategy", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
list: [{ id: "alfred" }, { id: "baerbel" }],
|
||||
},
|
||||
broadcast: {
|
||||
strategy: "parallel",
|
||||
"120363403215116621@g.us": ["alfred", "baerbel"],
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid broadcast strategy", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
broadcast: { strategy: "nope" },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-array broadcast entries", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
broadcast: { "120363403215116621@g.us": 123 },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
56
src/config/config.compaction-settings.test.ts
Normal file
56
src/config/config.compaction-settings.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "./test-helpers.js";
|
||||
|
||||
describe("config compaction settings", () => {
|
||||
it("preserves memory flush config values", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
mode: "safeguard",
|
||||
reserveTokensFloor: 12_345,
|
||||
memoryFlush: {
|
||||
enabled: false,
|
||||
softThresholdTokens: 1234,
|
||||
prompt: "Write notes.",
|
||||
systemPrompt: "Flush memory now.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.agents?.defaults?.compaction?.reserveTokensFloor).toBe(12_345);
|
||||
expect(cfg.agents?.defaults?.compaction?.mode).toBe("safeguard");
|
||||
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.enabled).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
cfg.agents?.defaults?.compaction?.memoryFlush?.softThresholdTokens,
|
||||
).toBe(1234);
|
||||
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.prompt).toBe(
|
||||
"Write notes.",
|
||||
);
|
||||
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.systemPrompt).toBe(
|
||||
"Flush memory now.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
68
src/config/config.discord.test.ts
Normal file
68
src/config/config.discord.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "./test-helpers.js";
|
||||
|
||||
describe("config discord", () => {
|
||||
let previousHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
previousHome = process.env.HOME;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = previousHome;
|
||||
});
|
||||
|
||||
it("loads discord guild map + dm group settings", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: 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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
81
src/config/config.env-vars.test.ts
Normal file
81
src/config/config.env-vars.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnvOverride, withTempHome } from "./test-helpers.js";
|
||||
|
||||
describe("config env vars", () => {
|
||||
it("applies env vars from env block when missing", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
env: { OPENROUTER_API_KEY: "config-key" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await withEnvOverride({ OPENROUTER_API_KEY: undefined }, async () => {
|
||||
const { loadConfig } = await import("./config.js");
|
||||
loadConfig();
|
||||
expect(process.env.OPENROUTER_API_KEY).toBe("config-key");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not override existing env vars", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
env: { OPENROUTER_API_KEY: "config-key" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await withEnvOverride(
|
||||
{ OPENROUTER_API_KEY: "existing-key" },
|
||||
async () => {
|
||||
const { loadConfig } = await import("./config.js");
|
||||
loadConfig();
|
||||
expect(process.env.OPENROUTER_API_KEY).toBe("existing-key");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies env vars from env.vars when missing", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
env: { vars: { GROQ_API_KEY: "gsk-config" } },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await withEnvOverride({ GROQ_API_KEY: undefined }, async () => {
|
||||
const { loadConfig } = await import("./config.js");
|
||||
loadConfig();
|
||||
expect(process.env.GROQ_API_KEY).toBe("gsk-config");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
356
src/config/config.identity-defaults.test.ts
Normal file
356
src/config/config.identity-defaults.test.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "./test-helpers.js";
|
||||
|
||||
describe("config identity defaults", () => {
|
||||
let previousHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
previousHome = process.env.HOME;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = previousHome;
|
||||
});
|
||||
|
||||
it("does not derive mentionPatterns when identity is set", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
identity: {
|
||||
name: "Samantha",
|
||||
theme: "helpful sloth",
|
||||
emoji: "🦥",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
messages: {},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.messages?.responsePrefix).toBeUndefined();
|
||||
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults ackReactionScope without setting ackReaction", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
identity: {
|
||||
name: "Samantha",
|
||||
theme: "helpful sloth",
|
||||
emoji: "🦥",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
messages: {},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.messages?.ackReaction).toBeUndefined();
|
||||
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps ackReaction unset when identity is missing", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
messages: {},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.messages?.ackReaction).toBeUndefined();
|
||||
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not override explicit values", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
identity: {
|
||||
name: "Samantha Sloth",
|
||||
theme: "space lobster",
|
||||
emoji: "🦞",
|
||||
},
|
||||
groupChat: { mentionPatterns: ["@clawd"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
messages: {
|
||||
responsePrefix: "✅",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.messages?.responsePrefix).toBe("✅");
|
||||
expect(cfg.agents?.list?.[0]?.groupChat?.mentionPatterns).toEqual([
|
||||
"@clawd",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("supports provider textChunkLimit config", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
messages: {
|
||||
messagePrefix: "[clawdbot]",
|
||||
responsePrefix: "🦞",
|
||||
// legacy field should be ignored (moved to providers)
|
||||
textChunkLimit: 9999,
|
||||
},
|
||||
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 },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
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;
|
||||
expect(legacy).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts blank model provider apiKey values", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
apiKey: "",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.models?.providers?.minimax?.baseUrl).toBe(
|
||||
"https://api.minimax.io/anthropic",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("respects empty responsePrefix to disable identity defaults", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
identity: {
|
||||
name: "Samantha",
|
||||
theme: "helpful sloth",
|
||||
emoji: "🦥",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
messages: { responsePrefix: "" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.messages?.responsePrefix).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not synthesize agent/session when absent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
messages: {},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.messages?.responsePrefix).toBeUndefined();
|
||||
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
|
||||
expect(cfg.agents).toBeUndefined();
|
||||
expect(cfg.session).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not derive responsePrefix from identity emoji", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
identity: {
|
||||
name: "Clawd",
|
||||
theme: "space lobster",
|
||||
emoji: "🦞",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
messages: {},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.messages?.responsePrefix).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
415
src/config/config.legacy-config-detection.part-1.test.ts
Normal file
415
src/config/config.legacy-config-detection.part-1.test.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("legacy config detection", () => {
|
||||
it("rejects routing.allowFrom", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
routing: { allowFrom: ["+15555550123"] },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("routing.allowFrom");
|
||||
}
|
||||
});
|
||||
it("rejects routing.groupChat.requireMention", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
routing: { groupChat: { requireMention: false } },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention");
|
||||
}
|
||||
});
|
||||
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 → channels.whatsapp.allowFrom.",
|
||||
);
|
||||
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
|
||||
expect(res.config?.routing?.allowFrom).toBeUndefined();
|
||||
});
|
||||
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 → channels.whatsapp.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
'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?.routing?.groupChat?.requireMention).toBeUndefined();
|
||||
});
|
||||
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { groupChat: { mentionPatterns: ["@clawd"] } },
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
"Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.",
|
||||
);
|
||||
expect(res.config?.messages?.groupChat?.mentionPatterns).toEqual([
|
||||
"@clawd",
|
||||
]);
|
||||
expect(res.config?.routing?.groupChat?.mentionPatterns).toBeUndefined();
|
||||
});
|
||||
it("migrates routing agentToAgent/queue/transcribeAudio to tools/messages/audio", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
routing: {
|
||||
agentToAgent: { enabled: true, allow: ["main"] },
|
||||
queue: { mode: "queue", cap: 3 },
|
||||
transcribeAudio: {
|
||||
command: ["whisper", "--model", "base"],
|
||||
timeoutSeconds: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
"Moved routing.agentToAgent → tools.agentToAgent.",
|
||||
);
|
||||
expect(res.changes).toContain("Moved routing.queue → messages.queue.");
|
||||
expect(res.changes).toContain(
|
||||
"Moved routing.transcribeAudio → tools.audio.transcription.",
|
||||
);
|
||||
expect(res.config?.tools?.agentToAgent).toEqual({
|
||||
enabled: true,
|
||||
allow: ["main"],
|
||||
});
|
||||
expect(res.config?.messages?.queue).toEqual({
|
||||
mode: "queue",
|
||||
cap: 3,
|
||||
});
|
||||
expect(res.config?.tools?.audio?.transcription).toEqual({
|
||||
args: ["--model", "base"],
|
||||
timeoutSeconds: 2,
|
||||
});
|
||||
expect(res.config?.routing).toBeUndefined();
|
||||
});
|
||||
it("migrates agent config into agents.defaults and tools", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
agent: {
|
||||
model: "openai/gpt-5.2",
|
||||
tools: { allow: ["sessions.list"], deny: ["danger"] },
|
||||
elevated: { enabled: true, allowFrom: { discord: ["user:1"] } },
|
||||
bash: { timeoutSec: 12 },
|
||||
sandbox: { tools: { allow: ["browser.open"] } },
|
||||
subagents: { tools: { deny: ["sandbox"] } },
|
||||
},
|
||||
});
|
||||
expect(res.changes).toContain("Moved agent.tools.allow → tools.allow.");
|
||||
expect(res.changes).toContain("Moved agent.tools.deny → tools.deny.");
|
||||
expect(res.changes).toContain("Moved agent.elevated → tools.elevated.");
|
||||
expect(res.changes).toContain("Moved agent.bash → tools.exec.");
|
||||
expect(res.changes).toContain(
|
||||
"Moved agent.sandbox.tools → tools.sandbox.tools.",
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
"Moved agent.subagents.tools → tools.subagents.tools.",
|
||||
);
|
||||
expect(res.changes).toContain("Moved agent → agents.defaults.");
|
||||
expect(res.config?.agents?.defaults?.model).toEqual({
|
||||
primary: "openai/gpt-5.2",
|
||||
fallbacks: [],
|
||||
});
|
||||
expect(res.config?.tools?.allow).toEqual(["sessions.list"]);
|
||||
expect(res.config?.tools?.deny).toEqual(["danger"]);
|
||||
expect(res.config?.tools?.elevated).toEqual({
|
||||
enabled: true,
|
||||
allowFrom: { discord: ["user:1"] },
|
||||
});
|
||||
expect(res.config?.tools?.exec).toEqual({ timeoutSec: 12 });
|
||||
expect(res.config?.tools?.sandbox?.tools).toEqual({
|
||||
allow: ["browser.open"],
|
||||
});
|
||||
expect(res.config?.tools?.subagents?.tools).toEqual({
|
||||
deny: ["sandbox"],
|
||||
});
|
||||
expect((res.config as { agent?: unknown }).agent).toBeUndefined();
|
||||
});
|
||||
it("accepts per-agent tools.elevated overrides", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
tools: {
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+15555550123"] },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "work",
|
||||
workspace: "~/clawd-work",
|
||||
tools: {
|
||||
elevated: {
|
||||
enabled: false,
|
||||
allowFrom: { whatsapp: ["+15555550123"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config?.agents?.list?.[0]?.tools?.elevated).toEqual({
|
||||
enabled: false,
|
||||
allowFrom: { whatsapp: ["+15555550123"] },
|
||||
});
|
||||
}
|
||||
});
|
||||
it("rejects telegram.requireMention", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
telegram: { requireMention: true },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(
|
||||
res.issues.some((issue) => issue.path === "telegram.requireMention"),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
it("rejects gateway.token", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
gateway: { token: "legacy-token" },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("gateway.token");
|
||||
}
|
||||
});
|
||||
it("migrates gateway.token to gateway.auth.token", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: { token: "legacy-token" },
|
||||
});
|
||||
expect(res.changes).toContain("Moved gateway.token → gateway.auth.token.");
|
||||
expect(res.config?.gateway?.auth?.token).toBe("legacy-token");
|
||||
expect(res.config?.gateway?.auth?.mode).toBe("token");
|
||||
expect((res.config?.gateway as { token?: string })?.token).toBeUndefined();
|
||||
});
|
||||
it("migrates gateway.bind and bridge.bind from 'tailnet' to 'auto'", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: { bind: "tailnet" as const },
|
||||
bridge: { bind: "tailnet" as const },
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
"Migrated gateway.bind from 'tailnet' to 'auto'.",
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
"Migrated bridge.bind from 'tailnet' to 'auto'.",
|
||||
);
|
||||
expect(res.config?.gateway?.bind).toBe("auto");
|
||||
expect(res.config?.bridge?.bind).toBe("auto");
|
||||
});
|
||||
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
channels: { telegram: { dmPolicy: "open", allowFrom: ["123456789"] } },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.telegram.allowFrom");
|
||||
}
|
||||
});
|
||||
it('accepts telegram.dmPolicy="open" with allowFrom "*"', async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
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({ channels: { telegram: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
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({ channels: { telegram: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
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({ channels: { telegram: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.telegram?.streamMode).toBe("partial");
|
||||
}
|
||||
});
|
||||
it('rejects whatsapp.dmPolicy="open" without allowFrom "*"', async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
whatsapp: { dmPolicy: "open", allowFrom: ["+15555550123"] },
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.whatsapp.allowFrom");
|
||||
}
|
||||
});
|
||||
it('accepts whatsapp.dmPolicy="open" with allowFrom "*"', async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
channels: { whatsapp: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
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({ channels: { whatsapp: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
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({ channels: { whatsapp: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.whatsapp?.groupPolicy).toBe("allowlist");
|
||||
}
|
||||
});
|
||||
it('rejects signal.dmPolicy="open" without allowFrom "*"', async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
channels: { signal: { dmPolicy: "open", allowFrom: ["+15555550123"] } },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.signal.allowFrom");
|
||||
}
|
||||
});
|
||||
it('accepts signal.dmPolicy="open" with allowFrom "*"', async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
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({ channels: { signal: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
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({ channels: { signal: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.signal?.groupPolicy).toBe("allowlist");
|
||||
}
|
||||
});
|
||||
it("accepts historyLimit overrides per provider and account", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
messages: { groupChat: { historyLimit: 12 } },
|
||||
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.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);
|
||||
}
|
||||
});
|
||||
it('rejects imessage.dmPolicy="open" without allowFrom "*"', async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
imessage: { dmPolicy: "open", allowFrom: ["+15555550123"] },
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.imessage.allowFrom");
|
||||
}
|
||||
});
|
||||
});
|
||||
416
src/config/config.legacy-config-detection.part-2.test.ts
Normal file
416
src/config/config.legacy-config-detection.part-2.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "./test-helpers.js";
|
||||
|
||||
describe("legacy config detection", () => {
|
||||
it('accepts imessage.dmPolicy="open" with allowFrom "*"', async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
channels: { imessage: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
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({ channels: { imessage: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
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({ channels: { imessage: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
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({ channels: { discord: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
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({ channels: { slack: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
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({ channels: { msteams: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.msteams?.groupPolicy).toBe("allowlist");
|
||||
}
|
||||
});
|
||||
it("rejects unsafe executable config values", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
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 === "channels.imessage.cliPath"),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
it("accepts tools audio transcription without cli", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
tools: { audio: { transcription: { args: ["--model", "base"] } } },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
it("accepts path-like executable values with spaces", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
channels: { imessage: { cliPath: "/Applications/Imsg Tools/imsg" } },
|
||||
tools: {
|
||||
audio: {
|
||||
transcription: {
|
||||
args: ["--model"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
it('rejects discord.dm.policy="open" without allowFrom "*"', async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
channels: { discord: { dm: { policy: "open", allowFrom: ["123"] } } },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.discord.dm.allowFrom");
|
||||
}
|
||||
});
|
||||
it('rejects slack.dm.policy="open" without allowFrom "*"', async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
channels: { slack: { dm: { policy: "open", allowFrom: ["U123"] } } },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.slack.dm.allowFrom");
|
||||
}
|
||||
});
|
||||
it("rejects legacy agent.model string", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
agent: { model: "anthropic/claude-opus-4-5" },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues.some((i) => i.path === "agent.model")).toBe(true);
|
||||
}
|
||||
});
|
||||
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 → channels.telegram.groups."*".requireMention.',
|
||||
);
|
||||
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 () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
modelFallbacks: ["openai/gpt-4.1-mini"],
|
||||
imageModel: "openai/gpt-4.1-mini",
|
||||
imageModelFallbacks: ["anthropic/claude-opus-4-5"],
|
||||
allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"],
|
||||
modelAliases: { Opus: "anthropic/claude-opus-4-5" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.config?.agents?.defaults?.model?.primary).toBe(
|
||||
"anthropic/claude-opus-4-5",
|
||||
);
|
||||
expect(res.config?.agents?.defaults?.model?.fallbacks).toEqual([
|
||||
"openai/gpt-4.1-mini",
|
||||
]);
|
||||
expect(res.config?.agents?.defaults?.imageModel?.primary).toBe(
|
||||
"openai/gpt-4.1-mini",
|
||||
);
|
||||
expect(res.config?.agents?.defaults?.imageModel?.fallbacks).toEqual([
|
||||
"anthropic/claude-opus-4-5",
|
||||
]);
|
||||
expect(
|
||||
res.config?.agents?.defaults?.models?.["anthropic/claude-opus-4-5"],
|
||||
).toMatchObject({ alias: "Opus" });
|
||||
expect(
|
||||
res.config?.agents?.defaults?.models?.["openai/gpt-4.1-mini"],
|
||||
).toBeTruthy();
|
||||
expect(res.config?.agent).toBeUndefined();
|
||||
});
|
||||
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 });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ routing: { allowFrom: ["+15555550123"] } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.resetModules();
|
||||
try {
|
||||
const { readConfigFileSnapshot } = await import("./config.js");
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
43
src/config/config.msteams.test.ts
Normal file
43
src/config/config.msteams.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("config msteams", () => {
|
||||
it("accepts replyStyle at global/team/channel levels", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
msteams: {
|
||||
replyStyle: "top-level",
|
||||
teams: {
|
||||
team123: {
|
||||
replyStyle: "thread",
|
||||
channels: {
|
||||
chan456: { replyStyle: "top-level" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.msteams?.replyStyle).toBe("top-level");
|
||||
expect(res.config.channels?.msteams?.teams?.team123?.replyStyle).toBe(
|
||||
"thread",
|
||||
);
|
||||
expect(
|
||||
res.config.channels?.msteams?.teams?.team123?.channels?.chan456
|
||||
?.replyStyle,
|
||||
).toBe("top-level");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid replyStyle", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
channels: { msteams: { replyStyle: "nope" } },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
57
src/config/config.multi-agent-agentdir-validation.test.ts
Normal file
57
src/config/config.multi-agent-agentdir-validation.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "./test-helpers.js";
|
||||
|
||||
describe("multi-agent agentDir validation", () => {
|
||||
it("rejects shared agents.list agentDir", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const shared = path.join(tmpdir(), "clawdbot-shared-agentdir");
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "a", agentDir: shared },
|
||||
{ id: "b", agentDir: shared },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues.some((i) => i.path === "agents.list")).toBe(true);
|
||||
expect(res.issues[0]?.message).toContain("Duplicate agentDir");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on shared agentDir during loadConfig()", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "a", agentDir: "~/.clawdbot/agents/shared/agent" },
|
||||
{ id: "b", agentDir: "~/.clawdbot/agents/shared/agent" },
|
||||
],
|
||||
},
|
||||
bindings: [{ agentId: "a", match: { provider: "telegram" } }],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { loadConfig } = await import("./config.js");
|
||||
expect(() => loadConfig()).toThrow(/duplicate agentDir/i);
|
||||
expect(spy.mock.calls.flat().join(" ")).toMatch(/Duplicate agentDir/i);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
271
src/config/config.nix-integration-u3-u5-u9.test.ts
Normal file
271
src/config/config.nix-integration-u3-u5-u9.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withEnvOverride, withTempHome } from "./test-helpers.js";
|
||||
|
||||
describe("Nix integration (U3, U5, U9)", () => {
|
||||
describe("U3: isNixMode env var detection", () => {
|
||||
it("isNixMode is false when CLAWDBOT_NIX_MODE is not set", async () => {
|
||||
await withEnvOverride({ CLAWDBOT_NIX_MODE: undefined }, async () => {
|
||||
const { isNixMode } = await import("./config.js");
|
||||
expect(isNixMode).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("isNixMode is false when CLAWDBOT_NIX_MODE is empty", async () => {
|
||||
await withEnvOverride({ CLAWDBOT_NIX_MODE: "" }, async () => {
|
||||
const { isNixMode } = await import("./config.js");
|
||||
expect(isNixMode).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("isNixMode is false when CLAWDBOT_NIX_MODE is not '1'", async () => {
|
||||
await withEnvOverride({ CLAWDBOT_NIX_MODE: "true" }, async () => {
|
||||
const { isNixMode } = await import("./config.js");
|
||||
expect(isNixMode).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("isNixMode is true when CLAWDBOT_NIX_MODE=1", async () => {
|
||||
await withEnvOverride({ CLAWDBOT_NIX_MODE: "1" }, async () => {
|
||||
const { isNixMode } = await import("./config.js");
|
||||
expect(isNixMode).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("U5: CONFIG_PATH and STATE_DIR env var overrides", () => {
|
||||
it("STATE_DIR_CLAWDBOT defaults to ~/.clawdbot when env not set", async () => {
|
||||
await withEnvOverride({ CLAWDBOT_STATE_DIR: undefined }, async () => {
|
||||
const { STATE_DIR_CLAWDBOT } = await import("./config.js");
|
||||
expect(STATE_DIR_CLAWDBOT).toMatch(/\.clawdbot$/);
|
||||
});
|
||||
});
|
||||
|
||||
it("STATE_DIR_CLAWDBOT respects CLAWDBOT_STATE_DIR override", async () => {
|
||||
await withEnvOverride(
|
||||
{ CLAWDBOT_STATE_DIR: "/custom/state/dir" },
|
||||
async () => {
|
||||
const { STATE_DIR_CLAWDBOT } = await import("./config.js");
|
||||
expect(STATE_DIR_CLAWDBOT).toBe(path.resolve("/custom/state/dir"));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("CONFIG_PATH_CLAWDBOT defaults to ~/.clawdbot/clawdbot.json when env not set", async () => {
|
||||
await withEnvOverride(
|
||||
{ CLAWDBOT_CONFIG_PATH: undefined, CLAWDBOT_STATE_DIR: undefined },
|
||||
async () => {
|
||||
const { CONFIG_PATH_CLAWDBOT } = await import("./config.js");
|
||||
expect(CONFIG_PATH_CLAWDBOT).toMatch(
|
||||
/\.clawdbot[\\/]clawdbot\.json$/,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("CONFIG_PATH_CLAWDBOT respects CLAWDBOT_CONFIG_PATH override", async () => {
|
||||
await withEnvOverride(
|
||||
{ CLAWDBOT_CONFIG_PATH: "/nix/store/abc/clawdbot.json" },
|
||||
async () => {
|
||||
const { CONFIG_PATH_CLAWDBOT } = await import("./config.js");
|
||||
expect(CONFIG_PATH_CLAWDBOT).toBe(
|
||||
path.resolve("/nix/store/abc/clawdbot.json"),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("CONFIG_PATH_CLAWDBOT expands ~ in CLAWDBOT_CONFIG_PATH override", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await withEnvOverride(
|
||||
{ CLAWDBOT_CONFIG_PATH: "~/.clawdbot/custom.json" },
|
||||
async () => {
|
||||
const { CONFIG_PATH_CLAWDBOT } = await import("./config.js");
|
||||
expect(CONFIG_PATH_CLAWDBOT).toBe(
|
||||
path.join(home, ".clawdbot", "custom.json"),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("CONFIG_PATH_CLAWDBOT uses STATE_DIR_CLAWDBOT when only state dir is overridden", async () => {
|
||||
await withEnvOverride(
|
||||
{
|
||||
CLAWDBOT_CONFIG_PATH: undefined,
|
||||
CLAWDBOT_STATE_DIR: "/custom/state",
|
||||
},
|
||||
async () => {
|
||||
const { CONFIG_PATH_CLAWDBOT } = await import("./config.js");
|
||||
expect(CONFIG_PATH_CLAWDBOT).toBe(
|
||||
path.join(path.resolve("/custom/state"), "clawdbot.json"),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("U5b: tilde expansion for config paths", () => {
|
||||
it("expands ~ in common path-ish config fields", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
plugins: {
|
||||
load: {
|
||||
paths: ["~/plugins/demo-plugin"],
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: { workspace: "~/ws-default" },
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
workspace: "~/ws-agent",
|
||||
agentDir: "~/.clawdbot/agents/main",
|
||||
sandbox: { workspaceRoot: "~/sandbox-root" },
|
||||
},
|
||||
],
|
||||
},
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accounts: {
|
||||
personal: {
|
||||
authDir: "~/.clawdbot/credentials/wa-personal",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.plugins?.load?.paths?.[0]).toBe(
|
||||
path.join(home, "plugins", "demo-plugin"),
|
||||
);
|
||||
expect(cfg.agents?.defaults?.workspace).toBe(
|
||||
path.join(home, "ws-default"),
|
||||
);
|
||||
expect(cfg.agents?.list?.[0]?.workspace).toBe(
|
||||
path.join(home, "ws-agent"),
|
||||
);
|
||||
expect(cfg.agents?.list?.[0]?.agentDir).toBe(
|
||||
path.join(home, ".clawdbot", "agents", "main"),
|
||||
);
|
||||
expect(cfg.agents?.list?.[0]?.sandbox?.workspaceRoot).toBe(
|
||||
path.join(home, "sandbox-root"),
|
||||
);
|
||||
expect(cfg.channels?.whatsapp?.accounts?.personal?.authDir).toBe(
|
||||
path.join(home, ".clawdbot", "credentials", "wa-personal"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("U6: gateway port resolution", () => {
|
||||
it("uses default when env and config are unset", async () => {
|
||||
await withEnvOverride({ CLAWDBOT_GATEWAY_PORT: undefined }, async () => {
|
||||
const { DEFAULT_GATEWAY_PORT, resolveGatewayPort } = await import(
|
||||
"./config.js"
|
||||
);
|
||||
expect(resolveGatewayPort({})).toBe(DEFAULT_GATEWAY_PORT);
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers CLAWDBOT_GATEWAY_PORT over config", async () => {
|
||||
await withEnvOverride({ CLAWDBOT_GATEWAY_PORT: "19001" }, async () => {
|
||||
const { resolveGatewayPort } = await import("./config.js");
|
||||
expect(resolveGatewayPort({ gateway: { port: 19002 } })).toBe(19001);
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to config when env is invalid", async () => {
|
||||
await withEnvOverride({ CLAWDBOT_GATEWAY_PORT: "nope" }, async () => {
|
||||
const { resolveGatewayPort } = await import("./config.js");
|
||||
expect(resolveGatewayPort({ gateway: { port: 19003 } })).toBe(19003);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("U9: telegram.tokenFile schema validation", () => {
|
||||
it("accepts config with only botToken", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify({
|
||||
channels: { telegram: { botToken: "123:ABC" } },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
expect(cfg.channels?.telegram?.botToken).toBe("123:ABC");
|
||||
expect(cfg.channels?.telegram?.tokenFile).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts config with only tokenFile", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify({
|
||||
channels: { telegram: { tokenFile: "/run/agenix/telegram-token" } },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
expect(cfg.channels?.telegram?.tokenFile).toBe(
|
||||
"/run/agenix/telegram-token",
|
||||
);
|
||||
expect(cfg.channels?.telegram?.botToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts config with both botToken and tokenFile", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "fallback:token",
|
||||
tokenFile: "/run/agenix/telegram-token",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
expect(cfg.channels?.telegram?.botToken).toBe("fallback:token");
|
||||
expect(cfg.channels?.telegram?.tokenFile).toBe(
|
||||
"/run/agenix/telegram-token",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
50
src/config/config.preservation-on-validation-failure.test.ts
Normal file
50
src/config/config.preservation-on-validation-failure.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "./test-helpers.js";
|
||||
|
||||
describe("config preservation on validation failure", () => {
|
||||
it("preserves unknown fields via passthrough", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
agents: { list: [{ id: "pi" }] },
|
||||
customUnknownField: { nested: "value" },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(
|
||||
(res as { config: Record<string, unknown> }).config.customUnknownField,
|
||||
).toEqual({
|
||||
nested: "value",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves config data when validation fails", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify({
|
||||
agents: { list: [{ id: "pi" }] },
|
||||
routing: { allowFrom: ["+15555550123"] },
|
||||
customData: { preserved: true },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { readConfigFileSnapshot } = await import("./config.js");
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.legacyIssues).toHaveLength(0);
|
||||
expect((snap.config as Record<string, unknown>).customData).toEqual({
|
||||
preserved: true,
|
||||
});
|
||||
expect(snap.config.channels?.whatsapp?.allowFrom).toEqual([
|
||||
"+15555550123",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
src/config/config.pruning-defaults.test.ts
Normal file
46
src/config/config.pruning-defaults.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "./test-helpers.js";
|
||||
|
||||
describe("config pruning defaults", () => {
|
||||
it("defaults contextPruning mode to adaptive", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify({ agents: { defaults: {} } }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("adaptive");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not override explicit contextPruning mode", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
{ agents: { defaults: { contextPruning: { mode: "off" } } } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("off");
|
||||
});
|
||||
});
|
||||
});
|
||||
51
src/config/config.talk-api-key-fallback.test.ts
Normal file
51
src/config/config.talk-api-key-fallback.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "./test-helpers.js";
|
||||
|
||||
describe("talk api key fallback", () => {
|
||||
let previousEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
previousEnv = process.env.ELEVENLABS_API_KEY;
|
||||
delete process.env.ELEVENLABS_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ELEVENLABS_API_KEY = previousEnv;
|
||||
});
|
||||
|
||||
it("injects talk.apiKey from profile when config is missing", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await fs.writeFile(
|
||||
path.join(home, ".profile"),
|
||||
"export ELEVENLABS_API_KEY=profile-key\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { readConfigFileSnapshot } = await import("./config.js");
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.config?.talk?.apiKey).toBe("profile-key");
|
||||
expect(snap.exists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers ELEVENLABS_API_KEY env over profile", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await fs.writeFile(
|
||||
path.join(home, ".profile"),
|
||||
"export ELEVENLABS_API_KEY=profile-key\n",
|
||||
"utf-8",
|
||||
);
|
||||
process.env.ELEVENLABS_API_KEY = "env-key";
|
||||
|
||||
vi.resetModules();
|
||||
const { readConfigFileSnapshot } = await import("./config.js");
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.config?.talk?.apiKey).toBe("env-key");
|
||||
});
|
||||
});
|
||||
});
|
||||
30
src/config/config.talk-voicealiases.test.ts
Normal file
30
src/config/config.talk-voicealiases.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("talk.voiceAliases", () => {
|
||||
it("accepts a string map of voice aliases", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
talk: {
|
||||
voiceAliases: {
|
||||
Clawd: "EXAVITQu4vr4xnSDxMaL",
|
||||
Roger: "CwhRBWXzGAHq8TQ4Fs17",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-string voice alias values", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
talk: {
|
||||
voiceAliases: {
|
||||
Clawd: 123,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,709 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
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 { ChannelId } from "../channels/plugins/types.js";
|
||||
import { CHANNEL_IDS } from "../channels/registry.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
DEFAULT_AGENT_ID,
|
||||
normalizeAgentId,
|
||||
normalizeMainKey,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import {
|
||||
getFileMtimeMs,
|
||||
isCacheEnabled,
|
||||
resolveCacheTtlMs,
|
||||
} from "./cache-utils.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { resolveStateDir } from "./paths.js";
|
||||
|
||||
// ============================================================================
|
||||
// Session Store Cache with TTL Support
|
||||
// ============================================================================
|
||||
|
||||
type SessionStoreCacheEntry = {
|
||||
store: Record<string, SessionEntry>;
|
||||
loadedAt: number;
|
||||
storePath: string;
|
||||
mtimeMs?: number;
|
||||
};
|
||||
|
||||
const SESSION_STORE_CACHE = new Map<string, SessionStoreCacheEntry>();
|
||||
const DEFAULT_SESSION_STORE_TTL_MS = 45_000; // 45 seconds (between 30-60s)
|
||||
|
||||
function getSessionStoreTtl(): number {
|
||||
return resolveCacheTtlMs({
|
||||
envValue: process.env.CLAWDBOT_SESSION_CACHE_TTL_MS,
|
||||
defaultTtlMs: DEFAULT_SESSION_STORE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function isSessionStoreCacheEnabled(): boolean {
|
||||
return isCacheEnabled(getSessionStoreTtl());
|
||||
}
|
||||
|
||||
function isSessionStoreCacheValid(entry: SessionStoreCacheEntry): boolean {
|
||||
const now = Date.now();
|
||||
const ttl = getSessionStoreTtl();
|
||||
return now - entry.loadedAt <= ttl;
|
||||
}
|
||||
|
||||
function invalidateSessionStoreCache(storePath: string): void {
|
||||
SESSION_STORE_CACHE.delete(storePath);
|
||||
}
|
||||
|
||||
export function clearSessionStoreCacheForTest(): void {
|
||||
SESSION_STORE_CACHE.clear();
|
||||
}
|
||||
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
|
||||
export type SessionChannelId = ChannelId | "webchat";
|
||||
|
||||
const GROUP_SURFACES = new Set<string>([...CHANNEL_IDS, "webchat"]);
|
||||
|
||||
export type SessionChatType = "direct" | "group" | "room";
|
||||
|
||||
export type SessionEntry = {
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
sessionFile?: string;
|
||||
/** Parent session key that spawned this session (used for sandbox session-tool scoping). */
|
||||
spawnedBy?: string;
|
||||
systemSent?: boolean;
|
||||
abortedLastRun?: boolean;
|
||||
chatType?: SessionChatType;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
reasoningLevel?: string;
|
||||
elevatedLevel?: string;
|
||||
responseUsage?: "on" | "off";
|
||||
providerOverride?: string;
|
||||
modelOverride?: string;
|
||||
authProfileOverride?: string;
|
||||
groupActivation?: "mention" | "always";
|
||||
groupActivationNeedsSystemIntro?: boolean;
|
||||
sendPolicy?: "allow" | "deny";
|
||||
queueMode?:
|
||||
| "steer"
|
||||
| "followup"
|
||||
| "collect"
|
||||
| "steer-backlog"
|
||||
| "steer+backlog"
|
||||
| "queue"
|
||||
| "interrupt";
|
||||
queueDebounceMs?: number;
|
||||
queueCap?: number;
|
||||
queueDrop?: "old" | "new" | "summarize";
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
contextTokens?: number;
|
||||
compactionCount?: number;
|
||||
memoryFlushAt?: number;
|
||||
memoryFlushCompactionCount?: number;
|
||||
cliSessionIds?: Record<string, string>;
|
||||
claudeCliSessionId?: string;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
channel?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
lastChannel?: SessionChannelId;
|
||||
lastTo?: string;
|
||||
lastAccountId?: string;
|
||||
skillsSnapshot?: SessionSkillSnapshot;
|
||||
};
|
||||
|
||||
export function mergeSessionEntry(
|
||||
existing: SessionEntry | undefined,
|
||||
patch: Partial<SessionEntry>,
|
||||
): SessionEntry {
|
||||
const sessionId =
|
||||
patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID();
|
||||
const updatedAt = Math.max(
|
||||
existing?.updatedAt ?? 0,
|
||||
patch.updatedAt ?? 0,
|
||||
Date.now(),
|
||||
);
|
||||
if (!existing) return { ...patch, sessionId, updatedAt };
|
||||
return { ...existing, ...patch, sessionId, updatedAt };
|
||||
}
|
||||
export type GroupKeyResolution = {
|
||||
key: string;
|
||||
legacyKey?: string;
|
||||
channel?: string;
|
||||
id?: string;
|
||||
chatType?: SessionChatType;
|
||||
};
|
||||
|
||||
export type SessionSkillSnapshot = {
|
||||
prompt: string;
|
||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||
resolvedSkills?: Skill[];
|
||||
};
|
||||
|
||||
function resolveAgentSessionsDir(
|
||||
agentId?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = os.homedir,
|
||||
): string {
|
||||
const root = resolveStateDir(env, homedir);
|
||||
const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID);
|
||||
return path.join(root, "agents", id, "sessions");
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = os.homedir,
|
||||
): string {
|
||||
return resolveAgentSessionsDir(DEFAULT_AGENT_ID, env, homedir);
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptsDirForAgent(
|
||||
agentId?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = os.homedir,
|
||||
): string {
|
||||
return resolveAgentSessionsDir(agentId, env, homedir);
|
||||
}
|
||||
|
||||
export function resolveDefaultSessionStorePath(agentId?: string): string {
|
||||
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
|
||||
}
|
||||
export const DEFAULT_RESET_TRIGGER = "/new";
|
||||
export const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"];
|
||||
export const DEFAULT_IDLE_MINUTES = 60;
|
||||
|
||||
export function resolveSessionTranscriptPath(
|
||||
sessionId: string,
|
||||
agentId?: string,
|
||||
topicId?: number,
|
||||
): string {
|
||||
const fileName =
|
||||
topicId !== undefined
|
||||
? `${sessionId}-topic-${topicId}.jsonl`
|
||||
: `${sessionId}.jsonl`;
|
||||
return path.join(resolveAgentSessionsDir(agentId), fileName);
|
||||
}
|
||||
|
||||
export function resolveSessionFilePath(
|
||||
sessionId: string,
|
||||
entry?: SessionEntry,
|
||||
opts?: { agentId?: string },
|
||||
): string {
|
||||
const candidate = entry?.sessionFile?.trim();
|
||||
return candidate
|
||||
? candidate
|
||||
: resolveSessionTranscriptPath(sessionId, opts?.agentId);
|
||||
}
|
||||
|
||||
export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
|
||||
const agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID);
|
||||
if (!store) return resolveDefaultSessionStorePath(agentId);
|
||||
if (store.includes("{agentId}")) {
|
||||
const expanded = store.replaceAll("{agentId}", agentId);
|
||||
if (expanded.startsWith("~")) {
|
||||
return path.resolve(expanded.replace(/^~(?=$|[\\/])/, os.homedir()));
|
||||
}
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
if (store.startsWith("~"))
|
||||
return path.resolve(store.replace(/^~(?=$|[\\/])/, os.homedir()));
|
||||
return path.resolve(store);
|
||||
}
|
||||
|
||||
export function resolveMainSessionKey(cfg?: {
|
||||
session?: { scope?: SessionScope; mainKey?: string };
|
||||
agents?: { list?: Array<{ id?: string; default?: boolean }> };
|
||||
}): string {
|
||||
if (cfg?.session?.scope === "global") return "global";
|
||||
const agents = cfg?.agents?.list ?? [];
|
||||
const defaultAgentId =
|
||||
agents.find((agent) => agent?.default)?.id ??
|
||||
agents[0]?.id ??
|
||||
DEFAULT_AGENT_ID;
|
||||
const agentId = normalizeAgentId(defaultAgentId);
|
||||
const mainKey = normalizeMainKey(cfg?.session?.mainKey);
|
||||
return buildAgentMainSessionKey({ agentId, mainKey });
|
||||
}
|
||||
|
||||
export function resolveMainSessionKeyFromConfig(): string {
|
||||
return resolveMainSessionKey(loadConfig());
|
||||
}
|
||||
|
||||
export { resolveAgentIdFromSessionKey };
|
||||
|
||||
export function resolveAgentMainSessionKey(params: {
|
||||
cfg?: { session?: { mainKey?: string } };
|
||||
agentId: string;
|
||||
}): string {
|
||||
const mainKey = normalizeMainKey(params.cfg?.session?.mainKey);
|
||||
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
|
||||
}
|
||||
|
||||
export function canonicalizeMainSessionAlias(params: {
|
||||
cfg?: { session?: { scope?: SessionScope; mainKey?: string } };
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
}): string {
|
||||
const raw = params.sessionKey.trim();
|
||||
if (!raw) return raw;
|
||||
|
||||
const agentId = normalizeAgentId(params.agentId);
|
||||
const mainKey = normalizeMainKey(params.cfg?.session?.mainKey);
|
||||
const agentMainSessionKey = buildAgentMainSessionKey({ agentId, mainKey });
|
||||
const agentMainAliasKey = buildAgentMainSessionKey({
|
||||
agentId,
|
||||
mainKey: "main",
|
||||
});
|
||||
|
||||
const isMainAlias =
|
||||
raw === "main" ||
|
||||
raw === mainKey ||
|
||||
raw === agentMainSessionKey ||
|
||||
raw === agentMainAliasKey;
|
||||
|
||||
if (params.cfg?.session?.scope === "global" && isMainAlias) return "global";
|
||||
if (isMainAlias) return agentMainSessionKey;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeGroupLabel(raw?: string) {
|
||||
const trimmed = raw?.trim().toLowerCase() ?? "";
|
||||
if (!trimmed) return "";
|
||||
const dashed = trimmed.replace(/\s+/g, "-");
|
||||
const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-");
|
||||
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
||||
}
|
||||
|
||||
function shortenGroupId(value?: string) {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.length <= 14) return trimmed;
|
||||
return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function buildGroupDisplayName(params: {
|
||||
provider?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
id?: string;
|
||||
key: string;
|
||||
}) {
|
||||
const providerKey = (params.provider?.trim().toLowerCase() || "group").trim();
|
||||
const room = params.room?.trim();
|
||||
const space = params.space?.trim();
|
||||
const subject = params.subject?.trim();
|
||||
const detail =
|
||||
(room && space
|
||||
? `${space}${room.startsWith("#") ? "" : "#"}${room}`
|
||||
: room || subject || space || "") || "";
|
||||
const fallbackId = params.id?.trim() || params.key.replace(/^group:/, "");
|
||||
const rawLabel = detail || fallbackId;
|
||||
let token = normalizeGroupLabel(rawLabel);
|
||||
if (!token) {
|
||||
token = normalizeGroupLabel(shortenGroupId(rawLabel));
|
||||
}
|
||||
if (!params.room && token.startsWith("#")) {
|
||||
token = token.replace(/^#+/, "");
|
||||
}
|
||||
if (
|
||||
token &&
|
||||
!/^[@#]/.test(token) &&
|
||||
!token.startsWith("g-") &&
|
||||
!token.includes("#")
|
||||
) {
|
||||
token = `g-${token}`;
|
||||
}
|
||||
return token ? `${providerKey}:${token}` : providerKey;
|
||||
}
|
||||
|
||||
export function resolveGroupSessionKey(
|
||||
ctx: MsgContext,
|
||||
): GroupKeyResolution | null {
|
||||
const from = typeof ctx.From === "string" ? ctx.From.trim() : "";
|
||||
if (!from) return null;
|
||||
const chatType = ctx.ChatType?.trim().toLowerCase();
|
||||
const isGroup =
|
||||
chatType === "group" ||
|
||||
from.startsWith("group:") ||
|
||||
from.includes("@g.us") ||
|
||||
from.includes(":group:") ||
|
||||
from.includes(":channel:");
|
||||
if (!isGroup) return null;
|
||||
|
||||
const providerHint = ctx.Provider?.trim().toLowerCase();
|
||||
const hasLegacyGroupPrefix = from.startsWith("group:");
|
||||
const raw = (
|
||||
hasLegacyGroupPrefix ? from.slice("group:".length) : from
|
||||
).trim();
|
||||
|
||||
let provider: string | undefined;
|
||||
let kind: "group" | "channel" | undefined;
|
||||
let id = "";
|
||||
|
||||
const parseKind = (value: string) => {
|
||||
if (value === "channel") return "channel";
|
||||
return "group";
|
||||
};
|
||||
|
||||
const parseParts = (parts: string[]) => {
|
||||
if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) {
|
||||
provider = parts[0];
|
||||
if (parts.length >= 3) {
|
||||
const kindCandidate = parts[1];
|
||||
if (["group", "channel"].includes(kindCandidate)) {
|
||||
kind = parseKind(kindCandidate);
|
||||
id = parts.slice(2).join(":");
|
||||
} else {
|
||||
id = parts.slice(1).join(":");
|
||||
}
|
||||
} else {
|
||||
id = parts[1];
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (parts.length >= 2 && ["group", "channel"].includes(parts[0])) {
|
||||
kind = parseKind(parts[0]);
|
||||
id = parts.slice(1).join(":");
|
||||
}
|
||||
};
|
||||
|
||||
if (hasLegacyGroupPrefix) {
|
||||
const legacyParts = raw.split(":").filter(Boolean);
|
||||
if (legacyParts.length > 1) {
|
||||
parseParts(legacyParts);
|
||||
} else {
|
||||
id = raw;
|
||||
}
|
||||
} else if (from.includes("@g.us") && !from.includes(":")) {
|
||||
id = from;
|
||||
} else {
|
||||
parseParts(from.split(":").filter(Boolean));
|
||||
if (!id) {
|
||||
id = raw || from;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedProvider = provider ?? providerHint;
|
||||
if (!resolvedProvider) {
|
||||
const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`;
|
||||
return {
|
||||
key: legacy,
|
||||
id: raw || from,
|
||||
legacyKey: legacy,
|
||||
chatType: "group",
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedKind = kind === "channel" ? "channel" : "group";
|
||||
const key = `${resolvedProvider}:${resolvedKind}:${id || raw || from}`;
|
||||
let legacyKey: string | undefined;
|
||||
if (hasLegacyGroupPrefix || from.includes("@g.us")) {
|
||||
legacyKey = `group:${id || raw || from}`;
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
legacyKey,
|
||||
channel: resolvedProvider,
|
||||
id: id || raw || from,
|
||||
chatType: resolvedKind === "channel" ? "room" : "group",
|
||||
};
|
||||
}
|
||||
|
||||
export function loadSessionStore(
|
||||
storePath: string,
|
||||
): Record<string, SessionEntry> {
|
||||
// Check cache first if enabled
|
||||
if (isSessionStoreCacheEnabled()) {
|
||||
const cached = SESSION_STORE_CACHE.get(storePath);
|
||||
if (cached && isSessionStoreCacheValid(cached)) {
|
||||
const currentMtimeMs = getFileMtimeMs(storePath);
|
||||
if (currentMtimeMs === cached.mtimeMs) {
|
||||
// Return a shallow copy to prevent external mutations affecting cache
|
||||
return { ...cached.store };
|
||||
}
|
||||
invalidateSessionStoreCache(storePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or disabled - load from disk
|
||||
let store: Record<string, SessionEntry> = {};
|
||||
let mtimeMs = getFileMtimeMs(storePath);
|
||||
try {
|
||||
const raw = fs.readFileSync(storePath, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
store = parsed as Record<string, SessionEntry>;
|
||||
}
|
||||
mtimeMs = getFileMtimeMs(storePath) ?? mtimeMs;
|
||||
} catch {
|
||||
// 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, {
|
||||
store: { ...store }, // Store a copy to prevent external mutations
|
||||
loadedAt: Date.now(),
|
||||
storePath,
|
||||
mtimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
async function saveSessionStoreUnlocked(
|
||||
storePath: string,
|
||||
store: Record<string, SessionEntry>,
|
||||
): Promise<void> {
|
||||
// Invalidate cache on write to ensure consistency
|
||||
invalidateSessionStoreCache(storePath);
|
||||
|
||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||
const json = JSON.stringify(store, null, 2);
|
||||
|
||||
// Windows: avoid atomic rename swaps (can be flaky under concurrent access).
|
||||
// We serialize writers via the session-store lock instead.
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
await fs.promises.writeFile(storePath, json, "utf-8");
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOENT") return;
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.promises.writeFile(tmp, json, "utf-8");
|
||||
await fs.promises.rename(tmp, storePath);
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
|
||||
if (code === "ENOENT") {
|
||||
// In tests the temp session-store directory may be deleted while writes are in-flight.
|
||||
// Best-effort: try a direct write (recreating the parent dir), otherwise ignore.
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.promises.writeFile(storePath, json, "utf-8");
|
||||
} catch (err2) {
|
||||
const code2 =
|
||||
err2 && typeof err2 === "object" && "code" in err2
|
||||
? String((err2 as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code2 === "ENOENT") return;
|
||||
throw err2;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSessionStore(
|
||||
storePath: string,
|
||||
store: Record<string, SessionEntry>,
|
||||
): Promise<void> {
|
||||
await withSessionStoreLock(storePath, async () => {
|
||||
await saveSessionStoreUnlocked(storePath, store);
|
||||
});
|
||||
}
|
||||
|
||||
type SessionStoreLockOptions = {
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
staleMs?: number;
|
||||
};
|
||||
|
||||
async function withSessionStoreLock<T>(
|
||||
storePath: string,
|
||||
fn: () => Promise<T>,
|
||||
opts: SessionStoreLockOptions = {},
|
||||
): Promise<T> {
|
||||
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? 25;
|
||||
const staleMs = opts.staleMs ?? 30_000;
|
||||
const lockPath = `${storePath}.lock`;
|
||||
const startedAt = Date.now();
|
||||
|
||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const handle = await fs.promises.open(lockPath, "wx");
|
||||
try {
|
||||
await handle.writeFile(
|
||||
JSON.stringify({ pid: process.pid, startedAt: Date.now() }),
|
||||
"utf-8",
|
||||
);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
await handle.close();
|
||||
break;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOENT") {
|
||||
// Store directory may be deleted/recreated in tests while writes are in-flight.
|
||||
// Best-effort: recreate the parent dir and retry until timeout.
|
||||
await fs.promises
|
||||
.mkdir(path.dirname(storePath), { recursive: true })
|
||||
.catch(() => undefined);
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
continue;
|
||||
}
|
||||
if (code !== "EEXIST") throw err;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - startedAt > timeoutMs) {
|
||||
throw new Error(`timeout acquiring session store lock: ${lockPath}`);
|
||||
}
|
||||
|
||||
// Best-effort stale lock eviction (e.g. crashed process).
|
||||
try {
|
||||
const st = await fs.promises.stat(lockPath);
|
||||
const ageMs = now - st.mtimeMs;
|
||||
if (ageMs > staleMs) {
|
||||
await fs.promises.unlink(lockPath);
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await fs.promises.unlink(lockPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSessionStoreEntry(params: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
update: (entry: SessionEntry) => Promise<Partial<SessionEntry> | null>;
|
||||
}): Promise<SessionEntry | null> {
|
||||
const { storePath, sessionKey, update } = params;
|
||||
return await withSessionStoreLock(storePath, async () => {
|
||||
const store = loadSessionStore(storePath);
|
||||
const existing = store[sessionKey];
|
||||
if (!existing) return null;
|
||||
const patch = await update(existing);
|
||||
if (!patch) return existing;
|
||||
const next = mergeSessionEntry(existing, patch);
|
||||
store[sessionKey] = next;
|
||||
await saveSessionStoreUnlocked(storePath, store);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateLastRoute(params: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
channel: SessionEntry["lastChannel"];
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
}) {
|
||||
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),
|
||||
lastChannel: channel,
|
||||
lastTo: to?.trim() ? to.trim() : undefined,
|
||||
lastAccountId: accountId?.trim()
|
||||
? accountId.trim()
|
||||
: existing?.lastAccountId,
|
||||
});
|
||||
store[sessionKey] = next;
|
||||
await saveSessionStoreUnlocked(storePath, store);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Decide which session bucket to use (per-sender vs global).
|
||||
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
||||
if (scope === "global") return "global";
|
||||
const resolvedGroup = resolveGroupSessionKey(ctx);
|
||||
if (resolvedGroup) return resolvedGroup.key;
|
||||
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
||||
return from || "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the session key with a canonical direct-chat bucket (default: "main").
|
||||
* All non-group direct chats collapse to this bucket; groups stay isolated.
|
||||
*/
|
||||
export function resolveSessionKey(
|
||||
scope: SessionScope,
|
||||
ctx: MsgContext,
|
||||
mainKey?: string,
|
||||
) {
|
||||
const explicit = ctx.SessionKey?.trim();
|
||||
if (explicit) return explicit;
|
||||
const raw = deriveSessionKey(scope, ctx);
|
||||
if (scope === "global") return raw;
|
||||
const canonicalMainKey = normalizeMainKey(mainKey);
|
||||
const canonical = buildAgentMainSessionKey({
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
mainKey: canonicalMainKey,
|
||||
});
|
||||
const isGroup =
|
||||
raw.startsWith("group:") ||
|
||||
raw.includes(":group:") ||
|
||||
raw.includes(":channel:");
|
||||
if (!isGroup) return canonical;
|
||||
return `agent:${DEFAULT_AGENT_ID}:${raw}`;
|
||||
}
|
||||
export * from "./sessions/group.js";
|
||||
export * from "./sessions/main-session.js";
|
||||
export * from "./sessions/paths.js";
|
||||
export * from "./sessions/session-key.js";
|
||||
export * from "./sessions/store.js";
|
||||
export * from "./sessions/types.js";
|
||||
|
||||
150
src/config/sessions/group.ts
Normal file
150
src/config/sessions/group.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import { CHANNEL_IDS } from "../../channels/registry.js";
|
||||
import type { GroupKeyResolution } from "./types.js";
|
||||
|
||||
const GROUP_SURFACES = new Set<string>([...CHANNEL_IDS, "webchat"]);
|
||||
|
||||
function normalizeGroupLabel(raw?: string) {
|
||||
const trimmed = raw?.trim().toLowerCase() ?? "";
|
||||
if (!trimmed) return "";
|
||||
const dashed = trimmed.replace(/\s+/g, "-");
|
||||
const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-");
|
||||
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
||||
}
|
||||
|
||||
function shortenGroupId(value?: string) {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.length <= 14) return trimmed;
|
||||
return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function buildGroupDisplayName(params: {
|
||||
provider?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
id?: string;
|
||||
key: string;
|
||||
}) {
|
||||
const providerKey = (params.provider?.trim().toLowerCase() || "group").trim();
|
||||
const room = params.room?.trim();
|
||||
const space = params.space?.trim();
|
||||
const subject = params.subject?.trim();
|
||||
const detail =
|
||||
(room && space
|
||||
? `${space}${room.startsWith("#") ? "" : "#"}${room}`
|
||||
: room || subject || space || "") || "";
|
||||
const fallbackId = params.id?.trim() || params.key.replace(/^group:/, "");
|
||||
const rawLabel = detail || fallbackId;
|
||||
let token = normalizeGroupLabel(rawLabel);
|
||||
if (!token) {
|
||||
token = normalizeGroupLabel(shortenGroupId(rawLabel));
|
||||
}
|
||||
if (!params.room && token.startsWith("#")) {
|
||||
token = token.replace(/^#+/, "");
|
||||
}
|
||||
if (
|
||||
token &&
|
||||
!/^[@#]/.test(token) &&
|
||||
!token.startsWith("g-") &&
|
||||
!token.includes("#")
|
||||
) {
|
||||
token = `g-${token}`;
|
||||
}
|
||||
return token ? `${providerKey}:${token}` : providerKey;
|
||||
}
|
||||
|
||||
export function resolveGroupSessionKey(
|
||||
ctx: MsgContext,
|
||||
): GroupKeyResolution | null {
|
||||
const from = typeof ctx.From === "string" ? ctx.From.trim() : "";
|
||||
if (!from) return null;
|
||||
const chatType = ctx.ChatType?.trim().toLowerCase();
|
||||
const isGroup =
|
||||
chatType === "group" ||
|
||||
from.startsWith("group:") ||
|
||||
from.includes("@g.us") ||
|
||||
from.includes(":group:") ||
|
||||
from.includes(":channel:");
|
||||
if (!isGroup) return null;
|
||||
|
||||
const providerHint = ctx.Provider?.trim().toLowerCase();
|
||||
const hasLegacyGroupPrefix = from.startsWith("group:");
|
||||
const raw = (
|
||||
hasLegacyGroupPrefix ? from.slice("group:".length) : from
|
||||
).trim();
|
||||
|
||||
let provider: string | undefined;
|
||||
let kind: "group" | "channel" | undefined;
|
||||
let id = "";
|
||||
|
||||
const parseKind = (value: string) => {
|
||||
if (value === "channel") return "channel";
|
||||
return "group";
|
||||
};
|
||||
|
||||
const parseParts = (parts: string[]) => {
|
||||
if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) {
|
||||
provider = parts[0];
|
||||
if (parts.length >= 3) {
|
||||
const kindCandidate = parts[1];
|
||||
if (["group", "channel"].includes(kindCandidate)) {
|
||||
kind = parseKind(kindCandidate);
|
||||
id = parts.slice(2).join(":");
|
||||
} else {
|
||||
id = parts.slice(1).join(":");
|
||||
}
|
||||
} else {
|
||||
id = parts[1];
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (parts.length >= 2 && ["group", "channel"].includes(parts[0])) {
|
||||
kind = parseKind(parts[0]);
|
||||
id = parts.slice(1).join(":");
|
||||
}
|
||||
};
|
||||
|
||||
if (hasLegacyGroupPrefix) {
|
||||
const legacyParts = raw.split(":").filter(Boolean);
|
||||
if (legacyParts.length > 1) {
|
||||
parseParts(legacyParts);
|
||||
} else {
|
||||
id = raw;
|
||||
}
|
||||
} else if (from.includes("@g.us") && !from.includes(":")) {
|
||||
id = from;
|
||||
} else {
|
||||
parseParts(from.split(":").filter(Boolean));
|
||||
if (!id) {
|
||||
id = raw || from;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedProvider = provider ?? providerHint;
|
||||
if (!resolvedProvider) {
|
||||
const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`;
|
||||
return {
|
||||
key: legacy,
|
||||
id: raw || from,
|
||||
legacyKey: legacy,
|
||||
chatType: "group",
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedKind = kind === "channel" ? "channel" : "group";
|
||||
const key = `${resolvedProvider}:${resolvedKind}:${id || raw || from}`;
|
||||
let legacyKey: string | undefined;
|
||||
if (hasLegacyGroupPrefix || from.includes("@g.us")) {
|
||||
legacyKey = `group:${id || raw || from}`;
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
legacyKey,
|
||||
channel: resolvedProvider,
|
||||
id: id || raw || from,
|
||||
chatType: resolvedKind === "channel" ? "room" : "group",
|
||||
};
|
||||
}
|
||||
65
src/config/sessions/main-session.ts
Normal file
65
src/config/sessions/main-session.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
DEFAULT_AGENT_ID,
|
||||
normalizeAgentId,
|
||||
normalizeMainKey,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
import type { SessionScope } from "./types.js";
|
||||
|
||||
export function resolveMainSessionKey(cfg?: {
|
||||
session?: { scope?: SessionScope; mainKey?: string };
|
||||
agents?: { list?: Array<{ id?: string; default?: boolean }> };
|
||||
}): string {
|
||||
if (cfg?.session?.scope === "global") return "global";
|
||||
const agents = cfg?.agents?.list ?? [];
|
||||
const defaultAgentId =
|
||||
agents.find((agent) => agent?.default)?.id ??
|
||||
agents[0]?.id ??
|
||||
DEFAULT_AGENT_ID;
|
||||
const agentId = normalizeAgentId(defaultAgentId);
|
||||
const mainKey = normalizeMainKey(cfg?.session?.mainKey);
|
||||
return buildAgentMainSessionKey({ agentId, mainKey });
|
||||
}
|
||||
|
||||
export function resolveMainSessionKeyFromConfig(): string {
|
||||
return resolveMainSessionKey(loadConfig());
|
||||
}
|
||||
|
||||
export { resolveAgentIdFromSessionKey };
|
||||
|
||||
export function resolveAgentMainSessionKey(params: {
|
||||
cfg?: { session?: { mainKey?: string } };
|
||||
agentId: string;
|
||||
}): string {
|
||||
const mainKey = normalizeMainKey(params.cfg?.session?.mainKey);
|
||||
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
|
||||
}
|
||||
|
||||
export function canonicalizeMainSessionAlias(params: {
|
||||
cfg?: { session?: { scope?: SessionScope; mainKey?: string } };
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
}): string {
|
||||
const raw = params.sessionKey.trim();
|
||||
if (!raw) return raw;
|
||||
|
||||
const agentId = normalizeAgentId(params.agentId);
|
||||
const mainKey = normalizeMainKey(params.cfg?.session?.mainKey);
|
||||
const agentMainSessionKey = buildAgentMainSessionKey({ agentId, mainKey });
|
||||
const agentMainAliasKey = buildAgentMainSessionKey({
|
||||
agentId,
|
||||
mainKey: "main",
|
||||
});
|
||||
|
||||
const isMainAlias =
|
||||
raw === "main" ||
|
||||
raw === mainKey ||
|
||||
raw === agentMainSessionKey ||
|
||||
raw === agentMainAliasKey;
|
||||
|
||||
if (params.cfg?.session?.scope === "global" && isMainAlias) return "global";
|
||||
if (isMainAlias) return agentMainSessionKey;
|
||||
return raw;
|
||||
}
|
||||
75
src/config/sessions/paths.ts
Normal file
75
src/config/sessions/paths.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
normalizeAgentId,
|
||||
} from "../../routing/session-key.js";
|
||||
import { resolveStateDir } from "../paths.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
function resolveAgentSessionsDir(
|
||||
agentId?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = os.homedir,
|
||||
): string {
|
||||
const root = resolveStateDir(env, homedir);
|
||||
const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID);
|
||||
return path.join(root, "agents", id, "sessions");
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = os.homedir,
|
||||
): string {
|
||||
return resolveAgentSessionsDir(DEFAULT_AGENT_ID, env, homedir);
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptsDirForAgent(
|
||||
agentId?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = os.homedir,
|
||||
): string {
|
||||
return resolveAgentSessionsDir(agentId, env, homedir);
|
||||
}
|
||||
|
||||
export function resolveDefaultSessionStorePath(agentId?: string): string {
|
||||
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptPath(
|
||||
sessionId: string,
|
||||
agentId?: string,
|
||||
topicId?: number,
|
||||
): string {
|
||||
const fileName =
|
||||
topicId !== undefined
|
||||
? `${sessionId}-topic-${topicId}.jsonl`
|
||||
: `${sessionId}.jsonl`;
|
||||
return path.join(resolveAgentSessionsDir(agentId), fileName);
|
||||
}
|
||||
|
||||
export function resolveSessionFilePath(
|
||||
sessionId: string,
|
||||
entry?: SessionEntry,
|
||||
opts?: { agentId?: string },
|
||||
): string {
|
||||
const candidate = entry?.sessionFile?.trim();
|
||||
return candidate
|
||||
? candidate
|
||||
: resolveSessionTranscriptPath(sessionId, opts?.agentId);
|
||||
}
|
||||
|
||||
export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
|
||||
const agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID);
|
||||
if (!store) return resolveDefaultSessionStorePath(agentId);
|
||||
if (store.includes("{agentId}")) {
|
||||
const expanded = store.replaceAll("{agentId}", agentId);
|
||||
if (expanded.startsWith("~")) {
|
||||
return path.resolve(expanded.replace(/^~(?=$|[\\/])/, os.homedir()));
|
||||
}
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
if (store.startsWith("~"))
|
||||
return path.resolve(store.replace(/^~(?=$|[\\/])/, os.homedir()));
|
||||
return path.resolve(store);
|
||||
}
|
||||
44
src/config/sessions/session-key.ts
Normal file
44
src/config/sessions/session-key.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
DEFAULT_AGENT_ID,
|
||||
normalizeMainKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { normalizeE164 } from "../../utils.js";
|
||||
import { resolveGroupSessionKey } from "./group.js";
|
||||
import type { SessionScope } from "./types.js";
|
||||
|
||||
// Decide which session bucket to use (per-sender vs global).
|
||||
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
||||
if (scope === "global") return "global";
|
||||
const resolvedGroup = resolveGroupSessionKey(ctx);
|
||||
if (resolvedGroup) return resolvedGroup.key;
|
||||
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
||||
return from || "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the session key with a canonical direct-chat bucket (default: "main").
|
||||
* All non-group direct chats collapse to this bucket; groups stay isolated.
|
||||
*/
|
||||
export function resolveSessionKey(
|
||||
scope: SessionScope,
|
||||
ctx: MsgContext,
|
||||
mainKey?: string,
|
||||
) {
|
||||
const explicit = ctx.SessionKey?.trim();
|
||||
if (explicit) return explicit;
|
||||
const raw = deriveSessionKey(scope, ctx);
|
||||
if (scope === "global") return raw;
|
||||
const canonicalMainKey = normalizeMainKey(mainKey);
|
||||
const canonical = buildAgentMainSessionKey({
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
mainKey: canonicalMainKey,
|
||||
});
|
||||
const isGroup =
|
||||
raw.startsWith("group:") ||
|
||||
raw.includes(":group:") ||
|
||||
raw.includes(":channel:");
|
||||
if (!isGroup) return canonical;
|
||||
return `agent:${DEFAULT_AGENT_ID}:${raw}`;
|
||||
}
|
||||
299
src/config/sessions/store.ts
Normal file
299
src/config/sessions/store.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import JSON5 from "json5";
|
||||
import {
|
||||
getFileMtimeMs,
|
||||
isCacheEnabled,
|
||||
resolveCacheTtlMs,
|
||||
} from "../cache-utils.js";
|
||||
import { mergeSessionEntry, type SessionEntry } from "./types.js";
|
||||
|
||||
// ============================================================================
|
||||
// Session Store Cache with TTL Support
|
||||
// ============================================================================
|
||||
|
||||
type SessionStoreCacheEntry = {
|
||||
store: Record<string, SessionEntry>;
|
||||
loadedAt: number;
|
||||
storePath: string;
|
||||
mtimeMs?: number;
|
||||
};
|
||||
|
||||
const SESSION_STORE_CACHE = new Map<string, SessionStoreCacheEntry>();
|
||||
const DEFAULT_SESSION_STORE_TTL_MS = 45_000; // 45 seconds (between 30-60s)
|
||||
|
||||
function getSessionStoreTtl(): number {
|
||||
return resolveCacheTtlMs({
|
||||
envValue: process.env.CLAWDBOT_SESSION_CACHE_TTL_MS,
|
||||
defaultTtlMs: DEFAULT_SESSION_STORE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function isSessionStoreCacheEnabled(): boolean {
|
||||
return isCacheEnabled(getSessionStoreTtl());
|
||||
}
|
||||
|
||||
function isSessionStoreCacheValid(entry: SessionStoreCacheEntry): boolean {
|
||||
const now = Date.now();
|
||||
const ttl = getSessionStoreTtl();
|
||||
return now - entry.loadedAt <= ttl;
|
||||
}
|
||||
|
||||
function invalidateSessionStoreCache(storePath: string): void {
|
||||
SESSION_STORE_CACHE.delete(storePath);
|
||||
}
|
||||
|
||||
export function clearSessionStoreCacheForTest(): void {
|
||||
SESSION_STORE_CACHE.clear();
|
||||
}
|
||||
|
||||
export function loadSessionStore(
|
||||
storePath: string,
|
||||
): Record<string, SessionEntry> {
|
||||
// Check cache first if enabled
|
||||
if (isSessionStoreCacheEnabled()) {
|
||||
const cached = SESSION_STORE_CACHE.get(storePath);
|
||||
if (cached && isSessionStoreCacheValid(cached)) {
|
||||
const currentMtimeMs = getFileMtimeMs(storePath);
|
||||
if (currentMtimeMs === cached.mtimeMs) {
|
||||
// Return a shallow copy to prevent external mutations affecting cache
|
||||
return { ...cached.store };
|
||||
}
|
||||
invalidateSessionStoreCache(storePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or disabled - load from disk
|
||||
let store: Record<string, SessionEntry> = {};
|
||||
let mtimeMs = getFileMtimeMs(storePath);
|
||||
try {
|
||||
const raw = fs.readFileSync(storePath, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
store = parsed as Record<string, SessionEntry>;
|
||||
}
|
||||
mtimeMs = getFileMtimeMs(storePath) ?? mtimeMs;
|
||||
} catch {
|
||||
// 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, {
|
||||
store: { ...store }, // Store a copy to prevent external mutations
|
||||
loadedAt: Date.now(),
|
||||
storePath,
|
||||
mtimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
async function saveSessionStoreUnlocked(
|
||||
storePath: string,
|
||||
store: Record<string, SessionEntry>,
|
||||
): Promise<void> {
|
||||
// Invalidate cache on write to ensure consistency
|
||||
invalidateSessionStoreCache(storePath);
|
||||
|
||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||
const json = JSON.stringify(store, null, 2);
|
||||
|
||||
// Windows: avoid atomic rename swaps (can be flaky under concurrent access).
|
||||
// We serialize writers via the session-store lock instead.
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
await fs.promises.writeFile(storePath, json, "utf-8");
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOENT") return;
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.promises.writeFile(tmp, json, "utf-8");
|
||||
await fs.promises.rename(tmp, storePath);
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
|
||||
if (code === "ENOENT") {
|
||||
// In tests the temp session-store directory may be deleted while writes are in-flight.
|
||||
// Best-effort: try a direct write (recreating the parent dir), otherwise ignore.
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.promises.writeFile(storePath, json, "utf-8");
|
||||
} catch (err2) {
|
||||
const code2 =
|
||||
err2 && typeof err2 === "object" && "code" in err2
|
||||
? String((err2 as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code2 === "ENOENT") return;
|
||||
throw err2;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSessionStore(
|
||||
storePath: string,
|
||||
store: Record<string, SessionEntry>,
|
||||
): Promise<void> {
|
||||
await withSessionStoreLock(storePath, async () => {
|
||||
await saveSessionStoreUnlocked(storePath, store);
|
||||
});
|
||||
}
|
||||
|
||||
type SessionStoreLockOptions = {
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
staleMs?: number;
|
||||
};
|
||||
|
||||
async function withSessionStoreLock<T>(
|
||||
storePath: string,
|
||||
fn: () => Promise<T>,
|
||||
opts: SessionStoreLockOptions = {},
|
||||
): Promise<T> {
|
||||
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? 25;
|
||||
const staleMs = opts.staleMs ?? 30_000;
|
||||
const lockPath = `${storePath}.lock`;
|
||||
const startedAt = Date.now();
|
||||
|
||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const handle = await fs.promises.open(lockPath, "wx");
|
||||
try {
|
||||
await handle.writeFile(
|
||||
JSON.stringify({ pid: process.pid, startedAt: Date.now() }),
|
||||
"utf-8",
|
||||
);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
await handle.close();
|
||||
break;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOENT") {
|
||||
// Store directory may be deleted/recreated in tests while writes are in-flight.
|
||||
// Best-effort: recreate the parent dir and retry until timeout.
|
||||
await fs.promises
|
||||
.mkdir(path.dirname(storePath), { recursive: true })
|
||||
.catch(() => undefined);
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
continue;
|
||||
}
|
||||
if (code !== "EEXIST") throw err;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - startedAt > timeoutMs) {
|
||||
throw new Error(`timeout acquiring session store lock: ${lockPath}`);
|
||||
}
|
||||
|
||||
// Best-effort stale lock eviction (e.g. crashed process).
|
||||
try {
|
||||
const st = await fs.promises.stat(lockPath);
|
||||
const ageMs = now - st.mtimeMs;
|
||||
if (ageMs > staleMs) {
|
||||
await fs.promises.unlink(lockPath);
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await fs.promises.unlink(lockPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSessionStoreEntry(params: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
update: (entry: SessionEntry) => Promise<Partial<SessionEntry> | null>;
|
||||
}): Promise<SessionEntry | null> {
|
||||
const { storePath, sessionKey, update } = params;
|
||||
return await withSessionStoreLock(storePath, async () => {
|
||||
const store = loadSessionStore(storePath);
|
||||
const existing = store[sessionKey];
|
||||
if (!existing) return null;
|
||||
const patch = await update(existing);
|
||||
if (!patch) return existing;
|
||||
const next = mergeSessionEntry(existing, patch);
|
||||
store[sessionKey] = next;
|
||||
await saveSessionStoreUnlocked(storePath, store);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateLastRoute(params: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
channel: SessionEntry["lastChannel"];
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
}) {
|
||||
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),
|
||||
lastChannel: channel,
|
||||
lastTo: to?.trim() ? to.trim() : undefined,
|
||||
lastAccountId: accountId?.trim()
|
||||
? accountId.trim()
|
||||
: existing?.lastAccountId,
|
||||
});
|
||||
store[sessionKey] = next;
|
||||
await saveSessionStoreUnlocked(storePath, store);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
97
src/config/sessions/types.ts
Normal file
97
src/config/sessions/types.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
|
||||
export type SessionChannelId = ChannelId | "webchat";
|
||||
|
||||
export type SessionChatType = "direct" | "group" | "room";
|
||||
|
||||
export type SessionEntry = {
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
sessionFile?: string;
|
||||
/** Parent session key that spawned this session (used for sandbox session-tool scoping). */
|
||||
spawnedBy?: string;
|
||||
systemSent?: boolean;
|
||||
abortedLastRun?: boolean;
|
||||
chatType?: SessionChatType;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
reasoningLevel?: string;
|
||||
elevatedLevel?: string;
|
||||
responseUsage?: "on" | "off";
|
||||
providerOverride?: string;
|
||||
modelOverride?: string;
|
||||
authProfileOverride?: string;
|
||||
groupActivation?: "mention" | "always";
|
||||
groupActivationNeedsSystemIntro?: boolean;
|
||||
sendPolicy?: "allow" | "deny";
|
||||
queueMode?:
|
||||
| "steer"
|
||||
| "followup"
|
||||
| "collect"
|
||||
| "steer-backlog"
|
||||
| "steer+backlog"
|
||||
| "queue"
|
||||
| "interrupt";
|
||||
queueDebounceMs?: number;
|
||||
queueCap?: number;
|
||||
queueDrop?: "old" | "new" | "summarize";
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
contextTokens?: number;
|
||||
compactionCount?: number;
|
||||
memoryFlushAt?: number;
|
||||
memoryFlushCompactionCount?: number;
|
||||
cliSessionIds?: Record<string, string>;
|
||||
claudeCliSessionId?: string;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
channel?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
lastChannel?: SessionChannelId;
|
||||
lastTo?: string;
|
||||
lastAccountId?: string;
|
||||
skillsSnapshot?: SessionSkillSnapshot;
|
||||
};
|
||||
|
||||
export function mergeSessionEntry(
|
||||
existing: SessionEntry | undefined,
|
||||
patch: Partial<SessionEntry>,
|
||||
): SessionEntry {
|
||||
const sessionId =
|
||||
patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID();
|
||||
const updatedAt = Math.max(
|
||||
existing?.updatedAt ?? 0,
|
||||
patch.updatedAt ?? 0,
|
||||
Date.now(),
|
||||
);
|
||||
if (!existing) return { ...patch, sessionId, updatedAt };
|
||||
return { ...existing, ...patch, sessionId, updatedAt };
|
||||
}
|
||||
|
||||
export type GroupKeyResolution = {
|
||||
key: string;
|
||||
legacyKey?: string;
|
||||
channel?: string;
|
||||
id?: string;
|
||||
chatType?: SessionChatType;
|
||||
};
|
||||
|
||||
export type SessionSkillSnapshot = {
|
||||
prompt: string;
|
||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||
resolvedSkills?: Skill[];
|
||||
};
|
||||
|
||||
export const DEFAULT_RESET_TRIGGER = "/new";
|
||||
export const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"];
|
||||
export const DEFAULT_IDLE_MINUTES = 60;
|
||||
40
src/config/test-helpers.ts
Normal file
40
src/config/test-helpers.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
|
||||
export async function withTempHome<T>(
|
||||
fn: (home: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "clawdbot-config-" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to test env var overrides. Saves/restores env vars and resets modules.
|
||||
*/
|
||||
export async function withEnvOverride<T>(
|
||||
overrides: Record<string, string | undefined>,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const key of Object.keys(overrides)) {
|
||||
saved[key] = process.env[key];
|
||||
if (overrides[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = overrides[key];
|
||||
}
|
||||
}
|
||||
vi.resetModules();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(saved)) {
|
||||
if (saved[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = saved[key];
|
||||
}
|
||||
}
|
||||
vi.resetModules();
|
||||
}
|
||||
}
|
||||
239
src/config/types.agent-defaults.ts
Normal file
239
src/config/types.agent-defaults.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import type {
|
||||
BlockStreamingChunkConfig,
|
||||
BlockStreamingCoalesceConfig,
|
||||
HumanDelayConfig,
|
||||
TypingMode,
|
||||
} from "./types.base.js";
|
||||
import type {
|
||||
SandboxBrowserSettings,
|
||||
SandboxDockerSettings,
|
||||
SandboxPruneSettings,
|
||||
} from "./types.sandbox.js";
|
||||
import type { MemorySearchConfig } from "./types.tools.js";
|
||||
|
||||
export type AgentModelEntryConfig = {
|
||||
alias?: string;
|
||||
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AgentModelListConfig = {
|
||||
primary?: string;
|
||||
fallbacks?: string[];
|
||||
};
|
||||
|
||||
export type AgentContextPruningConfig = {
|
||||
mode?: "off" | "adaptive" | "aggressive";
|
||||
keepLastAssistants?: number;
|
||||
softTrimRatio?: number;
|
||||
hardClearRatio?: number;
|
||||
minPrunableToolChars?: number;
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
softTrim?: {
|
||||
maxChars?: number;
|
||||
headChars?: number;
|
||||
tailChars?: number;
|
||||
};
|
||||
hardClear?: {
|
||||
enabled?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type CliBackendConfig = {
|
||||
/** CLI command to execute (absolute path or on PATH). */
|
||||
command: string;
|
||||
/** Base args applied to every invocation. */
|
||||
args?: string[];
|
||||
/** Output parsing mode (default: json). */
|
||||
output?: "json" | "text" | "jsonl";
|
||||
/** Output parsing mode when resuming a CLI session. */
|
||||
resumeOutput?: "json" | "text" | "jsonl";
|
||||
/** Prompt input mode (default: arg). */
|
||||
input?: "arg" | "stdin";
|
||||
/** Max prompt length for arg mode (if exceeded, stdin is used). */
|
||||
maxPromptArgChars?: number;
|
||||
/** Extra env vars injected for this CLI. */
|
||||
env?: Record<string, string>;
|
||||
/** Env vars to remove before launching this CLI. */
|
||||
clearEnv?: string[];
|
||||
/** Flag used to pass model id (e.g. --model). */
|
||||
modelArg?: string;
|
||||
/** Model aliases mapping (config model id → CLI model id). */
|
||||
modelAliases?: Record<string, string>;
|
||||
/** Flag used to pass session id (e.g. --session-id). */
|
||||
sessionArg?: string;
|
||||
/** Extra args used when resuming a session (use {sessionId} placeholder). */
|
||||
sessionArgs?: string[];
|
||||
/** Alternate args to use when resuming a session (use {sessionId} placeholder). */
|
||||
resumeArgs?: string[];
|
||||
/** When to pass session ids. */
|
||||
sessionMode?: "always" | "existing" | "none";
|
||||
/** JSON fields to read session id from (in order). */
|
||||
sessionIdFields?: string[];
|
||||
/** Flag used to pass system prompt. */
|
||||
systemPromptArg?: string;
|
||||
/** System prompt behavior (append vs replace). */
|
||||
systemPromptMode?: "append" | "replace";
|
||||
/** When to send system prompt. */
|
||||
systemPromptWhen?: "first" | "always" | "never";
|
||||
/** Flag used to pass image paths. */
|
||||
imageArg?: string;
|
||||
/** How to pass multiple images. */
|
||||
imageMode?: "repeat" | "list";
|
||||
/** Serialize runs for this CLI. */
|
||||
serialize?: boolean;
|
||||
};
|
||||
|
||||
export type AgentDefaultsConfig = {
|
||||
/** Primary model and fallbacks (provider/model). */
|
||||
model?: AgentModelListConfig;
|
||||
/** Optional image-capable model and fallbacks (provider/model). */
|
||||
imageModel?: AgentModelListConfig;
|
||||
/** Model catalog with optional aliases (full provider/model keys). */
|
||||
models?: Record<string, AgentModelEntryConfig>;
|
||||
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
||||
workspace?: string;
|
||||
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
|
||||
skipBootstrap?: boolean;
|
||||
/** Max chars for injected bootstrap files before truncation (default: 20000). */
|
||||
bootstrapMaxChars?: number;
|
||||
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
|
||||
userTimezone?: string;
|
||||
/** Optional display-only context window override (used for % in status UIs). */
|
||||
contextTokens?: number;
|
||||
/** Optional CLI backends for text-only fallback (claude-cli, etc.). */
|
||||
cliBackends?: Record<string, CliBackendConfig>;
|
||||
/** Opt-in: prune old tool results from the LLM context to reduce token usage. */
|
||||
contextPruning?: AgentContextPruningConfig;
|
||||
/** Compaction tuning and pre-compaction memory flush behavior. */
|
||||
compaction?: AgentCompactionConfig;
|
||||
/** Vector memory search configuration (per-agent overrides supported). */
|
||||
memorySearch?: MemorySearchConfig;
|
||||
/** Default thinking level when no /think directive is present. */
|
||||
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
/** Default verbose level when no /verbose directive is present. */
|
||||
verboseDefault?: "off" | "on";
|
||||
/** Default elevated level when no /elevated directive is present. */
|
||||
elevatedDefault?: "off" | "on";
|
||||
/** Default block streaming level when no override is present. */
|
||||
blockStreamingDefault?: "off" | "on";
|
||||
/**
|
||||
* Block streaming boundary:
|
||||
* - "text_end": end of each assistant text content block (before tool calls)
|
||||
* - "message_end": end of the whole assistant message (may include tool blocks)
|
||||
*/
|
||||
blockStreamingBreak?: "text_end" | "message_end";
|
||||
/** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */
|
||||
blockStreamingChunk?: BlockStreamingChunkConfig;
|
||||
/**
|
||||
* Block reply coalescing (merge streamed chunks before send).
|
||||
* idleMs: wait time before flushing when idle.
|
||||
*/
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** Human-like delay between block replies. */
|
||||
humanDelay?: HumanDelayConfig;
|
||||
timeoutSeconds?: number;
|
||||
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
|
||||
mediaMaxMb?: number;
|
||||
typingIntervalSeconds?: number;
|
||||
/** Typing indicator start mode (never|instant|thinking|message). */
|
||||
typingMode?: TypingMode;
|
||||
/** Periodic background heartbeat runs. */
|
||||
heartbeat?: {
|
||||
/** Heartbeat interval (duration string, default unit: minutes; default: 30m). */
|
||||
every?: string;
|
||||
/** Heartbeat model override (provider/model). */
|
||||
model?: string;
|
||||
/** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */
|
||||
target?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "msteams"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "none";
|
||||
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
|
||||
to?: string;
|
||||
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."). */
|
||||
prompt?: string;
|
||||
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
|
||||
ackMaxChars?: number;
|
||||
/**
|
||||
* When enabled, deliver the model's reasoning payload for heartbeat runs (when available)
|
||||
* as a separate message prefixed with `Reasoning:` (same as `/reasoning on`).
|
||||
*
|
||||
* Default: false (only the final heartbeat payload is delivered).
|
||||
*/
|
||||
includeReasoning?: boolean;
|
||||
};
|
||||
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
|
||||
maxConcurrent?: number;
|
||||
/** Sub-agent defaults (spawned via sessions_spawn). */
|
||||
subagents?: {
|
||||
/** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */
|
||||
maxConcurrent?: number;
|
||||
/** Auto-archive sub-agent sessions after N minutes (default: 60). */
|
||||
archiveAfterMinutes?: number;
|
||||
/** Default model selection for spawned sub-agents (string or {primary,fallbacks}). */
|
||||
model?: string | { primary?: string; fallbacks?: string[] };
|
||||
};
|
||||
/** Optional sandbox settings for non-main sessions. */
|
||||
sandbox?: {
|
||||
/** Enable sandboxing for sessions. */
|
||||
mode?: "off" | "non-main" | "all";
|
||||
/**
|
||||
* Agent workspace access inside the sandbox.
|
||||
* - "none": do not mount the agent workspace into the container; use a sandbox workspace under workspaceRoot
|
||||
* - "ro": mount the agent workspace read-only; disables write/edit tools
|
||||
* - "rw": mount the agent workspace read/write; enables write/edit tools
|
||||
*/
|
||||
workspaceAccess?: "none" | "ro" | "rw";
|
||||
/**
|
||||
* Session tools visibility for sandboxed sessions.
|
||||
* - "spawned": only allow session tools to target sessions spawned from this session (default)
|
||||
* - "all": allow session tools to target any session
|
||||
*/
|
||||
sessionToolsVisibility?: "spawned" | "all";
|
||||
/** Container/workspace scope for sandbox isolation. */
|
||||
scope?: "session" | "agent" | "shared";
|
||||
/** Legacy alias for scope ("session" when true, "shared" when false). */
|
||||
perSession?: boolean;
|
||||
/** Root directory for sandbox workspaces. */
|
||||
workspaceRoot?: string;
|
||||
/** Docker-specific sandbox settings. */
|
||||
docker?: SandboxDockerSettings;
|
||||
/** Optional sandboxed browser settings. */
|
||||
browser?: SandboxBrowserSettings;
|
||||
/** Auto-prune sandbox containers. */
|
||||
prune?: SandboxPruneSettings;
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentCompactionMode = "default" | "safeguard";
|
||||
|
||||
export type AgentCompactionConfig = {
|
||||
/** Compaction summarization mode. */
|
||||
mode?: AgentCompactionMode;
|
||||
/** Minimum reserve tokens enforced for Pi compaction (0 disables the floor). */
|
||||
reserveTokensFloor?: number;
|
||||
/** Pre-compaction memory flush (agentic turn). Default: enabled. */
|
||||
memoryFlush?: AgentCompactionMemoryFlushConfig;
|
||||
};
|
||||
|
||||
export type AgentCompactionMemoryFlushConfig = {
|
||||
/** Enable the pre-compaction memory flush (default: true). */
|
||||
enabled?: boolean;
|
||||
/** Run the memory flush when context is within this many tokens of the compaction threshold. */
|
||||
softThresholdTokens?: number;
|
||||
/** User prompt used for the memory flush turn (NO_REPLY is enforced if missing). */
|
||||
prompt?: string;
|
||||
/** System prompt appended for the memory flush turn. */
|
||||
systemPrompt?: string;
|
||||
};
|
||||
77
src/config/types.agents.ts
Normal file
77
src/config/types.agents.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { AgentDefaultsConfig } from "./types.agent-defaults.js";
|
||||
import type { HumanDelayConfig, IdentityConfig } from "./types.base.js";
|
||||
import type { GroupChatConfig } from "./types.messages.js";
|
||||
import type {
|
||||
SandboxBrowserSettings,
|
||||
SandboxDockerSettings,
|
||||
SandboxPruneSettings,
|
||||
} from "./types.sandbox.js";
|
||||
import type { AgentToolsConfig, MemorySearchConfig } from "./types.tools.js";
|
||||
|
||||
export type AgentModelConfig =
|
||||
| string
|
||||
| {
|
||||
/** Primary model (provider/model). */
|
||||
primary?: string;
|
||||
/** Per-agent model fallbacks (provider/model). */
|
||||
fallbacks?: string[];
|
||||
};
|
||||
|
||||
export type AgentConfig = {
|
||||
id: string;
|
||||
default?: boolean;
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
model?: AgentModelConfig;
|
||||
memorySearch?: MemorySearchConfig;
|
||||
/** Human-like delay between block replies for this agent. */
|
||||
humanDelay?: HumanDelayConfig;
|
||||
identity?: IdentityConfig;
|
||||
groupChat?: GroupChatConfig;
|
||||
subagents?: {
|
||||
/** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */
|
||||
allowAgents?: string[];
|
||||
/** Per-agent default model for spawned sub-agents (string or {primary,fallbacks}). */
|
||||
model?: string | { primary?: string; fallbacks?: string[] };
|
||||
};
|
||||
sandbox?: {
|
||||
mode?: "off" | "non-main" | "all";
|
||||
/** Agent workspace access inside the sandbox. */
|
||||
workspaceAccess?: "none" | "ro" | "rw";
|
||||
/**
|
||||
* Session tools visibility for sandboxed sessions.
|
||||
* - "spawned": only allow session tools to target sessions spawned from this session (default)
|
||||
* - "all": allow session tools to target any session
|
||||
*/
|
||||
sessionToolsVisibility?: "spawned" | "all";
|
||||
/** Container/workspace scope for sandbox isolation. */
|
||||
scope?: "session" | "agent" | "shared";
|
||||
/** Legacy alias for scope ("session" when true, "shared" when false). */
|
||||
perSession?: boolean;
|
||||
workspaceRoot?: string;
|
||||
/** Docker-specific sandbox overrides for this agent. */
|
||||
docker?: SandboxDockerSettings;
|
||||
/** Optional sandboxed browser overrides for this agent. */
|
||||
browser?: SandboxBrowserSettings;
|
||||
/** Auto-prune overrides for this agent. */
|
||||
prune?: SandboxPruneSettings;
|
||||
};
|
||||
tools?: AgentToolsConfig;
|
||||
};
|
||||
|
||||
export type AgentsConfig = {
|
||||
defaults?: AgentDefaultsConfig;
|
||||
list?: AgentConfig[];
|
||||
};
|
||||
|
||||
export type AgentBinding = {
|
||||
agentId: string;
|
||||
match: {
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
peer?: { kind: "dm" | "group" | "channel"; id: string };
|
||||
guildId?: string;
|
||||
teamId?: string;
|
||||
};
|
||||
};
|
||||
29
src/config/types.auth.ts
Normal file
29
src/config/types.auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type AuthProfileConfig = {
|
||||
provider: string;
|
||||
/**
|
||||
* Credential type expected in auth-profiles.json for this profile id.
|
||||
* - api_key: static provider API key
|
||||
* - oauth: refreshable OAuth credentials (access+refresh+expires)
|
||||
* - token: static bearer-style token (optionally expiring; no refresh)
|
||||
*/
|
||||
mode: "api_key" | "oauth" | "token";
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type AuthConfig = {
|
||||
profiles?: Record<string, AuthProfileConfig>;
|
||||
order?: Record<string, string[]>;
|
||||
cooldowns?: {
|
||||
/** Default billing backoff (hours). Default: 5. */
|
||||
billingBackoffHours?: number;
|
||||
/** Optional per-provider billing backoff (hours). */
|
||||
billingBackoffHoursByProvider?: Record<string, number>;
|
||||
/** Billing backoff cap (hours). Default: 24. */
|
||||
billingMaxHours?: number;
|
||||
/**
|
||||
* Failure window for backoff counters (hours). If no failures occur within
|
||||
* this window, counters reset. Default: 24.
|
||||
*/
|
||||
failureWindowHours?: number;
|
||||
};
|
||||
};
|
||||
113
src/config/types.base.ts
Normal file
113
src/config/types.base.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
export type ReplyMode = "text" | "command";
|
||||
export type TypingMode = "never" | "instant" | "thinking" | "message";
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
export type ReplyToMode = "off" | "first" | "all";
|
||||
export type GroupPolicy = "open" | "disabled" | "allowlist";
|
||||
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
||||
|
||||
export type OutboundRetryConfig = {
|
||||
/** Max retry attempts for outbound requests (default: 3). */
|
||||
attempts?: number;
|
||||
/** Minimum retry delay in ms (default: 300-500ms depending on provider). */
|
||||
minDelayMs?: number;
|
||||
/** Maximum retry delay cap in ms (default: 30000). */
|
||||
maxDelayMs?: number;
|
||||
/** Jitter factor (0-1) applied to delays (default: 0.1). */
|
||||
jitter?: number;
|
||||
};
|
||||
|
||||
export type BlockStreamingCoalesceConfig = {
|
||||
minChars?: number;
|
||||
maxChars?: number;
|
||||
idleMs?: number;
|
||||
};
|
||||
|
||||
export type BlockStreamingChunkConfig = {
|
||||
minChars?: number;
|
||||
maxChars?: number;
|
||||
breakPreference?: "paragraph" | "newline" | "sentence";
|
||||
};
|
||||
|
||||
export type HumanDelayConfig = {
|
||||
/** Delay style for block replies (off|natural|custom). */
|
||||
mode?: "off" | "natural" | "custom";
|
||||
/** Minimum delay in milliseconds (default: 800). */
|
||||
minMs?: number;
|
||||
/** Maximum delay in milliseconds (default: 2500). */
|
||||
maxMs?: number;
|
||||
};
|
||||
|
||||
export type SessionSendPolicyAction = "allow" | "deny";
|
||||
export type SessionSendPolicyMatch = {
|
||||
channel?: string;
|
||||
chatType?: "direct" | "group" | "room";
|
||||
keyPrefix?: string;
|
||||
};
|
||||
export type SessionSendPolicyRule = {
|
||||
action: SessionSendPolicyAction;
|
||||
match?: SessionSendPolicyMatch;
|
||||
};
|
||||
export type SessionSendPolicyConfig = {
|
||||
default?: SessionSendPolicyAction;
|
||||
rules?: SessionSendPolicyRule[];
|
||||
};
|
||||
|
||||
export type SessionConfig = {
|
||||
scope?: SessionScope;
|
||||
resetTriggers?: string[];
|
||||
idleMinutes?: number;
|
||||
heartbeatIdleMinutes?: number;
|
||||
store?: string;
|
||||
typingIntervalSeconds?: number;
|
||||
typingMode?: TypingMode;
|
||||
mainKey?: string;
|
||||
sendPolicy?: SessionSendPolicyConfig;
|
||||
agentToAgent?: {
|
||||
/** Max ping-pong turns between requester/target (0–5). Default: 5. */
|
||||
maxPingPongTurns?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type LoggingConfig = {
|
||||
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
|
||||
file?: string;
|
||||
consoleLevel?:
|
||||
| "silent"
|
||||
| "fatal"
|
||||
| "error"
|
||||
| "warn"
|
||||
| "info"
|
||||
| "debug"
|
||||
| "trace";
|
||||
consoleStyle?: "pretty" | "compact" | "json";
|
||||
/** Redact sensitive tokens in tool summaries. Default: "tools". */
|
||||
redactSensitive?: "off" | "tools";
|
||||
/** Regex patterns used to redact sensitive tokens (defaults apply when unset). */
|
||||
redactPatterns?: string[];
|
||||
};
|
||||
|
||||
export type WebReconnectConfig = {
|
||||
initialMs?: number;
|
||||
maxMs?: number;
|
||||
factor?: number;
|
||||
jitter?: number;
|
||||
maxAttempts?: number; // 0 = unlimited
|
||||
};
|
||||
|
||||
export type WebConfig = {
|
||||
/** If false, do not start the WhatsApp web provider. Default: true. */
|
||||
enabled?: boolean;
|
||||
heartbeatSeconds?: number;
|
||||
reconnect?: WebReconnectConfig;
|
||||
};
|
||||
|
||||
// Provider docking: allowlists keyed by provider id (and internal "webchat").
|
||||
export type AgentElevatedAllowFromConfig = Partial<
|
||||
Record<string, Array<string | number>>
|
||||
>;
|
||||
|
||||
export type IdentityConfig = {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
emoji?: string;
|
||||
};
|
||||
29
src/config/types.browser.ts
Normal file
29
src/config/types.browser.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type BrowserProfileConfig = {
|
||||
/** CDP port for this profile. Allocated once at creation, persisted permanently. */
|
||||
cdpPort?: number;
|
||||
/** CDP URL for this profile (use for remote Chrome). */
|
||||
cdpUrl?: string;
|
||||
/** Profile color (hex). Auto-assigned at creation. */
|
||||
color: string;
|
||||
};
|
||||
export type BrowserConfig = {
|
||||
enabled?: boolean;
|
||||
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
||||
controlUrl?: string;
|
||||
/** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */
|
||||
cdpUrl?: string;
|
||||
/** Accent color for the clawd browser profile (hex). Default: #FF4500 */
|
||||
color?: string;
|
||||
/** Override the browser executable path (macOS/Linux). */
|
||||
executablePath?: string;
|
||||
/** Start Chrome headless (best-effort). Default: false */
|
||||
headless?: boolean;
|
||||
/** Pass --no-sandbox to Chrome (Linux containers). Default: false */
|
||||
noSandbox?: boolean;
|
||||
/** If true: never launch; only attach to an existing browser. Default: false */
|
||||
attachOnly?: boolean;
|
||||
/** Default profile to use when profile param is omitted. Default: "clawd" */
|
||||
defaultProfile?: string;
|
||||
/** Named browser profiles with explicit CDP ports or URLs. */
|
||||
profiles?: Record<string, BrowserProfileConfig>;
|
||||
};
|
||||
17
src/config/types.channels.ts
Normal file
17
src/config/types.channels.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { DiscordConfig } from "./types.discord.js";
|
||||
import type { IMessageConfig } from "./types.imessage.js";
|
||||
import type { MSTeamsConfig } from "./types.msteams.js";
|
||||
import type { SignalConfig } from "./types.signal.js";
|
||||
import type { SlackConfig } from "./types.slack.js";
|
||||
import type { TelegramConfig } from "./types.telegram.js";
|
||||
import type { WhatsAppConfig } from "./types.whatsapp.js";
|
||||
|
||||
export type ChannelsConfig = {
|
||||
whatsapp?: WhatsAppConfig;
|
||||
telegram?: TelegramConfig;
|
||||
discord?: DiscordConfig;
|
||||
slack?: SlackConfig;
|
||||
signal?: SignalConfig;
|
||||
imessage?: IMessageConfig;
|
||||
msteams?: MSTeamsConfig;
|
||||
};
|
||||
98
src/config/types.clawdbot.ts
Normal file
98
src/config/types.clawdbot.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { AgentBinding, AgentsConfig } from "./types.agents.js";
|
||||
import type { AuthConfig } from "./types.auth.js";
|
||||
import type { LoggingConfig, SessionConfig, WebConfig } from "./types.base.js";
|
||||
import type { BrowserConfig } from "./types.browser.js";
|
||||
import type { ChannelsConfig } from "./types.channels.js";
|
||||
import type { CronConfig } from "./types.cron.js";
|
||||
import type {
|
||||
BridgeConfig,
|
||||
CanvasHostConfig,
|
||||
DiscoveryConfig,
|
||||
GatewayConfig,
|
||||
TalkConfig,
|
||||
} from "./types.gateway.js";
|
||||
import type { HooksConfig } from "./types.hooks.js";
|
||||
import type {
|
||||
AudioConfig,
|
||||
BroadcastConfig,
|
||||
CommandsConfig,
|
||||
MessagesConfig,
|
||||
} from "./types.messages.js";
|
||||
import type { ModelsConfig } from "./types.models.js";
|
||||
import type { PluginsConfig } from "./types.plugins.js";
|
||||
import type { SkillsConfig } from "./types.skills.js";
|
||||
import type { ToolsConfig } from "./types.tools.js";
|
||||
|
||||
export type ClawdbotConfig = {
|
||||
auth?: AuthConfig;
|
||||
env?: {
|
||||
/** Opt-in: import missing secrets from a login shell environment (exec `$SHELL -l -c 'env -0'`). */
|
||||
shellEnv?: {
|
||||
enabled?: boolean;
|
||||
/** Timeout for the login shell exec (ms). Default: 15000. */
|
||||
timeoutMs?: number;
|
||||
};
|
||||
/** Inline env vars to apply when not already present in the process env. */
|
||||
vars?: Record<string, string>;
|
||||
/** Sugar: allow env vars directly under env (string values only). */
|
||||
[key: string]:
|
||||
| string
|
||||
| Record<string, string>
|
||||
| { enabled?: boolean; timeoutMs?: number }
|
||||
| undefined;
|
||||
};
|
||||
wizard?: {
|
||||
lastRunAt?: string;
|
||||
lastRunVersion?: string;
|
||||
lastRunCommit?: string;
|
||||
lastRunCommand?: string;
|
||||
lastRunMode?: "local" | "remote";
|
||||
};
|
||||
logging?: LoggingConfig;
|
||||
browser?: BrowserConfig;
|
||||
ui?: {
|
||||
/** Accent color for Clawdbot UI chrome (hex). */
|
||||
seamColor?: string;
|
||||
};
|
||||
skills?: SkillsConfig;
|
||||
plugins?: PluginsConfig;
|
||||
models?: ModelsConfig;
|
||||
agents?: AgentsConfig;
|
||||
tools?: ToolsConfig;
|
||||
bindings?: AgentBinding[];
|
||||
broadcast?: BroadcastConfig;
|
||||
audio?: AudioConfig;
|
||||
messages?: MessagesConfig;
|
||||
commands?: CommandsConfig;
|
||||
session?: SessionConfig;
|
||||
web?: WebConfig;
|
||||
channels?: ChannelsConfig;
|
||||
cron?: CronConfig;
|
||||
hooks?: HooksConfig;
|
||||
bridge?: BridgeConfig;
|
||||
discovery?: DiscoveryConfig;
|
||||
canvasHost?: CanvasHostConfig;
|
||||
talk?: TalkConfig;
|
||||
gateway?: GatewayConfig;
|
||||
};
|
||||
|
||||
export type ConfigValidationIssue = {
|
||||
path: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type LegacyConfigIssue = {
|
||||
path: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ConfigFileSnapshot = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
raw: string | null;
|
||||
parsed: unknown;
|
||||
valid: boolean;
|
||||
config: ClawdbotConfig;
|
||||
issues: ConfigValidationIssue[];
|
||||
legacyIssues: LegacyConfigIssue[];
|
||||
};
|
||||
5
src/config/types.cron.ts
Normal file
5
src/config/types.cron.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type CronConfig = {
|
||||
enabled?: boolean;
|
||||
store?: string;
|
||||
maxConcurrentRuns?: number;
|
||||
};
|
||||
123
src/config/types.discord.ts
Normal file
123
src/config/types.discord.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
OutboundRetryConfig,
|
||||
ReplyToMode,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||
|
||||
export type DiscordDmConfig = {
|
||||
/** If false, ignore all incoming Discord DMs. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Direct message access policy (default: pairing). */
|
||||
policy?: DmPolicy;
|
||||
/** Allowlist for DM senders (ids or names). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** If true, allow group DMs (default: false). */
|
||||
groupEnabled?: boolean;
|
||||
/** Optional allowlist for group DM channels (ids or slugs). */
|
||||
groupChannels?: Array<string | number>;
|
||||
};
|
||||
|
||||
export type DiscordGuildChannelConfig = {
|
||||
allow?: boolean;
|
||||
requireMention?: boolean;
|
||||
/** If specified, only load these skills for this channel. Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** If false, disable the bot for this channel. */
|
||||
enabled?: boolean;
|
||||
/** Optional allowlist for channel senders (ids or names). */
|
||||
users?: Array<string | number>;
|
||||
/** Optional system prompt snippet for this channel. */
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type DiscordReactionNotificationMode =
|
||||
| "off"
|
||||
| "own"
|
||||
| "all"
|
||||
| "allowlist";
|
||||
|
||||
export type DiscordGuildEntry = {
|
||||
slug?: string;
|
||||
requireMention?: boolean;
|
||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||
reactionNotifications?: DiscordReactionNotificationMode;
|
||||
users?: Array<string | number>;
|
||||
channels?: Record<string, DiscordGuildChannelConfig>;
|
||||
};
|
||||
|
||||
export type DiscordActionConfig = {
|
||||
reactions?: boolean;
|
||||
stickers?: boolean;
|
||||
polls?: boolean;
|
||||
permissions?: boolean;
|
||||
messages?: boolean;
|
||||
threads?: boolean;
|
||||
pins?: boolean;
|
||||
search?: boolean;
|
||||
memberInfo?: boolean;
|
||||
roleInfo?: boolean;
|
||||
roles?: boolean;
|
||||
channelInfo?: boolean;
|
||||
voiceStatus?: boolean;
|
||||
events?: boolean;
|
||||
moderation?: boolean;
|
||||
emojiUploads?: boolean;
|
||||
stickerUploads?: boolean;
|
||||
channels?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** Override native command registration for Discord (bool or "auto"). */
|
||||
commands?: ProviderCommandsConfig;
|
||||
/** If false, do not start this Discord account. Default: true. */
|
||||
enabled?: boolean;
|
||||
token?: string;
|
||||
/** Allow bot-authored messages to trigger replies (default: false). */
|
||||
allowBots?: boolean;
|
||||
/**
|
||||
* Controls how guild channel messages are handled:
|
||||
* - "open": guild channels bypass allowlists; mention-gating applies
|
||||
* - "disabled": block all guild channel messages
|
||||
* - "allowlist": only allow channels present in discord.guilds.*.channels
|
||||
*/
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Outbound text chunk size (chars). Default: 2000. */
|
||||
textChunkLimit?: number;
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/**
|
||||
* Soft max line count per Discord message.
|
||||
* Discord clients can clip/collapse very tall messages; splitting by lines
|
||||
* keeps replies readable in-channel. Default: 17.
|
||||
*/
|
||||
maxLinesPerMessage?: number;
|
||||
mediaMaxMb?: number;
|
||||
historyLimit?: number;
|
||||
/** Max DM turns to keep as history context. */
|
||||
dmHistoryLimit?: number;
|
||||
/** Per-DM config overrides keyed by user ID. */
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Retry policy for outbound Discord API calls. */
|
||||
retry?: OutboundRetryConfig;
|
||||
/** Per-action tool gating (default: true for all). */
|
||||
actions?: DiscordActionConfig;
|
||||
/** Control reply threading when reply tags are present (off|first|all). */
|
||||
replyToMode?: ReplyToMode;
|
||||
dm?: DiscordDmConfig;
|
||||
/** New per-guild config keyed by guild id or slug. */
|
||||
guilds?: Record<string, DiscordGuildEntry>;
|
||||
};
|
||||
|
||||
export type DiscordConfig = {
|
||||
/** Optional per-account Discord configuration (multi-account). */
|
||||
accounts?: Record<string, DiscordAccountConfig>;
|
||||
} & DiscordAccountConfig;
|
||||
141
src/config/types.gateway.ts
Normal file
141
src/config/types.gateway.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
export type BridgeBindMode = "auto" | "lan" | "loopback" | "custom";
|
||||
|
||||
export type BridgeConfig = {
|
||||
enabled?: boolean;
|
||||
port?: number;
|
||||
/**
|
||||
* Bind address policy for the node bridge server.
|
||||
* - auto: Tailnet IPv4 if available, else 0.0.0.0 (fallback to all interfaces)
|
||||
* - lan: 0.0.0.0 (all interfaces, no fallback)
|
||||
* - loopback: 127.0.0.1 (local-only)
|
||||
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost on gateway)
|
||||
*/
|
||||
bind?: BridgeBindMode;
|
||||
};
|
||||
|
||||
export type WideAreaDiscoveryConfig = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type DiscoveryConfig = {
|
||||
wideArea?: WideAreaDiscoveryConfig;
|
||||
};
|
||||
|
||||
export type CanvasHostConfig = {
|
||||
enabled?: boolean;
|
||||
/** Directory to serve (default: ~/clawd/canvas). */
|
||||
root?: string;
|
||||
/** HTTP port to listen on (default: 18793). */
|
||||
port?: number;
|
||||
/** Enable live-reload file watching + WS reloads (default: true). */
|
||||
liveReload?: boolean;
|
||||
};
|
||||
|
||||
export type TalkConfig = {
|
||||
/** Default ElevenLabs voice ID for Talk mode. */
|
||||
voiceId?: string;
|
||||
/** Optional voice name -> ElevenLabs voice ID map. */
|
||||
voiceAliases?: Record<string, string>;
|
||||
/** Default ElevenLabs model ID for Talk mode. */
|
||||
modelId?: string;
|
||||
/** Default ElevenLabs output format (e.g. mp3_44100_128). */
|
||||
outputFormat?: string;
|
||||
/** ElevenLabs API key (optional; falls back to ELEVENLABS_API_KEY). */
|
||||
apiKey?: string;
|
||||
/** Stop speaking when user starts talking (default: true). */
|
||||
interruptOnSpeech?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayControlUiConfig = {
|
||||
/** If false, the Gateway will not serve the Control UI (default /). */
|
||||
enabled?: boolean;
|
||||
/** Optional base path prefix for the Control UI (e.g. "/clawdbot"). */
|
||||
basePath?: string;
|
||||
};
|
||||
|
||||
export type GatewayAuthMode = "token" | "password";
|
||||
|
||||
export type GatewayAuthConfig = {
|
||||
/** Authentication mode for Gateway connections. Defaults to token when set. */
|
||||
mode?: GatewayAuthMode;
|
||||
/** Shared token for token mode (stored locally for CLI auth). */
|
||||
token?: string;
|
||||
/** Shared password for password mode (consider env instead). */
|
||||
password?: string;
|
||||
/** Allow Tailscale identity headers when serve mode is enabled. */
|
||||
allowTailscale?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayTailscaleMode = "off" | "serve" | "funnel";
|
||||
|
||||
export type GatewayTailscaleConfig = {
|
||||
/** Tailscale exposure mode for the Gateway control UI. */
|
||||
mode?: GatewayTailscaleMode;
|
||||
/** Reset serve/funnel configuration on shutdown. */
|
||||
resetOnExit?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayRemoteConfig = {
|
||||
/** Remote Gateway WebSocket URL (ws:// or wss://). */
|
||||
url?: string;
|
||||
/** Token for remote auth (when the gateway requires token auth). */
|
||||
token?: string;
|
||||
/** Password for remote auth (when the gateway requires password auth). */
|
||||
password?: string;
|
||||
/** SSH target for tunneling remote Gateway (user@host). */
|
||||
sshTarget?: string;
|
||||
/** SSH identity file path for tunneling remote Gateway. */
|
||||
sshIdentity?: string;
|
||||
};
|
||||
|
||||
export type GatewayReloadMode = "off" | "restart" | "hot" | "hybrid";
|
||||
|
||||
export type GatewayReloadConfig = {
|
||||
/** Reload strategy for config changes (default: hybrid). */
|
||||
mode?: GatewayReloadMode;
|
||||
/** Debounce window for config reloads (ms). Default: 300. */
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
||||
export type GatewayHttpChatCompletionsConfig = {
|
||||
/**
|
||||
* If false, the Gateway will not serve `POST /v1/chat/completions`.
|
||||
* Default: false when absent.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayHttpEndpointsConfig = {
|
||||
chatCompletions?: GatewayHttpChatCompletionsConfig;
|
||||
};
|
||||
|
||||
export type GatewayHttpConfig = {
|
||||
endpoints?: GatewayHttpEndpointsConfig;
|
||||
};
|
||||
|
||||
export type GatewayConfig = {
|
||||
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
|
||||
port?: number;
|
||||
/**
|
||||
* Explicit gateway mode. When set to "remote", local gateway start is disabled.
|
||||
* When set to "local", the CLI may start the gateway locally.
|
||||
*/
|
||||
mode?: "local" | "remote";
|
||||
/**
|
||||
* Bind address policy for the Gateway WebSocket + Control UI HTTP server.
|
||||
* - auto: Tailnet IPv4 if available, else 0.0.0.0 (fallback to all interfaces)
|
||||
* - lan: 0.0.0.0 (all interfaces, no fallback)
|
||||
* - loopback: 127.0.0.1 (local-only)
|
||||
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost)
|
||||
* Default: loopback (127.0.0.1).
|
||||
*/
|
||||
bind?: BridgeBindMode;
|
||||
/** Custom IP address for bind="custom" mode. Fallback: 0.0.0.0. */
|
||||
customBindHost?: string;
|
||||
controlUi?: GatewayControlUiConfig;
|
||||
auth?: GatewayAuthConfig;
|
||||
tailscale?: GatewayTailscaleConfig;
|
||||
remote?: GatewayRemoteConfig;
|
||||
reload?: GatewayReloadConfig;
|
||||
http?: GatewayHttpConfig;
|
||||
};
|
||||
76
src/config/types.hooks.ts
Normal file
76
src/config/types.hooks.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export type HookMappingMatch = {
|
||||
path?: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type HookMappingTransform = {
|
||||
module: string;
|
||||
export?: string;
|
||||
};
|
||||
|
||||
export type HookMappingConfig = {
|
||||
id?: string;
|
||||
match?: HookMappingMatch;
|
||||
action?: "wake" | "agent";
|
||||
wakeMode?: "now" | "next-heartbeat";
|
||||
name?: string;
|
||||
sessionKey?: string;
|
||||
messageTemplate?: string;
|
||||
textTemplate?: string;
|
||||
deliver?: boolean;
|
||||
channel?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "msteams";
|
||||
to?: string;
|
||||
/** Override model for this hook (provider/model or alias). */
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
timeoutSeconds?: number;
|
||||
transform?: HookMappingTransform;
|
||||
};
|
||||
|
||||
export type HooksGmailTailscaleMode = "off" | "serve" | "funnel";
|
||||
|
||||
export type HooksGmailConfig = {
|
||||
account?: string;
|
||||
label?: string;
|
||||
topic?: string;
|
||||
subscription?: string;
|
||||
pushToken?: string;
|
||||
hookUrl?: string;
|
||||
includeBody?: boolean;
|
||||
maxBytes?: number;
|
||||
renewEveryMinutes?: number;
|
||||
serve?: {
|
||||
bind?: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
};
|
||||
tailscale?: {
|
||||
mode?: HooksGmailTailscaleMode;
|
||||
path?: string;
|
||||
/** Optional tailscale serve/funnel target (port, host:port, or full URL). */
|
||||
target?: string;
|
||||
};
|
||||
/** Optional model override for Gmail hook processing (provider/model or alias). */
|
||||
model?: string;
|
||||
/** Optional thinking level override for Gmail hook processing. */
|
||||
thinking?: "off" | "minimal" | "low" | "medium" | "high";
|
||||
};
|
||||
|
||||
export type HooksConfig = {
|
||||
enabled?: boolean;
|
||||
path?: string;
|
||||
token?: string;
|
||||
maxBodyBytes?: number;
|
||||
presets?: string[];
|
||||
transformsDir?: string;
|
||||
mappings?: HookMappingConfig[];
|
||||
gmail?: HooksGmailConfig;
|
||||
};
|
||||
62
src/config/types.imessage.ts
Normal file
62
src/config/types.imessage.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig } from "./types.messages.js";
|
||||
|
||||
export type IMessageAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** If false, do not start this iMessage account. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** imsg CLI binary path (default: imsg). */
|
||||
cliPath?: string;
|
||||
/** Optional Messages db path override. */
|
||||
dbPath?: string;
|
||||
/** Optional default send service (imessage|sms|auto). */
|
||||
service?: "imessage" | "sms" | "auto";
|
||||
/** Optional default region (used when sending SMS). */
|
||||
region?: string;
|
||||
/** Direct message access policy (default: pairing). */
|
||||
dmPolicy?: DmPolicy;
|
||||
/** Optional allowlist for inbound handles or chat_id targets. */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Optional allowlist for group senders or chat_id targets. */
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
/**
|
||||
* Controls how group messages are handled:
|
||||
* - "open": groups bypass allowFrom; mention-gating applies
|
||||
* - "disabled": block all group messages entirely
|
||||
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
|
||||
*/
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Max group messages to keep as history context (0 disables). */
|
||||
historyLimit?: number;
|
||||
/** Max DM turns to keep as history context. */
|
||||
dmHistoryLimit?: number;
|
||||
/** Per-DM config overrides keyed by user ID. */
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Include attachments + reactions in watch payloads. */
|
||||
includeAttachments?: boolean;
|
||||
/** Max outbound media size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
groups?: Record<
|
||||
string,
|
||||
{
|
||||
requireMention?: boolean;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export type IMessageConfig = {
|
||||
/** Optional per-account iMessage configuration (multi-account). */
|
||||
accounts?: Record<string, IMessageAccountConfig>;
|
||||
} & IMessageAccountConfig;
|
||||
90
src/config/types.messages.ts
Normal file
90
src/config/types.messages.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
QueueDropPolicy,
|
||||
QueueMode,
|
||||
QueueModeByProvider,
|
||||
} from "./types.queue.js";
|
||||
|
||||
export type GroupChatConfig = {
|
||||
mentionPatterns?: string[];
|
||||
historyLimit?: number;
|
||||
};
|
||||
|
||||
export type DmConfig = {
|
||||
historyLimit?: number;
|
||||
};
|
||||
|
||||
export type QueueConfig = {
|
||||
mode?: QueueMode;
|
||||
byChannel?: QueueModeByProvider;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
drop?: QueueDropPolicy;
|
||||
};
|
||||
|
||||
export type BroadcastStrategy = "parallel" | "sequential";
|
||||
|
||||
export type BroadcastConfig = {
|
||||
/** Default processing strategy for broadcast peers. */
|
||||
strategy?: BroadcastStrategy;
|
||||
/**
|
||||
* Map peer IDs to arrays of agent IDs that should ALL process messages.
|
||||
*
|
||||
* Note: the index signature includes `undefined` so `strategy?: ...` remains type-safe.
|
||||
*/
|
||||
[peerId: string]: string[] | BroadcastStrategy | undefined;
|
||||
};
|
||||
|
||||
export type AudioConfig = {
|
||||
/** @deprecated Use tools.audio.transcription instead. */
|
||||
transcription?: {
|
||||
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
||||
command: string[];
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type MessagesConfig = {
|
||||
/** @deprecated Use `whatsapp.messagePrefix` (WhatsApp-only inbound prefix). */
|
||||
messagePrefix?: string;
|
||||
/**
|
||||
* Prefix auto-added to all outbound replies.
|
||||
* - string: explicit prefix
|
||||
* - special value: `"auto"` derives `[{agents.list[].identity.name}]` for the routed agent (when set)
|
||||
* Default: none
|
||||
*/
|
||||
responsePrefix?: string;
|
||||
groupChat?: GroupChatConfig;
|
||||
queue?: QueueConfig;
|
||||
/** Emoji reaction used to acknowledge inbound messages (empty disables). */
|
||||
ackReaction?: string;
|
||||
/** When to send ack reactions. Default: "group-mentions". */
|
||||
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all";
|
||||
/** Remove ack reaction after reply is sent (default: false). */
|
||||
removeAckAfterReply?: boolean;
|
||||
};
|
||||
|
||||
export type NativeCommandsSetting = boolean | "auto";
|
||||
|
||||
export type CommandsConfig = {
|
||||
/** Enable native command registration when supported (default: "auto"). */
|
||||
native?: NativeCommandsSetting;
|
||||
/** Enable text command parsing (default: true). */
|
||||
text?: boolean;
|
||||
/** Allow bash chat command (`!`; `/bash` alias) (default: false). */
|
||||
bash?: boolean;
|
||||
/** How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately). */
|
||||
bashForegroundMs?: number;
|
||||
/** Allow /config command (default: false). */
|
||||
config?: boolean;
|
||||
/** Allow /debug command (default: false). */
|
||||
debug?: boolean;
|
||||
/** Allow restart commands/tools (default: false). */
|
||||
restart?: boolean;
|
||||
/** Enforce access-group allowlists/policies for commands (default: true). */
|
||||
useAccessGroups?: boolean;
|
||||
};
|
||||
|
||||
export type ProviderCommandsConfig = {
|
||||
/** Override native command registration for this provider (bool or "auto"). */
|
||||
native?: NativeCommandsSetting;
|
||||
};
|
||||
45
src/config/types.models.ts
Normal file
45
src/config/types.models.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export type ModelApi =
|
||||
| "openai-completions"
|
||||
| "openai-responses"
|
||||
| "anthropic-messages"
|
||||
| "google-generative-ai"
|
||||
| "github-copilot";
|
||||
|
||||
export type ModelCompatConfig = {
|
||||
supportsStore?: boolean;
|
||||
supportsDeveloperRole?: boolean;
|
||||
supportsReasoningEffort?: boolean;
|
||||
maxTokensField?: "max_completion_tokens" | "max_tokens";
|
||||
};
|
||||
|
||||
export type ModelDefinitionConfig = {
|
||||
id: string;
|
||||
name: string;
|
||||
api?: ModelApi;
|
||||
reasoning: boolean;
|
||||
input: Array<"text" | "image">;
|
||||
cost: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
};
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
headers?: Record<string, string>;
|
||||
compat?: ModelCompatConfig;
|
||||
};
|
||||
|
||||
export type ModelProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
api?: ModelApi;
|
||||
headers?: Record<string, string>;
|
||||
authHeader?: boolean;
|
||||
models: ModelDefinitionConfig[];
|
||||
};
|
||||
|
||||
export type ModelsConfig = {
|
||||
mode?: "merge" | "replace";
|
||||
providers?: Record<string, ModelProviderConfig>;
|
||||
};
|
||||
83
src/config/types.msteams.ts
Normal file
83
src/config/types.msteams.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig } from "./types.messages.js";
|
||||
|
||||
export type MSTeamsWebhookConfig = {
|
||||
/** Port for the webhook server. Default: 3978. */
|
||||
port?: number;
|
||||
/** Path for the messages endpoint. Default: /api/messages. */
|
||||
path?: string;
|
||||
};
|
||||
|
||||
/** Reply style for MS Teams messages. */
|
||||
export type MSTeamsReplyStyle = "thread" | "top-level";
|
||||
|
||||
/** Channel-level config for MS Teams. */
|
||||
export type MSTeamsChannelConfig = {
|
||||
/** Require @mention to respond. Default: true. */
|
||||
requireMention?: boolean;
|
||||
/** Reply style: "thread" replies to the message, "top-level" posts a new message. */
|
||||
replyStyle?: MSTeamsReplyStyle;
|
||||
};
|
||||
|
||||
/** Team-level config for MS Teams. */
|
||||
export type MSTeamsTeamConfig = {
|
||||
/** Default requireMention for channels in this team. */
|
||||
requireMention?: boolean;
|
||||
/** Default reply style for channels in this team. */
|
||||
replyStyle?: MSTeamsReplyStyle;
|
||||
/** Per-channel overrides. Key is conversation ID (e.g., "19:...@thread.tacv2"). */
|
||||
channels?: Record<string, MSTeamsChannelConfig>;
|
||||
};
|
||||
|
||||
export type MSTeamsConfig = {
|
||||
/** If false, do not start the MS Teams provider. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** Azure Bot App ID (from Azure Bot registration). */
|
||||
appId?: string;
|
||||
/** Azure Bot App Password / Client Secret. */
|
||||
appPassword?: string;
|
||||
/** Azure AD Tenant ID (for single-tenant bots). */
|
||||
tenantId?: string;
|
||||
/** Webhook server configuration. */
|
||||
webhook?: MSTeamsWebhookConfig;
|
||||
/** Direct message access policy (default: pairing). */
|
||||
dmPolicy?: DmPolicy;
|
||||
/** Allowlist for DM senders (AAD object IDs or UPNs). */
|
||||
allowFrom?: Array<string>;
|
||||
/** Optional allowlist for group/channel senders (AAD object IDs or UPNs). */
|
||||
groupAllowFrom?: Array<string>;
|
||||
/**
|
||||
* Controls how group/channel messages are handled:
|
||||
* - "open": groups bypass allowFrom; mention-gating applies
|
||||
* - "disabled": block all group messages
|
||||
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
|
||||
*/
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/**
|
||||
* Allowed host suffixes for inbound attachment downloads.
|
||||
* Use ["*"] to allow any host (not recommended).
|
||||
*/
|
||||
mediaAllowHosts?: Array<string>;
|
||||
/** Default: require @mention to respond in channels/groups. */
|
||||
requireMention?: boolean;
|
||||
/** Max group/channel messages to keep as history context (0 disables). */
|
||||
historyLimit?: number;
|
||||
/** Max DM turns to keep as history context. */
|
||||
dmHistoryLimit?: number;
|
||||
/** Per-DM config overrides keyed by user ID. */
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Default reply style: "thread" replies to the message, "top-level" posts a new message. */
|
||||
replyStyle?: MSTeamsReplyStyle;
|
||||
/** Per-team config. Key is team ID (from the /team/ URL path segment). */
|
||||
teams?: Record<string, MSTeamsTeamConfig>;
|
||||
};
|
||||
20
src/config/types.plugins.ts
Normal file
20
src/config/types.plugins.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type PluginEntryConfig = {
|
||||
enabled?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PluginsLoadConfig = {
|
||||
/** Additional plugin/extension paths to load. */
|
||||
paths?: string[];
|
||||
};
|
||||
|
||||
export type PluginsConfig = {
|
||||
/** Enable or disable plugin loading. */
|
||||
enabled?: boolean;
|
||||
/** Optional plugin allowlist (plugin ids). */
|
||||
allow?: string[];
|
||||
/** Optional plugin denylist (plugin ids). */
|
||||
deny?: string[];
|
||||
load?: PluginsLoadConfig;
|
||||
entries?: Record<string, PluginEntryConfig>;
|
||||
};
|
||||
20
src/config/types.queue.ts
Normal file
20
src/config/types.queue.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type QueueMode =
|
||||
| "steer"
|
||||
| "followup"
|
||||
| "collect"
|
||||
| "steer-backlog"
|
||||
| "steer+backlog"
|
||||
| "queue"
|
||||
| "interrupt";
|
||||
export type QueueDropPolicy = "old" | "new" | "summarize";
|
||||
|
||||
export type QueueModeByProvider = {
|
||||
whatsapp?: QueueMode;
|
||||
telegram?: QueueMode;
|
||||
discord?: QueueMode;
|
||||
slack?: QueueMode;
|
||||
signal?: QueueMode;
|
||||
imessage?: QueueMode;
|
||||
msteams?: QueueMode;
|
||||
webchat?: QueueMode;
|
||||
};
|
||||
90
src/config/types.sandbox.ts
Normal file
90
src/config/types.sandbox.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export type SandboxDockerSettings = {
|
||||
/** Docker image to use for sandbox containers. */
|
||||
image?: string;
|
||||
/** Prefix for sandbox container names. */
|
||||
containerPrefix?: string;
|
||||
/** Container workdir mount path (default: /workspace). */
|
||||
workdir?: string;
|
||||
/** Run container rootfs read-only. */
|
||||
readOnlyRoot?: boolean;
|
||||
/** Extra tmpfs mounts for read-only containers. */
|
||||
tmpfs?: string[];
|
||||
/** Container network mode (bridge|none|custom). */
|
||||
network?: string;
|
||||
/** Container user (uid:gid). */
|
||||
user?: string;
|
||||
/** Drop Linux capabilities. */
|
||||
capDrop?: string[];
|
||||
/** Extra environment variables for sandbox exec. */
|
||||
env?: Record<string, string>;
|
||||
/** Optional setup command run once after container creation. */
|
||||
setupCommand?: string;
|
||||
/** Limit container PIDs (0 = Docker default). */
|
||||
pidsLimit?: number;
|
||||
/** Limit container memory (e.g. 512m, 2g, or bytes as number). */
|
||||
memory?: string | number;
|
||||
/** Limit container memory swap (same format as memory). */
|
||||
memorySwap?: string | number;
|
||||
/** Limit container CPU shares (e.g. 0.5, 1, 2). */
|
||||
cpus?: number;
|
||||
/**
|
||||
* Set ulimit values by name (e.g. nofile, nproc).
|
||||
* Use "soft:hard" string, a number, or { soft, hard }.
|
||||
*/
|
||||
ulimits?: Record<string, string | number | { soft?: number; hard?: number }>;
|
||||
/** Seccomp profile (path or profile name). */
|
||||
seccompProfile?: string;
|
||||
/** AppArmor profile name. */
|
||||
apparmorProfile?: string;
|
||||
/** DNS servers (e.g. ["1.1.1.1", "8.8.8.8"]). */
|
||||
dns?: string[];
|
||||
/** Extra host mappings (e.g. ["api.local:10.0.0.2"]). */
|
||||
extraHosts?: string[];
|
||||
/** Additional bind mounts (host:container:mode format, e.g. ["/host/path:/container/path:rw"]). */
|
||||
binds?: string[];
|
||||
};
|
||||
|
||||
export type SandboxBrowserSettings = {
|
||||
enabled?: boolean;
|
||||
image?: string;
|
||||
containerPrefix?: string;
|
||||
cdpPort?: number;
|
||||
vncPort?: number;
|
||||
noVncPort?: number;
|
||||
headless?: boolean;
|
||||
enableNoVnc?: boolean;
|
||||
/**
|
||||
* Allow sandboxed sessions to target the host browser control server.
|
||||
* Default: false.
|
||||
*/
|
||||
allowHostControl?: boolean;
|
||||
/**
|
||||
* Allowlist of exact control URLs for target="custom".
|
||||
* When set, any custom controlUrl must match this list.
|
||||
*/
|
||||
allowedControlUrls?: string[];
|
||||
/**
|
||||
* Allowlist of hostnames for control URLs (hostname only, no ports).
|
||||
* When set, controlUrl hostname must match.
|
||||
*/
|
||||
allowedControlHosts?: string[];
|
||||
/**
|
||||
* Allowlist of ports for control URLs.
|
||||
* When set, controlUrl port must match (defaults: http=80, https=443).
|
||||
*/
|
||||
allowedControlPorts?: number[];
|
||||
/**
|
||||
* When true (default), sandboxed browser control will try to start/reattach to
|
||||
* the sandbox browser container when a tool call needs it.
|
||||
*/
|
||||
autoStart?: boolean;
|
||||
/** Max time to wait for CDP to become reachable after auto-start (ms). */
|
||||
autoStartTimeoutMs?: number;
|
||||
};
|
||||
|
||||
export type SandboxPruneSettings = {
|
||||
/** Prune if idle for more than N hours (0 disables). */
|
||||
idleHours?: number;
|
||||
/** Prune if older than N days (0 disables). */
|
||||
maxAgeDays?: number;
|
||||
};
|
||||
70
src/config/types.signal.ts
Normal file
70
src/config/types.signal.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig } from "./types.messages.js";
|
||||
|
||||
export type SignalReactionNotificationMode =
|
||||
| "off"
|
||||
| "own"
|
||||
| "all"
|
||||
| "allowlist";
|
||||
|
||||
export type SignalAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** If false, do not start this Signal account. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Optional explicit E.164 account for signal-cli. */
|
||||
account?: string;
|
||||
/** Optional full base URL for signal-cli HTTP daemon. */
|
||||
httpUrl?: string;
|
||||
/** HTTP host for signal-cli daemon (default 127.0.0.1). */
|
||||
httpHost?: string;
|
||||
/** HTTP port for signal-cli daemon (default 8080). */
|
||||
httpPort?: number;
|
||||
/** signal-cli binary path (default: signal-cli). */
|
||||
cliPath?: string;
|
||||
/** Auto-start signal-cli daemon (default: true if httpUrl not set). */
|
||||
autoStart?: boolean;
|
||||
receiveMode?: "on-start" | "manual";
|
||||
ignoreAttachments?: boolean;
|
||||
ignoreStories?: boolean;
|
||||
sendReadReceipts?: boolean;
|
||||
/** Direct message access policy (default: pairing). */
|
||||
dmPolicy?: DmPolicy;
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Optional allowlist for Signal group senders (E.164). */
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
/**
|
||||
* Controls how group messages are handled:
|
||||
* - "open": groups bypass allowFrom, no extra gating
|
||||
* - "disabled": block all group messages
|
||||
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
|
||||
*/
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Max group messages to keep as history context (0 disables). */
|
||||
historyLimit?: number;
|
||||
/** Max DM turns to keep as history context. */
|
||||
dmHistoryLimit?: number;
|
||||
/** Per-DM config overrides keyed by user ID. */
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
mediaMaxMb?: number;
|
||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||
reactionNotifications?: SignalReactionNotificationMode;
|
||||
/** Allowlist for reaction notifications when mode is allowlist. */
|
||||
reactionAllowlist?: Array<string | number>;
|
||||
};
|
||||
|
||||
export type SignalConfig = {
|
||||
/** Optional per-account Signal configuration (multi-account). */
|
||||
accounts?: Record<string, SignalAccountConfig>;
|
||||
} & SignalAccountConfig;
|
||||
27
src/config/types.skills.ts
Normal file
27
src/config/types.skills.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type SkillConfig = {
|
||||
enabled?: boolean;
|
||||
apiKey?: string;
|
||||
env?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SkillsLoadConfig = {
|
||||
/**
|
||||
* Additional skill folders to scan (lowest precedence).
|
||||
* Each directory should contain skill subfolders with `SKILL.md`.
|
||||
*/
|
||||
extraDirs?: string[];
|
||||
};
|
||||
|
||||
export type SkillsInstallConfig = {
|
||||
preferBrew?: boolean;
|
||||
nodeManager?: "npm" | "pnpm" | "yarn" | "bun";
|
||||
};
|
||||
|
||||
export type SkillsConfig = {
|
||||
/** Optional bundled-skill allowlist (only affects bundled skills). */
|
||||
allowBundled?: string[];
|
||||
load?: SkillsLoadConfig;
|
||||
install?: SkillsInstallConfig;
|
||||
entries?: Record<string, SkillConfig>;
|
||||
};
|
||||
109
src/config/types.slack.ts
Normal file
109
src/config/types.slack.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
ReplyToMode,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||
|
||||
export type SlackDmConfig = {
|
||||
/** If false, ignore all incoming Slack DMs. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Direct message access policy (default: pairing). */
|
||||
policy?: DmPolicy;
|
||||
/** Allowlist for DM senders (ids). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** If true, allow group DMs (default: false). */
|
||||
groupEnabled?: boolean;
|
||||
/** Optional allowlist for group DM channels (ids or slugs). */
|
||||
groupChannels?: Array<string | number>;
|
||||
};
|
||||
|
||||
export type SlackChannelConfig = {
|
||||
/** If false, disable the bot in this channel. (Alias for allow: false.) */
|
||||
enabled?: boolean;
|
||||
/** Legacy channel allow toggle; prefer enabled. */
|
||||
allow?: boolean;
|
||||
/** Require mentioning the bot to trigger replies. */
|
||||
requireMention?: boolean;
|
||||
/** Allow bot-authored messages to trigger replies (default: false). */
|
||||
allowBots?: boolean;
|
||||
/** Allowlist of users that can invoke the bot in this channel. */
|
||||
users?: Array<string | number>;
|
||||
/** Optional skill filter for this channel. */
|
||||
skills?: string[];
|
||||
/** Optional system prompt for this channel. */
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
||||
|
||||
export type SlackActionConfig = {
|
||||
reactions?: boolean;
|
||||
messages?: boolean;
|
||||
pins?: boolean;
|
||||
search?: boolean;
|
||||
permissions?: boolean;
|
||||
memberInfo?: boolean;
|
||||
channelInfo?: boolean;
|
||||
emojiList?: boolean;
|
||||
};
|
||||
|
||||
export type SlackSlashCommandConfig = {
|
||||
/** Enable handling for the configured slash command (default: false). */
|
||||
enabled?: boolean;
|
||||
/** Slash command name (default: "clawd"). */
|
||||
name?: string;
|
||||
/** Session key prefix for slash commands (default: "slack:slash"). */
|
||||
sessionPrefix?: string;
|
||||
/** Reply ephemerally (default: true). */
|
||||
ephemeral?: boolean;
|
||||
};
|
||||
|
||||
export type SlackAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** Override native command registration for Slack (bool or "auto"). */
|
||||
commands?: ProviderCommandsConfig;
|
||||
/** If false, do not start this Slack account. Default: true. */
|
||||
enabled?: boolean;
|
||||
botToken?: string;
|
||||
appToken?: string;
|
||||
/** Allow bot-authored messages to trigger replies (default: false). */
|
||||
allowBots?: boolean;
|
||||
/**
|
||||
* Controls how channel messages are handled:
|
||||
* - "open": channels bypass allowlists; mention-gating applies
|
||||
* - "disabled": block all channel messages
|
||||
* - "allowlist": only allow channels present in channels.slack.channels
|
||||
*/
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Max channel messages to keep as history context (0 disables). */
|
||||
historyLimit?: number;
|
||||
/** Max DM turns to keep as history context. */
|
||||
dmHistoryLimit?: number;
|
||||
/** Per-DM config overrides keyed by user ID. */
|
||||
dms?: Record<string, DmConfig>;
|
||||
textChunkLimit?: number;
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
mediaMaxMb?: number;
|
||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||
reactionNotifications?: SlackReactionNotificationMode;
|
||||
/** Allowlist for reaction notifications when mode is allowlist. */
|
||||
reactionAllowlist?: Array<string | number>;
|
||||
/** Control reply threading when reply tags are present (off|first|all). */
|
||||
replyToMode?: ReplyToMode;
|
||||
actions?: SlackActionConfig;
|
||||
slashCommand?: SlackSlashCommandConfig;
|
||||
dm?: SlackDmConfig;
|
||||
channels?: Record<string, SlackChannelConfig>;
|
||||
};
|
||||
|
||||
export type SlackConfig = {
|
||||
/** Optional per-account Slack configuration (multi-account). */
|
||||
accounts?: Record<string, SlackAccountConfig>;
|
||||
} & SlackAccountConfig;
|
||||
105
src/config/types.telegram.ts
Normal file
105
src/config/types.telegram.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type {
|
||||
BlockStreamingChunkConfig,
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
OutboundRetryConfig,
|
||||
ReplyToMode,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||
|
||||
export type TelegramActionConfig = {
|
||||
reactions?: boolean;
|
||||
sendMessage?: boolean;
|
||||
};
|
||||
|
||||
export type TelegramAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** Override native command registration for Telegram (bool or "auto"). */
|
||||
commands?: ProviderCommandsConfig;
|
||||
/**
|
||||
* Controls how Telegram direct chats (DMs) are handled:
|
||||
* - "pairing" (default): unknown senders get a pairing code; owner must approve
|
||||
* - "allowlist": only allow senders in allowFrom (or paired allow store)
|
||||
* - "open": allow all inbound DMs (requires allowFrom to include "*")
|
||||
* - "disabled": ignore all inbound DMs
|
||||
*/
|
||||
dmPolicy?: DmPolicy;
|
||||
/** If false, do not start this Telegram account. Default: true. */
|
||||
enabled?: boolean;
|
||||
botToken?: string;
|
||||
/** Path to file containing bot token (for secret managers like agenix). */
|
||||
tokenFile?: string;
|
||||
/** Control reply threading when reply tags are present (off|first|all). */
|
||||
replyToMode?: ReplyToMode;
|
||||
groups?: Record<string, TelegramGroupConfig>;
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Optional allowlist for Telegram group senders (user ids or usernames). */
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
/**
|
||||
* Controls how group messages are handled:
|
||||
* - "open": groups bypass allowFrom, only mention-gating applies
|
||||
* - "disabled": block all group messages entirely
|
||||
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
|
||||
*/
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Max group messages to keep as history context (0 disables). */
|
||||
historyLimit?: number;
|
||||
/** Max DM turns to keep as history context. */
|
||||
dmHistoryLimit?: number;
|
||||
/** Per-DM config overrides keyed by user ID. */
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Chunking config for draft streaming in `streamMode: "block"`. */
|
||||
draftChunk?: BlockStreamingChunkConfig;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** Draft streaming mode for Telegram (off|partial|block). Default: partial. */
|
||||
streamMode?: "off" | "partial" | "block";
|
||||
mediaMaxMb?: number;
|
||||
/** Retry policy for outbound Telegram API calls. */
|
||||
retry?: OutboundRetryConfig;
|
||||
proxy?: string;
|
||||
webhookUrl?: string;
|
||||
webhookSecret?: string;
|
||||
webhookPath?: string;
|
||||
/** Per-action tool gating (default: true for all). */
|
||||
actions?: TelegramActionConfig;
|
||||
};
|
||||
|
||||
export type TelegramTopicConfig = {
|
||||
requireMention?: boolean;
|
||||
/** If specified, only load these skills for this topic. Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** If false, disable the bot for this topic. */
|
||||
enabled?: boolean;
|
||||
/** Optional allowlist for topic senders (ids or usernames). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Optional system prompt snippet for this topic. */
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type TelegramGroupConfig = {
|
||||
requireMention?: boolean;
|
||||
/** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** Per-topic configuration (key is message_thread_id as string) */
|
||||
topics?: Record<string, TelegramTopicConfig>;
|
||||
/** If false, disable the bot for this group (and its topics). */
|
||||
enabled?: boolean;
|
||||
/** Optional allowlist for group senders (ids or usernames). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Optional system prompt snippet for this group. */
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type TelegramConfig = {
|
||||
/** Optional per-account Telegram configuration (multi-account). */
|
||||
accounts?: Record<string, TelegramAccountConfig>;
|
||||
} & TelegramAccountConfig;
|
||||
140
src/config/types.tools.ts
Normal file
140
src/config/types.tools.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { AgentElevatedAllowFromConfig } from "./types.base.js";
|
||||
|
||||
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
|
||||
|
||||
export type AgentToolsConfig = {
|
||||
/** Base tool profile applied before allow/deny lists. */
|
||||
profile?: ToolProfileId;
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
/** Per-agent elevated exec gate (can only further restrict global tools.elevated). */
|
||||
elevated?: {
|
||||
/** Enable or disable elevated mode for this agent (default: true). */
|
||||
enabled?: boolean;
|
||||
/** Approved senders for /elevated (per-provider allowlists). */
|
||||
allowFrom?: AgentElevatedAllowFromConfig;
|
||||
};
|
||||
sandbox?: {
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type MemorySearchConfig = {
|
||||
/** Enable vector memory search (default: true). */
|
||||
enabled?: boolean;
|
||||
/** Embedding provider mode. */
|
||||
provider?: "openai" | "local";
|
||||
remote?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
/** Fallback behavior when local embeddings fail. */
|
||||
fallback?: "openai" | "none";
|
||||
/** Embedding model id (remote) or alias (local). */
|
||||
model?: string;
|
||||
/** Local embedding settings (node-llama-cpp). */
|
||||
local?: {
|
||||
/** GGUF model path or hf: URI. */
|
||||
modelPath?: string;
|
||||
/** Optional cache directory for local models. */
|
||||
modelCacheDir?: string;
|
||||
};
|
||||
/** Index storage configuration. */
|
||||
store?: {
|
||||
driver?: "sqlite";
|
||||
path?: string;
|
||||
};
|
||||
/** Chunking configuration. */
|
||||
chunking?: {
|
||||
tokens?: number;
|
||||
overlap?: number;
|
||||
};
|
||||
/** Sync behavior. */
|
||||
sync?: {
|
||||
onSessionStart?: boolean;
|
||||
onSearch?: boolean;
|
||||
watch?: boolean;
|
||||
watchDebounceMs?: number;
|
||||
intervalMinutes?: number;
|
||||
};
|
||||
/** Query behavior. */
|
||||
query?: {
|
||||
maxResults?: number;
|
||||
minScore?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ToolsConfig = {
|
||||
/** Base tool profile applied before allow/deny lists. */
|
||||
profile?: ToolProfileId;
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
audio?: {
|
||||
transcription?: {
|
||||
/** CLI args (template-enabled). */
|
||||
args?: string[];
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
};
|
||||
agentToAgent?: {
|
||||
/** Enable agent-to-agent messaging tools. Default: false. */
|
||||
enabled?: boolean;
|
||||
/** Allowlist of agent ids or patterns (implementation-defined). */
|
||||
allow?: string[];
|
||||
};
|
||||
/** Elevated exec permissions for the host machine. */
|
||||
elevated?: {
|
||||
/** Enable or disable elevated mode (default: true). */
|
||||
enabled?: boolean;
|
||||
/** Approved senders for /elevated (per-provider allowlists). */
|
||||
allowFrom?: AgentElevatedAllowFromConfig;
|
||||
};
|
||||
/** Exec tool defaults. */
|
||||
exec?: {
|
||||
/** Default time (ms) before an exec command auto-backgrounds. */
|
||||
backgroundMs?: number;
|
||||
/** Default timeout (seconds) before auto-killing exec commands. */
|
||||
timeoutSec?: number;
|
||||
/** How long to keep finished sessions in memory (ms). */
|
||||
cleanupMs?: number;
|
||||
/** apply_patch subtool configuration (experimental). */
|
||||
applyPatch?: {
|
||||
/** Enable apply_patch for OpenAI models (default: false). */
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Optional allowlist of model ids that can use apply_patch.
|
||||
* Accepts either raw ids (e.g. "gpt-5.2") or full ids (e.g. "openai/gpt-5.2").
|
||||
*/
|
||||
allowModels?: string[];
|
||||
};
|
||||
};
|
||||
/** @deprecated Use tools.exec. */
|
||||
bash?: {
|
||||
/** Default time (ms) before a bash command auto-backgrounds. */
|
||||
backgroundMs?: number;
|
||||
/** Default timeout (seconds) before auto-killing bash commands. */
|
||||
timeoutSec?: number;
|
||||
/** How long to keep finished sessions in memory (ms). */
|
||||
cleanupMs?: number;
|
||||
};
|
||||
/** Sub-agent tool policy defaults (deny wins). */
|
||||
subagents?: {
|
||||
/** Default model selection for spawned sub-agents (string or {primary,fallbacks}). */
|
||||
model?: string | { primary?: string; fallbacks?: string[] };
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
};
|
||||
/** Sandbox tool policy defaults (deny wins). */
|
||||
sandbox?: {
|
||||
tools?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
1868
src/config/types.ts
1868
src/config/types.ts
File diff suppressed because it is too large
Load Diff
130
src/config/types.whatsapp.ts
Normal file
130
src/config/types.whatsapp.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig } from "./types.messages.js";
|
||||
|
||||
export type WhatsAppActionConfig = {
|
||||
reactions?: boolean;
|
||||
sendMessage?: boolean;
|
||||
polls?: boolean;
|
||||
};
|
||||
|
||||
export type WhatsAppConfig = {
|
||||
/** Optional per-account WhatsApp configuration (multi-account). */
|
||||
accounts?: Record<string, WhatsAppAccountConfig>;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/**
|
||||
* Inbound message prefix (WhatsApp only).
|
||||
* Default: `[{agents.list[].identity.name}]` (or `[clawdbot]`) when allowFrom is empty, else `""`.
|
||||
*/
|
||||
messagePrefix?: string;
|
||||
/** Direct message access policy (default: pairing). */
|
||||
dmPolicy?: DmPolicy;
|
||||
/**
|
||||
* Same-phone setup (bot uses your personal WhatsApp number).
|
||||
*/
|
||||
selfChatMode?: boolean;
|
||||
/** Optional allowlist for WhatsApp direct chats (E.164). */
|
||||
allowFrom?: string[];
|
||||
/** Optional allowlist for WhatsApp group senders (E.164). */
|
||||
groupAllowFrom?: string[];
|
||||
/**
|
||||
* Controls how group messages are handled:
|
||||
* - "open": groups bypass allowFrom, only mention-gating applies
|
||||
* - "disabled": block all group messages entirely
|
||||
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
|
||||
*/
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Max group messages to keep as history context (0 disables). */
|
||||
historyLimit?: number;
|
||||
/** Max DM turns to keep as history context. */
|
||||
dmHistoryLimit?: number;
|
||||
/** Per-DM config overrides keyed by user ID. */
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Maximum media file size in MB. Default: 50. */
|
||||
mediaMaxMb?: number;
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** Per-action tool gating (default: true for all). */
|
||||
actions?: WhatsAppActionConfig;
|
||||
groups?: Record<
|
||||
string,
|
||||
{
|
||||
requireMention?: boolean;
|
||||
}
|
||||
>;
|
||||
/** Acknowledgment reaction sent immediately upon message receipt. */
|
||||
ackReaction?: {
|
||||
/** Emoji to use for acknowledgment (e.g., "👀"). Empty = disabled. */
|
||||
emoji?: string;
|
||||
/** Send reactions in direct chats. Default: true. */
|
||||
direct?: boolean;
|
||||
/**
|
||||
* Send reactions in group chats:
|
||||
* - "always": react to all group messages
|
||||
* - "mentions": react only when bot is mentioned
|
||||
* - "never": never react in groups
|
||||
* Default: "mentions"
|
||||
*/
|
||||
group?: "always" | "mentions" | "never";
|
||||
};
|
||||
};
|
||||
|
||||
export type WhatsAppAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** If false, do not start this WhatsApp account provider. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Inbound message prefix override for this account (WhatsApp only). */
|
||||
messagePrefix?: string;
|
||||
/** Override auth directory (Baileys multi-file auth state). */
|
||||
authDir?: string;
|
||||
/** Direct message access policy (default: pairing). */
|
||||
dmPolicy?: DmPolicy;
|
||||
/** Same-phone setup for this account (bot uses your personal WhatsApp number). */
|
||||
selfChatMode?: boolean;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Max group messages to keep as history context (0 disables). */
|
||||
historyLimit?: number;
|
||||
/** Max DM turns to keep as history context. */
|
||||
dmHistoryLimit?: number;
|
||||
/** Per-DM config overrides keyed by user ID. */
|
||||
dms?: Record<string, DmConfig>;
|
||||
textChunkLimit?: number;
|
||||
mediaMaxMb?: number;
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
groups?: Record<
|
||||
string,
|
||||
{
|
||||
requireMention?: boolean;
|
||||
}
|
||||
>;
|
||||
/** Acknowledgment reaction sent immediately upon message receipt. */
|
||||
ackReaction?: {
|
||||
/** Emoji to use for acknowledgment (e.g., "👀"). Empty = disabled. */
|
||||
emoji?: string;
|
||||
/** Send reactions in direct chats. Default: true. */
|
||||
direct?: boolean;
|
||||
/**
|
||||
* Send reactions in group chats:
|
||||
* - "always": react to all group messages
|
||||
* - "mentions": react only when bot is mentioned
|
||||
* - "never": never react in groups
|
||||
* Default: "mentions"
|
||||
*/
|
||||
group?: "always" | "mentions" | "never";
|
||||
};
|
||||
};
|
||||
172
src/config/zod-schema.agent-defaults.ts
Normal file
172
src/config/zod-schema.agent-defaults.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
HeartbeatSchema,
|
||||
MemorySearchSchema,
|
||||
SandboxBrowserSchema,
|
||||
SandboxDockerSchema,
|
||||
SandboxPruneSchema,
|
||||
} from "./zod-schema.agent-runtime.js";
|
||||
import {
|
||||
BlockStreamingChunkSchema,
|
||||
BlockStreamingCoalesceSchema,
|
||||
CliBackendSchema,
|
||||
HumanDelaySchema,
|
||||
} from "./zod-schema.core.js";
|
||||
|
||||
export const AgentDefaultsSchema = z
|
||||
.object({
|
||||
model: z
|
||||
.object({
|
||||
primary: z.string().optional(),
|
||||
fallbacks: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
imageModel: z
|
||||
.object({
|
||||
primary: z.string().optional(),
|
||||
fallbacks: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
models: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
alias: z.string().optional(),
|
||||
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
|
||||
params: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
workspace: z.string().optional(),
|
||||
skipBootstrap: z.boolean().optional(),
|
||||
bootstrapMaxChars: z.number().int().positive().optional(),
|
||||
userTimezone: z.string().optional(),
|
||||
contextTokens: z.number().int().positive().optional(),
|
||||
cliBackends: z.record(z.string(), CliBackendSchema).optional(),
|
||||
memorySearch: MemorySearchSchema,
|
||||
contextPruning: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([
|
||||
z.literal("off"),
|
||||
z.literal("adaptive"),
|
||||
z.literal("aggressive"),
|
||||
])
|
||||
.optional(),
|
||||
keepLastAssistants: z.number().int().nonnegative().optional(),
|
||||
softTrimRatio: z.number().min(0).max(1).optional(),
|
||||
hardClearRatio: z.number().min(0).max(1).optional(),
|
||||
minPrunableToolChars: z.number().int().nonnegative().optional(),
|
||||
tools: z
|
||||
.object({
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
softTrim: z
|
||||
.object({
|
||||
maxChars: z.number().int().nonnegative().optional(),
|
||||
headChars: z.number().int().nonnegative().optional(),
|
||||
tailChars: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.optional(),
|
||||
hardClear: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
compaction: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([z.literal("default"), z.literal("safeguard")])
|
||||
.optional(),
|
||||
reserveTokensFloor: z.number().int().nonnegative().optional(),
|
||||
memoryFlush: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
softThresholdTokens: z.number().int().nonnegative().optional(),
|
||||
prompt: z.string().optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
thinkingDefault: z
|
||||
.union([
|
||||
z.literal("off"),
|
||||
z.literal("minimal"),
|
||||
z.literal("low"),
|
||||
z.literal("medium"),
|
||||
z.literal("high"),
|
||||
z.literal("xhigh"),
|
||||
])
|
||||
.optional(),
|
||||
verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
|
||||
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
|
||||
blockStreamingDefault: z
|
||||
.union([z.literal("off"), z.literal("on")])
|
||||
.optional(),
|
||||
blockStreamingBreak: z
|
||||
.union([z.literal("text_end"), z.literal("message_end")])
|
||||
.optional(),
|
||||
blockStreamingChunk: BlockStreamingChunkSchema.optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
humanDelay: HumanDelaySchema.optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||
typingMode: z
|
||||
.union([
|
||||
z.literal("never"),
|
||||
z.literal("instant"),
|
||||
z.literal("thinking"),
|
||||
z.literal("message"),
|
||||
])
|
||||
.optional(),
|
||||
heartbeat: HeartbeatSchema,
|
||||
maxConcurrent: z.number().int().positive().optional(),
|
||||
subagents: z
|
||||
.object({
|
||||
maxConcurrent: z.number().int().positive().optional(),
|
||||
archiveAfterMinutes: z.number().int().positive().optional(),
|
||||
model: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
primary: z.string().optional(),
|
||||
fallbacks: z.array(z.string()).optional(),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
sandbox: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
|
||||
.optional(),
|
||||
workspaceAccess: z
|
||||
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
|
||||
.optional(),
|
||||
sessionToolsVisibility: z
|
||||
.union([z.literal("spawned"), z.literal("all")])
|
||||
.optional(),
|
||||
scope: z
|
||||
.union([
|
||||
z.literal("session"),
|
||||
z.literal("agent"),
|
||||
z.literal("shared"),
|
||||
])
|
||||
.optional(),
|
||||
perSession: z.boolean().optional(),
|
||||
workspaceRoot: z.string().optional(),
|
||||
docker: SandboxDockerSchema,
|
||||
browser: SandboxBrowserSchema,
|
||||
prune: SandboxPruneSchema,
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
308
src/config/zod-schema.agent-runtime.ts
Normal file
308
src/config/zod-schema.agent-runtime.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import {
|
||||
GroupChatSchema,
|
||||
HumanDelaySchema,
|
||||
IdentitySchema,
|
||||
ToolsAudioTranscriptionSchema,
|
||||
} from "./zod-schema.core.js";
|
||||
|
||||
export const HeartbeatSchema = z
|
||||
.object({
|
||||
every: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
includeReasoning: z.boolean().optional(),
|
||||
target: z
|
||||
.union([
|
||||
z.literal("last"),
|
||||
z.literal("whatsapp"),
|
||||
z.literal("telegram"),
|
||||
z.literal("discord"),
|
||||
z.literal("slack"),
|
||||
z.literal("msteams"),
|
||||
z.literal("signal"),
|
||||
z.literal("imessage"),
|
||||
z.literal("none"),
|
||||
])
|
||||
.optional(),
|
||||
to: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
ackMaxChars: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val.every) return;
|
||||
try {
|
||||
parseDurationMs(val.every, { defaultUnit: "m" });
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["every"],
|
||||
message: "invalid duration (use ms, s, m, h)",
|
||||
});
|
||||
}
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const SandboxDockerSchema = z
|
||||
.object({
|
||||
image: z.string().optional(),
|
||||
containerPrefix: z.string().optional(),
|
||||
workdir: z.string().optional(),
|
||||
readOnlyRoot: z.boolean().optional(),
|
||||
tmpfs: z.array(z.string()).optional(),
|
||||
network: z.string().optional(),
|
||||
user: z.string().optional(),
|
||||
capDrop: z.array(z.string()).optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
setupCommand: z.string().optional(),
|
||||
pidsLimit: z.number().int().positive().optional(),
|
||||
memory: z.union([z.string(), z.number()]).optional(),
|
||||
memorySwap: z.union([z.string(), z.number()]).optional(),
|
||||
cpus: z.number().positive().optional(),
|
||||
ulimits: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.object({
|
||||
soft: z.number().int().nonnegative().optional(),
|
||||
hard: z.number().int().nonnegative().optional(),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.optional(),
|
||||
seccompProfile: z.string().optional(),
|
||||
apparmorProfile: z.string().optional(),
|
||||
dns: z.array(z.string()).optional(),
|
||||
extraHosts: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const SandboxBrowserSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
image: z.string().optional(),
|
||||
containerPrefix: z.string().optional(),
|
||||
cdpPort: z.number().int().positive().optional(),
|
||||
vncPort: z.number().int().positive().optional(),
|
||||
noVncPort: z.number().int().positive().optional(),
|
||||
headless: z.boolean().optional(),
|
||||
enableNoVnc: z.boolean().optional(),
|
||||
allowHostControl: z.boolean().optional(),
|
||||
allowedControlUrls: z.array(z.string()).optional(),
|
||||
allowedControlHosts: z.array(z.string()).optional(),
|
||||
allowedControlPorts: z.array(z.number().int().positive()).optional(),
|
||||
autoStart: z.boolean().optional(),
|
||||
autoStartTimeoutMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const SandboxPruneSchema = z
|
||||
.object({
|
||||
idleHours: z.number().int().nonnegative().optional(),
|
||||
maxAgeDays: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const ToolPolicySchema = z
|
||||
.object({
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const ToolProfileSchema = z
|
||||
.union([
|
||||
z.literal("minimal"),
|
||||
z.literal("coding"),
|
||||
z.literal("messaging"),
|
||||
z.literal("full"),
|
||||
])
|
||||
.optional();
|
||||
|
||||
// Provider docking: allowlists keyed by provider id (no schema updates when adding providers).
|
||||
export const ElevatedAllowFromSchema = z
|
||||
.record(z.string(), z.array(z.union([z.string(), z.number()])))
|
||||
.optional();
|
||||
|
||||
export const AgentSandboxSchema = z
|
||||
.object({
|
||||
mode: z
|
||||
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
|
||||
.optional(),
|
||||
workspaceAccess: z
|
||||
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
|
||||
.optional(),
|
||||
sessionToolsVisibility: z
|
||||
.union([z.literal("spawned"), z.literal("all")])
|
||||
.optional(),
|
||||
scope: z
|
||||
.union([z.literal("session"), z.literal("agent"), z.literal("shared")])
|
||||
.optional(),
|
||||
perSession: z.boolean().optional(),
|
||||
workspaceRoot: z.string().optional(),
|
||||
docker: SandboxDockerSchema,
|
||||
browser: SandboxBrowserSchema,
|
||||
prune: SandboxPruneSchema,
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const AgentToolsSchema = z
|
||||
.object({
|
||||
profile: ToolProfileSchema,
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
elevated: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: ElevatedAllowFromSchema,
|
||||
})
|
||||
.optional(),
|
||||
sandbox: z
|
||||
.object({
|
||||
tools: ToolPolicySchema,
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const MemorySearchSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
provider: z.union([z.literal("openai"), z.literal("local")]).optional(),
|
||||
remote: z
|
||||
.object({
|
||||
baseUrl: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
fallback: z.union([z.literal("openai"), z.literal("none")]).optional(),
|
||||
model: z.string().optional(),
|
||||
local: z
|
||||
.object({
|
||||
modelPath: z.string().optional(),
|
||||
modelCacheDir: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
store: z
|
||||
.object({
|
||||
driver: z.literal("sqlite").optional(),
|
||||
path: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
chunking: z
|
||||
.object({
|
||||
tokens: z.number().int().positive().optional(),
|
||||
overlap: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.optional(),
|
||||
sync: z
|
||||
.object({
|
||||
onSessionStart: z.boolean().optional(),
|
||||
onSearch: z.boolean().optional(),
|
||||
watch: z.boolean().optional(),
|
||||
watchDebounceMs: z.number().int().nonnegative().optional(),
|
||||
intervalMinutes: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.optional(),
|
||||
query: z
|
||||
.object({
|
||||
maxResults: z.number().int().positive().optional(),
|
||||
minScore: z.number().min(0).max(1).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
export const AgentModelSchema = z.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
primary: z.string().optional(),
|
||||
fallbacks: z.array(z.string()).optional(),
|
||||
}),
|
||||
]);
|
||||
export const AgentEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
default: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
agentDir: z.string().optional(),
|
||||
model: AgentModelSchema.optional(),
|
||||
memorySearch: MemorySearchSchema,
|
||||
humanDelay: HumanDelaySchema.optional(),
|
||||
identity: IdentitySchema,
|
||||
groupChat: GroupChatSchema,
|
||||
subagents: z
|
||||
.object({
|
||||
allowAgents: z.array(z.string()).optional(),
|
||||
model: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
primary: z.string().optional(),
|
||||
fallbacks: z.array(z.string()).optional(),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
sandbox: AgentSandboxSchema,
|
||||
tools: AgentToolsSchema,
|
||||
});
|
||||
|
||||
export const ToolsSchema = z
|
||||
.object({
|
||||
profile: ToolProfileSchema,
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
audio: z
|
||||
.object({
|
||||
transcription: ToolsAudioTranscriptionSchema,
|
||||
})
|
||||
.optional(),
|
||||
agentToAgent: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allow: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
elevated: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: ElevatedAllowFromSchema,
|
||||
})
|
||||
.optional(),
|
||||
exec: z
|
||||
.object({
|
||||
backgroundMs: z.number().int().positive().optional(),
|
||||
timeoutSec: z.number().int().positive().optional(),
|
||||
cleanupMs: z.number().int().positive().optional(),
|
||||
applyPatch: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allowModels: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
bash: z
|
||||
.object({
|
||||
backgroundMs: z.number().int().positive().optional(),
|
||||
timeoutSec: z.number().int().positive().optional(),
|
||||
cleanupMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
subagents: z
|
||||
.object({
|
||||
tools: ToolPolicySchema,
|
||||
})
|
||||
.optional(),
|
||||
sandbox: z
|
||||
.object({
|
||||
tools: ToolPolicySchema,
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
50
src/config/zod-schema.agents.ts
Normal file
50
src/config/zod-schema.agents.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { z } from "zod";
|
||||
import { AgentDefaultsSchema } from "./zod-schema.agent-defaults.js";
|
||||
import { AgentEntrySchema } from "./zod-schema.agent-runtime.js";
|
||||
import { TranscribeAudioSchema } from "./zod-schema.core.js";
|
||||
|
||||
export const AgentsSchema = z
|
||||
.object({
|
||||
defaults: z.lazy(() => AgentDefaultsSchema).optional(),
|
||||
list: z.array(AgentEntrySchema).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const BindingsSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
agentId: z.string(),
|
||||
match: z.object({
|
||||
channel: z.string(),
|
||||
accountId: z.string().optional(),
|
||||
peer: z
|
||||
.object({
|
||||
kind: z.union([
|
||||
z.literal("dm"),
|
||||
z.literal("group"),
|
||||
z.literal("channel"),
|
||||
]),
|
||||
id: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
guildId: z.string().optional(),
|
||||
teamId: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.optional();
|
||||
|
||||
export const BroadcastStrategySchema = z.enum(["parallel", "sequential"]);
|
||||
|
||||
export const BroadcastSchema = z
|
||||
.object({
|
||||
strategy: BroadcastStrategySchema.optional(),
|
||||
})
|
||||
.catchall(z.array(z.string()))
|
||||
.optional();
|
||||
|
||||
export const AudioSchema = z
|
||||
.object({
|
||||
transcription: TranscribeAudioSchema,
|
||||
})
|
||||
.optional();
|
||||
264
src/config/zod-schema.core.ts
Normal file
264
src/config/zod-schema.core.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { isSafeExecutableValue } from "../infra/exec-safety.js";
|
||||
|
||||
export const ModelApiSchema = z.union([
|
||||
z.literal("openai-completions"),
|
||||
z.literal("openai-responses"),
|
||||
z.literal("anthropic-messages"),
|
||||
z.literal("google-generative-ai"),
|
||||
z.literal("github-copilot"),
|
||||
]);
|
||||
|
||||
export const ModelCompatSchema = z
|
||||
.object({
|
||||
supportsStore: z.boolean().optional(),
|
||||
supportsDeveloperRole: z.boolean().optional(),
|
||||
supportsReasoningEffort: z.boolean().optional(),
|
||||
maxTokensField: z
|
||||
.union([z.literal("max_completion_tokens"), z.literal("max_tokens")])
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const ModelDefinitionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
api: ModelApiSchema.optional(),
|
||||
reasoning: z.boolean(),
|
||||
input: z.array(z.union([z.literal("text"), z.literal("image")])),
|
||||
cost: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cacheRead: z.number(),
|
||||
cacheWrite: z.number(),
|
||||
}),
|
||||
contextWindow: z.number().positive(),
|
||||
maxTokens: z.number().positive(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
compat: ModelCompatSchema,
|
||||
});
|
||||
|
||||
export const ModelProviderSchema = z.object({
|
||||
baseUrl: z.string().min(1),
|
||||
apiKey: z.string().optional(),
|
||||
api: ModelApiSchema.optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
authHeader: z.boolean().optional(),
|
||||
models: z.array(ModelDefinitionSchema),
|
||||
});
|
||||
|
||||
export const ModelsConfigSchema = z
|
||||
.object({
|
||||
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
|
||||
providers: z.record(z.string(), ModelProviderSchema).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const GroupChatSchema = z
|
||||
.object({
|
||||
mentionPatterns: z.array(z.string()).optional(),
|
||||
historyLimit: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const DmConfigSchema = z.object({
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
export const IdentitySchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const QueueModeSchema = z.union([
|
||||
z.literal("steer"),
|
||||
z.literal("followup"),
|
||||
z.literal("collect"),
|
||||
z.literal("steer-backlog"),
|
||||
z.literal("steer+backlog"),
|
||||
z.literal("queue"),
|
||||
z.literal("interrupt"),
|
||||
]);
|
||||
export const QueueDropSchema = z.union([
|
||||
z.literal("old"),
|
||||
z.literal("new"),
|
||||
z.literal("summarize"),
|
||||
]);
|
||||
export const ReplyToModeSchema = z.union([
|
||||
z.literal("off"),
|
||||
z.literal("first"),
|
||||
z.literal("all"),
|
||||
]);
|
||||
|
||||
// GroupPolicySchema: controls how group messages are handled
|
||||
// Used with .default("allowlist").optional() pattern:
|
||||
// - .optional() allows field omission in input config
|
||||
// - .default("allowlist") ensures runtime always resolves to "allowlist" if not provided
|
||||
export const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
|
||||
|
||||
export const DmPolicySchema = z.enum([
|
||||
"pairing",
|
||||
"allowlist",
|
||||
"open",
|
||||
"disabled",
|
||||
]);
|
||||
|
||||
export const BlockStreamingCoalesceSchema = z.object({
|
||||
minChars: z.number().int().positive().optional(),
|
||||
maxChars: z.number().int().positive().optional(),
|
||||
idleMs: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const BlockStreamingChunkSchema = z.object({
|
||||
minChars: z.number().int().positive().optional(),
|
||||
maxChars: z.number().int().positive().optional(),
|
||||
breakPreference: z
|
||||
.union([
|
||||
z.literal("paragraph"),
|
||||
z.literal("newline"),
|
||||
z.literal("sentence"),
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const HumanDelaySchema = z.object({
|
||||
mode: z
|
||||
.union([z.literal("off"), z.literal("natural"), z.literal("custom")])
|
||||
.optional(),
|
||||
minMs: z.number().int().nonnegative().optional(),
|
||||
maxMs: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const CliBackendSchema = z.object({
|
||||
command: z.string(),
|
||||
args: z.array(z.string()).optional(),
|
||||
output: z
|
||||
.union([z.literal("json"), z.literal("text"), z.literal("jsonl")])
|
||||
.optional(),
|
||||
resumeOutput: z
|
||||
.union([z.literal("json"), z.literal("text"), z.literal("jsonl")])
|
||||
.optional(),
|
||||
input: z.union([z.literal("arg"), z.literal("stdin")]).optional(),
|
||||
maxPromptArgChars: z.number().int().positive().optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
clearEnv: z.array(z.string()).optional(),
|
||||
modelArg: z.string().optional(),
|
||||
modelAliases: z.record(z.string(), z.string()).optional(),
|
||||
sessionArg: z.string().optional(),
|
||||
sessionArgs: z.array(z.string()).optional(),
|
||||
resumeArgs: z.array(z.string()).optional(),
|
||||
sessionMode: z
|
||||
.union([z.literal("always"), z.literal("existing"), z.literal("none")])
|
||||
.optional(),
|
||||
sessionIdFields: z.array(z.string()).optional(),
|
||||
systemPromptArg: z.string().optional(),
|
||||
systemPromptMode: z
|
||||
.union([z.literal("append"), z.literal("replace")])
|
||||
.optional(),
|
||||
systemPromptWhen: z
|
||||
.union([z.literal("first"), z.literal("always"), z.literal("never")])
|
||||
.optional(),
|
||||
imageArg: z.string().optional(),
|
||||
imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(),
|
||||
serialize: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const normalizeAllowFrom = (values?: Array<string | number>): string[] =>
|
||||
(values ?? []).map((v) => String(v).trim()).filter(Boolean);
|
||||
|
||||
export const requireOpenAllowFrom = (params: {
|
||||
policy?: string;
|
||||
allowFrom?: Array<string | number>;
|
||||
ctx: z.RefinementCtx;
|
||||
path: Array<string | number>;
|
||||
message: string;
|
||||
}) => {
|
||||
if (params.policy !== "open") return;
|
||||
const allow = normalizeAllowFrom(params.allowFrom);
|
||||
if (allow.includes("*")) return;
|
||||
params.ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: params.path,
|
||||
message: params.message,
|
||||
});
|
||||
};
|
||||
|
||||
export const MSTeamsReplyStyleSchema = z.enum(["thread", "top-level"]);
|
||||
|
||||
export const RetryConfigSchema = z
|
||||
.object({
|
||||
attempts: z.number().int().min(1).optional(),
|
||||
minDelayMs: z.number().int().min(0).optional(),
|
||||
maxDelayMs: z.number().int().min(0).optional(),
|
||||
jitter: z.number().min(0).max(1).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const QueueModeBySurfaceSchema = z
|
||||
.object({
|
||||
whatsapp: QueueModeSchema.optional(),
|
||||
telegram: QueueModeSchema.optional(),
|
||||
discord: QueueModeSchema.optional(),
|
||||
slack: QueueModeSchema.optional(),
|
||||
signal: QueueModeSchema.optional(),
|
||||
imessage: QueueModeSchema.optional(),
|
||||
msteams: QueueModeSchema.optional(),
|
||||
webchat: QueueModeSchema.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const QueueSchema = z
|
||||
.object({
|
||||
mode: QueueModeSchema.optional(),
|
||||
byChannel: QueueModeBySurfaceSchema,
|
||||
debounceMs: z.number().int().nonnegative().optional(),
|
||||
cap: z.number().int().positive().optional(),
|
||||
drop: QueueDropSchema.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const TranscribeAudioSchema = z
|
||||
.object({
|
||||
command: z.array(z.string()).superRefine((value, ctx) => {
|
||||
const executable = value[0];
|
||||
if (!isSafeExecutableValue(executable)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [0],
|
||||
message: "expected safe executable name or path",
|
||||
});
|
||||
}
|
||||
}),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const HexColorSchema = z
|
||||
.string()
|
||||
.regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)");
|
||||
|
||||
export const ExecutableTokenSchema = z
|
||||
.string()
|
||||
.refine(isSafeExecutableValue, "expected safe executable name or path");
|
||||
|
||||
export const ToolsAudioTranscriptionSchema = z
|
||||
.object({
|
||||
args: z.array(z.string()).optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const NativeCommandsSettingSchema = z.union([
|
||||
z.boolean(),
|
||||
z.literal("auto"),
|
||||
]);
|
||||
|
||||
export const ProviderCommandsSchema = z
|
||||
.object({
|
||||
native: NativeCommandsSettingSchema.optional(),
|
||||
})
|
||||
.optional();
|
||||
84
src/config/zod-schema.hooks.ts
Normal file
84
src/config/zod-schema.hooks.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const HookMappingSchema = z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
match: z
|
||||
.object({
|
||||
path: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
action: z.union([z.literal("wake"), z.literal("agent")]).optional(),
|
||||
wakeMode: z
|
||||
.union([z.literal("now"), z.literal("next-heartbeat")])
|
||||
.optional(),
|
||||
name: z.string().optional(),
|
||||
sessionKey: z.string().optional(),
|
||||
messageTemplate: z.string().optional(),
|
||||
textTemplate: z.string().optional(),
|
||||
deliver: z.boolean().optional(),
|
||||
channel: z
|
||||
.union([
|
||||
z.literal("last"),
|
||||
z.literal("whatsapp"),
|
||||
z.literal("telegram"),
|
||||
z.literal("discord"),
|
||||
z.literal("slack"),
|
||||
z.literal("signal"),
|
||||
z.literal("imessage"),
|
||||
z.literal("msteams"),
|
||||
])
|
||||
.optional(),
|
||||
to: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
thinking: z.string().optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
transform: z
|
||||
.object({
|
||||
module: z.string(),
|
||||
export: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const HooksGmailSchema = z
|
||||
.object({
|
||||
account: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
topic: z.string().optional(),
|
||||
subscription: z.string().optional(),
|
||||
pushToken: z.string().optional(),
|
||||
hookUrl: z.string().optional(),
|
||||
includeBody: z.boolean().optional(),
|
||||
maxBytes: z.number().int().positive().optional(),
|
||||
renewEveryMinutes: z.number().int().positive().optional(),
|
||||
serve: z
|
||||
.object({
|
||||
bind: z.string().optional(),
|
||||
port: z.number().int().positive().optional(),
|
||||
path: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
tailscale: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([z.literal("off"), z.literal("serve"), z.literal("funnel")])
|
||||
.optional(),
|
||||
path: z.string().optional(),
|
||||
target: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
model: z.string().optional(),
|
||||
thinking: z
|
||||
.union([
|
||||
z.literal("off"),
|
||||
z.literal("minimal"),
|
||||
z.literal("low"),
|
||||
z.literal("medium"),
|
||||
z.literal("high"),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
416
src/config/zod-schema.providers-core.ts
Normal file
416
src/config/zod-schema.providers-core.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
BlockStreamingChunkSchema,
|
||||
BlockStreamingCoalesceSchema,
|
||||
DmConfigSchema,
|
||||
DmPolicySchema,
|
||||
ExecutableTokenSchema,
|
||||
GroupPolicySchema,
|
||||
MSTeamsReplyStyleSchema,
|
||||
ProviderCommandsSchema,
|
||||
ReplyToModeSchema,
|
||||
RetryConfigSchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "./zod-schema.core.js";
|
||||
|
||||
export const TelegramTopicSchema = z.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TelegramGroupSchema = z.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(),
|
||||
});
|
||||
|
||||
export const TelegramAccountSchemaBase = z.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
commands: ProviderCommandsSchema,
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
botToken: z.string().optional(),
|
||||
tokenFile: z.string().optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).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(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
draftChunk: BlockStreamingChunkSchema.optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
retry: RetryConfigSchema,
|
||||
proxy: z.string().optional(),
|
||||
webhookUrl: z.string().optional(),
|
||||
webhookSecret: z.string().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine(
|
||||
(value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
|
||||
accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
export const DiscordDmSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
policy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupEnabled: z.boolean().optional(),
|
||||
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.policy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.discord.dm.policy="open" requires channels.discord.dm.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
export const DiscordGuildChannelSchema = z.object({
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
autoThread: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const DiscordGuildSchema = z.object({
|
||||
slug: z.string().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
channels: z
|
||||
.record(z.string(), DiscordGuildChannelSchema.optional())
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const DiscordAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
commands: ProviderCommandsSchema,
|
||||
token: z.string().optional(),
|
||||
allowBots: z.boolean().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(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
maxLinesPerMessage: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
retry: RetryConfigSchema,
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
stickers: z.boolean().optional(),
|
||||
polls: z.boolean().optional(),
|
||||
permissions: z.boolean().optional(),
|
||||
messages: z.boolean().optional(),
|
||||
threads: z.boolean().optional(),
|
||||
pins: z.boolean().optional(),
|
||||
search: z.boolean().optional(),
|
||||
memberInfo: z.boolean().optional(),
|
||||
roleInfo: z.boolean().optional(),
|
||||
roles: z.boolean().optional(),
|
||||
channelInfo: z.boolean().optional(),
|
||||
voiceStatus: z.boolean().optional(),
|
||||
events: z.boolean().optional(),
|
||||
moderation: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
dm: DiscordDmSchema.optional(),
|
||||
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
|
||||
});
|
||||
|
||||
export const DiscordConfigSchema = DiscordAccountSchema.extend({
|
||||
accounts: z.record(z.string(), DiscordAccountSchema.optional()).optional(),
|
||||
});
|
||||
|
||||
export const SlackDmSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
policy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupEnabled: z.boolean().optional(),
|
||||
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.policy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.slack.dm.policy="open" requires channels.slack.dm.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
export const SlackChannelSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
allowBots: z.boolean().optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SlackAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
commands: ProviderCommandsSchema,
|
||||
botToken: z.string().optional(),
|
||||
appToken: z.string().optional(),
|
||||
allowBots: z.boolean().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(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
messages: z.boolean().optional(),
|
||||
pins: z.boolean().optional(),
|
||||
search: z.boolean().optional(),
|
||||
permissions: z.boolean().optional(),
|
||||
memberInfo: z.boolean().optional(),
|
||||
channelInfo: z.boolean().optional(),
|
||||
emojiList: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
slashCommand: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
sessionPrefix: z.string().optional(),
|
||||
ephemeral: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
dm: SlackDmSchema.optional(),
|
||||
channels: z.record(z.string(), SlackChannelSchema.optional()).optional(),
|
||||
});
|
||||
|
||||
export const SlackConfigSchema = SlackAccountSchema.extend({
|
||||
accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(),
|
||||
});
|
||||
|
||||
export const SignalAccountSchemaBase = z.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
account: z.string().optional(),
|
||||
httpUrl: z.string().optional(),
|
||||
httpHost: z.string().optional(),
|
||||
httpPort: z.number().int().positive().optional(),
|
||||
cliPath: ExecutableTokenSchema.optional(),
|
||||
autoStart: z.boolean().optional(),
|
||||
receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(),
|
||||
ignoreAttachments: z.boolean().optional(),
|
||||
ignoreStories: z.boolean().optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).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(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
});
|
||||
|
||||
export const SignalAccountSchema = SignalAccountSchemaBase.superRefine(
|
||||
(value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const SignalConfigSchema = SignalAccountSchemaBase.extend({
|
||||
accounts: z.record(z.string(), SignalAccountSchema.optional()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
export const IMessageAccountSchemaBase = z.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
cliPath: ExecutableTokenSchema.optional(),
|
||||
dbPath: z.string().optional(),
|
||||
service: z
|
||||
.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")])
|
||||
.optional(),
|
||||
region: z.string().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).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(),
|
||||
includeAttachments: z.boolean().optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
textChunkLimit: 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(),
|
||||
});
|
||||
|
||||
export const IMessageAccountSchema = IMessageAccountSchemaBase.superRefine(
|
||||
(value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.imessage.dmPolicy="open" requires channels.imessage.allowFrom to include "*"',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({
|
||||
accounts: z.record(z.string(), IMessageAccountSchema.optional()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.imessage.dmPolicy="open" requires channels.imessage.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
export const MSTeamsChannelSchema = z.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
||||
});
|
||||
|
||||
export const MSTeamsTeamSchema = z.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
||||
channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(),
|
||||
});
|
||||
|
||||
export const MSTeamsConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
appId: z.string().optional(),
|
||||
appPassword: z.string().optional(),
|
||||
tenantId: z.string().optional(),
|
||||
webhook: z
|
||||
.object({
|
||||
port: z.number().int().positive().optional(),
|
||||
path: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
groupAllowFrom: z.array(z.string()).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
mediaAllowHosts: z.array(z.string()).optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
||||
teams: z.record(z.string(), MSTeamsTeamSchema.optional()).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.msteams.dmPolicy="open" requires channels.msteams.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
122
src/config/zod-schema.providers-whatsapp.ts
Normal file
122
src/config/zod-schema.providers-whatsapp.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
BlockStreamingCoalesceSchema,
|
||||
DmConfigSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
} from "./zod-schema.core.js";
|
||||
|
||||
export 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 "*"',
|
||||
});
|
||||
});
|
||||
|
||||
export 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 "*"',
|
||||
});
|
||||
});
|
||||
26
src/config/zod-schema.providers.ts
Normal file
26
src/config/zod-schema.providers.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
DiscordConfigSchema,
|
||||
IMessageConfigSchema,
|
||||
MSTeamsConfigSchema,
|
||||
SignalConfigSchema,
|
||||
SlackConfigSchema,
|
||||
TelegramConfigSchema,
|
||||
} from "./zod-schema.providers-core.js";
|
||||
import { WhatsAppConfigSchema } from "./zod-schema.providers-whatsapp.js";
|
||||
|
||||
export * from "./zod-schema.providers-core.js";
|
||||
export * from "./zod-schema.providers-whatsapp.js";
|
||||
|
||||
export 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();
|
||||
85
src/config/zod-schema.session.ts
Normal file
85
src/config/zod-schema.session.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
GroupChatSchema,
|
||||
NativeCommandsSettingSchema,
|
||||
QueueSchema,
|
||||
} from "./zod-schema.core.js";
|
||||
|
||||
export const SessionSchema = z
|
||||
.object({
|
||||
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
||||
resetTriggers: z.array(z.string()).optional(),
|
||||
idleMinutes: z.number().int().positive().optional(),
|
||||
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
||||
store: z.string().optional(),
|
||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||
typingMode: z
|
||||
.union([
|
||||
z.literal("never"),
|
||||
z.literal("instant"),
|
||||
z.literal("thinking"),
|
||||
z.literal("message"),
|
||||
])
|
||||
.optional(),
|
||||
mainKey: z.string().optional(),
|
||||
sendPolicy: z
|
||||
.object({
|
||||
default: z.union([z.literal("allow"), z.literal("deny")]).optional(),
|
||||
rules: z
|
||||
.array(
|
||||
z.object({
|
||||
action: z.union([z.literal("allow"), z.literal("deny")]),
|
||||
match: z
|
||||
.object({
|
||||
channel: z.string().optional(),
|
||||
chatType: z
|
||||
.union([
|
||||
z.literal("direct"),
|
||||
z.literal("group"),
|
||||
z.literal("room"),
|
||||
])
|
||||
.optional(),
|
||||
keyPrefix: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
agentToAgent: z
|
||||
.object({
|
||||
maxPingPongTurns: z.number().int().min(0).max(5).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const MessagesSchema = z
|
||||
.object({
|
||||
messagePrefix: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
groupChat: GroupChatSchema,
|
||||
queue: QueueSchema,
|
||||
ackReaction: z.string().optional(),
|
||||
ackReactionScope: z
|
||||
.enum(["group-mentions", "group-all", "direct", "all"])
|
||||
.optional(),
|
||||
removeAckAfterReply: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const CommandsSchema = z
|
||||
.object({
|
||||
native: NativeCommandsSettingSchema.optional().default("auto"),
|
||||
text: z.boolean().optional(),
|
||||
bash: z.boolean().optional(),
|
||||
bashForegroundMs: z.number().int().min(0).max(30_000).optional(),
|
||||
config: z.boolean().optional(),
|
||||
debug: z.boolean().optional(),
|
||||
restart: z.boolean().optional(),
|
||||
useAccessGroups: z.boolean().optional(),
|
||||
})
|
||||
.optional()
|
||||
.default({ native: "auto" });
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user