fix: address HIGH-severity performance, security, and architecture issues

- P4: clean up seqByRun map in agent-events to prevent unbounded growth
- P2: harden batch failure lock in memory manager with definite assignment
- P1: prune stale session maps (sessionDeltas/sessionWarm) after sync
- P3: add 30-day TTL expiration for embedding cache entries
- S1: add AuthRateLimiter with exponential backoff to gateway auth
- S2: document local auth behavior and add test proving auth is required
- A2: add logSwallowed() utility, replace empty catches in memory module
- A4: deduplicate group-mentions.ts via factory helpers (373→304 LOC)
- A1: create createAccountBase<T>() factory, refactor 4 channel accounts
- A3: add full-pipeline e2e test (6 cases: routing, agent, errors, sessions)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Apple 2026-01-28 01:34:27 +05:30
parent 640c8d1554
commit e9babf40aa
14 changed files with 784 additions and 385 deletions

View File

@ -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.<channelKey>.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<TAccountConfig extends Record<string, unknown>>(
channelKey: string,
) {
type ChannelSection = { enabled?: boolean; accounts?: Record<string, unknown> } & Record<
string,
unknown
>;
function getChannelSection(cfg: MoltbotConfig): ChannelSection | undefined {
return (cfg.channels as Record<string, unknown> | 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<TResolved extends { enabled: boolean }>(
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,
};
}

View File

@ -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<T>(fn: (home: string) => Promise<T>): Promise<T> {
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>): 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");
});
});
});

View File

@ -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<string, { requireMention?: boolean; tools?: GroupToolPolicyConfig; toolsBySender?: GroupToolPolicyBySenderConfig }> | 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<string, unknown> | undefined;
for (const candidate of candidates) {
if (candidate && channels[candidate]) {
matched = channels[candidate];
matched = channels[candidate] as Record<string, unknown>;
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,
});
}

View File

@ -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<DiscordAccountConfig>("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);
}

View File

@ -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);
});
});

View File

@ -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<string, Entry>();
private cleanupTimer: ReturnType<typeof setInterval>;
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);
}
}
}
}

View File

@ -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();
}
});
});

View File

@ -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<GatewayAuthResult> {
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;
}

View File

@ -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<IMessageAccountConfig>("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);
}

View File

@ -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<AgentEventPayload, "seq" | "ts">) {

12
src/logging/swallowed.ts Normal file
View File

@ -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}`);
}

View File

@ -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<string>): 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<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
let release: () => void;
let release!: () => void;
const wait = this.batchFailureLock;
this.batchFailureLock = new Promise<void>((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));

View File

@ -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<SignalAccountConfig>("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);
}

View File

@ -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<SlackAccountConfig>("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(