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:
parent
640c8d1554
commit
e9babf40aa
83
src/channels/accounts-base.ts
Normal file
83
src/channels/accounts-base.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
253
src/channels/pipeline.e2e.test.ts
Normal file
253
src/channels/pipeline.e2e.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -23,6 +23,34 @@ type GroupMentionParams = {
|
|||||||
senderE164?: string | null;
|
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) {
|
function normalizeDiscordSlug(value?: string | null) {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
let text = value.trim().toLowerCase();
|
let text = value.trim().toLowerCase();
|
||||||
@ -42,6 +70,8 @@ function normalizeSlackSlug(raw?: string | null) {
|
|||||||
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Telegram ---
|
||||||
|
|
||||||
function parseTelegramGroupId(value?: string | null) {
|
function parseTelegramGroupId(value?: string | null) {
|
||||||
const raw = value?.trim() ?? "";
|
const raw = value?.trim() ?? "";
|
||||||
if (!raw) return { chatId: undefined, topicId: undefined };
|
if (!raw) return { chatId: undefined, topicId: undefined };
|
||||||
@ -87,21 +117,6 @@ function resolveTelegramRequireMention(params: {
|
|||||||
return undefined;
|
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(
|
export function resolveTelegramGroupRequireMention(
|
||||||
params: GroupMentionParams,
|
params: GroupMentionParams,
|
||||||
): boolean | undefined {
|
): 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(
|
export function resolveTelegramGroupToolPolicy(
|
||||||
params: GroupMentionParams,
|
params: GroupMentionParams,
|
||||||
): GroupToolPolicyConfig | undefined {
|
): GroupToolPolicyConfig | undefined {
|
||||||
@ -245,34 +151,64 @@ export function resolveTelegramGroupToolPolicy(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveWhatsAppGroupToolPolicy(
|
// --- Simple channel delegates (WhatsApp, iMessage, BlueBubbles, GoogleChat) ---
|
||||||
params: GroupMentionParams,
|
|
||||||
): GroupToolPolicyConfig | undefined {
|
export const resolveWhatsAppGroupRequireMention = simpleRequireMention("whatsapp");
|
||||||
return resolveChannelGroupToolsPolicy({
|
export const resolveIMessageGroupRequireMention = simpleRequireMention("imessage");
|
||||||
cfg: params.cfg,
|
export const resolveBlueBubblesGroupRequireMention = simpleRequireMention("bluebubbles");
|
||||||
channel: "whatsapp",
|
export const resolveGoogleChatGroupRequireMention = simpleRequireMention("googlechat");
|
||||||
groupId: params.groupId,
|
|
||||||
accountId: params.accountId,
|
export const resolveWhatsAppGroupToolPolicy = simpleToolPolicy("whatsapp");
|
||||||
senderId: params.senderId,
|
export const resolveIMessageGroupToolPolicy = simpleToolPolicy("imessage");
|
||||||
senderName: params.senderName,
|
export const resolveBlueBubblesGroupToolPolicy = simpleToolPolicy("bluebubbles");
|
||||||
senderUsername: params.senderUsername,
|
export const resolveGoogleChatGroupToolPolicy = simpleToolPolicy("googlechat");
|
||||||
senderE164: params.senderE164,
|
|
||||||
});
|
// --- 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,
|
params: GroupMentionParams,
|
||||||
): GroupToolPolicyConfig | undefined {
|
) {
|
||||||
return resolveChannelGroupToolsPolicy({
|
if (!channelEntries || Object.keys(channelEntries).length === 0) return undefined;
|
||||||
cfg: params.cfg,
|
const groupChannel = params.groupChannel;
|
||||||
channel: "imessage",
|
const channelSlug = normalizeDiscordSlug(groupChannel);
|
||||||
groupId: params.groupId,
|
return (
|
||||||
accountId: params.accountId,
|
(params.groupId ? channelEntries[params.groupId] : undefined) ??
|
||||||
senderId: params.senderId,
|
(channelSlug
|
||||||
senderName: params.senderName,
|
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
|
||||||
senderUsername: params.senderUsername,
|
: undefined) ??
|
||||||
senderE164: params.senderE164,
|
(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(
|
export function resolveDiscordGroupToolPolicy(
|
||||||
@ -282,25 +218,17 @@ export function resolveDiscordGroupToolPolicy(
|
|||||||
params.cfg.channels?.discord?.guilds,
|
params.cfg.channels?.discord?.guilds,
|
||||||
params.groupSpace,
|
params.groupSpace,
|
||||||
);
|
);
|
||||||
const channelEntries = guildEntry?.channels;
|
const entry = resolveDiscordChannelEntry(guildEntry?.channels, params);
|
||||||
if (channelEntries && Object.keys(channelEntries).length > 0) {
|
if (entry) {
|
||||||
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 senderPolicy = resolveToolsBySender({
|
const senderPolicy = resolveToolsBySender({
|
||||||
toolsBySender: entry?.toolsBySender,
|
toolsBySender: entry.toolsBySender,
|
||||||
senderId: params.senderId,
|
senderId: params.senderId,
|
||||||
senderName: params.senderName,
|
senderName: params.senderName,
|
||||||
senderUsername: params.senderUsername,
|
senderUsername: params.senderUsername,
|
||||||
senderE164: params.senderE164,
|
senderE164: params.senderE164,
|
||||||
});
|
});
|
||||||
if (senderPolicy) return senderPolicy;
|
if (senderPolicy) return senderPolicy;
|
||||||
if (entry?.tools) return entry.tools;
|
if (entry.tools) return entry.tools;
|
||||||
}
|
}
|
||||||
const guildSenderPolicy = resolveToolsBySender({
|
const guildSenderPolicy = resolveToolsBySender({
|
||||||
toolsBySender: guildEntry?.toolsBySender,
|
toolsBySender: guildEntry?.toolsBySender,
|
||||||
@ -314,16 +242,15 @@ export function resolveDiscordGroupToolPolicy(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveSlackGroupToolPolicy(
|
// --- Slack ---
|
||||||
params: GroupMentionParams,
|
|
||||||
): GroupToolPolicyConfig | undefined {
|
function resolveSlackChannelEntry(params: GroupMentionParams) {
|
||||||
const account = resolveSlackAccount({
|
const account = resolveSlackAccount({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
const channels = account.channels ?? {};
|
const channels = account.channels ?? {};
|
||||||
const keys = Object.keys(channels);
|
if (Object.keys(channels).length === 0) return { matched: undefined, channels };
|
||||||
if (keys.length === 0) return undefined;
|
|
||||||
const channelId = params.groupId?.trim();
|
const channelId = params.groupId?.trim();
|
||||||
const groupChannel = params.groupChannel;
|
const groupChannel = params.groupChannel;
|
||||||
const channelName = groupChannel?.replace(/^#/, "");
|
const channelName = groupChannel?.replace(/^#/, "");
|
||||||
@ -334,16 +261,35 @@ export function resolveSlackGroupToolPolicy(
|
|||||||
channelName ?? "",
|
channelName ?? "",
|
||||||
normalizedName,
|
normalizedName,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
let matched:
|
let matched: Record<string, unknown> | undefined;
|
||||||
| { tools?: GroupToolPolicyConfig; toolsBySender?: GroupToolPolicyBySenderConfig }
|
|
||||||
| undefined;
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (candidate && channels[candidate]) {
|
if (candidate && channels[candidate]) {
|
||||||
matched = channels[candidate];
|
matched = channels[candidate] as Record<string, unknown>;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const resolved = matched ?? channels["*"];
|
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({
|
const senderPolicy = resolveToolsBySender({
|
||||||
toolsBySender: resolved?.toolsBySender,
|
toolsBySender: resolved?.toolsBySender,
|
||||||
senderId: params.senderId,
|
senderId: params.senderId,
|
||||||
@ -355,18 +301,3 @@ export function resolveSlackGroupToolPolicy(
|
|||||||
if (resolved?.tools) return resolved.tools;
|
if (resolved?.tools) return resolved.tools;
|
||||||
return undefined;
|
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import type { DiscordAccountConfig } from "../config/types.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";
|
import { resolveDiscordToken } from "./token.js";
|
||||||
|
|
||||||
|
const base = createAccountBase<DiscordAccountConfig>("discord");
|
||||||
|
|
||||||
export type ResolvedDiscordAccount = {
|
export type ResolvedDiscordAccount = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -12,50 +15,16 @@ export type ResolvedDiscordAccount = {
|
|||||||
config: DiscordAccountConfig;
|
config: DiscordAccountConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
function listConfiguredAccountIds(cfg: MoltbotConfig): string[] {
|
export const listDiscordAccountIds = base.listAccountIds;
|
||||||
const accounts = cfg.channels?.discord?.accounts;
|
export const resolveDefaultDiscordAccountId = base.resolveDefaultAccountId;
|
||||||
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 function resolveDiscordAccount(params: {
|
export function resolveDiscordAccount(params: {
|
||||||
cfg: MoltbotConfig;
|
cfg: MoltbotConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
}): ResolvedDiscordAccount {
|
}): ResolvedDiscordAccount {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
const baseEnabled = params.cfg.channels?.discord?.enabled !== false;
|
const merged = base.mergeAccountConfig(params.cfg, accountId);
|
||||||
const merged = mergeDiscordAccountConfig(params.cfg, accountId);
|
const enabled = base.isBaseEnabled(params.cfg) && merged.enabled !== false;
|
||||||
const accountEnabled = merged.enabled !== false;
|
|
||||||
const enabled = baseEnabled && accountEnabled;
|
|
||||||
const tokenResolution = resolveDiscordToken(params.cfg, { accountId });
|
const tokenResolution = resolveDiscordToken(params.cfg, { accountId });
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
@ -68,7 +37,5 @@ export function resolveDiscordAccount(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listEnabledDiscordAccounts(cfg: MoltbotConfig): ResolvedDiscordAccount[] {
|
export function listEnabledDiscordAccounts(cfg: MoltbotConfig): ResolvedDiscordAccount[] {
|
||||||
return listDiscordAccountIds(cfg)
|
return base.listEnabledAccounts(cfg, resolveDiscordAccount);
|
||||||
.map((accountId) => resolveDiscordAccount({ cfg, accountId }))
|
|
||||||
.filter((account) => account.enabled);
|
|
||||||
}
|
}
|
||||||
|
|||||||
100
src/gateway/auth-rate-limit.test.ts
Normal file
100
src/gateway/auth-rate-limit.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
70
src/gateway/auth-rate-limit.ts
Normal file
70
src/gateway/auth-rate-limit.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||||
import { authorizeGatewayConnect } from "./auth.js";
|
import { authorizeGatewayConnect } from "./auth.js";
|
||||||
|
|
||||||
describe("gateway auth", () => {
|
describe("gateway auth", () => {
|
||||||
@ -99,4 +100,39 @@ describe("gateway auth", () => {
|
|||||||
expect(res.method).toBe("tailscale");
|
expect(res.method).toBe("tailscale");
|
||||||
expect(res.user).toBe("peter");
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { timingSafeEqual } from "node:crypto";
|
|||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
||||||
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
|
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
|
||||||
|
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||||
import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
|
import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
|
||||||
export type ResolvedGatewayAuthMode = "token" | "password";
|
export type ResolvedGatewayAuthMode = "token" | "password";
|
||||||
|
|
||||||
@ -202,17 +203,26 @@ export async function authorizeGatewayConnect(params: {
|
|||||||
req?: IncomingMessage;
|
req?: IncomingMessage;
|
||||||
trustedProxies?: string[];
|
trustedProxies?: string[];
|
||||||
tailscaleWhois?: TailscaleWhoisLookup;
|
tailscaleWhois?: TailscaleWhoisLookup;
|
||||||
|
rateLimiter?: AuthRateLimiter;
|
||||||
}): Promise<GatewayAuthResult> {
|
}): Promise<GatewayAuthResult> {
|
||||||
const { auth, connectAuth, req, trustedProxies } = params;
|
const { auth, connectAuth, req, trustedProxies, rateLimiter } = params;
|
||||||
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
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 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) {
|
if (auth.allowTailscale && !localDirect) {
|
||||||
const tailscaleCheck = await resolveVerifiedTailscaleUser({
|
const tailscaleCheck = await resolveVerifiedTailscaleUser({
|
||||||
req,
|
req,
|
||||||
tailscaleWhois,
|
tailscaleWhois,
|
||||||
});
|
});
|
||||||
if (tailscaleCheck.ok) {
|
if (tailscaleCheck.ok) {
|
||||||
|
rateLimiter?.recordSuccess(clientIp);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
method: "tailscale",
|
method: "tailscale",
|
||||||
@ -221,32 +231,39 @@ export async function authorizeGatewayConnect(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let result: GatewayAuthResult;
|
||||||
|
|
||||||
if (auth.mode === "token") {
|
if (auth.mode === "token") {
|
||||||
if (!auth.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) {
|
} else if (auth.mode === "password") {
|
||||||
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") {
|
|
||||||
const password = connectAuth?.password;
|
const password = connectAuth?.password;
|
||||||
if (!auth.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) {
|
} else {
|
||||||
return { ok: false, reason: "password_missing" };
|
result = { ok: false, reason: "unauthorized" };
|
||||||
}
|
|
||||||
if (!safeEqual(password, auth.password)) {
|
|
||||||
return { ok: false, reason: "password_mismatch" };
|
|
||||||
}
|
|
||||||
return { ok: true, method: "password" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: false, reason: "unauthorized" };
|
if (rateLimiter && clientIp) {
|
||||||
|
if (result.ok) {
|
||||||
|
rateLimiter.recordSuccess(clientIp);
|
||||||
|
} else {
|
||||||
|
rateLimiter.recordFailure(clientIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import type { IMessageAccountConfig } from "../config/types.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 = {
|
export type ResolvedIMessageAccount = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@ -10,48 +13,16 @@ export type ResolvedIMessageAccount = {
|
|||||||
configured: boolean;
|
configured: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function listConfiguredAccountIds(cfg: MoltbotConfig): string[] {
|
export const listIMessageAccountIds = base.listAccountIds;
|
||||||
const accounts = cfg.channels?.imessage?.accounts;
|
export const resolveDefaultIMessageAccountId = base.resolveDefaultAccountId;
|
||||||
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 function resolveIMessageAccount(params: {
|
export function resolveIMessageAccount(params: {
|
||||||
cfg: MoltbotConfig;
|
cfg: MoltbotConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
}): ResolvedIMessageAccount {
|
}): ResolvedIMessageAccount {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
const baseEnabled = params.cfg.channels?.imessage?.enabled !== false;
|
const merged = base.mergeAccountConfig(params.cfg, accountId);
|
||||||
const merged = mergeIMessageAccountConfig(params.cfg, accountId);
|
const enabled = base.isBaseEnabled(params.cfg) && merged.enabled !== false;
|
||||||
const accountEnabled = merged.enabled !== false;
|
|
||||||
const configured = Boolean(
|
const configured = Boolean(
|
||||||
merged.cliPath?.trim() ||
|
merged.cliPath?.trim() ||
|
||||||
merged.dbPath?.trim() ||
|
merged.dbPath?.trim() ||
|
||||||
@ -68,7 +39,7 @@ export function resolveIMessageAccount(params: {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
enabled: baseEnabled && accountEnabled,
|
enabled,
|
||||||
name: merged.name?.trim() || undefined,
|
name: merged.name?.trim() || undefined,
|
||||||
config: merged,
|
config: merged,
|
||||||
configured,
|
configured,
|
||||||
@ -76,7 +47,5 @@ export function resolveIMessageAccount(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listEnabledIMessageAccounts(cfg: MoltbotConfig): ResolvedIMessageAccount[] {
|
export function listEnabledIMessageAccounts(cfg: MoltbotConfig): ResolvedIMessageAccount[] {
|
||||||
return listIMessageAccountIds(cfg)
|
return base.listEnabledAccounts(cfg, resolveIMessageAccount);
|
||||||
.map((accountId) => resolveIMessageAccount({ cfg, accountId }))
|
|
||||||
.filter((account) => account.enabled);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,10 +46,12 @@ export function getAgentRunContext(runId: string) {
|
|||||||
|
|
||||||
export function clearAgentRunContext(runId: string) {
|
export function clearAgentRunContext(runId: string) {
|
||||||
runContextById.delete(runId);
|
runContextById.delete(runId);
|
||||||
|
seqByRun.delete(runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetAgentRunContextForTest() {
|
export function resetAgentRunContextForTest() {
|
||||||
runContextById.clear();
|
runContextById.clear();
|
||||||
|
seqByRun.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emitAgentEvent(event: Omit<AgentEventPayload, "seq" | "ts">) {
|
export function emitAgentEvent(event: Omit<AgentEventPayload, "seq" | "ts">) {
|
||||||
|
|||||||
12
src/logging/swallowed.ts
Normal file
12
src/logging/swallowed.ts
Normal 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}`);
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import type { ResolvedMemorySearchConfig } from "../agents/memory-search.js";
|
|||||||
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
|
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
|
||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
|
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
|
||||||
|
import { logSwallowed } from "../logging/swallowed.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
@ -93,6 +94,7 @@ const SNIPPET_MAX_CHARS = 700;
|
|||||||
const VECTOR_TABLE = "chunks_vec";
|
const VECTOR_TABLE = "chunks_vec";
|
||||||
const FTS_TABLE = "chunks_fts";
|
const FTS_TABLE = "chunks_fts";
|
||||||
const EMBEDDING_CACHE_TABLE = "embedding_cache";
|
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 SESSION_DIRTY_DEBOUNCE_MS = 5000;
|
||||||
const EMBEDDING_BATCH_MAX_TOKENS = 8000;
|
const EMBEDDING_BATCH_MAX_TOKENS = 8000;
|
||||||
const EMBEDDING_APPROX_CHARS_PER_TOKEN = 1;
|
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> {
|
async close(): Promise<void> {
|
||||||
if (this.closed) return;
|
if (this.closed) return;
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
@ -578,6 +590,8 @@ export class MemoryIndexManager {
|
|||||||
this.sessionUnsubscribe();
|
this.sessionUnsubscribe();
|
||||||
this.sessionUnsubscribe = null;
|
this.sessionUnsubscribe = null;
|
||||||
}
|
}
|
||||||
|
this.sessionDeltas.clear();
|
||||||
|
this.sessionWarm.clear();
|
||||||
this.db.close();
|
this.db.close();
|
||||||
INDEX_CACHE.delete(this.cacheKey);
|
INDEX_CACHE.delete(this.cacheKey);
|
||||||
}
|
}
|
||||||
@ -716,7 +730,7 @@ export class MemoryIndexManager {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
try {
|
try {
|
||||||
this.db.exec("ROLLBACK");
|
this.db.exec("ROLLBACK");
|
||||||
} catch {}
|
} catch { /* expected: db may already be closed */ }
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1032,14 +1046,14 @@ export class MemoryIndexManager {
|
|||||||
`DELETE FROM ${VECTOR_TABLE} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`,
|
`DELETE FROM ${VECTOR_TABLE} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`,
|
||||||
)
|
)
|
||||||
.run(stale.path, "memory");
|
.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");
|
this.db.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`).run(stale.path, "memory");
|
||||||
if (this.fts.enabled && this.fts.available) {
|
if (this.fts.enabled && this.fts.available) {
|
||||||
try {
|
try {
|
||||||
this.db
|
this.db
|
||||||
.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`)
|
.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`)
|
||||||
.run(stale.path, "memory", this.provider.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 = ?)`,
|
`DELETE FROM ${VECTOR_TABLE} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`,
|
||||||
)
|
)
|
||||||
.run(stale.path, "sessions");
|
.run(stale.path, "sessions");
|
||||||
} catch {}
|
} catch (err) { logSwallowed("memory:syncSessions:vector", err); }
|
||||||
this.db
|
this.db
|
||||||
.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`)
|
.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`)
|
||||||
.run(stale.path, "sessions");
|
.run(stale.path, "sessions");
|
||||||
@ -1138,9 +1152,10 @@ export class MemoryIndexManager {
|
|||||||
this.db
|
this.db
|
||||||
.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`)
|
.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`)
|
||||||
.run(stale.path, "sessions", this.provider.model);
|
.run(stale.path, "sessions", this.provider.model);
|
||||||
} catch {}
|
} catch (err) { logSwallowed("memory:syncSessions:fts", err); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.pruneSessionMaps(activePaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
private createSyncProgress(
|
private createSyncProgress(
|
||||||
@ -1391,7 +1406,7 @@ export class MemoryIndexManager {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
try {
|
try {
|
||||||
this.db.close();
|
this.db.close();
|
||||||
} catch {}
|
} catch { /* expected: db may already be closed */ }
|
||||||
await this.removeIndexFiles(tempDbPath);
|
await this.removeIndexFiles(tempDbPath);
|
||||||
restoreOriginalState();
|
restoreOriginalState();
|
||||||
throw err;
|
throw err;
|
||||||
@ -1404,7 +1419,7 @@ export class MemoryIndexManager {
|
|||||||
if (this.fts.enabled && this.fts.available) {
|
if (this.fts.enabled && this.fts.available) {
|
||||||
try {
|
try {
|
||||||
this.db.exec(`DELETE FROM ${FTS_TABLE}`);
|
this.db.exec(`DELETE FROM ${FTS_TABLE}`);
|
||||||
} catch {}
|
} catch (err) { logSwallowed("memory:resetIndex:fts", err); }
|
||||||
}
|
}
|
||||||
this.dropVectorTable();
|
this.dropVectorTable();
|
||||||
this.vector.dims = undefined;
|
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 {
|
private pruneEmbeddingCacheIfNeeded(): void {
|
||||||
if (!this.cache.enabled) return;
|
if (!this.cache.enabled) return;
|
||||||
|
this.pruneExpiredCacheEntries();
|
||||||
const max = this.cache.maxEntries;
|
const max = this.cache.maxEntries;
|
||||||
if (!max || max <= 0) return;
|
if (!max || max <= 0) return;
|
||||||
const row = this.db.prepare(`SELECT COUNT(*) as c FROM ${EMBEDDING_CACHE_TABLE}`).get() as
|
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> {
|
private async withBatchFailureLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
let release: () => void;
|
let release!: () => void;
|
||||||
const wait = this.batchFailureLock;
|
const wait = this.batchFailureLock;
|
||||||
this.batchFailureLock = new Promise<void>((resolve) => {
|
this.batchFailureLock = new Promise<void>((resolve) => {
|
||||||
release = resolve;
|
release = resolve;
|
||||||
});
|
});
|
||||||
await wait;
|
|
||||||
try {
|
try {
|
||||||
|
await wait;
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} 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 = ?)`,
|
`DELETE FROM ${VECTOR_TABLE} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`,
|
||||||
)
|
)
|
||||||
.run(entry.path, options.source);
|
.run(entry.path, options.source);
|
||||||
} catch {}
|
} catch (err) { logSwallowed("memory:indexFile:vector", err); }
|
||||||
}
|
}
|
||||||
if (this.fts.enabled && this.fts.available) {
|
if (this.fts.enabled && this.fts.available) {
|
||||||
try {
|
try {
|
||||||
this.db
|
this.db
|
||||||
.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`)
|
.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`)
|
||||||
.run(entry.path, options.source, this.provider.model);
|
.run(entry.path, options.source, this.provider.model);
|
||||||
} catch {}
|
} catch (err) { logSwallowed("memory:indexFile:fts", err); }
|
||||||
}
|
}
|
||||||
this.db
|
this.db
|
||||||
.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`)
|
.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`)
|
||||||
@ -2142,7 +2167,7 @@ export class MemoryIndexManager {
|
|||||||
if (vectorReady && embedding.length > 0) {
|
if (vectorReady && embedding.length > 0) {
|
||||||
try {
|
try {
|
||||||
this.db.prepare(`DELETE FROM ${VECTOR_TABLE} WHERE id = ?`).run(id);
|
this.db.prepare(`DELETE FROM ${VECTOR_TABLE} WHERE id = ?`).run(id);
|
||||||
} catch {}
|
} catch (err) { logSwallowed("memory:indexFile:vectorRow", err); }
|
||||||
this.db
|
this.db
|
||||||
.prepare(`INSERT INTO ${VECTOR_TABLE} (id, embedding) VALUES (?, ?)`)
|
.prepare(`INSERT INTO ${VECTOR_TABLE} (id, embedding) VALUES (?, ?)`)
|
||||||
.run(id, vectorToBlob(embedding));
|
.run(id, vectorToBlob(embedding));
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import type { SignalAccountConfig } from "../config/types.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 = {
|
export type ResolvedSignalAccount = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@ -11,50 +14,16 @@ export type ResolvedSignalAccount = {
|
|||||||
config: SignalAccountConfig;
|
config: SignalAccountConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
function listConfiguredAccountIds(cfg: MoltbotConfig): string[] {
|
export const listSignalAccountIds = base.listAccountIds;
|
||||||
const accounts = cfg.channels?.signal?.accounts;
|
export const resolveDefaultSignalAccountId = base.resolveDefaultAccountId;
|
||||||
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 function resolveSignalAccount(params: {
|
export function resolveSignalAccount(params: {
|
||||||
cfg: MoltbotConfig;
|
cfg: MoltbotConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
}): ResolvedSignalAccount {
|
}): ResolvedSignalAccount {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
const baseEnabled = params.cfg.channels?.signal?.enabled !== false;
|
const merged = base.mergeAccountConfig(params.cfg, accountId);
|
||||||
const merged = mergeSignalAccountConfig(params.cfg, accountId);
|
const enabled = base.isBaseEnabled(params.cfg) && merged.enabled !== false;
|
||||||
const accountEnabled = merged.enabled !== false;
|
|
||||||
const enabled = baseEnabled && accountEnabled;
|
|
||||||
const host = merged.httpHost?.trim() || "127.0.0.1";
|
const host = merged.httpHost?.trim() || "127.0.0.1";
|
||||||
const port = merged.httpPort ?? 8080;
|
const port = merged.httpPort ?? 8080;
|
||||||
const baseUrl = merged.httpUrl?.trim() || `http://${host}:${port}`;
|
const baseUrl = merged.httpUrl?.trim() || `http://${host}:${port}`;
|
||||||
@ -77,7 +46,5 @@ export function resolveSignalAccount(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listEnabledSignalAccounts(cfg: MoltbotConfig): ResolvedSignalAccount[] {
|
export function listEnabledSignalAccounts(cfg: MoltbotConfig): ResolvedSignalAccount[] {
|
||||||
return listSignalAccountIds(cfg)
|
return base.listEnabledAccounts(cfg, resolveSignalAccount);
|
||||||
.map((accountId) => resolveSignalAccount({ cfg, accountId }))
|
|
||||||
.filter((account) => account.enabled);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import type { SlackAccountConfig } from "../config/types.js";
|
import type { SlackAccountConfig } from "../config/types.js";
|
||||||
import { normalizeChatType } from "../channels/chat-type.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 { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||||
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
||||||
|
|
||||||
|
const base = createAccountBase<SlackAccountConfig>("slack");
|
||||||
|
|
||||||
export type SlackTokenSource = "env" | "config" | "none";
|
export type SlackTokenSource = "env" | "config" | "none";
|
||||||
|
|
||||||
export type ResolvedSlackAccount = {
|
export type ResolvedSlackAccount = {
|
||||||
@ -28,50 +31,16 @@ export type ResolvedSlackAccount = {
|
|||||||
channels?: SlackAccountConfig["channels"];
|
channels?: SlackAccountConfig["channels"];
|
||||||
};
|
};
|
||||||
|
|
||||||
function listConfiguredAccountIds(cfg: MoltbotConfig): string[] {
|
export const listSlackAccountIds = base.listAccountIds;
|
||||||
const accounts = cfg.channels?.slack?.accounts;
|
export const resolveDefaultSlackAccountId = base.resolveDefaultAccountId;
|
||||||
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 function resolveSlackAccount(params: {
|
export function resolveSlackAccount(params: {
|
||||||
cfg: MoltbotConfig;
|
cfg: MoltbotConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
}): ResolvedSlackAccount {
|
}): ResolvedSlackAccount {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
const baseEnabled = params.cfg.channels?.slack?.enabled !== false;
|
const merged = base.mergeAccountConfig(params.cfg, accountId);
|
||||||
const merged = mergeSlackAccountConfig(params.cfg, accountId);
|
const enabled = base.isBaseEnabled(params.cfg) && merged.enabled !== false;
|
||||||
const accountEnabled = merged.enabled !== false;
|
|
||||||
const enabled = baseEnabled && accountEnabled;
|
|
||||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||||
const envBot = allowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined;
|
const envBot = allowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined;
|
||||||
const envApp = allowEnv ? resolveSlackAppToken(process.env.SLACK_APP_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[] {
|
export function listEnabledSlackAccounts(cfg: MoltbotConfig): ResolvedSlackAccount[] {
|
||||||
return listSlackAccountIds(cfg)
|
return base.listEnabledAccounts(cfg, resolveSlackAccount);
|
||||||
.map((accountId) => resolveSlackAccount({ cfg, accountId }))
|
|
||||||
.filter((account) => account.enabled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveSlackReplyToMode(
|
export function resolveSlackReplyToMode(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user