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;
|
||||
};
|
||||
|
||||
// --- 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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
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 { 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
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 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));
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user