diff --git a/src/channels/accounts-base.ts b/src/channels/accounts-base.ts new file mode 100644 index 000000000..bb85677fd --- /dev/null +++ b/src/channels/accounts-base.ts @@ -0,0 +1,83 @@ +import type { MoltbotConfig } from "../config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; + +/** + * Shared account resolution utilities for channels that follow the standard + * accounts pattern: cfg.channels..accounts with base+account merging. + * + * Channels with unique resolution logic (Telegram agent-bindings, WhatsApp auth + * directories, LINE custom listing) should not use this factory. + */ +export function createAccountBase>( + channelKey: string, +) { + type ChannelSection = { enabled?: boolean; accounts?: Record } & Record< + string, + unknown + >; + + function getChannelSection(cfg: MoltbotConfig): ChannelSection | undefined { + return (cfg.channels as Record | undefined)?.[channelKey] as + | ChannelSection + | undefined; + } + + function listConfiguredAccountIds(cfg: MoltbotConfig): string[] { + const accounts = getChannelSection(cfg)?.accounts; + if (!accounts || typeof accounts !== "object") return []; + return Object.keys(accounts).filter(Boolean); + } + + function listAccountIds(cfg: MoltbotConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; + return ids.sort((a, b) => a.localeCompare(b)); + } + + function resolveDefaultAccountId(cfg: MoltbotConfig): string { + const ids = listAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; + } + + function resolveAccountConfig( + cfg: MoltbotConfig, + accountId: string, + ): TAccountConfig | undefined { + const accounts = getChannelSection(cfg)?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + return accounts[accountId] as TAccountConfig | undefined; + } + + function mergeAccountConfig(cfg: MoltbotConfig, accountId: string): TAccountConfig { + const { accounts: _ignored, ...base } = (getChannelSection(cfg) ?? {}) as TAccountConfig & { + accounts?: unknown; + }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account } as TAccountConfig; + } + + function isBaseEnabled(cfg: MoltbotConfig): boolean { + return getChannelSection(cfg)?.enabled !== false; + } + + function listEnabledAccounts( + cfg: MoltbotConfig, + resolve: (params: { cfg: MoltbotConfig; accountId?: string | null }) => TResolved, + ): TResolved[] { + return listAccountIds(cfg) + .map((accountId) => resolve({ cfg, accountId })) + .filter((account) => account.enabled); + } + + return { + listConfiguredAccountIds, + listAccountIds, + resolveDefaultAccountId, + resolveAccountConfig, + mergeAccountConfig, + isBaseEnabled, + listEnabledAccounts, + normalizeAccountId, + }; +} diff --git a/src/channels/pipeline.e2e.test.ts b/src/channels/pipeline.e2e.test.ts new file mode 100644 index 000000000..49b4eae95 --- /dev/null +++ b/src/channels/pipeline.e2e.test.ts @@ -0,0 +1,253 @@ +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; + +vi.mock("../agents/pi-embedded.js", () => ({ + abortEmbeddedPiRun: vi.fn().mockReturnValue(false), + compactEmbeddedPiSession: vi.fn(), + runEmbeddedPiAgent: vi.fn(), + queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, + isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), + isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), +})); + +const usageMocks = vi.hoisted(() => ({ + loadProviderUsageSummary: vi.fn().mockResolvedValue({ + updatedAt: 0, + providers: [], + }), + formatUsageSummaryLine: vi.fn().mockReturnValue("Usage: Claude 80% left"), + resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]), +})); +vi.mock("../infra/provider-usage.js", () => usageMocks); + +const modelCatalogMocks = vi.hoisted(() => ({ + loadModelCatalog: vi.fn().mockResolvedValue([ + { + provider: "anthropic", + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + contextWindow: 200000, + }, + ]), + resetModelCatalogCacheForTest: vi.fn(), +})); +vi.mock("../agents/model-catalog.js", () => modelCatalogMocks); + +const webMocks = vi.hoisted(() => ({ + webAuthExists: vi.fn().mockResolvedValue(true), + getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), + readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }), +})); +vi.mock("../web/session.js", () => webMocks); + +import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { getReplyFromConfig } from "../auto-reply/reply.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; +import type { MoltbotConfig } from "../config/config.js"; + +async function withTempHome(fn: (home: string) => Promise): Promise { + return withTempHomeBase( + async (home) => { + vi.mocked(runEmbeddedPiAgent).mockClear(); + vi.mocked(abortEmbeddedPiRun).mockClear(); + return await fn(home); + }, + { prefix: "moltbot-pipeline-e2e-" }, + ); +} + +function makeCfg(home: string, overrides?: Partial): MoltbotConfig { + return { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + }, + channels: { + whatsapp: { allowFrom: ["*"] }, + }, + session: { store: join(home, "sessions.json") }, + ...overrides, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("full pipeline: inbound -> routing -> agent -> reply", () => { + it("routes a WhatsApp DM through the agent and returns a reply", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "Hello from the agent!" }], + meta: { + durationMs: 100, + agentMeta: { sessionId: "s1", provider: "anthropic", model: "claude-opus-4-5" }, + }, + }); + + const cfg = makeCfg(home); + + // Verify routing resolves to default agent + const route = resolveAgentRoute({ + cfg, + channel: "whatsapp", + accountId: null, + peer: { kind: "dm", id: "+15551234567" }, + }); + expect(route.agentId).toBe("main"); + expect(route.matchedBy).toBe("default"); + + // Run the full reply pipeline + const res = await getReplyFromConfig( + { + Body: "hello", + From: "+15551234567", + To: "+1999", + Provider: "whatsapp", + ChatType: "direct", + }, + {}, + cfg, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("Hello from the agent!"); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + }); + }); + + it("routes to a specific agent via peer binding", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "Hi from custom agent" }], + meta: { + durationMs: 50, + agentMeta: { sessionId: "s2", provider: "anthropic", model: "claude-opus-4-5" }, + }, + }); + + const cfg = makeCfg(home, { + bindings: [ + { + agentId: "coder", + match: { + channel: "telegram", + peer: { kind: "dm", id: "12345" }, + }, + }, + ], + }); + + const route = resolveAgentRoute({ + cfg, + channel: "telegram", + accountId: null, + peer: { kind: "dm", id: "12345" }, + }); + // Agent "coder" doesn't exist in agents.list, so it falls back to + // the default agent but still returns the binding match type. + expect(route.matchedBy).toBe("binding.peer"); + }); + }); + + it("returns an error reply when the agent throws", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockRejectedValue( + new Error("model rate limit exceeded"), + ); + + const cfg = makeCfg(home); + + const res = await getReplyFromConfig( + { + Body: "hello", + From: "+15551234567", + To: "+1999", + Provider: "whatsapp", + }, + {}, + cfg, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("model rate limit exceeded"); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + }); + }); + + it("uses per-peer session isolation when configured", async () => { + await withTempHome(async (home) => { + const cfg = makeCfg(home, { + session: { + store: join(home, "sessions.json"), + dmScope: "per-peer", + }, + }); + + const routeA = resolveAgentRoute({ + cfg, + channel: "whatsapp", + accountId: null, + peer: { kind: "dm", id: "+1111" }, + }); + + const routeB = resolveAgentRoute({ + cfg, + channel: "whatsapp", + accountId: null, + peer: { kind: "dm", id: "+2222" }, + }); + + expect(routeA.sessionKey).toBe("agent:main:dm:+1111"); + expect(routeB.sessionKey).toBe("agent:main:dm:+2222"); + expect(routeA.sessionKey).not.toBe(routeB.sessionKey); + }); + }); + + it("routes group messages to the default agent with group session key", async () => { + await withTempHome(async (home) => { + const cfg = makeCfg(home); + + const route = resolveAgentRoute({ + cfg, + channel: "whatsapp", + accountId: null, + peer: { kind: "group", id: "groupchat@g.us" }, + }); + + expect(route.agentId).toBe("main"); + expect(route.sessionKey).toBe("agent:main:whatsapp:group:groupchat@g.us"); + }); + }); + + it("routes multi-account setups to the correct account", async () => { + await withTempHome(async (home) => { + const cfg = makeCfg(home, { + bindings: [ + { + agentId: "main", + match: { + channel: "discord", + accountId: "work-bot", + }, + }, + ], + }); + + const route = resolveAgentRoute({ + cfg, + channel: "discord", + accountId: "work-bot", + peer: { kind: "dm", id: "user123" }, + }); + + expect(route.agentId).toBe("main"); + expect(route.accountId).toBe("work-bot"); + expect(route.matchedBy).toBe("binding.account"); + }); + }); +}); diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index e91fee690..1231e4c6e 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -23,6 +23,34 @@ type GroupMentionParams = { senderE164?: string | null; }; +// --- Helpers for simple channel delegates --- + +function simpleRequireMention(channel: string) { + return (params: GroupMentionParams): boolean => + resolveChannelGroupRequireMention({ + cfg: params.cfg, + channel, + groupId: params.groupId, + accountId: params.accountId, + }); +} + +function simpleToolPolicy(channel: string) { + return (params: GroupMentionParams): GroupToolPolicyConfig | undefined => + resolveChannelGroupToolsPolicy({ + cfg: params.cfg, + channel, + groupId: params.groupId, + accountId: params.accountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); +} + +// --- Normalization --- + function normalizeDiscordSlug(value?: string | null) { if (!value) return ""; let text = value.trim().toLowerCase(); @@ -42,6 +70,8 @@ function normalizeSlackSlug(raw?: string | null) { return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, ""); } +// --- Telegram --- + function parseTelegramGroupId(value?: string | null) { const raw = value?.trim() ?? ""; if (!raw) return { chatId: undefined, topicId: undefined }; @@ -87,21 +117,6 @@ function resolveTelegramRequireMention(params: { return undefined; } -function resolveDiscordGuildEntry(guilds: DiscordConfig["guilds"], groupSpace?: string | null) { - if (!guilds || Object.keys(guilds).length === 0) return null; - const space = groupSpace?.trim() ?? ""; - if (space && guilds[space]) return guilds[space]; - const normalized = normalizeDiscordSlug(space); - if (normalized && guilds[normalized]) return guilds[normalized]; - if (normalized) { - const match = Object.values(guilds).find( - (entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized, - ); - if (match) return match; - } - return guilds["*"] ?? null; -} - export function resolveTelegramGroupRequireMention( params: GroupMentionParams, ): boolean | undefined { @@ -120,115 +135,6 @@ export function resolveTelegramGroupRequireMention( }); } -export function resolveWhatsAppGroupRequireMention(params: GroupMentionParams): boolean { - return resolveChannelGroupRequireMention({ - cfg: params.cfg, - channel: "whatsapp", - groupId: params.groupId, - accountId: params.accountId, - }); -} - -export function resolveIMessageGroupRequireMention(params: GroupMentionParams): boolean { - return resolveChannelGroupRequireMention({ - cfg: params.cfg, - channel: "imessage", - groupId: params.groupId, - accountId: params.accountId, - }); -} - -export function resolveDiscordGroupRequireMention(params: GroupMentionParams): boolean { - const guildEntry = resolveDiscordGuildEntry( - params.cfg.channels?.discord?.guilds, - params.groupSpace, - ); - const channelEntries = guildEntry?.channels; - if (channelEntries && Object.keys(channelEntries).length > 0) { - const groupChannel = params.groupChannel; - const channelSlug = normalizeDiscordSlug(groupChannel); - const entry = - (params.groupId ? channelEntries[params.groupId] : undefined) ?? - (channelSlug - ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) - : undefined) ?? - (groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined); - if (entry && typeof entry.requireMention === "boolean") { - return entry.requireMention; - } - } - if (typeof guildEntry?.requireMention === "boolean") { - return guildEntry.requireMention; - } - return true; -} - -export function resolveGoogleChatGroupRequireMention(params: GroupMentionParams): boolean { - return resolveChannelGroupRequireMention({ - cfg: params.cfg, - channel: "googlechat", - groupId: params.groupId, - accountId: params.accountId, - }); -} - -export function resolveGoogleChatGroupToolPolicy( - params: GroupMentionParams, -): GroupToolPolicyConfig | undefined { - return resolveChannelGroupToolsPolicy({ - cfg: params.cfg, - channel: "googlechat", - groupId: params.groupId, - accountId: params.accountId, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }); -} - -export function resolveSlackGroupRequireMention(params: GroupMentionParams): boolean { - const account = resolveSlackAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - const channels = account.channels ?? {}; - const keys = Object.keys(channels); - if (keys.length === 0) return true; - const channelId = params.groupId?.trim(); - const groupChannel = params.groupChannel; - const channelName = groupChannel?.replace(/^#/, ""); - const normalizedName = normalizeSlackSlug(channelName); - const candidates = [ - channelId ?? "", - channelName ? `#${channelName}` : "", - channelName ?? "", - normalizedName, - ].filter(Boolean); - let matched: { requireMention?: boolean } | undefined; - for (const candidate of candidates) { - if (candidate && channels[candidate]) { - matched = channels[candidate]; - break; - } - } - const fallback = channels["*"]; - const resolved = matched ?? fallback; - if (typeof resolved?.requireMention === "boolean") { - return resolved.requireMention; - } - return true; -} - -export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams): boolean { - return resolveChannelGroupRequireMention({ - cfg: params.cfg, - channel: "bluebubbles", - groupId: params.groupId, - accountId: params.accountId, - }); -} - export function resolveTelegramGroupToolPolicy( params: GroupMentionParams, ): GroupToolPolicyConfig | undefined { @@ -245,34 +151,64 @@ export function resolveTelegramGroupToolPolicy( }); } -export function resolveWhatsAppGroupToolPolicy( - params: GroupMentionParams, -): GroupToolPolicyConfig | undefined { - return resolveChannelGroupToolsPolicy({ - cfg: params.cfg, - channel: "whatsapp", - groupId: params.groupId, - accountId: params.accountId, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }); +// --- Simple channel delegates (WhatsApp, iMessage, BlueBubbles, GoogleChat) --- + +export const resolveWhatsAppGroupRequireMention = simpleRequireMention("whatsapp"); +export const resolveIMessageGroupRequireMention = simpleRequireMention("imessage"); +export const resolveBlueBubblesGroupRequireMention = simpleRequireMention("bluebubbles"); +export const resolveGoogleChatGroupRequireMention = simpleRequireMention("googlechat"); + +export const resolveWhatsAppGroupToolPolicy = simpleToolPolicy("whatsapp"); +export const resolveIMessageGroupToolPolicy = simpleToolPolicy("imessage"); +export const resolveBlueBubblesGroupToolPolicy = simpleToolPolicy("bluebubbles"); +export const resolveGoogleChatGroupToolPolicy = simpleToolPolicy("googlechat"); + +// --- Discord --- + +function resolveDiscordGuildEntry(guilds: DiscordConfig["guilds"], groupSpace?: string | null) { + if (!guilds || Object.keys(guilds).length === 0) return null; + const space = groupSpace?.trim() ?? ""; + if (space && guilds[space]) return guilds[space]; + const normalized = normalizeDiscordSlug(space); + if (normalized && guilds[normalized]) return guilds[normalized]; + if (normalized) { + const match = Object.values(guilds).find( + (entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized, + ); + if (match) return match; + } + return guilds["*"] ?? null; } -export function resolveIMessageGroupToolPolicy( +function resolveDiscordChannelEntry( + channelEntries: Record | undefined, params: GroupMentionParams, -): GroupToolPolicyConfig | undefined { - return resolveChannelGroupToolsPolicy({ - cfg: params.cfg, - channel: "imessage", - groupId: params.groupId, - accountId: params.accountId, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }); +) { + if (!channelEntries || Object.keys(channelEntries).length === 0) return undefined; + const groupChannel = params.groupChannel; + const channelSlug = normalizeDiscordSlug(groupChannel); + return ( + (params.groupId ? channelEntries[params.groupId] : undefined) ?? + (channelSlug + ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) + : undefined) ?? + (groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined) + ); +} + +export function resolveDiscordGroupRequireMention(params: GroupMentionParams): boolean { + const guildEntry = resolveDiscordGuildEntry( + params.cfg.channels?.discord?.guilds, + params.groupSpace, + ); + const entry = resolveDiscordChannelEntry(guildEntry?.channels, params); + if (entry && typeof entry.requireMention === "boolean") { + return entry.requireMention; + } + if (typeof guildEntry?.requireMention === "boolean") { + return guildEntry.requireMention; + } + return true; } export function resolveDiscordGroupToolPolicy( @@ -282,25 +218,17 @@ export function resolveDiscordGroupToolPolicy( params.cfg.channels?.discord?.guilds, params.groupSpace, ); - const channelEntries = guildEntry?.channels; - if (channelEntries && Object.keys(channelEntries).length > 0) { - const groupChannel = params.groupChannel; - const channelSlug = normalizeDiscordSlug(groupChannel); - const entry = - (params.groupId ? channelEntries[params.groupId] : undefined) ?? - (channelSlug - ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) - : undefined) ?? - (groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined); + const entry = resolveDiscordChannelEntry(guildEntry?.channels, params); + if (entry) { const senderPolicy = resolveToolsBySender({ - toolsBySender: entry?.toolsBySender, + toolsBySender: entry.toolsBySender, senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, senderE164: params.senderE164, }); if (senderPolicy) return senderPolicy; - if (entry?.tools) return entry.tools; + if (entry.tools) return entry.tools; } const guildSenderPolicy = resolveToolsBySender({ toolsBySender: guildEntry?.toolsBySender, @@ -314,16 +242,15 @@ export function resolveDiscordGroupToolPolicy( return undefined; } -export function resolveSlackGroupToolPolicy( - params: GroupMentionParams, -): GroupToolPolicyConfig | undefined { +// --- Slack --- + +function resolveSlackChannelEntry(params: GroupMentionParams) { const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId, }); const channels = account.channels ?? {}; - const keys = Object.keys(channels); - if (keys.length === 0) return undefined; + if (Object.keys(channels).length === 0) return { matched: undefined, channels }; const channelId = params.groupId?.trim(); const groupChannel = params.groupChannel; const channelName = groupChannel?.replace(/^#/, ""); @@ -334,16 +261,35 @@ export function resolveSlackGroupToolPolicy( channelName ?? "", normalizedName, ].filter(Boolean); - let matched: - | { tools?: GroupToolPolicyConfig; toolsBySender?: GroupToolPolicyBySenderConfig } - | undefined; + let matched: Record | undefined; for (const candidate of candidates) { if (candidate && channels[candidate]) { - matched = channels[candidate]; + matched = channels[candidate] as Record; break; } } const resolved = matched ?? channels["*"]; + return { matched: resolved, channels }; +} + +export function resolveSlackGroupRequireMention(params: GroupMentionParams): boolean { + const { matched, channels } = resolveSlackChannelEntry(params); + if (Object.keys(channels).length === 0) return true; + const resolved = matched as { requireMention?: boolean } | undefined; + if (typeof resolved?.requireMention === "boolean") { + return resolved.requireMention; + } + return true; +} + +export function resolveSlackGroupToolPolicy( + params: GroupMentionParams, +): GroupToolPolicyConfig | undefined { + const { matched, channels } = resolveSlackChannelEntry(params); + if (Object.keys(channels).length === 0) return undefined; + const resolved = matched as + | { tools?: GroupToolPolicyConfig; toolsBySender?: GroupToolPolicyBySenderConfig } + | undefined; const senderPolicy = resolveToolsBySender({ toolsBySender: resolved?.toolsBySender, senderId: params.senderId, @@ -355,18 +301,3 @@ export function resolveSlackGroupToolPolicy( if (resolved?.tools) return resolved.tools; return undefined; } - -export function resolveBlueBubblesGroupToolPolicy( - params: GroupMentionParams, -): GroupToolPolicyConfig | undefined { - return resolveChannelGroupToolsPolicy({ - cfg: params.cfg, - channel: "bluebubbles", - groupId: params.groupId, - accountId: params.accountId, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }); -} diff --git a/src/discord/accounts.ts b/src/discord/accounts.ts index 7964a2a4e..b5af830e6 100644 --- a/src/discord/accounts.ts +++ b/src/discord/accounts.ts @@ -1,8 +1,11 @@ import type { MoltbotConfig } from "../config/config.js"; import type { DiscordAccountConfig } from "../config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import { createAccountBase } from "../channels/accounts-base.js"; +import { normalizeAccountId } from "../routing/session-key.js"; import { resolveDiscordToken } from "./token.js"; +const base = createAccountBase("discord"); + export type ResolvedDiscordAccount = { accountId: string; enabled: boolean; @@ -12,50 +15,16 @@ export type ResolvedDiscordAccount = { config: DiscordAccountConfig; }; -function listConfiguredAccountIds(cfg: MoltbotConfig): string[] { - const accounts = cfg.channels?.discord?.accounts; - if (!accounts || typeof accounts !== "object") return []; - return Object.keys(accounts).filter(Boolean); -} - -export function listDiscordAccountIds(cfg: MoltbotConfig): string[] { - const ids = listConfiguredAccountIds(cfg); - if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; - return ids.sort((a, b) => a.localeCompare(b)); -} - -export function resolveDefaultDiscordAccountId(cfg: MoltbotConfig): string { - const ids = listDiscordAccountIds(cfg); - if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; - return ids[0] ?? DEFAULT_ACCOUNT_ID; -} - -function resolveAccountConfig( - cfg: MoltbotConfig, - accountId: string, -): DiscordAccountConfig | undefined { - const accounts = cfg.channels?.discord?.accounts; - if (!accounts || typeof accounts !== "object") return undefined; - return accounts[accountId] as DiscordAccountConfig | undefined; -} - -function mergeDiscordAccountConfig(cfg: MoltbotConfig, accountId: string): DiscordAccountConfig { - const { accounts: _ignored, ...base } = (cfg.channels?.discord ?? {}) as DiscordAccountConfig & { - accounts?: unknown; - }; - const account = resolveAccountConfig(cfg, accountId) ?? {}; - return { ...base, ...account }; -} +export const listDiscordAccountIds = base.listAccountIds; +export const resolveDefaultDiscordAccountId = base.resolveDefaultAccountId; export function resolveDiscordAccount(params: { cfg: MoltbotConfig; accountId?: string | null; }): ResolvedDiscordAccount { const accountId = normalizeAccountId(params.accountId); - const baseEnabled = params.cfg.channels?.discord?.enabled !== false; - const merged = mergeDiscordAccountConfig(params.cfg, accountId); - const accountEnabled = merged.enabled !== false; - const enabled = baseEnabled && accountEnabled; + const merged = base.mergeAccountConfig(params.cfg, accountId); + const enabled = base.isBaseEnabled(params.cfg) && merged.enabled !== false; const tokenResolution = resolveDiscordToken(params.cfg, { accountId }); return { accountId, @@ -68,7 +37,5 @@ export function resolveDiscordAccount(params: { } export function listEnabledDiscordAccounts(cfg: MoltbotConfig): ResolvedDiscordAccount[] { - return listDiscordAccountIds(cfg) - .map((accountId) => resolveDiscordAccount({ cfg, accountId })) - .filter((account) => account.enabled); + return base.listEnabledAccounts(cfg, resolveDiscordAccount); } diff --git a/src/gateway/auth-rate-limit.test.ts b/src/gateway/auth-rate-limit.test.ts new file mode 100644 index 000000000..6c56a7bfb --- /dev/null +++ b/src/gateway/auth-rate-limit.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AuthRateLimiter } from "./auth-rate-limit.js"; + +describe("AuthRateLimiter", () => { + let limiter: AuthRateLimiter; + + beforeEach(() => { + vi.useFakeTimers(); + limiter = new AuthRateLimiter(); + }); + + afterEach(() => { + limiter.close(); + vi.useRealTimers(); + }); + + it("allows requests with no prior failures", () => { + expect(limiter.check("1.2.3.4")).toBe(false); + }); + + it("allows requests below the failure threshold", () => { + for (let i = 0; i < 4; i++) { + limiter.recordFailure("1.2.3.4"); + } + expect(limiter.check("1.2.3.4")).toBe(false); + }); + + it("blocks after reaching the failure threshold", () => { + for (let i = 0; i < 5; i++) { + limiter.recordFailure("1.2.3.4"); + } + expect(limiter.check("1.2.3.4")).toBe(true); + }); + + it("unblocks after the delay expires", () => { + for (let i = 0; i < 5; i++) { + limiter.recordFailure("1.2.3.4"); + } + expect(limiter.check("1.2.3.4")).toBe(true); + // First block is 1s (BASE_DELAY_MS * 2^0) + vi.advanceTimersByTime(1_001); + expect(limiter.check("1.2.3.4")).toBe(false); + }); + + it("applies exponential backoff on repeated failures", () => { + // 5 failures = 1s block, 6 = 2s, 7 = 4s + for (let i = 0; i < 7; i++) { + limiter.recordFailure("1.2.3.4"); + } + // Should be blocked for 4s (2^(7-5) = 4) + vi.advanceTimersByTime(3_999); + expect(limiter.check("1.2.3.4")).toBe(true); + vi.advanceTimersByTime(2); + expect(limiter.check("1.2.3.4")).toBe(false); + }); + + it("caps delay at 60s", () => { + for (let i = 0; i < 25; i++) { + limiter.recordFailure("1.2.3.4"); + } + vi.advanceTimersByTime(60_001); + expect(limiter.check("1.2.3.4")).toBe(false); + }); + + it("resets on successful auth", () => { + for (let i = 0; i < 10; i++) { + limiter.recordFailure("1.2.3.4"); + } + expect(limiter.check("1.2.3.4")).toBe(true); + limiter.recordSuccess("1.2.3.4"); + expect(limiter.check("1.2.3.4")).toBe(false); + }); + + it("tracks IPs independently", () => { + for (let i = 0; i < 5; i++) { + limiter.recordFailure("1.2.3.4"); + } + expect(limiter.check("1.2.3.4")).toBe(true); + expect(limiter.check("5.6.7.8")).toBe(false); + }); + + it("decays entries after 15min inactivity", () => { + for (let i = 0; i < 5; i++) { + limiter.recordFailure("1.2.3.4"); + } + // Advance past decay window (15min) + vi.advanceTimersByTime(15 * 60 * 1_000 + 1); + expect(limiter.check("1.2.3.4")).toBe(false); + }); + + it("resets failure count after decay window", () => { + for (let i = 0; i < 4; i++) { + limiter.recordFailure("1.2.3.4"); + } + vi.advanceTimersByTime(15 * 60 * 1_000 + 1); + // After decay, one more failure should not block (starts fresh) + limiter.recordFailure("1.2.3.4"); + expect(limiter.check("1.2.3.4")).toBe(false); + }); +}); diff --git a/src/gateway/auth-rate-limit.ts b/src/gateway/auth-rate-limit.ts new file mode 100644 index 000000000..c7e7d3618 --- /dev/null +++ b/src/gateway/auth-rate-limit.ts @@ -0,0 +1,70 @@ +/** Per-IP auth rate limiter with exponential backoff. */ +const MAX_ATTEMPTS = 5; +const BASE_DELAY_MS = 1_000; +const MAX_DELAY_MS = 60_000; +const DECAY_MS = 15 * 60 * 1_000; // reset after 15min of inactivity +const CLEANUP_INTERVAL_MS = 5 * 60 * 1_000; + +type Entry = { + failures: number; + lastFailure: number; + blockedUntil: number; +}; + +export class AuthRateLimiter { + private entries = new Map(); + private cleanupTimer: ReturnType; + + constructor() { + this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS); + this.cleanupTimer.unref(); + } + + /** Returns true if the IP is currently blocked, false if allowed. */ + check(ip: string): boolean { + const entry = this.entries.get(ip); + if (!entry) return false; + if (Date.now() - entry.lastFailure > DECAY_MS) { + this.entries.delete(ip); + return false; + } + return entry.blockedUntil > Date.now(); + } + + recordFailure(ip: string): void { + const now = Date.now(); + const entry = this.entries.get(ip); + if (entry && now - entry.lastFailure > DECAY_MS) { + this.entries.delete(ip); + } + const existing = this.entries.get(ip); + const failures = (existing?.failures ?? 0) + 1; + const delay = + failures >= MAX_ATTEMPTS + ? Math.min(BASE_DELAY_MS * 2 ** (failures - MAX_ATTEMPTS), MAX_DELAY_MS) + : 0; + this.entries.set(ip, { + failures, + lastFailure: now, + blockedUntil: now + delay, + }); + } + + recordSuccess(ip: string): void { + this.entries.delete(ip); + } + + close(): void { + clearInterval(this.cleanupTimer); + this.entries.clear(); + } + + private cleanup(): void { + const now = Date.now(); + for (const [ip, entry] of this.entries) { + if (now - entry.lastFailure > DECAY_MS) { + this.entries.delete(ip); + } + } + } +} diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 7e1022124..db4a71749 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { AuthRateLimiter } from "./auth-rate-limit.js"; import { authorizeGatewayConnect } from "./auth.js"; describe("gateway auth", () => { @@ -99,4 +100,39 @@ describe("gateway auth", () => { expect(res.method).toBe("tailscale"); expect(res.user).toBe("peter"); }); + + it("local direct requests still require token/password auth", async () => { + const res = await authorizeGatewayConnect({ + auth: { mode: "token", token: "secret", allowTailscale: false }, + connectAuth: {}, + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { host: "localhost" }, + } as never, + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("token_missing"); + }); + + it("returns rate_limited when rate limiter blocks", async () => { + const limiter = new AuthRateLimiter(); + try { + for (let i = 0; i < 5; i++) { + limiter.recordFailure("10.0.0.1"); + } + const res = await authorizeGatewayConnect({ + auth: { mode: "token", token: "secret", allowTailscale: false }, + connectAuth: { token: "secret" }, + rateLimiter: limiter, + req: { + socket: { remoteAddress: "10.0.0.1" }, + headers: { host: "gateway.example.com" }, + } as never, + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("rate_limited"); + } finally { + limiter.close(); + } + }); }); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 1adc367a2..1f2b6de1d 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -2,6 +2,7 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage } from "node:http"; import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js"; import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js"; export type ResolvedGatewayAuthMode = "token" | "password"; @@ -202,17 +203,26 @@ export async function authorizeGatewayConnect(params: { req?: IncomingMessage; trustedProxies?: string[]; tailscaleWhois?: TailscaleWhoisLookup; + rateLimiter?: AuthRateLimiter; }): Promise { - const { auth, connectAuth, req, trustedProxies } = params; + const { auth, connectAuth, req, trustedProxies, rateLimiter } = params; const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity; + // Local direct requests skip Tailscale path (Tailscale serve proxies from loopback). + // Token/password auth is still required for all requests including local ones. const localDirect = isLocalDirectRequest(req, trustedProxies); + const clientIp = resolveRequestClientIp(req, trustedProxies) ?? ""; + if (rateLimiter && clientIp && rateLimiter.check(clientIp)) { + return { ok: false, reason: "rate_limited" }; + } + if (auth.allowTailscale && !localDirect) { const tailscaleCheck = await resolveVerifiedTailscaleUser({ req, tailscaleWhois, }); if (tailscaleCheck.ok) { + rateLimiter?.recordSuccess(clientIp); return { ok: true, method: "tailscale", @@ -221,32 +231,39 @@ export async function authorizeGatewayConnect(params: { } } + let result: GatewayAuthResult; + if (auth.mode === "token") { if (!auth.token) { - return { ok: false, reason: "token_missing_config" }; + result = { ok: false, reason: "token_missing_config" }; + } else if (!connectAuth?.token) { + result = { ok: false, reason: "token_missing" }; + } else if (!safeEqual(connectAuth.token, auth.token)) { + result = { ok: false, reason: "token_mismatch" }; + } else { + result = { ok: true, method: "token" }; } - if (!connectAuth?.token) { - return { ok: false, reason: "token_missing" }; - } - if (!safeEqual(connectAuth.token, auth.token)) { - return { ok: false, reason: "token_mismatch" }; - } - return { ok: true, method: "token" }; - } - - if (auth.mode === "password") { + } else if (auth.mode === "password") { const password = connectAuth?.password; if (!auth.password) { - return { ok: false, reason: "password_missing_config" }; + result = { ok: false, reason: "password_missing_config" }; + } else if (!password) { + result = { ok: false, reason: "password_missing" }; + } else if (!safeEqual(password, auth.password)) { + result = { ok: false, reason: "password_mismatch" }; + } else { + result = { ok: true, method: "password" }; } - if (!password) { - return { ok: false, reason: "password_missing" }; - } - if (!safeEqual(password, auth.password)) { - return { ok: false, reason: "password_mismatch" }; - } - return { ok: true, method: "password" }; + } else { + result = { ok: false, reason: "unauthorized" }; } - return { ok: false, reason: "unauthorized" }; + if (rateLimiter && clientIp) { + if (result.ok) { + rateLimiter.recordSuccess(clientIp); + } else { + rateLimiter.recordFailure(clientIp); + } + } + return result; } diff --git a/src/imessage/accounts.ts b/src/imessage/accounts.ts index 25e825d83..bdee3be2d 100644 --- a/src/imessage/accounts.ts +++ b/src/imessage/accounts.ts @@ -1,6 +1,9 @@ import type { MoltbotConfig } from "../config/config.js"; import type { IMessageAccountConfig } from "../config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import { createAccountBase } from "../channels/accounts-base.js"; +import { normalizeAccountId } from "../routing/session-key.js"; + +const base = createAccountBase("imessage"); export type ResolvedIMessageAccount = { accountId: string; @@ -10,48 +13,16 @@ export type ResolvedIMessageAccount = { configured: boolean; }; -function listConfiguredAccountIds(cfg: MoltbotConfig): string[] { - const accounts = cfg.channels?.imessage?.accounts; - if (!accounts || typeof accounts !== "object") return []; - return Object.keys(accounts).filter(Boolean); -} - -export function listIMessageAccountIds(cfg: MoltbotConfig): string[] { - const ids = listConfiguredAccountIds(cfg); - if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; - return ids.sort((a, b) => a.localeCompare(b)); -} - -export function resolveDefaultIMessageAccountId(cfg: MoltbotConfig): string { - const ids = listIMessageAccountIds(cfg); - if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; - return ids[0] ?? DEFAULT_ACCOUNT_ID; -} - -function resolveAccountConfig( - cfg: MoltbotConfig, - accountId: string, -): IMessageAccountConfig | undefined { - const accounts = cfg.channels?.imessage?.accounts; - if (!accounts || typeof accounts !== "object") return undefined; - return accounts[accountId] as IMessageAccountConfig | undefined; -} - -function mergeIMessageAccountConfig(cfg: MoltbotConfig, accountId: string): IMessageAccountConfig { - const { accounts: _ignored, ...base } = (cfg.channels?.imessage ?? - {}) as IMessageAccountConfig & { accounts?: unknown }; - const account = resolveAccountConfig(cfg, accountId) ?? {}; - return { ...base, ...account }; -} +export const listIMessageAccountIds = base.listAccountIds; +export const resolveDefaultIMessageAccountId = base.resolveDefaultAccountId; export function resolveIMessageAccount(params: { cfg: MoltbotConfig; accountId?: string | null; }): ResolvedIMessageAccount { const accountId = normalizeAccountId(params.accountId); - const baseEnabled = params.cfg.channels?.imessage?.enabled !== false; - const merged = mergeIMessageAccountConfig(params.cfg, accountId); - const accountEnabled = merged.enabled !== false; + const merged = base.mergeAccountConfig(params.cfg, accountId); + const enabled = base.isBaseEnabled(params.cfg) && merged.enabled !== false; const configured = Boolean( merged.cliPath?.trim() || merged.dbPath?.trim() || @@ -68,7 +39,7 @@ export function resolveIMessageAccount(params: { ); return { accountId, - enabled: baseEnabled && accountEnabled, + enabled, name: merged.name?.trim() || undefined, config: merged, configured, @@ -76,7 +47,5 @@ export function resolveIMessageAccount(params: { } export function listEnabledIMessageAccounts(cfg: MoltbotConfig): ResolvedIMessageAccount[] { - return listIMessageAccountIds(cfg) - .map((accountId) => resolveIMessageAccount({ cfg, accountId })) - .filter((account) => account.enabled); + return base.listEnabledAccounts(cfg, resolveIMessageAccount); } diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index 5c41c3c95..8c16398a3 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -46,10 +46,12 @@ export function getAgentRunContext(runId: string) { export function clearAgentRunContext(runId: string) { runContextById.delete(runId); + seqByRun.delete(runId); } export function resetAgentRunContextForTest() { runContextById.clear(); + seqByRun.clear(); } export function emitAgentEvent(event: Omit) { diff --git a/src/logging/swallowed.ts b/src/logging/swallowed.ts new file mode 100644 index 000000000..7e0bf7b26 --- /dev/null +++ b/src/logging/swallowed.ts @@ -0,0 +1,12 @@ +import { createSubsystemLogger } from "./subsystem.js"; + +const logger = createSubsystemLogger("swallowed"); + +/** + * Log a swallowed error at debug level so it's visible in verbose mode + * but not noisy in production. Use this instead of empty `catch {}` blocks. + */ +export function logSwallowed(context: string, err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + logger.debug(`${context}: ${message}`); +} diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 9a9991d10..c4b732ca8 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -10,6 +10,7 @@ import type { ResolvedMemorySearchConfig } from "../agents/memory-search.js"; import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import type { MoltbotConfig } from "../config/config.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; +import { logSwallowed } from "../logging/swallowed.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { resolveUserPath } from "../utils.js"; @@ -93,6 +94,7 @@ const SNIPPET_MAX_CHARS = 700; const VECTOR_TABLE = "chunks_vec"; const FTS_TABLE = "chunks_fts"; const EMBEDDING_CACHE_TABLE = "embedding_cache"; +const EMBEDDING_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days const SESSION_DIRTY_DEBOUNCE_MS = 5000; const EMBEDDING_BATCH_MAX_TOKENS = 8000; const EMBEDDING_APPROX_CHARS_PER_TOKEN = 1; @@ -555,6 +557,16 @@ export class MemoryIndexManager { } } + /** Remove entries from sessionDeltas/sessionWarm for files that no longer exist. */ + private pruneSessionMaps(activePaths: Set): void { + for (const key of this.sessionDeltas.keys()) { + if (!activePaths.has(key)) this.sessionDeltas.delete(key); + } + for (const key of this.sessionWarm) { + if (!activePaths.has(key)) this.sessionWarm.delete(key); + } + } + async close(): Promise { if (this.closed) return; this.closed = true; @@ -578,6 +590,8 @@ export class MemoryIndexManager { this.sessionUnsubscribe(); this.sessionUnsubscribe = null; } + this.sessionDeltas.clear(); + this.sessionWarm.clear(); this.db.close(); INDEX_CACHE.delete(this.cacheKey); } @@ -716,7 +730,7 @@ export class MemoryIndexManager { } catch (err) { try { this.db.exec("ROLLBACK"); - } catch {} + } catch { /* expected: db may already be closed */ } throw err; } } @@ -1032,14 +1046,14 @@ export class MemoryIndexManager { `DELETE FROM ${VECTOR_TABLE} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`, ) .run(stale.path, "memory"); - } catch {} + } catch (err) { logSwallowed("memory:syncMemory:vector", err); } this.db.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`).run(stale.path, "memory"); if (this.fts.enabled && this.fts.available) { try { this.db .prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`) .run(stale.path, "memory", this.provider.model); - } catch {} + } catch (err) { logSwallowed("memory:syncMemory:fts", err); } } } } @@ -1129,7 +1143,7 @@ export class MemoryIndexManager { `DELETE FROM ${VECTOR_TABLE} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`, ) .run(stale.path, "sessions"); - } catch {} + } catch (err) { logSwallowed("memory:syncSessions:vector", err); } this.db .prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`) .run(stale.path, "sessions"); @@ -1138,9 +1152,10 @@ export class MemoryIndexManager { this.db .prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`) .run(stale.path, "sessions", this.provider.model); - } catch {} + } catch (err) { logSwallowed("memory:syncSessions:fts", err); } } } + this.pruneSessionMaps(activePaths); } private createSyncProgress( @@ -1391,7 +1406,7 @@ export class MemoryIndexManager { } catch (err) { try { this.db.close(); - } catch {} + } catch { /* expected: db may already be closed */ } await this.removeIndexFiles(tempDbPath); restoreOriginalState(); throw err; @@ -1404,7 +1419,7 @@ export class MemoryIndexManager { if (this.fts.enabled && this.fts.available) { try { this.db.exec(`DELETE FROM ${FTS_TABLE}`); - } catch {} + } catch (err) { logSwallowed("memory:resetIndex:fts", err); } } this.dropVectorTable(); this.vector.dims = undefined; @@ -1612,8 +1627,18 @@ export class MemoryIndexManager { } } + /** Remove embedding cache entries older than EMBEDDING_CACHE_TTL_MS. */ + private pruneExpiredCacheEntries(): void { + if (!this.cache.enabled) return; + const cutoffMs = Date.now() - EMBEDDING_CACHE_TTL_MS; + this.db + .prepare(`DELETE FROM ${EMBEDDING_CACHE_TABLE} WHERE updated_at < ?`) + .run(cutoffMs); + } + private pruneEmbeddingCacheIfNeeded(): void { if (!this.cache.enabled) return; + this.pruneExpiredCacheEntries(); const max = this.cache.maxEntries; if (!max || max <= 0) return; const row = this.db.prepare(`SELECT COUNT(*) as c FROM ${EMBEDDING_CACHE_TABLE}`).get() as @@ -1967,16 +1992,16 @@ export class MemoryIndexManager { } private async withBatchFailureLock(fn: () => Promise): Promise { - let release: () => void; + let release!: () => void; const wait = this.batchFailureLock; this.batchFailureLock = new Promise((resolve) => { release = resolve; }); - await wait; try { + await wait; return await fn(); } finally { - release!(); + release(); } } @@ -2098,14 +2123,14 @@ export class MemoryIndexManager { `DELETE FROM ${VECTOR_TABLE} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`, ) .run(entry.path, options.source); - } catch {} + } catch (err) { logSwallowed("memory:indexFile:vector", err); } } if (this.fts.enabled && this.fts.available) { try { this.db .prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`) .run(entry.path, options.source, this.provider.model); - } catch {} + } catch (err) { logSwallowed("memory:indexFile:fts", err); } } this.db .prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`) @@ -2142,7 +2167,7 @@ export class MemoryIndexManager { if (vectorReady && embedding.length > 0) { try { this.db.prepare(`DELETE FROM ${VECTOR_TABLE} WHERE id = ?`).run(id); - } catch {} + } catch (err) { logSwallowed("memory:indexFile:vectorRow", err); } this.db .prepare(`INSERT INTO ${VECTOR_TABLE} (id, embedding) VALUES (?, ?)`) .run(id, vectorToBlob(embedding)); diff --git a/src/signal/accounts.ts b/src/signal/accounts.ts index f17d51735..9a9a88b5b 100644 --- a/src/signal/accounts.ts +++ b/src/signal/accounts.ts @@ -1,6 +1,9 @@ import type { MoltbotConfig } from "../config/config.js"; import type { SignalAccountConfig } from "../config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import { createAccountBase } from "../channels/accounts-base.js"; +import { normalizeAccountId } from "../routing/session-key.js"; + +const base = createAccountBase("signal"); export type ResolvedSignalAccount = { accountId: string; @@ -11,50 +14,16 @@ export type ResolvedSignalAccount = { config: SignalAccountConfig; }; -function listConfiguredAccountIds(cfg: MoltbotConfig): string[] { - const accounts = cfg.channels?.signal?.accounts; - if (!accounts || typeof accounts !== "object") return []; - return Object.keys(accounts).filter(Boolean); -} - -export function listSignalAccountIds(cfg: MoltbotConfig): string[] { - const ids = listConfiguredAccountIds(cfg); - if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; - return ids.sort((a, b) => a.localeCompare(b)); -} - -export function resolveDefaultSignalAccountId(cfg: MoltbotConfig): string { - const ids = listSignalAccountIds(cfg); - if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; - return ids[0] ?? DEFAULT_ACCOUNT_ID; -} - -function resolveAccountConfig( - cfg: MoltbotConfig, - accountId: string, -): SignalAccountConfig | undefined { - const accounts = cfg.channels?.signal?.accounts; - if (!accounts || typeof accounts !== "object") return undefined; - return accounts[accountId] as SignalAccountConfig | undefined; -} - -function mergeSignalAccountConfig(cfg: MoltbotConfig, accountId: string): SignalAccountConfig { - const { accounts: _ignored, ...base } = (cfg.channels?.signal ?? {}) as SignalAccountConfig & { - accounts?: unknown; - }; - const account = resolveAccountConfig(cfg, accountId) ?? {}; - return { ...base, ...account }; -} +export const listSignalAccountIds = base.listAccountIds; +export const resolveDefaultSignalAccountId = base.resolveDefaultAccountId; export function resolveSignalAccount(params: { cfg: MoltbotConfig; accountId?: string | null; }): ResolvedSignalAccount { const accountId = normalizeAccountId(params.accountId); - const baseEnabled = params.cfg.channels?.signal?.enabled !== false; - const merged = mergeSignalAccountConfig(params.cfg, accountId); - const accountEnabled = merged.enabled !== false; - const enabled = baseEnabled && accountEnabled; + const merged = base.mergeAccountConfig(params.cfg, accountId); + const enabled = base.isBaseEnabled(params.cfg) && merged.enabled !== false; const host = merged.httpHost?.trim() || "127.0.0.1"; const port = merged.httpPort ?? 8080; const baseUrl = merged.httpUrl?.trim() || `http://${host}:${port}`; @@ -77,7 +46,5 @@ export function resolveSignalAccount(params: { } export function listEnabledSignalAccounts(cfg: MoltbotConfig): ResolvedSignalAccount[] { - return listSignalAccountIds(cfg) - .map((accountId) => resolveSignalAccount({ cfg, accountId })) - .filter((account) => account.enabled); + return base.listEnabledAccounts(cfg, resolveSignalAccount); } diff --git a/src/slack/accounts.ts b/src/slack/accounts.ts index 1ae42b5aa..cc863e11c 100644 --- a/src/slack/accounts.ts +++ b/src/slack/accounts.ts @@ -1,9 +1,12 @@ import type { MoltbotConfig } from "../config/config.js"; import type { SlackAccountConfig } from "../config/types.js"; import { normalizeChatType } from "../channels/chat-type.js"; +import { createAccountBase } from "../channels/accounts-base.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; +const base = createAccountBase("slack"); + export type SlackTokenSource = "env" | "config" | "none"; export type ResolvedSlackAccount = { @@ -28,50 +31,16 @@ export type ResolvedSlackAccount = { channels?: SlackAccountConfig["channels"]; }; -function listConfiguredAccountIds(cfg: MoltbotConfig): string[] { - const accounts = cfg.channels?.slack?.accounts; - if (!accounts || typeof accounts !== "object") return []; - return Object.keys(accounts).filter(Boolean); -} - -export function listSlackAccountIds(cfg: MoltbotConfig): string[] { - const ids = listConfiguredAccountIds(cfg); - if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; - return ids.sort((a, b) => a.localeCompare(b)); -} - -export function resolveDefaultSlackAccountId(cfg: MoltbotConfig): string { - const ids = listSlackAccountIds(cfg); - if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; - return ids[0] ?? DEFAULT_ACCOUNT_ID; -} - -function resolveAccountConfig( - cfg: MoltbotConfig, - accountId: string, -): SlackAccountConfig | undefined { - const accounts = cfg.channels?.slack?.accounts; - if (!accounts || typeof accounts !== "object") return undefined; - return accounts[accountId] as SlackAccountConfig | undefined; -} - -function mergeSlackAccountConfig(cfg: MoltbotConfig, accountId: string): SlackAccountConfig { - const { accounts: _ignored, ...base } = (cfg.channels?.slack ?? {}) as SlackAccountConfig & { - accounts?: unknown; - }; - const account = resolveAccountConfig(cfg, accountId) ?? {}; - return { ...base, ...account }; -} +export const listSlackAccountIds = base.listAccountIds; +export const resolveDefaultSlackAccountId = base.resolveDefaultAccountId; export function resolveSlackAccount(params: { cfg: MoltbotConfig; accountId?: string | null; }): ResolvedSlackAccount { const accountId = normalizeAccountId(params.accountId); - const baseEnabled = params.cfg.channels?.slack?.enabled !== false; - const merged = mergeSlackAccountConfig(params.cfg, accountId); - const accountEnabled = merged.enabled !== false; - const enabled = baseEnabled && accountEnabled; + const merged = base.mergeAccountConfig(params.cfg, accountId); + const enabled = base.isBaseEnabled(params.cfg) && merged.enabled !== false; const allowEnv = accountId === DEFAULT_ACCOUNT_ID; const envBot = allowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined; const envApp = allowEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined; @@ -106,9 +75,7 @@ export function resolveSlackAccount(params: { } export function listEnabledSlackAccounts(cfg: MoltbotConfig): ResolvedSlackAccount[] { - return listSlackAccountIds(cfg) - .map((accountId) => resolveSlackAccount({ cfg, accountId })) - .filter((account) => account.enabled); + return base.listEnabledAccounts(cfg, resolveSlackAccount); } export function resolveSlackReplyToMode(