diff --git a/apps/macos/Sources/Clawdis/ConfigSettings.swift b/apps/macos/Sources/Clawdis/ConfigSettings.swift index b3e6f6728..043139351 100644 --- a/apps/macos/Sources/Clawdis/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdis/ConfigSettings.swift @@ -66,7 +66,7 @@ struct ConfigSettings: View { private var header: some View { Text("Clawdis CLI config") .font(.title3.weight(.semibold)) - Text("Edit ~/.clawdis/clawdis.json (agent / inbound.session).") + Text("Edit ~/.clawdis/clawdis.json (agent / session / routing / messages).") .font(.callout) .foregroundStyle(.secondary) } diff --git a/apps/macos/Sources/Clawdis/DebugActions.swift b/apps/macos/Sources/Clawdis/DebugActions.swift index aa2f2fddf..781bc68bc 100644 --- a/apps/macos/Sources/Clawdis/DebugActions.swift +++ b/apps/macos/Sources/Clawdis/DebugActions.swift @@ -163,8 +163,7 @@ enum DebugActions { guard let data = try? Data(contentsOf: configURL), let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let inbound = parsed["inbound"] as? [String: Any], - let session = inbound["session"] as? [String: Any], + let session = parsed["session"] as? [String: Any], let path = session["store"] as? String, !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index 1afc1ad89..d69602b19 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -681,8 +681,7 @@ struct DebugSettings: View { guard let data = try? Data(contentsOf: url), let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let inbound = parsed["inbound"] as? [String: Any], - let session = inbound["session"] as? [String: Any], + let session = parsed["session"] as? [String: Any], let path = session["store"] as? String else { self.sessionStorePath = SessionLoader.defaultStorePath @@ -701,11 +700,9 @@ struct DebugSettings: View { root = parsed } - var inbound = root["inbound"] as? [String: Any] ?? [:] - var session = inbound["session"] as? [String: Any] ?? [:] + var session = root["session"] as? [String: Any] ?? [:] session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed - inbound["session"] = session - root["inbound"] = inbound + root["session"] = session do { let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) diff --git a/apps/macos/Sources/Clawdis/GatewayConnection.swift b/apps/macos/Sources/Clawdis/GatewayConnection.swift index ddba672a5..3bafd4184 100644 --- a/apps/macos/Sources/Clawdis/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdis/GatewayConnection.swift @@ -287,15 +287,11 @@ actor GatewayConnection { extension GatewayConnection { struct ConfigGetSnapshot: Decodable, Sendable { struct SnapshotConfig: Decodable, Sendable { - struct Inbound: Decodable, Sendable { - struct Session: Decodable, Sendable { - let mainKey: String? - } - - let session: Session? + struct Session: Decodable, Sendable { + let mainKey: String? } - let inbound: Inbound? + let session: Session? } let config: SnapshotConfig? @@ -303,7 +299,7 @@ extension GatewayConnection { static func mainSessionKey(fromConfigGetData data: Data) throws -> String { let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data) - let raw = snapshot.config?.inbound?.session?.mainKey + let raw = snapshot.config?.session?.mainKey let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "main" : trimmed } diff --git a/apps/macos/Tests/ClawdisIPCTests/WebChatMainSessionKeyTests.swift b/apps/macos/Tests/ClawdisIPCTests/WebChatMainSessionKeyTests.swift index ad0f58269..9eecc94e7 100644 --- a/apps/macos/Tests/ClawdisIPCTests/WebChatMainSessionKeyTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/WebChatMainSessionKeyTests.swift @@ -27,7 +27,7 @@ import Testing "raw": null, "parsed": {}, "valid": true, - "config": { "inbound": { "session": { "mainKey": " primary " } } }, + "config": { "session": { "mainKey": " primary " } }, "issues": [] } """ @@ -38,7 +38,7 @@ import Testing @Test func configGetSnapshotMainKeyFallsBackWhenEmptyOrWhitespace() throws { let json = """ { - "config": { "inbound": { "session": { "mainKey": " " } } } + "config": { "session": { "mainKey": " " } } } """ let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8)) diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index d826d8f7d..bded70828 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -106,10 +106,10 @@ describe("directive parsing", () => { model: "claude-opus-4-5", workspace: path.join(home, "clawd"), }, - inbound: { + routing: { allowFrom: ["*"], - session: { store: path.join(home, "sessions.json") }, }, + session: { store: path.join(home, "sessions.json") }, }, ); @@ -134,9 +134,7 @@ describe("directive parsing", () => { model: "claude-opus-4-5", workspace: path.join(home, "clawd"), }, - inbound: { - session: { store: path.join(home, "sessions.json") }, - }, + session: { store: path.join(home, "sessions.json") }, }, ); @@ -189,10 +187,10 @@ describe("directive parsing", () => { model: "claude-opus-4-5", workspace: path.join(home, "clawd"), }, - inbound: { + routing: { allowFrom: ["*"], - session: { store: storePath }, }, + session: { store: storePath }, }, ); @@ -251,10 +249,10 @@ describe("directive parsing", () => { model: "claude-opus-4-5", workspace: path.join(home, "clawd"), }, - inbound: { + routing: { allowFrom: ["*"], - session: { store: storePath }, }, + session: { store: storePath }, }, ); @@ -284,9 +282,7 @@ describe("directive parsing", () => { "openai/gpt-4.1-mini", ], }, - inbound: { - session: { store: storePath }, - }, + session: { store: storePath }, }, ); @@ -313,9 +309,7 @@ describe("directive parsing", () => { workspace: path.join(home, "clawd"), allowedModels: ["openai/gpt-4.1-mini"], }, - inbound: { - session: { store: storePath }, - }, + session: { store: storePath }, }, ); @@ -354,10 +348,10 @@ describe("directive parsing", () => { workspace: path.join(home, "clawd"), allowedModels: ["openai/gpt-4.1-mini"], }, - inbound: { + routing: { allowFrom: ["*"], - session: { store: storePath }, }, + session: { store: storePath }, }, ); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 667491256..97d6c4bd0 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -39,10 +39,10 @@ function makeCfg(home: string) { model: "claude-opus-4-5", workspace: join(home, "clawd"), }, - inbound: { + routing: { allowFrom: ["*"], - session: { store: join(home, "sessions.json") }, }, + session: { store: join(home, "sessions.json") }, }; } @@ -119,7 +119,7 @@ describe("trigger handling", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("Group activation set to always"); const store = JSON.parse( - await fs.readFile(cfg.inbound.session.store, "utf-8"), + await fs.readFile(cfg.session.store, "utf-8"), ) as Record; expect(store["group:123@g.us"]?.groupActivation).toBe("always"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); @@ -172,11 +172,11 @@ describe("trigger handling", () => { model: "claude-opus-4-5", workspace: join(home, "clawd"), }, - inbound: { + routing: { allowFrom: ["*"], - session: { store: join(home, "sessions.json") }, groupChat: { requireMention: false }, }, + session: { store: join(home, "sessions.json") }, }, ); @@ -214,11 +214,11 @@ describe("trigger handling", () => { model: "claude-opus-4-5", workspace: join(home, "clawd"), }, - inbound: { + routing: { allowFrom: ["*"], - session: { - store: join(tmpdir(), `clawdis-session-test-${Date.now()}.json`), - }, + }, + session: { + store: join(tmpdir(), `clawdis-session-test-${Date.now()}.json`), }, }, ); @@ -254,11 +254,11 @@ describe("trigger handling", () => { model: "claude-opus-4-5", workspace: join(home, "clawd"), }, - inbound: { + routing: { allowFrom: ["*"], - session: { - store: join(tmpdir(), `clawdis-session-test-${Date.now()}.json`), - }, + }, + session: { + store: join(tmpdir(), `clawdis-session-test-${Date.now()}.json`), }, }, ); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index f7a2e3b27..5ddff6af9 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -137,7 +137,7 @@ function stripMentions( cfg: ClawdisConfig | undefined, ): string { let result = text; - const patterns = cfg?.inbound?.groupChat?.mentionPatterns ?? []; + const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? []; for (const p of patterns) { try { const re = new RegExp(p, "gi"); @@ -166,7 +166,7 @@ export async function getReplyFromConfig( const cfg = configOverride ?? loadConfig(); const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; const agentCfg = cfg.agent; - const sessionCfg = cfg.inbound?.session; + const sessionCfg = cfg.session; const defaultProvider = agentCfg?.provider?.trim() || DEFAULT_PROVIDER; const defaultModel = agentCfg?.model?.trim() || DEFAULT_MODEL; @@ -227,7 +227,7 @@ export async function getReplyFromConfig( let transcribedText: string | undefined; // Optional audio transcription before templating/session handling. - if (cfg.inbound?.transcribeAudio && isAudio(ctx.MediaType)) { + if (cfg.routing?.transcribeAudio && isAudio(ctx.MediaType)) { const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime); if (transcribed?.text) { transcribedText = transcribed.text; @@ -361,7 +361,7 @@ export async function getReplyFromConfig( sessionCtx.BodyStripped = modelCleaned; const defaultGroupActivation = () => { - const requireMention = cfg.inbound?.groupChat?.requireMention; + const requireMention = cfg.routing?.groupChat?.requireMention; return requireMention === false ? "always" : "mention"; }; @@ -611,7 +611,7 @@ export async function getReplyFromConfig( } // Optional allowlist by origin number (E.164 without whatsapp: prefix) - const configuredAllowFrom = cfg.inbound?.allowFrom; + const configuredAllowFrom = cfg.routing?.allowFrom; const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); const isSamePhone = from && to && from === to; diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 989e8acfa..2af3bf78a 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -16,7 +16,7 @@ import { } from "../config/sessions.js"; import type { ThinkLevel, VerboseLevel } from "./thinking.js"; -type AgentConfig = NonNullable["agent"]; +type AgentConfig = NonNullable; type StatusArgs = { agent: AgentConfig; diff --git a/src/auto-reply/transcription.test.ts b/src/auto-reply/transcription.test.ts index 3100e2acd..8bdbff0bd 100644 --- a/src/auto-reply/transcription.test.ts +++ b/src/auto-reply/transcription.test.ts @@ -38,7 +38,7 @@ describe("transcribeInboundAudio", () => { global.fetch = fetchMock; const cfg = { - inbound: { + routing: { transcribeAudio: { command: ["echo", "{{MediaPath}}"], timeoutSeconds: 5, @@ -58,7 +58,7 @@ describe("transcribeInboundAudio", () => { it("returns undefined when no transcription command", async () => { const res = await transcribeInboundAudio( - { inbound: {} } as never, + { routing: {} } as never, {} as never, runtime as never, ); diff --git a/src/auto-reply/transcription.ts b/src/auto-reply/transcription.ts index 4cecb8f95..598c871f3 100644 --- a/src/auto-reply/transcription.ts +++ b/src/auto-reply/transcription.ts @@ -18,7 +18,7 @@ export async function transcribeInboundAudio( ctx: MsgContext, runtime: RuntimeEnv, ): Promise<{ text: string } | undefined> { - const transcriber = cfg.inbound?.transcribeAudio; + const transcriber = cfg.routing?.transcribeAudio; if (!transcriber?.command?.length) return undefined; const timeoutMs = Math.max((transcriber.timeoutSeconds ?? 45) * 1000, 1_000); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index a430dd5bc..aed7d923f 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -46,7 +46,7 @@ async function withTempHome(fn: (home: string) => Promise): Promise { function mockConfig( home: string, storePath: string, - inboundOverrides?: Partial>, + routingOverrides?: Partial>, ) { configSpy.mockReturnValue({ agent: { @@ -54,10 +54,8 @@ function mockConfig( model: "claude-opus-4-5", workspace: path.join(home, "clawd"), }, - inbound: { - session: { store: storePath, mainKey: "main" }, - ...inboundOverrides, - }, + session: { store: storePath, mainKey: "main" }, + routing: routingOverrides ? { ...routingOverrides } : undefined, }); } diff --git a/src/commands/agent.ts b/src/commands/agent.ts index cff92978a..739d72a38 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -68,7 +68,7 @@ function resolveSession(opts: { to?: string; sessionId?: string; }): SessionResolution { - const sessionCfg = opts.cfg.inbound?.session; + const sessionCfg = opts.cfg.session; const scope = sessionCfg?.scope ?? "per-sender"; const mainKey = sessionCfg?.mainKey ?? "main"; const idleMinutes = Math.max( @@ -150,7 +150,7 @@ export async function agentCommand( }); const workspaceDir = workspace.dir; - const allowFrom = (cfg.inbound?.allowFrom ?? []) + const allowFrom = (cfg.routing?.allowFrom ?? []) .map((val) => normalizeE164(val)) .filter((val) => val.length > 1); @@ -421,7 +421,7 @@ export async function agentCommand( if (deliver) { if (deliveryProvider === "whatsapp" && !whatsappTarget) { const err = new Error( - "Delivering to WhatsApp requires --to or inbound.allowFrom[0]", + "Delivering to WhatsApp requires --to or routing.allowFrom[0]", ); if (!bestEffortDeliver) throw err; logDeliveryError(err); diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index e48e9dd05..c31123404 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -32,7 +32,7 @@ describe("getHealthSnapshot", () => { }); it("skips telegram probe when not configured", async () => { - testConfig = { inbound: { reply: { session: { store: "/tmp/x" } } } }; + testConfig = { session: { store: "/tmp/x" } }; testStore = { global: { updatedAt: Date.now() }, unknown: { updatedAt: Date.now() }, diff --git a/src/commands/health.ts b/src/commands/health.ts index 6eaa02193..cd1b1aec6 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -55,7 +55,7 @@ export async function getHealthSnapshot( const linked = await webAuthExists(); const authAgeMs = getWebAuthAgeMs(); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); - const storePath = resolveStorePath(cfg.inbound?.session?.store); + const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); const sessions = Object.entries(store) .filter(([key]) => key !== "global" && key !== "unknown") diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index da0bd18d0..adad00577 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -156,7 +156,7 @@ export async function sessionsCommand( lookupContextTokens(cfg.agent?.model) ?? DEFAULT_CONTEXT_TOKENS; const configModel = cfg.agent?.model ?? DEFAULT_MODEL; - const storePath = resolveStorePath(opts.store ?? cfg.inbound?.session?.store); + const storePath = resolveStorePath(opts.store ?? cfg.session?.store); const store = loadSessionStore(storePath); let activeMinutes: number | undefined; diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 16c3324c9..705e7560d 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -45,7 +45,6 @@ export async function setupCommand( const existingRaw = await readConfigFileRaw(); const cfg = existingRaw.parsed; - const inbound = cfg.inbound ?? {}; const agent = cfg.agent ?? {}; const workspace = diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index ba5855b35..83b784a36 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -32,7 +32,7 @@ vi.mock("../web/session.js", () => ({ logWebSelfId: mocks.logWebSelfId, })); vi.mock("../config/config.js", () => ({ - loadConfig: () => ({ inbound: { reply: { session: {} } } }), + loadConfig: () => ({ session: {} }), })); import { statusCommand } from "./status.js"; diff --git a/src/commands/status.ts b/src/commands/status.ts index ea046657a..2e75da381 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -66,7 +66,7 @@ export async function getStatusSummary(): Promise { lookupContextTokens(configModel) ?? DEFAULT_CONTEXT_TOKENS; - const storePath = resolveStorePath(cfg.inbound?.session?.store); + const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); const now = Date.now(); const sessions = Object.entries(store) diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 268aea890..a94ff0c6d 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -36,7 +36,8 @@ describe("config identity defaults", () => { JSON.stringify( { identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" }, - inbound: {}, + messages: {}, + routing: {}, }, null, 2, @@ -48,8 +49,8 @@ describe("config identity defaults", () => { const { loadConfig } = await import("./config.js"); const cfg = loadConfig(); - expect(cfg.inbound?.responsePrefix).toBe("🦥"); - expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual([ + expect(cfg.messages?.responsePrefix).toBe("🦥"); + expect(cfg.routing?.groupChat?.mentionPatterns).toEqual([ "\\b@?Samantha\\b", ]); }); @@ -68,8 +69,10 @@ describe("config identity defaults", () => { theme: "space lobster", emoji: "🦞", }, - inbound: { + messages: { responsePrefix: "✅", + }, + routing: { groupChat: { mentionPatterns: ["@clawd"] }, }, }, @@ -83,8 +86,8 @@ describe("config identity defaults", () => { const { loadConfig } = await import("./config.js"); const cfg = loadConfig(); - expect(cfg.inbound?.responsePrefix).toBe("✅"); - expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual(["@clawd"]); + expect(cfg.messages?.responsePrefix).toBe("✅"); + expect(cfg.routing?.groupChat?.mentionPatterns).toEqual(["@clawd"]); }); }); @@ -97,7 +100,8 @@ describe("config identity defaults", () => { JSON.stringify( { identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" }, - inbound: {}, + messages: {}, + routing: {}, }, null, 2, @@ -109,12 +113,12 @@ describe("config identity defaults", () => { const { loadConfig } = await import("./config.js"); const cfg = loadConfig(); - expect(cfg.inbound?.responsePrefix).toBe("🦥"); - expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual([ + expect(cfg.messages?.responsePrefix).toBe("🦥"); + expect(cfg.routing?.groupChat?.mentionPatterns).toEqual([ "\\b@?Samantha\\b", ]); expect(cfg.agent).toBeUndefined(); - expect(cfg.inbound?.session).toBeUndefined(); + expect(cfg.session).toBeUndefined(); }); }); }); diff --git a/src/config/config.ts b/src/config/config.ts index 617c4788d..f0093d697 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -79,6 +79,22 @@ export type GroupChatConfig = { historyLimit?: number; }; +export type RoutingConfig = { + allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) + transcribeAudio?: { + // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. + command: string[]; + timeoutSeconds?: number; + }; + groupChat?: GroupChatConfig; +}; + +export type MessagesConfig = { + messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdis]" if no allowFrom, else "") + responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") + timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC) +}; + export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback"; export type BridgeConfig = { @@ -249,19 +265,9 @@ export type ClawdisConfig = { /** Periodic background heartbeat runs (minutes). 0 disables. */ heartbeatMinutes?: number; }; - inbound?: { - allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) - messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdis]" if no allowFrom, else "") - responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") - timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC) - transcribeAudio?: { - // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. - command: string[]; - timeoutSeconds?: number; - }; - groupChat?: GroupChatConfig; - session?: SessionConfig; - }; + routing?: RoutingConfig; + messages?: MessagesConfig; + session?: SessionConfig; web?: WebConfig; telegram?: TelegramConfig; cron?: CronConfig; @@ -331,6 +337,49 @@ const ModelsConfigSchema = z }) .optional(); +const GroupChatSchema = z + .object({ + requireMention: z.boolean().optional(), + mentionPatterns: z.array(z.string()).optional(), + historyLimit: z.number().int().positive().optional(), + }) + .optional(); + +const TranscribeAudioSchema = z + .object({ + command: z.array(z.string()), + timeoutSeconds: z.number().int().positive().optional(), + }) + .optional(); + +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(), + mainKey: z.string().optional(), + }) + .optional(); + +const MessagesSchema = z + .object({ + messagePrefix: z.string().optional(), + responsePrefix: z.string().optional(), + timestampPrefix: z.union([z.boolean(), z.string()]).optional(), + }) + .optional(); + +const RoutingSchema = z + .object({ + allowFrom: z.array(z.string()).optional(), + groupChat: GroupChatSchema, + transcribeAudio: TranscribeAudioSchema, + }) + .optional(); + const ClawdisSchema = z.object({ identity: z .object({ @@ -402,40 +451,9 @@ const ClawdisSchema = z.object({ heartbeatMinutes: z.number().nonnegative().optional(), }) .optional(), - inbound: z - .object({ - allowFrom: z.array(z.string()).optional(), - messagePrefix: z.string().optional(), - responsePrefix: z.string().optional(), - timestampPrefix: z.union([z.boolean(), z.string()]).optional(), - groupChat: z - .object({ - requireMention: z.boolean().optional(), - mentionPatterns: z.array(z.string()).optional(), - historyLimit: z.number().int().positive().optional(), - }) - .optional(), - transcribeAudio: z - .object({ - command: z.array(z.string()), - timeoutSeconds: z.number().int().positive().optional(), - }) - .optional(), - session: 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(), - mainKey: z.string().optional(), - }) - .optional(), - }) - .optional(), + routing: RoutingSchema, + messages: MessagesSchema, + session: SessionSchema, cron: z .object({ enabled: z.boolean().optional(), @@ -590,14 +608,15 @@ function applyIdentityDefaults(cfg: ClawdisConfig): ClawdisConfig { const emoji = identity.emoji?.trim(); const name = identity.name?.trim(); - const inbound = cfg.inbound ?? {}; - const groupChat = inbound.groupChat ?? {}; + const messages = cfg.messages ?? {}; + const routing = cfg.routing ?? {}; + const groupChat = routing.groupChat ?? {}; let mutated = false; const next: ClawdisConfig = { ...cfg }; - if (emoji && !inbound.responsePrefix) { - next.inbound = { ...inbound, responsePrefix: emoji }; + if (emoji && !messages.responsePrefix) { + next.messages = { ...(next.messages ?? messages), responsePrefix: emoji }; mutated = true; } @@ -605,8 +624,8 @@ function applyIdentityDefaults(cfg: ClawdisConfig): ClawdisConfig { const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp); const re = parts.length ? parts.join("\\s+") : escapeRegExp(name); const pattern = `\\b@?${re}\\b`; - next.inbound = { - ...(next.inbound ?? inbound), + next.routing = { + ...(next.routing ?? routing), groupChat: { ...groupChat, mentionPatterns: [pattern] }, }; mutated = true; diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index ba3600f51..9b8751d49 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -57,9 +57,7 @@ function makeCfg(home: string, storePath: string): ClawdisConfig { model: "claude-opus-4-5", workspace: path.join(home, "clawd"), }, - inbound: { - session: { store: storePath, mainKey: "main" }, - }, + session: { store: storePath, mainKey: "main" }, } as ClawdisConfig; } diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 48c4d89a5..10cb3c237 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -63,7 +63,7 @@ function resolveDeliveryTarget( ? jobPayload.to.trim() : undefined; - const sessionCfg = cfg.inbound?.session; + const sessionCfg = cfg.session; const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; const storePath = resolveStorePath(sessionCfg?.store); const store = loadSessionStore(storePath); @@ -88,7 +88,7 @@ function resolveDeliveryTarget( const sanitizedWhatsappTo = (() => { if (channel !== "whatsapp") return to; - const rawAllow = cfg.inbound?.allowFrom ?? []; + const rawAllow = cfg.routing?.allowFrom ?? []; if (rawAllow.includes("*")) return to; const allowFrom = rawAllow .map((val) => normalizeE164(val)) @@ -111,7 +111,7 @@ function resolveCronSession(params: { sessionKey: string; nowMs: number; }) { - const sessionCfg = params.cfg.inbound?.session; + const sessionCfg = params.cfg.session; const idleMinutes = Math.max( sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1, diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index ebe3c82a3..7876bf83d 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -187,10 +187,10 @@ vi.mock("../config/config.js", () => { model: "claude-opus-4-5", workspace: path.join(os.tmpdir(), "clawd-gateway-test"), }, - inbound: { + routing: { allowFrom: testAllowFrom, - session: { mainKey: "main", store: testSessionStorePath }, }, + session: { mainKey: "main", store: testSessionStorePath }, gateway: (() => { const gateway: Record = {}; if (testGatewayBind) gateway.bind = testGatewayBind; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index cbd2a71c3..41a31949d 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -732,7 +732,7 @@ function capArrayByJsonBytes( function loadSessionEntry(sessionKey: string) { const cfg = loadConfig(); - const sessionCfg = cfg.inbound?.session; + const sessionCfg = cfg.session; const storePath = sessionCfg?.store ? resolveStorePath(sessionCfg.store) : resolveStorePath(undefined); @@ -1885,7 +1885,7 @@ export async function startGatewayServer( } const p = params as SessionsListParams; const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.inbound?.session?.store); + const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); const result = listSessionsFromStore({ cfg, @@ -1920,7 +1920,7 @@ export async function startGatewayServer( } const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.inbound?.session?.store); + const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); const now = Date.now(); @@ -2503,7 +2503,7 @@ export async function startGatewayServer( const sessionKeyRaw = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : ""; const mainKey = - (loadConfig().inbound?.session?.mainKey ?? "main").trim() || "main"; + (loadConfig().session?.mainKey ?? "main").trim() || "main"; const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : mainKey; const { storePath, store, entry } = loadSessionEntry(sessionKey); const now = Date.now(); @@ -4188,7 +4188,7 @@ export async function startGatewayServer( } const p = params as SessionsListParams; const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.inbound?.session?.store); + const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); const result = listSessionsFromStore({ cfg, @@ -4224,7 +4224,7 @@ export async function startGatewayServer( } const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.inbound?.session?.store); + const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); const now = Date.now(); @@ -5267,7 +5267,7 @@ export async function startGatewayServer( } resolvedSessionId = sessionId; const mainKey = - (cfg.inbound?.session?.mainKey ?? "main").trim() || "main"; + (cfg.session?.mainKey ?? "main").trim() || "main"; if (requestedSessionKey === mainKey) { chatRunSessions.set(sessionId, { sessionKey: requestedSessionKey, @@ -5338,7 +5338,7 @@ export async function startGatewayServer( if (explicit) return resolvedTo; const cfg = cfgForAgent ?? loadConfig(); - const rawAllow = cfg.inbound?.allowFrom ?? []; + const rawAllow = cfg.routing?.allowFrom ?? []; if (rawAllow.includes("*")) return resolvedTo; const allowFrom = rawAllow .map((val) => normalizeE164(val)) diff --git a/src/infra/provider-summary.ts b/src/infra/provider-summary.ts index 4acccce19..fcdc0c3c8 100644 --- a/src/infra/provider-summary.ts +++ b/src/infra/provider-summary.ts @@ -33,8 +33,8 @@ export async function buildProviderSummary( : chalk.cyan("Telegram: not configured"), ); - const allowFrom = effective.inbound?.allowFrom?.length - ? effective.inbound.allowFrom.map(normalizeE164).filter(Boolean) + const allowFrom = effective.routing?.allowFrom?.length + ? effective.routing.allowFrom.map(normalizeE164).filter(Boolean) : []; if (allowFrom.length) { lines.push(chalk.cyan(`AllowFrom: ${allowFrom.join(", ")}`)); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index a83651d8b..b3b7cec07 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -162,7 +162,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { } if (!isGroup) { - const sessionCfg = cfg.inbound?.session; + const sessionCfg = cfg.session; const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; const storePath = resolveStorePath(sessionCfg?.store); await updateLastRoute({ diff --git a/src/utils.ts b/src/utils.ts index 21a7a2bdc..6882209a7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -33,7 +33,7 @@ export function normalizeE164(number: string): string { /** * "Self-chat mode" heuristic (single phone): the gateway is logged in as the owner's own WhatsApp account, - * and `inbound.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the + * and `routing.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the * "bot" and the human are the same WhatsApp identity (e.g. auto read receipts, @mention JID triggers). */ export function isSelfChatMode( diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 1717dc656..58f3514df 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -158,9 +158,7 @@ describe("heartbeat helpers", () => { }); it("resolves heartbeat minutes with default and overrides", () => { - const cfgBase: ClawdisConfig = { - inbound: {}, - }; + const cfgBase: ClawdisConfig = {}; expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30); expect( resolveReplyHeartbeatMinutes({ @@ -183,10 +181,10 @@ describe("resolveHeartbeatRecipients", () => { main: { updatedAt: now, lastChannel: "whatsapp", lastTo: "+1000" }, }); const cfg: ClawdisConfig = { - inbound: { + routing: { allowFrom: ["+1999"], - session: { store: store.storePath }, }, + session: { store: store.storePath }, }; const result = resolveHeartbeatRecipients(cfg); expect(result.source).toBe("session-single"); @@ -201,10 +199,10 @@ describe("resolveHeartbeatRecipients", () => { alt: { updatedAt: now - 10, lastChannel: "whatsapp", lastTo: "+2000" }, }); const cfg: ClawdisConfig = { - inbound: { + routing: { allowFrom: ["+1999"], - session: { store: store.storePath }, }, + session: { store: store.storePath }, }; const result = resolveHeartbeatRecipients(cfg); expect(result.source).toBe("session-ambiguous"); @@ -215,10 +213,10 @@ describe("resolveHeartbeatRecipients", () => { it("filters wildcard allowFrom when no sessions exist", async () => { const store = await makeSessionStore({}); const cfg: ClawdisConfig = { - inbound: { + routing: { allowFrom: ["*"], - session: { store: store.storePath }, }, + session: { store: store.storePath }, }; const result = resolveHeartbeatRecipients(cfg); expect(result.recipients).toHaveLength(0); @@ -232,10 +230,10 @@ describe("resolveHeartbeatRecipients", () => { main: { updatedAt: now, lastChannel: "whatsapp", lastTo: "+1000" }, }); const cfg: ClawdisConfig = { - inbound: { + routing: { allowFrom: ["+1999"], - session: { store: store.storePath }, }, + session: { store: store.storePath }, }; const result = resolveHeartbeatRecipients(cfg, { all: true }); expect(result.source).toBe("all"); @@ -253,7 +251,7 @@ describe("partial reply gating", () => { const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" }); const mockConfig: ClawdisConfig = { - inbound: { + routing: { allowFrom: ["*"], }, }; @@ -300,10 +298,10 @@ describe("partial reply gating", () => { const replyResolver = vi.fn().mockResolvedValue(undefined); const mockConfig: ClawdisConfig = { - inbound: { + routing: { allowFrom: ["*"], - session: { store: store.storePath, mainKey: "main" }, }, + session: { store: store.storePath, mainKey: "main" }, }; setLoadConfigMock(mockConfig); @@ -391,10 +389,10 @@ describe("runWebHeartbeatOnce", () => { const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN })); await runWebHeartbeatOnce({ cfg: { - inbound: { + routing: { allowFrom: ["+1555"], - session: { store: store.storePath }, }, + session: { store: store.storePath }, }, to: "+1555", verbose: false, @@ -414,10 +412,10 @@ describe("runWebHeartbeatOnce", () => { const resolver = vi.fn(async () => ({ text: "ALERT" })); await runWebHeartbeatOnce({ cfg: { - inbound: { + routing: { allowFrom: ["+1555"], - session: { store: store.storePath }, }, + session: { store: store.storePath }, }, to: "+1555", verbose: false, @@ -443,10 +441,10 @@ describe("runWebHeartbeatOnce", () => { await fs.writeFile(storePath, JSON.stringify(sessionEntries)); await runWebHeartbeatOnce({ cfg: { - inbound: { + routing: { allowFrom: ["+1999"], - session: { store: storePath }, }, + session: { store: storePath }, }, to: "+1999", verbose: false, @@ -472,13 +470,13 @@ describe("runWebHeartbeatOnce", () => { const sender: typeof sendMessageWhatsApp = vi.fn(); const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN })); setLoadConfigMock({ - inbound: { + routing: { allowFrom: ["+1555"], - session: { - store: storePath, - idleMinutes: 60, - heartbeatIdleMinutes: 10, - }, + }, + session: { + store: storePath, + idleMinutes: 60, + heartbeatIdleMinutes: 10, }, }); @@ -509,19 +507,19 @@ describe("runWebHeartbeatOnce", () => { setLoadConfigMock(() => ({ agent: { heartbeatMinutes: 0.001 }, - inbound: { + routing: { allowFrom: ["+4367"], - session: { store: storePath, idleMinutes: 60 }, }, + session: { store: storePath, idleMinutes: 60 }, })); const replyResolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN }); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never; const cfg: ClawdisConfig = { - inbound: { + routing: { allowFrom: ["+4367"], - session: { store: storePath, idleMinutes: 60 }, }, + session: { store: storePath, idleMinutes: 60 }, }; await runWebHeartbeatOnce({ @@ -547,18 +545,18 @@ describe("runWebHeartbeatOnce", () => { const sessionId = "override-123"; setLoadConfigMock(() => ({ - inbound: { + routing: { allowFrom: ["+1999"], - session: { store: storePath, idleMinutes: 60 }, }, + session: { store: storePath, idleMinutes: 60 }, })); const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN })); const cfg: ClawdisConfig = { - inbound: { + routing: { allowFrom: ["+1999"], - session: { store: storePath, idleMinutes: 60 }, }, + session: { store: storePath, idleMinutes: 60 }, }; await runWebHeartbeatOnce({ cfg, @@ -586,10 +584,10 @@ describe("runWebHeartbeatOnce", () => { const resolver = vi.fn(); await runWebHeartbeatOnce({ cfg: { - inbound: { + routing: { allowFrom: ["+1555"], - session: { store: store.storePath }, }, + session: { store: store.storePath }, }, to: "+1555", verbose: false, @@ -610,10 +608,10 @@ describe("runWebHeartbeatOnce", () => { const resolver = vi.fn(); await runWebHeartbeatOnce({ cfg: { - inbound: { + routing: { allowFrom: ["+1555"], - session: { store: store.storePath }, }, + session: { store: store.storePath }, }, to: "+1555", verbose: false, @@ -762,10 +760,10 @@ describe("web auto-reply", () => { const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never; setLoadConfigMock(() => ({ - inbound: { + routing: { allowFrom: ["+1555"], - session: { store: storePath }, }, + session: { store: storePath }, })); const controller = new AbortController(); @@ -820,11 +818,11 @@ describe("web auto-reply", () => { const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never; setLoadConfigMock(() => ({ - inbound: { + routing: { allowFrom: ["+1555"], groupChat: { requireMention: true, mentionPatterns: ["@clawd"] }, - session: { store: store.storePath }, }, + session: { store: store.storePath }, })); const controller = new AbortController(); @@ -921,10 +919,10 @@ describe("web auto-reply", () => { }; setLoadConfigMock(() => ({ - inbound: { + messages: { timestampPrefix: "UTC", - session: { store: store.storePath }, }, + session: { store: store.storePath }, })); await monitorWebProvider(false, listenerFactory, false, resolver); @@ -1473,10 +1471,10 @@ describe("web auto-reply", () => { }); setLoadConfigMock(() => ({ - inbound: { + routing: { groupChat: { mentionPatterns: ["@clawd"] }, - session: { store: storePath }, }, + session: { store: storePath }, })); let capturedOnMessage: @@ -1547,7 +1545,7 @@ describe("web auto-reply", () => { const resolver = vi.fn().mockResolvedValue({ text: "ok" }); setLoadConfigMock(() => ({ - inbound: { + routing: { // Self-chat heuristic: allowFrom includes selfE164. allowFrom: ["+999"], groupChat: { @@ -1697,8 +1695,10 @@ describe("web auto-reply", () => { it("prefixes body with same-phone marker when from === to", async () => { // Enable messagePrefix for same-phone mode testing setLoadConfigMock(() => ({ - inbound: { + routing: { allowFrom: ["*"], + }, + messages: { messagePrefix: "[same-phone]", responsePrefix: undefined, timestampPrefix: false, @@ -1820,8 +1820,10 @@ describe("web auto-reply", () => { it("applies responsePrefix to regular replies", async () => { setLoadConfigMock(() => ({ - inbound: { + routing: { allowFrom: ["*"], + }, + messages: { messagePrefix: undefined, responsePrefix: "🦞", timestampPrefix: false, @@ -1863,8 +1865,10 @@ describe("web auto-reply", () => { it("skips responsePrefix for HEARTBEAT_OK responses", async () => { setLoadConfigMock(() => ({ - inbound: { + routing: { allowFrom: ["*"], + }, + messages: { messagePrefix: undefined, responsePrefix: "🦞", timestampPrefix: false, @@ -1907,8 +1911,10 @@ describe("web auto-reply", () => { it("does not double-prefix if responsePrefix already present", async () => { setLoadConfigMock(() => ({ - inbound: { + routing: { allowFrom: ["*"], + }, + messages: { messagePrefix: undefined, responsePrefix: "🦞", timestampPrefix: false, @@ -1951,8 +1957,10 @@ describe("web auto-reply", () => { it("sends tool summaries immediately with responsePrefix", async () => { setLoadConfigMock(() => ({ - inbound: { + routing: { allowFrom: ["*"], + }, + messages: { messagePrefix: undefined, responsePrefix: "🦞", timestampPrefix: false, diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index e4049104f..9d7764dfa 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -113,7 +113,7 @@ type MentionConfig = { }; function buildMentionConfig(cfg: ReturnType): MentionConfig { - const gc = cfg.inbound?.groupChat; + const gc = cfg.routing?.groupChat; const mentionRegexes = gc?.mentionPatterns ?.map((p) => { @@ -124,7 +124,7 @@ function buildMentionConfig(cfg: ReturnType): MentionConfig { } }) .filter((r): r is RegExp => Boolean(r)) ?? []; - return { mentionRegexes, allowFrom: cfg.inbound?.allowFrom }; + return { mentionRegexes, allowFrom: cfg.routing?.allowFrom }; } function isBotMentioned( @@ -252,12 +252,12 @@ export async function runWebHeartbeatOnce(opts: { }); const cfg = cfgOverride ?? loadConfig(); - const sessionCfg = cfg.inbound?.session; + const sessionCfg = cfg.session; const sessionScope = sessionCfg?.scope ?? "per-sender"; const mainKey = sessionCfg?.mainKey; const sessionKey = resolveSessionKey(sessionScope, { From: to }, mainKey); if (sessionId) { - const storePath = resolveStorePath(cfg.inbound?.session?.store); + const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); const current = store[sessionKey] ?? {}; store[sessionKey] = { @@ -356,7 +356,7 @@ export async function runWebHeartbeatOnce(opts: { const stripped = stripHeartbeatToken(replyPayload.text); if (stripped.shouldSkip && !hasMedia) { // Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works. - const storePath = resolveStorePath(cfg.inbound?.session?.store); + const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); if (sessionSnapshot.entry && store[sessionSnapshot.key]) { store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt; @@ -420,7 +420,7 @@ export async function runWebHeartbeatOnce(opts: { } function getFallbackRecipient(cfg: ReturnType) { - const sessionCfg = cfg.inbound?.session; + const sessionCfg = cfg.session; const storePath = resolveStorePath(sessionCfg?.store); const store = loadSessionStore(storePath); const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; @@ -433,18 +433,18 @@ function getFallbackRecipient(cfg: ReturnType) { } const allowFrom = - Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom.length > 0 - ? cfg.inbound.allowFrom.filter((v) => v !== "*") + Array.isArray(cfg.routing?.allowFrom) && cfg.routing.allowFrom.length > 0 + ? cfg.routing.allowFrom.filter((v) => v !== "*") : []; if (allowFrom.length === 0) return null; return allowFrom[0] ? normalizeE164(allowFrom[0]) : null; } function getSessionRecipients(cfg: ReturnType) { - const sessionCfg = cfg.inbound?.session; + const sessionCfg = cfg.session; const scope = sessionCfg?.scope ?? "per-sender"; if (scope === "global") return []; - const storePath = resolveStorePath(cfg.inbound?.session?.store); + const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); const isGroupKey = (key: string) => key.startsWith("group:") || key.includes("@g.us"); @@ -480,8 +480,8 @@ export function resolveHeartbeatRecipients( const sessionRecipients = getSessionRecipients(cfg); const allowFrom = - Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom.length > 0 - ? cfg.inbound.allowFrom.filter((v) => v !== "*").map(normalizeE164) + Array.isArray(cfg.routing?.allowFrom) && cfg.routing.allowFrom.length > 0 + ? cfg.routing.allowFrom.filter((v) => v !== "*").map(normalizeE164) : []; const unique = (list: string[]) => [...new Set(list.filter(Boolean))]; @@ -509,7 +509,7 @@ function getSessionSnapshot( from: string, isHeartbeat = false, ) { - const sessionCfg = cfg.inbound?.session; + const sessionCfg = cfg.session; const scope = sessionCfg?.scope ?? "per-sender"; const key = resolveSessionKey( scope, @@ -773,9 +773,9 @@ export async function monitorWebProvider( ); const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect); const mentionConfig = buildMentionConfig(cfg); - const sessionStorePath = resolveStorePath(cfg.inbound?.session?.store); + const sessionStorePath = resolveStorePath(cfg.session?.store); const groupHistoryLimit = - cfg.inbound?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT; + cfg.routing?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT; const groupHistories = new Map< string, Array<{ sender: string; body: string; timestamp?: number }> @@ -854,7 +854,7 @@ export async function monitorWebProvider( : `group:${conversationId}`; const store = loadSessionStore(sessionStorePath); const entry = store[key]; - const requireMention = cfg.inbound?.groupChat?.requireMention; + const requireMention = cfg.routing?.groupChat?.requireMention; const defaultActivation = requireMention === false ? "always" : "mention"; return ( normalizeGroupActivation(entry?.groupActivation) ?? defaultActivation @@ -953,9 +953,9 @@ export async function monitorWebProvider( const buildLine = (msg: WebInboundMsg) => { // Build message prefix: explicit config > default based on allowFrom - let messagePrefix = cfg.inbound?.messagePrefix; + let messagePrefix = cfg.messages?.messagePrefix; if (messagePrefix === undefined) { - const hasAllowFrom = (cfg.inbound?.allowFrom?.length ?? 0) > 0; + const hasAllowFrom = (cfg.routing?.allowFrom?.length ?? 0) > 0; messagePrefix = hasAllowFrom ? "" : "[clawdis]"; } const prefixStr = messagePrefix ? `${messagePrefix} ` : ""; @@ -1045,7 +1045,7 @@ export async function monitorWebProvider( } if (msg.chatType !== "group") { - const sessionCfg = cfg.inbound?.session; + const sessionCfg = cfg.session; const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; const storePath = resolveStorePath(sessionCfg?.store); const to = (() => { @@ -1075,7 +1075,7 @@ export async function monitorWebProvider( } } - const responsePrefix = cfg.inbound?.responsePrefix; + const responsePrefix = cfg.messages?.responsePrefix; let didSendReply = false; let toolSendChain: Promise = Promise.resolve(); const sendToolResult = (payload: ReplyPayload) => { @@ -1580,7 +1580,7 @@ export async function monitorWebProvider( // Apply response prefix if configured (same as regular messages) let finalText = stripped.text; - const responsePrefix = cfg.inbound?.responsePrefix; + const responsePrefix = cfg.messages?.responsePrefix; if ( responsePrefix && finalText && diff --git a/src/web/inbound.media.test.ts b/src/web/inbound.media.test.ts index 79a6fd83d..175cad76d 100644 --- a/src/web/inbound.media.test.ts +++ b/src/web/inbound.media.test.ts @@ -7,8 +7,10 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; vi.mock("../config/config.js", () => ({ loadConfig: vi.fn().mockReturnValue({ - inbound: { + routing: { allowFrom: ["*"], // Allow all in tests + }, + messages: { messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, diff --git a/src/web/inbound.ts b/src/web/inbound.ts index a847fbcba..80dd2dce7 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -145,7 +145,7 @@ export async function monitorWebInbox(options: { // Filter unauthorized senders early to prevent wasted processing // and potential session corruption from Bad MAC errors const cfg = loadConfig(); - const configuredAllowFrom = cfg.inbound?.allowFrom; + const configuredAllowFrom = cfg.routing?.allowFrom; // Without user config, default to self-only DM access so the owner can talk to themselves const defaultAllowFrom = (!configuredAllowFrom || configuredAllowFrom.length === 0) && selfE164 diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index 0c331ba12..dd885375a 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -10,8 +10,10 @@ vi.mock("../media/store.js", () => ({ })); const mockLoadConfig = vi.fn().mockReturnValue({ - inbound: { + routing: { allowFrom: ["*"], // Allow all in tests by default + }, + messages: { messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, @@ -411,8 +413,10 @@ describe("web monitor inbox", () => { it("still forwards group messages (with sender info) even when allowFrom is restrictive", async () => { mockLoadConfig.mockReturnValue({ - inbound: { + routing: { allowFrom: ["+111"], // does not include +777 + }, + messages: { messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, @@ -465,8 +469,10 @@ describe("web monitor inbox", () => { // Test for auto-recovery fix: early allowFrom filtering prevents Bad MAC errors // from unauthorized senders corrupting sessions mockLoadConfig.mockReturnValue({ - inbound: { + routing: { allowFrom: ["+111"], // Only allow +111 + }, + messages: { messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, @@ -503,8 +509,10 @@ describe("web monitor inbox", () => { // Reset mock for other tests mockLoadConfig.mockReturnValue({ - inbound: { + routing: { allowFrom: ["*"], + }, + messages: { messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, @@ -516,9 +524,11 @@ describe("web monitor inbox", () => { it("skips read receipts in self-chat mode", async () => { mockLoadConfig.mockReturnValue({ - inbound: { + routing: { // Self-chat heuristic: allowFrom includes selfE164 (+123). allowFrom: ["+123"], + }, + messages: { messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, @@ -551,8 +561,10 @@ describe("web monitor inbox", () => { // Reset mock for other tests mockLoadConfig.mockReturnValue({ - inbound: { + routing: { allowFrom: ["*"], + }, + messages: { messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, @@ -564,8 +576,10 @@ describe("web monitor inbox", () => { it("lets group messages through even when sender not in allowFrom", async () => { mockLoadConfig.mockReturnValue({ - inbound: { + routing: { allowFrom: ["+1234"], + }, + messages: { messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, @@ -604,8 +618,10 @@ describe("web monitor inbox", () => { it("allows messages from senders in allowFrom list", async () => { mockLoadConfig.mockReturnValue({ - inbound: { + routing: { allowFrom: ["+111", "+999"], // Allow +999 + }, + messages: { messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, @@ -637,8 +653,10 @@ describe("web monitor inbox", () => { // Reset mock for other tests mockLoadConfig.mockReturnValue({ - inbound: { + routing: { allowFrom: ["*"], + }, + messages: { messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, @@ -652,8 +670,10 @@ describe("web monitor inbox", () => { // Same-phone mode: when from === selfJid, should always be allowed // This allows users to message themselves even with restrictive allowFrom mockLoadConfig.mockReturnValue({ - inbound: { + routing: { allowFrom: ["+111"], // Only allow +111, but self is +123 + }, + messages: { messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, @@ -686,8 +706,10 @@ describe("web monitor inbox", () => { // Reset mock for other tests mockLoadConfig.mockReturnValue({ - inbound: { + routing: { allowFrom: ["*"], + }, + messages: { messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, @@ -751,8 +773,10 @@ it("defaults to self-only when no config is present", async () => { // Reset mock for other tests mockLoadConfig.mockReturnValue({ - inbound: { + routing: { allowFrom: ["*"], + }, + messages: { messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, diff --git a/src/web/test-helpers.ts b/src/web/test-helpers.ts index 64b0d14f9..21a6a5b9d 100644 --- a/src/web/test-helpers.ts +++ b/src/web/test-helpers.ts @@ -6,9 +6,11 @@ import { createMockBaileys } from "../../test/mocks/baileys.js"; // Use globalThis to store the mock config so it survives vi.mock hoisting const CONFIG_KEY = Symbol.for("clawdis:testConfigMock"); const DEFAULT_CONFIG = { - inbound: { + routing: { // Tests can override; default remains open to avoid surprising fixtures allowFrom: ["*"], + }, + messages: { messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false,