chore: merge origin/main
This commit is contained in:
@@ -10,7 +10,7 @@ describe("buildAgentSystemPromptAppend", () => {
|
||||
|
||||
expect(prompt).toContain("## User Identity");
|
||||
expect(prompt).toContain(
|
||||
"Owner numbers: +123, +456. Treat messages from these numbers as the user (Peter).",
|
||||
"Owner numbers: +123, +456. Treat messages from these numbers as the user.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export function buildAgentSystemPromptAppend(params: {
|
||||
.filter(Boolean);
|
||||
const ownerLine =
|
||||
ownerNumbers.length > 0
|
||||
? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user (Peter).`
|
||||
? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user.`
|
||||
: undefined;
|
||||
const reasoningHint = params.reasoningTagHint
|
||||
? [
|
||||
@@ -36,7 +36,7 @@ export function buildAgentSystemPromptAppend(params: {
|
||||
"Only text inside <final> is shown to the user; everything else is discarded and never seen by the user.",
|
||||
"Example:",
|
||||
"<think>Short internal reasoning.</think>",
|
||||
"<final>Hey Peter! What would you like to do next?</final>",
|
||||
"<final>Hey there! What would you like to do next?</final>",
|
||||
].join(" ")
|
||||
: undefined;
|
||||
const runtimeInfo = params.runtimeInfo;
|
||||
|
||||
@@ -118,7 +118,7 @@ describe("directive parsing", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
@@ -168,7 +168,7 @@ describe("directive parsing", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
routing: { allowFrom: ["*"] },
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
@@ -195,7 +195,7 @@ describe("directive parsing", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
routing: { allowFrom: ["*"] },
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
@@ -208,7 +208,7 @@ describe("directive parsing", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
routing: { allowFrom: ["*"] },
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
@@ -264,7 +264,7 @@ describe("directive parsing", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
@@ -325,7 +325,7 @@ describe("directive parsing", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
@@ -506,7 +506,7 @@ describe("directive parsing", () => {
|
||||
workspace: path.join(home, "clawd"),
|
||||
allowedModels: ["openai/gpt-4.1-mini"],
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
|
||||
@@ -42,7 +42,7 @@ function makeCfg(home: string) {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
@@ -220,6 +220,7 @@ describe("trigger handling", () => {
|
||||
From: "123@g.us",
|
||||
To: "+2000",
|
||||
ChatType: "group",
|
||||
Surface: "whatsapp",
|
||||
SenderE164: "+2000",
|
||||
},
|
||||
{},
|
||||
@@ -230,7 +231,7 @@ describe("trigger handling", () => {
|
||||
const store = JSON.parse(
|
||||
await fs.readFile(cfg.session.store, "utf-8"),
|
||||
) as Record<string, { groupActivation?: string }>;
|
||||
expect(store["group:123@g.us"]?.groupActivation).toBe("always");
|
||||
expect(store["whatsapp:group:123@g.us"]?.groupActivation).toBe("always");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -244,6 +245,7 @@ describe("trigger handling", () => {
|
||||
From: "123@g.us",
|
||||
To: "+2000",
|
||||
ChatType: "group",
|
||||
Surface: "whatsapp",
|
||||
SenderE164: "+999",
|
||||
},
|
||||
{},
|
||||
@@ -270,6 +272,7 @@ describe("trigger handling", () => {
|
||||
From: "123@g.us",
|
||||
To: "+2000",
|
||||
ChatType: "group",
|
||||
Surface: "whatsapp",
|
||||
SenderE164: "+2000",
|
||||
GroupSubject: "Test Group",
|
||||
GroupMembers: "Alice (+1), Bob (+2)",
|
||||
@@ -280,8 +283,10 @@ describe("trigger handling", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
routing: {
|
||||
groupChat: { requireMention: false },
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
@@ -321,7 +326,7 @@ describe("trigger handling", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: {
|
||||
@@ -360,7 +365,7 @@ describe("trigger handling", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: {
|
||||
|
||||
@@ -27,9 +27,11 @@ import {
|
||||
} from "../agents/workspace.js";
|
||||
import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
||||
import {
|
||||
buildGroupDisplayName,
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
DEFAULT_RESET_TRIGGERS,
|
||||
loadSessionStore,
|
||||
resolveGroupSessionKey,
|
||||
resolveSessionKey,
|
||||
resolveSessionTranscriptPath,
|
||||
resolveStorePath,
|
||||
@@ -364,9 +366,9 @@ export async function getReplyFromConfig(
|
||||
let persistedModelOverride: string | undefined;
|
||||
let persistedProviderOverride: string | undefined;
|
||||
|
||||
const groupResolution = resolveGroupSessionKey(ctx);
|
||||
const isGroup =
|
||||
typeof ctx.From === "string" &&
|
||||
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
||||
ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
|
||||
const triggerBodyNormalized = stripStructuralPrefixes(ctx.Body ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
@@ -399,6 +401,13 @@ export async function getReplyFromConfig(
|
||||
|
||||
sessionKey = resolveSessionKey(sessionScope, ctx, mainKey);
|
||||
sessionStore = loadSessionStore(storePath);
|
||||
if (groupResolution?.legacyKey && groupResolution.legacyKey !== sessionKey) {
|
||||
const legacyEntry = sessionStore[groupResolution.legacyKey];
|
||||
if (legacyEntry && !sessionStore[sessionKey]) {
|
||||
sessionStore[sessionKey] = legacyEntry;
|
||||
delete sessionStore[groupResolution.legacyKey];
|
||||
}
|
||||
}
|
||||
const entry = sessionStore[sessionKey];
|
||||
const idleMs = idleMinutes * 60_000;
|
||||
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||
@@ -431,7 +440,41 @@ export async function getReplyFromConfig(
|
||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||
queueMode: baseEntry?.queueMode,
|
||||
displayName: baseEntry?.displayName,
|
||||
chatType: baseEntry?.chatType,
|
||||
surface: baseEntry?.surface,
|
||||
subject: baseEntry?.subject,
|
||||
room: baseEntry?.room,
|
||||
space: baseEntry?.space,
|
||||
};
|
||||
if (groupResolution?.surface) {
|
||||
const surface = groupResolution.surface;
|
||||
const subject = ctx.GroupSubject?.trim();
|
||||
const space = ctx.GroupSpace?.trim();
|
||||
const explicitRoom = ctx.GroupRoom?.trim();
|
||||
const isRoomSurface = surface === "discord" || surface === "slack";
|
||||
const nextRoom =
|
||||
explicitRoom ??
|
||||
(isRoomSurface && subject && subject.startsWith("#")
|
||||
? subject
|
||||
: undefined);
|
||||
const nextSubject = nextRoom ? undefined : subject;
|
||||
sessionEntry.chatType = groupResolution.chatType ?? "group";
|
||||
sessionEntry.surface = surface;
|
||||
if (nextSubject) sessionEntry.subject = nextSubject;
|
||||
if (nextRoom) sessionEntry.room = nextRoom;
|
||||
if (space) sessionEntry.space = space;
|
||||
sessionEntry.displayName = buildGroupDisplayName({
|
||||
surface: sessionEntry.surface,
|
||||
subject: sessionEntry.subject,
|
||||
room: sessionEntry.room,
|
||||
space: sessionEntry.space,
|
||||
id: groupResolution.id,
|
||||
key: sessionKey,
|
||||
});
|
||||
} else if (!sessionEntry.chatType) {
|
||||
sessionEntry.chatType = "direct";
|
||||
}
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
|
||||
@@ -798,14 +841,20 @@ export async function getReplyFromConfig(
|
||||
const perMessageQueueMode =
|
||||
hasQueueDirective && !inlineQueueReset ? inlineQueueMode : undefined;
|
||||
|
||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||
const configuredAllowFrom = cfg.routing?.allowFrom;
|
||||
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
||||
const isWhatsAppSurface =
|
||||
surface === "whatsapp" ||
|
||||
(ctx.From ?? "").startsWith("whatsapp:") ||
|
||||
(ctx.To ?? "").startsWith("whatsapp:");
|
||||
|
||||
// WhatsApp owner allowlist (E.164 without whatsapp: prefix); used for group activation only.
|
||||
const configuredAllowFrom = isWhatsAppSurface
|
||||
? cfg.whatsapp?.allowFrom
|
||||
: undefined;
|
||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||
const isSamePhone = from && to && from === to;
|
||||
// If no config is present, default to self-only DM access.
|
||||
const defaultAllowFrom =
|
||||
(!configuredAllowFrom || configuredAllowFrom.length === 0) && to
|
||||
isWhatsAppSurface && (!configuredAllowFrom || configuredAllowFrom.length === 0) && to
|
||||
? [to]
|
||||
: undefined;
|
||||
const allowFrom =
|
||||
@@ -819,10 +868,12 @@ export async function getReplyFromConfig(
|
||||
: rawBodyNormalized;
|
||||
const activationCommand = parseActivationCommand(commandBodyNormalized);
|
||||
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
|
||||
const ownerCandidates = (allowFrom ?? []).filter(
|
||||
(entry) => entry && entry !== "*",
|
||||
);
|
||||
if (ownerCandidates.length === 0 && to) ownerCandidates.push(to);
|
||||
const ownerCandidates = isWhatsAppSurface
|
||||
? (allowFrom ?? []).filter((entry) => entry && entry !== "*")
|
||||
: [];
|
||||
if (isWhatsAppSurface && ownerCandidates.length === 0 && to) {
|
||||
ownerCandidates.push(to);
|
||||
}
|
||||
const ownerList = ownerCandidates
|
||||
.map((entry) => normalizeE164(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
@@ -833,20 +884,6 @@ export async function getReplyFromConfig(
|
||||
abortedLastRun = ABORT_MEMORY.get(abortKey) ?? false;
|
||||
}
|
||||
|
||||
// Same-phone mode (self-messaging) is always allowed
|
||||
if (isSamePhone) {
|
||||
logVerbose(`Allowing same-phone mode: from === to (${from})`);
|
||||
} else if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||
// Support "*" as wildcard to allow all senders
|
||||
if (!allowFrom.includes("*") && !allowFrom.includes(from)) {
|
||||
logVerbose(
|
||||
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
|
||||
);
|
||||
cleanupTyping();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (activationCommand.hasCommand) {
|
||||
if (!isGroup) {
|
||||
cleanupTyping();
|
||||
@@ -1038,8 +1075,7 @@ export async function getReplyFromConfig(
|
||||
// Prepend queued system events (transitions only) and (for new main sessions) a provider snapshot.
|
||||
// Token efficiency: we filter out periodic/heartbeat noise and keep the lines compact.
|
||||
const isGroupSession =
|
||||
typeof ctx.From === "string" &&
|
||||
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
||||
sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room";
|
||||
const isMainSession =
|
||||
!isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main");
|
||||
if (isMainSession) {
|
||||
|
||||
@@ -63,8 +63,9 @@ describe("buildStatusMessage", () => {
|
||||
sessionId: "g1",
|
||||
updatedAt: 0,
|
||||
groupActivation: "always",
|
||||
chatType: "group",
|
||||
},
|
||||
sessionKey: "group:123@g.us",
|
||||
sessionKey: "whatsapp:group:123@g.us",
|
||||
sessionScope: "per-sender",
|
||||
webLinked: true,
|
||||
});
|
||||
|
||||
@@ -191,7 +191,13 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
|
||||
const groupActivationLine = args.sessionKey?.startsWith("group:")
|
||||
const isGroupSession =
|
||||
entry?.chatType === "group" ||
|
||||
entry?.chatType === "room" ||
|
||||
Boolean(args.sessionKey?.includes(":group:")) ||
|
||||
Boolean(args.sessionKey?.includes(":channel:")) ||
|
||||
Boolean(args.sessionKey?.startsWith("group:"));
|
||||
const groupActivationLine = isGroupSession
|
||||
? `Group activation: ${entry?.groupActivation ?? "mention"}`
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export type MsgContext = {
|
||||
Body?: string;
|
||||
From?: string;
|
||||
To?: string;
|
||||
SessionKey?: string;
|
||||
MessageSid?: string;
|
||||
ReplyToId?: string;
|
||||
ReplyToBody?: string;
|
||||
@@ -12,6 +13,8 @@ export type MsgContext = {
|
||||
Transcript?: string;
|
||||
ChatType?: string;
|
||||
GroupSubject?: string;
|
||||
GroupRoom?: string;
|
||||
GroupSpace?: string;
|
||||
GroupMembers?: string;
|
||||
SenderName?: string;
|
||||
SenderE164?: string;
|
||||
|
||||
@@ -1 +1 @@
|
||||
13cc362f2bc44e2a05a6da5e5ba66ea602755f18ed82b18cf244c8044aa84c36
|
||||
969df6da368b3a802bf0f7f34bf2e30102ae51d91daf45f1fb9328877e2fb335
|
||||
|
||||
@@ -14,6 +14,7 @@ import { danger, setVerbose } from "../globals.js";
|
||||
import { loginWeb, logoutWeb } from "../provider-web.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { readConfigFileSnapshot } from "../config/config.js";
|
||||
import { registerBrowserCli } from "./browser-cli.js";
|
||||
import { registerCanvasCli } from "./canvas-cli.js";
|
||||
import { registerCronCli } from "./cron-cli.js";
|
||||
@@ -68,6 +69,21 @@ export function buildProgram() {
|
||||
}
|
||||
|
||||
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
||||
|
||||
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||
if (actionCommand.name() === "doctor") return;
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (snapshot.legacyIssues.length === 0) return;
|
||||
const issues = snapshot.legacyIssues
|
||||
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||
.join("\n");
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
`Legacy config entries detected. Run \"clawdis doctor\" (or ask your agent) to migrate.\n${issues}`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
const examples = [
|
||||
[
|
||||
"clawdis login --verbose",
|
||||
|
||||
@@ -158,7 +158,7 @@ export async function agentCommand(
|
||||
});
|
||||
const workspaceDir = workspace.dir;
|
||||
|
||||
const allowFrom = (cfg.routing?.allowFrom ?? [])
|
||||
const allowFrom = (cfg.whatsapp?.allowFrom ?? [])
|
||||
.map((val) => normalizeE164(val))
|
||||
.filter((val) => val.length > 1);
|
||||
|
||||
@@ -451,7 +451,7 @@ export async function agentCommand(
|
||||
if (deliver) {
|
||||
if (deliveryProvider === "whatsapp" && !whatsappTarget) {
|
||||
const err = new Error(
|
||||
"Delivering to WhatsApp requires --to <E.164> or routing.allowFrom[0]",
|
||||
"Delivering to WhatsApp requires --to <E.164> or whatsapp.allowFrom[0]",
|
||||
);
|
||||
if (!bestEffortDeliver) throw err;
|
||||
logDeliveryError(err);
|
||||
@@ -470,7 +470,7 @@ export async function agentCommand(
|
||||
}
|
||||
if (deliveryProvider === "signal" && !signalTarget) {
|
||||
const err = new Error(
|
||||
"Delivering to Signal requires --to <E.164|group:ID|signal:+E.164>",
|
||||
"Delivering to Signal requires --to <E.164|group:ID|signal:group:ID|signal:+E.164>",
|
||||
);
|
||||
if (!bestEffortDeliver) throw err;
|
||||
logDeliveryError(err);
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
printWizardHeader,
|
||||
probeGatewayReachable,
|
||||
randomToken,
|
||||
resolveControlUiLinks,
|
||||
summarizeExistingConfig,
|
||||
} from "./onboard-helpers.js";
|
||||
import { setupProviders } from "./onboard-providers.js";
|
||||
@@ -550,6 +551,30 @@ export async function runConfigureWizard(
|
||||
}
|
||||
}
|
||||
|
||||
note(
|
||||
(() => {
|
||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||
const links = resolveControlUiLinks({ bind, port: gatewayPort });
|
||||
return [`Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`].join(
|
||||
"\n",
|
||||
);
|
||||
})(),
|
||||
"Control UI",
|
||||
);
|
||||
|
||||
const wantsOpen = guardCancel(
|
||||
await confirm({
|
||||
message: "Open Control UI now?",
|
||||
initialValue: false,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (wantsOpen) {
|
||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||
const links = resolveControlUiLinks({ bind, port: gatewayPort });
|
||||
await openUrl(links.httpUrl);
|
||||
}
|
||||
|
||||
outro("Configure complete.");
|
||||
}
|
||||
|
||||
|
||||
101
src/commands/doctor.test.ts
Normal file
101
src/commands/doctor.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const readConfigFileSnapshot = vi.fn();
|
||||
const writeConfigFile = vi.fn().mockResolvedValue(undefined);
|
||||
const migrateLegacyConfig = vi.fn((raw: unknown) => ({
|
||||
config: raw as Record<string, unknown>,
|
||||
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
|
||||
}));
|
||||
|
||||
vi.mock("@clack/prompts", () => ({
|
||||
confirm: vi.fn().mockResolvedValue(true),
|
||||
intro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/skills-status.js", () => ({
|
||||
buildWorkspaceSkillStatus: () => ({ skills: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
CONFIG_PATH_CLAWDIS: "/tmp/clawdis.json",
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
migrateLegacyConfig,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
exit: () => {
|
||||
throw new Error("exit");
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../utils.js", () => ({
|
||||
resolveUserPath: (value: string) => value,
|
||||
sleep: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./health.js", () => ({
|
||||
healthCommand: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-helpers.js", () => ({
|
||||
applyWizardMetadata: (cfg: Record<string, unknown>) => cfg,
|
||||
DEFAULT_WORKSPACE: "/tmp",
|
||||
guardCancel: (value: unknown) => value,
|
||||
printWizardHeader: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("doctor", () => {
|
||||
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
path: "/tmp/clawdis.json",
|
||||
exists: true,
|
||||
raw: "{}",
|
||||
parsed: { routing: { allowFrom: ["+15555550123"] } },
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: [
|
||||
{
|
||||
path: "routing.allowFrom",
|
||||
message: "legacy",
|
||||
},
|
||||
],
|
||||
legacyIssues: [
|
||||
{
|
||||
path: "routing.allowFrom",
|
||||
message: "legacy",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { doctorCommand } = await import("./doctor.js");
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
migrateLegacyConfig.mockReturnValue({
|
||||
config: { whatsapp: { allowFrom: ["+15555550123"] } },
|
||||
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
|
||||
});
|
||||
|
||||
await doctorCommand(runtime);
|
||||
|
||||
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = writeConfigFile.mock.calls[0]?.[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect((written.whatsapp as Record<string, unknown>)?.allowFrom).toEqual([
|
||||
"+15555550123",
|
||||
]);
|
||||
expect(written.routing).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDIS,
|
||||
migrateLegacyConfig,
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
@@ -29,10 +30,36 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
||||
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
let cfg: ClawdisConfig = snapshot.valid ? snapshot.config : {};
|
||||
if (snapshot.exists && !snapshot.valid) {
|
||||
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
|
||||
note("Config invalid; doctor will run with defaults.", "Config");
|
||||
}
|
||||
|
||||
if (snapshot.legacyIssues.length > 0) {
|
||||
note(
|
||||
snapshot.legacyIssues
|
||||
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||
.join("\n"),
|
||||
"Legacy config keys detected",
|
||||
);
|
||||
const migrate = guardCancel(
|
||||
await confirm({
|
||||
message: "Migrate legacy config entries now?",
|
||||
initialValue: true,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (migrate) {
|
||||
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
|
||||
const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed);
|
||||
if (changes.length > 0) {
|
||||
note(changes.join("\n"), "Doctor changes");
|
||||
}
|
||||
if (migrated) {
|
||||
cfg = migrated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceDir = resolveUserPath(
|
||||
cfg.agent?.workspace ?? DEFAULT_WORKSPACE,
|
||||
);
|
||||
@@ -57,7 +84,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||
healthOk = true;
|
||||
} catch (err) {
|
||||
runtime.error(`Health check failed: ${String(err)}`);
|
||||
const message = String(err);
|
||||
if (message.includes("gateway closed")) {
|
||||
note("Gateway not running.", "Gateway");
|
||||
} else {
|
||||
runtime.error(`Health check failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!healthOk) {
|
||||
@@ -79,7 +111,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
||||
try {
|
||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||
} catch (err) {
|
||||
runtime.error(`Health check failed: ${String(err)}`);
|
||||
const message = String(err);
|
||||
if (message.includes("gateway closed")) {
|
||||
note("Gateway not running.", "Gateway");
|
||||
} else {
|
||||
runtime.error(`Health check failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { inspect } from "node:util";
|
||||
|
||||
import { cancel, isCancel } from "@clack/prompts";
|
||||
|
||||
@@ -12,6 +13,7 @@ import type { ClawdisConfig } from "../config/config.js";
|
||||
import { CONFIG_PATH_CLAWDIS } from "../config/config.js";
|
||||
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
@@ -195,7 +197,14 @@ export async function probeGatewayReachable(params: {
|
||||
}
|
||||
|
||||
function summarizeError(err: unknown): string {
|
||||
const raw = String(err ?? "unknown error");
|
||||
let raw = "unknown error";
|
||||
if (err instanceof Error) {
|
||||
raw = err.message || raw;
|
||||
} else if (typeof err === "string") {
|
||||
raw = err || raw;
|
||||
} else if (err !== undefined) {
|
||||
raw = inspect(err, { depth: 2 });
|
||||
}
|
||||
const line =
|
||||
raw
|
||||
.split("\n")
|
||||
@@ -205,3 +214,20 @@ function summarizeError(err: unknown): string {
|
||||
}
|
||||
|
||||
export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
|
||||
export function resolveControlUiLinks(params: {
|
||||
port: number;
|
||||
bind?: "auto" | "lan" | "tailnet" | "loopback";
|
||||
}): { httpUrl: string; wsUrl: string } {
|
||||
const port = params.port;
|
||||
const bind = params.bind ?? "loopback";
|
||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||
const host =
|
||||
bind === "tailnet" || (bind === "auto" && tailnetIPv4)
|
||||
? (tailnetIPv4 ?? "127.0.0.1")
|
||||
: "127.0.0.1";
|
||||
return {
|
||||
httpUrl: `http://${host}:${port}/`,
|
||||
wsUrl: `ws://${host}:${port}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, sleep } from "../utils.js";
|
||||
@@ -40,6 +39,7 @@ import {
|
||||
printWizardHeader,
|
||||
probeGatewayReachable,
|
||||
randomToken,
|
||||
resolveControlUiLinks,
|
||||
summarizeExistingConfig,
|
||||
} from "./onboard-helpers.js";
|
||||
import { setupProviders } from "./onboard-providers.js";
|
||||
@@ -280,8 +280,16 @@ export async function runInteractiveOnboarding(
|
||||
await select({
|
||||
message: "Gateway auth",
|
||||
options: [
|
||||
{ value: "off", label: "Off (loopback only)" },
|
||||
{ value: "token", label: "Token" },
|
||||
{
|
||||
value: "off",
|
||||
label: "Off (loopback only)",
|
||||
hint: "Recommended for single-machine setups",
|
||||
},
|
||||
{
|
||||
value: "token",
|
||||
label: "Token",
|
||||
hint: "Use for multi-machine access or non-loopback binds",
|
||||
},
|
||||
{ value: "password", label: "Password" },
|
||||
],
|
||||
}),
|
||||
@@ -344,6 +352,7 @@ export async function runInteractiveOnboarding(
|
||||
const tokenInput = guardCancel(
|
||||
await text({
|
||||
message: "Gateway token (blank to generate)",
|
||||
placeholder: "Needed for multi-machine or non-loopback access",
|
||||
initialValue: randomToken(),
|
||||
}),
|
||||
runtime,
|
||||
@@ -375,7 +384,11 @@ export async function runInteractiveOnboarding(
|
||||
...nextConfig,
|
||||
gateway: {
|
||||
...nextConfig.gateway,
|
||||
auth: { ...nextConfig.gateway?.auth, mode: "token" },
|
||||
auth: {
|
||||
...nextConfig.gateway?.auth,
|
||||
mode: "token",
|
||||
token: gatewayToken,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -481,18 +494,38 @@ export async function runInteractiveOnboarding(
|
||||
|
||||
note(
|
||||
(() => {
|
||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||
const host =
|
||||
bind === "tailnet" || (bind === "auto" && tailnetIPv4)
|
||||
? (tailnetIPv4 ?? "127.0.0.1")
|
||||
: "127.0.0.1";
|
||||
const links = resolveControlUiLinks({ bind, port });
|
||||
const tokenParam =
|
||||
authMode === "token" && gatewayToken
|
||||
? `?token=${encodeURIComponent(gatewayToken)}`
|
||||
: "";
|
||||
const authedUrl = `${links.httpUrl}${tokenParam}`;
|
||||
return [
|
||||
`Control UI: http://${host}:${port}/`,
|
||||
`Gateway WS: ws://${host}:${port}`,
|
||||
].join("\n");
|
||||
`Web UI: ${links.httpUrl}`,
|
||||
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
|
||||
`Gateway WS: ${links.wsUrl}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
})(),
|
||||
"Open the Control UI",
|
||||
"Control UI",
|
||||
);
|
||||
|
||||
const wantsOpen = guardCancel(
|
||||
await confirm({
|
||||
message: "Open Control UI now?",
|
||||
initialValue: true,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (wantsOpen) {
|
||||
const links = resolveControlUiLinks({ bind, port });
|
||||
const tokenParam =
|
||||
authMode === "token" && gatewayToken
|
||||
? `?token=${encodeURIComponent(gatewayToken)}`
|
||||
: "";
|
||||
await openUrl(`${links.httpUrl}${tokenParam}`);
|
||||
}
|
||||
|
||||
outro("Onboarding complete.");
|
||||
}
|
||||
|
||||
@@ -64,6 +64,93 @@ function noteDiscordTokenHelp(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function setWhatsAppAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) {
|
||||
return {
|
||||
...cfg,
|
||||
whatsapp: {
|
||||
...cfg.whatsapp,
|
||||
allowFrom,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function promptWhatsAppAllowFrom(
|
||||
cfg: ClawdisConfig,
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<ClawdisConfig> {
|
||||
const existingAllowFrom = cfg.whatsapp?.allowFrom ?? [];
|
||||
const existingLabel =
|
||||
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
|
||||
|
||||
note(
|
||||
[
|
||||
"WhatsApp direct chats are gated by `whatsapp.allowFrom`.",
|
||||
'Default (unset) = self-chat only; use "*" to allow anyone.',
|
||||
`Current: ${existingLabel}`,
|
||||
].join("\n"),
|
||||
"WhatsApp allowlist",
|
||||
);
|
||||
|
||||
const options =
|
||||
existingAllowFrom.length > 0
|
||||
? ([
|
||||
{ value: "keep", label: "Keep current" },
|
||||
{ value: "self", label: "Self-chat only (unset)" },
|
||||
{ value: "list", label: "Specific numbers (recommended)" },
|
||||
{ value: "any", label: "Anyone (*)" },
|
||||
] as const)
|
||||
: ([
|
||||
{ value: "self", label: "Self-chat only (default)" },
|
||||
{ value: "list", label: "Specific numbers (recommended)" },
|
||||
{ value: "any", label: "Anyone (*)" },
|
||||
] as const);
|
||||
|
||||
const mode = guardCancel(
|
||||
await select({
|
||||
message: "Who can trigger the bot via WhatsApp?",
|
||||
options: options.map((opt) => ({ value: opt.value, label: opt.label })),
|
||||
}),
|
||||
runtime,
|
||||
) as (typeof options)[number]["value"];
|
||||
|
||||
if (mode === "keep") return cfg;
|
||||
if (mode === "self") return setWhatsAppAllowFrom(cfg, undefined);
|
||||
if (mode === "any") return setWhatsAppAllowFrom(cfg, ["*"]);
|
||||
|
||||
const allowRaw = guardCancel(
|
||||
await text({
|
||||
message: "Allowed sender numbers (comma-separated, E.164)",
|
||||
placeholder: "+15555550123, +447700900123",
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
const parts = raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return "Required";
|
||||
for (const part of parts) {
|
||||
if (part === "*") continue;
|
||||
const normalized = normalizeE164(part);
|
||||
if (!normalized) return `Invalid number: ${part}`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
|
||||
const parts = String(allowRaw)
|
||||
.split(/[\n,;]+/g)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const normalized = parts.map((part) =>
|
||||
part === "*" ? "*" : normalizeE164(part),
|
||||
);
|
||||
const unique = [...new Set(normalized.filter(Boolean))];
|
||||
return setWhatsAppAllowFrom(cfg, unique);
|
||||
}
|
||||
|
||||
export async function setupProviders(
|
||||
cfg: ClawdisConfig,
|
||||
runtime: RuntimeEnv,
|
||||
@@ -198,70 +285,7 @@ export async function setupProviders(
|
||||
note("Run `clawdis login` later to link WhatsApp.", "WhatsApp");
|
||||
}
|
||||
|
||||
const existingAllowFrom = cfg.routing?.allowFrom ?? [];
|
||||
if (existingAllowFrom.length === 0) {
|
||||
note(
|
||||
[
|
||||
"WhatsApp direct chats are gated by `routing.allowFrom`.",
|
||||
'Default (unset) = self-chat only; use "*" to allow anyone.',
|
||||
].join("\n"),
|
||||
"Allowlist (recommended)",
|
||||
);
|
||||
const mode = guardCancel(
|
||||
await select({
|
||||
message: "Who can trigger the bot via WhatsApp?",
|
||||
options: [
|
||||
{ value: "self", label: "Self-chat only (default)" },
|
||||
{ value: "list", label: "Specific numbers (recommended)" },
|
||||
{ value: "any", label: "Anyone (*)" },
|
||||
],
|
||||
}),
|
||||
runtime,
|
||||
) as "self" | "list" | "any";
|
||||
|
||||
if (mode === "any") {
|
||||
next = {
|
||||
...next,
|
||||
routing: { ...next.routing, allowFrom: ["*"] },
|
||||
};
|
||||
} else if (mode === "list") {
|
||||
const allowRaw = guardCancel(
|
||||
await text({
|
||||
message: "Allowed sender numbers (comma-separated, E.164)",
|
||||
placeholder: "+15555550123, +447700900123",
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
const parts = raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return "Required";
|
||||
for (const part of parts) {
|
||||
if (part === "*") continue;
|
||||
const normalized = normalizeE164(part);
|
||||
if (!normalized) return `Invalid number: ${part}`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
|
||||
const parts = String(allowRaw)
|
||||
.split(/[\n,;]+/g)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const normalized = parts.map((part) =>
|
||||
part === "*" ? "*" : normalizeE164(part),
|
||||
);
|
||||
const unique = [...new Set(normalized.filter(Boolean))];
|
||||
next = {
|
||||
...next,
|
||||
routing: { ...next.routing, allowFrom: unique },
|
||||
};
|
||||
}
|
||||
}
|
||||
next = await promptWhatsAppAllowFrom(next, runtime);
|
||||
}
|
||||
|
||||
if (selection.includes("telegram")) {
|
||||
@@ -277,7 +301,15 @@ export async function setupProviders(
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (!keepEnv) {
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
telegram: {
|
||||
...next.telegram,
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
token = String(
|
||||
guardCancel(
|
||||
await text({
|
||||
@@ -344,7 +376,15 @@ export async function setupProviders(
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (!keepEnv) {
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
discord: {
|
||||
...next.discord,
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
token = String(
|
||||
guardCancel(
|
||||
await text({
|
||||
|
||||
@@ -22,6 +22,21 @@ function summarizeInstallFailure(message: string): string | undefined {
|
||||
return cleaned.length > maxLen ? `${cleaned.slice(0, maxLen - 1)}…` : cleaned;
|
||||
}
|
||||
|
||||
function formatSkillHint(skill: {
|
||||
description?: string;
|
||||
install: Array<{ label: string }>;
|
||||
}): string {
|
||||
const desc = skill.description?.trim();
|
||||
const installLabel = skill.install[0]?.label?.trim();
|
||||
const combined =
|
||||
desc && installLabel ? `${desc} — ${installLabel}` : desc || installLabel;
|
||||
if (!combined) return "install";
|
||||
const maxLen = 90;
|
||||
return combined.length > maxLen
|
||||
? `${combined.slice(0, maxLen - 1)}…`
|
||||
: combined;
|
||||
}
|
||||
|
||||
function upsertSkillEntry(
|
||||
cfg: ClawdisConfig,
|
||||
skillKey: string,
|
||||
@@ -104,7 +119,7 @@ export async function setupSkills(
|
||||
...installable.map((skill) => ({
|
||||
value: skill.name,
|
||||
label: `${skill.emoji ?? "🧩"} ${skill.name}`,
|
||||
hint: skill.install[0]?.label ?? "install",
|
||||
hint: formatSkillHint(skill),
|
||||
})),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("sessionsCommand", () => {
|
||||
|
||||
it("shows placeholder rows when tokens are missing", async () => {
|
||||
const store = writeStore({
|
||||
"group:demo": {
|
||||
"discord:group:demo": {
|
||||
sessionId: "xyz",
|
||||
updatedAt: Date.now() - 5 * 60_000,
|
||||
thinkingLevel: "high",
|
||||
@@ -89,7 +89,7 @@ describe("sessionsCommand", () => {
|
||||
|
||||
fs.rmSync(store);
|
||||
|
||||
const row = logs.find((line) => line.includes("group:demo")) ?? "";
|
||||
const row = logs.find((line) => line.includes("discord:group:demo")) ?? "";
|
||||
expect(row).toContain("-".padEnd(20));
|
||||
expect(row).toContain("think:high");
|
||||
expect(row).toContain("5m ago");
|
||||
|
||||
@@ -119,10 +119,17 @@ const formatAge = (ms: number | null | undefined) => {
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
function classifyKey(key: string): SessionRow["kind"] {
|
||||
function classifyKey(key: string, entry?: SessionEntry): SessionRow["kind"] {
|
||||
if (key === "global") return "global";
|
||||
if (key.startsWith("group:")) return "group";
|
||||
if (key === "unknown") return "unknown";
|
||||
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
|
||||
if (
|
||||
key.startsWith("group:") ||
|
||||
key.includes(":group:") ||
|
||||
key.includes(":channel:")
|
||||
) {
|
||||
return "group";
|
||||
}
|
||||
return "direct";
|
||||
}
|
||||
|
||||
@@ -132,7 +139,7 @@ function toRows(store: Record<string, SessionEntry>): SessionRow[] {
|
||||
const updatedAt = entry?.updatedAt ?? null;
|
||||
return {
|
||||
key,
|
||||
kind: classifyKey(key),
|
||||
kind: classifyKey(key, entry),
|
||||
updatedAt,
|
||||
ageMs: updatedAt ? Date.now() - updatedAt : null,
|
||||
sessionId: entry?.sessionId,
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function getStatusSummary(): Promise<StatusSummary> {
|
||||
|
||||
return {
|
||||
key,
|
||||
kind: classifyKey(key),
|
||||
kind: classifyKey(key, entry),
|
||||
sessionId: entry?.sessionId,
|
||||
updatedAt,
|
||||
age,
|
||||
@@ -169,10 +169,20 @@ const formatContextUsage = (
|
||||
return `tokens: ${formatKTokens(used)} used, ${formatKTokens(left)} left of ${formatKTokens(contextTokens)} (${pctLabel})`;
|
||||
};
|
||||
|
||||
const classifyKey = (key: string): SessionStatus["kind"] => {
|
||||
const classifyKey = (
|
||||
key: string,
|
||||
entry?: SessionEntry,
|
||||
): SessionStatus["kind"] => {
|
||||
if (key === "global") return "global";
|
||||
if (key.startsWith("group:")) return "group";
|
||||
if (key === "unknown") return "unknown";
|
||||
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
|
||||
if (
|
||||
key.startsWith("group:") ||
|
||||
key.includes(":group:") ||
|
||||
key.includes(":channel:")
|
||||
) {
|
||||
return "group";
|
||||
}
|
||||
return "direct";
|
||||
};
|
||||
|
||||
|
||||
@@ -206,6 +206,64 @@ describe("config identity defaults", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("config discord", () => {
|
||||
let previousHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
previousHome = process.env.HOME;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = previousHome;
|
||||
});
|
||||
|
||||
it("loads discord guild map + dm group settings", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdis");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "clawdis.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
discord: {
|
||||
enabled: true,
|
||||
dm: {
|
||||
enabled: true,
|
||||
allowFrom: ["steipete"],
|
||||
groupEnabled: true,
|
||||
groupChannels: ["clawd-dm"],
|
||||
},
|
||||
guilds: {
|
||||
"123": {
|
||||
slug: "friends-of-clawd",
|
||||
requireMention: false,
|
||||
users: ["steipete"],
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.discord?.enabled).toBe(true);
|
||||
expect(cfg.discord?.dm?.groupEnabled).toBe(true);
|
||||
expect(cfg.discord?.dm?.groupChannels).toEqual(["clawd-dm"]);
|
||||
expect(cfg.discord?.guilds?.["123"]?.slug).toBe("friends-of-clawd");
|
||||
expect(cfg.discord?.guilds?.["123"]?.channels?.general?.allow).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nix integration (U3, U5, U9)", () => {
|
||||
describe("U3: isNixMode env var detection", () => {
|
||||
it("isNixMode is false when CLAWDIS_NIX_MODE is not set", async () => {
|
||||
@@ -430,3 +488,48 @@ describe("talk.voiceAliases", () => {
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy config detection", () => {
|
||||
it("rejects routing.allowFrom", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
routing: { allowFrom: ["+15555550123"] },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("routing.allowFrom");
|
||||
}
|
||||
});
|
||||
|
||||
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { allowFrom: ["+15555550123"] },
|
||||
});
|
||||
expect(res.changes).toContain("Moved routing.allowFrom → whatsapp.allowFrom.");
|
||||
expect(res.config?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
|
||||
expect(res.config?.routing?.allowFrom).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces legacy issues in snapshot", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = path.join(home, ".clawdis", "clawdis.json");
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ routing: { allowFrom: ["+15555550123"] } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { readConfigFileSnapshot } = await import("./config.js");
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(false);
|
||||
expect(snap.legacyIssues.length).toBe(1);
|
||||
expect(snap.legacyIssues[0]?.path).toBe("routing.allowFrom");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,11 @@ export type WebConfig = {
|
||||
reconnect?: WebReconnectConfig;
|
||||
};
|
||||
|
||||
export type WhatsAppConfig = {
|
||||
/** Optional allowlist for WhatsApp direct chats (E.164). */
|
||||
allowFrom?: string[];
|
||||
};
|
||||
|
||||
export type BrowserConfig = {
|
||||
enabled?: boolean;
|
||||
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
||||
@@ -164,21 +169,52 @@ export type TelegramConfig = {
|
||||
webhookPath?: string;
|
||||
};
|
||||
|
||||
export type DiscordDmConfig = {
|
||||
/** If false, ignore all incoming Discord DMs. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Allowlist for DM senders (ids or names). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** If true, allow group DMs (default: false). */
|
||||
groupEnabled?: boolean;
|
||||
/** Optional allowlist for group DM channels (ids or slugs). */
|
||||
groupChannels?: Array<string | number>;
|
||||
};
|
||||
|
||||
export type DiscordGuildChannelConfig = {
|
||||
allow?: boolean;
|
||||
requireMention?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordGuildEntry = {
|
||||
slug?: string;
|
||||
requireMention?: boolean;
|
||||
users?: Array<string | number>;
|
||||
channels?: Record<string, DiscordGuildChannelConfig>;
|
||||
};
|
||||
|
||||
export type DiscordSlashCommandConfig = {
|
||||
/** Enable handling for the configured slash command (default: false). */
|
||||
enabled?: boolean;
|
||||
/** Slash command name (default: "clawd"). */
|
||||
name?: string;
|
||||
/** Session key prefix for slash commands (default: "discord:slash"). */
|
||||
sessionPrefix?: string;
|
||||
/** Reply ephemerally (default: true). */
|
||||
ephemeral?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordConfig = {
|
||||
/** If false, do not start the Discord provider. Default: true. */
|
||||
enabled?: boolean;
|
||||
token?: string;
|
||||
allowFrom?: Array<string | number>;
|
||||
guildAllowFrom?: {
|
||||
guilds?: Array<string | number>;
|
||||
users?: Array<string | number>;
|
||||
};
|
||||
requireMention?: boolean;
|
||||
mediaMaxMb?: number;
|
||||
/** Number of recent guild messages to include for context (default: 20). */
|
||||
historyLimit?: number;
|
||||
/** Allow agent-triggered Discord reactions (default: true). */
|
||||
enableReactions?: boolean;
|
||||
slashCommand?: DiscordSlashCommandConfig;
|
||||
dm?: DiscordDmConfig;
|
||||
/** New per-guild config keyed by guild id or slug. */
|
||||
guilds?: Record<string, DiscordGuildEntry>;
|
||||
};
|
||||
|
||||
export type SignalConfig = {
|
||||
@@ -241,7 +277,6 @@ export type GroupChatConfig = {
|
||||
};
|
||||
|
||||
export type RoutingConfig = {
|
||||
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
||||
transcribeAudio?: {
|
||||
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
||||
command: string[];
|
||||
@@ -316,6 +351,8 @@ export type GatewayAuthMode = "token" | "password";
|
||||
export type GatewayAuthConfig = {
|
||||
/** Authentication mode for Gateway connections. Defaults to token when set. */
|
||||
mode?: GatewayAuthMode;
|
||||
/** Shared token for token mode (stored locally for CLI auth). */
|
||||
token?: string;
|
||||
/** Shared password for password mode (consider env instead). */
|
||||
password?: string;
|
||||
/** Allow Tailscale identity headers when serve mode is enabled. */
|
||||
@@ -506,6 +543,7 @@ export type ClawdisConfig = {
|
||||
messages?: MessagesConfig;
|
||||
session?: SessionConfig;
|
||||
web?: WebConfig;
|
||||
whatsapp?: WhatsAppConfig;
|
||||
telegram?: TelegramConfig;
|
||||
discord?: DiscordConfig;
|
||||
signal?: SignalConfig;
|
||||
@@ -674,7 +712,6 @@ const HeartbeatSchema = z
|
||||
|
||||
const RoutingSchema = z
|
||||
.object({
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
groupChat: GroupChatSchema,
|
||||
transcribeAudio: TranscribeAudioSchema,
|
||||
queue: z
|
||||
@@ -890,6 +927,11 @@ const ClawdisSchema = z.object({
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
whatsapp: z
|
||||
.object({
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
telegram: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
@@ -908,17 +950,61 @@ const ClawdisSchema = z.object({
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
token: z.string().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
guildAllowFrom: z
|
||||
slashCommand: z
|
||||
.object({
|
||||
guilds: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
sessionPrefix: z.string().optional(),
|
||||
ephemeral: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
enableReactions: z.boolean().optional(),
|
||||
dm: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupEnabled: z.boolean().optional(),
|
||||
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
.optional(),
|
||||
guilds: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
slug: z.string().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
channels: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
guild: z
|
||||
.object({
|
||||
allowFrom: z
|
||||
.object({
|
||||
guilds: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
.optional(),
|
||||
channels: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
signal: z
|
||||
@@ -1013,6 +1099,7 @@ const ClawdisSchema = z.object({
|
||||
auth: z
|
||||
.object({
|
||||
mode: z.union([z.literal("token"), z.literal("password")]).optional(),
|
||||
token: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
allowTailscale: z.boolean().optional(),
|
||||
})
|
||||
@@ -1076,6 +1163,11 @@ export type ConfigValidationIssue = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type LegacyConfigIssue = {
|
||||
path: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ConfigFileSnapshot = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
@@ -1084,8 +1176,100 @@ export type ConfigFileSnapshot = {
|
||||
valid: boolean;
|
||||
config: ClawdisConfig;
|
||||
issues: ConfigValidationIssue[];
|
||||
legacyIssues: LegacyConfigIssue[];
|
||||
};
|
||||
|
||||
type LegacyConfigRule = {
|
||||
path: string[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
type LegacyConfigMigration = {
|
||||
id: string;
|
||||
describe: string;
|
||||
apply: (raw: Record<string, unknown>, changes: string[]) => void;
|
||||
};
|
||||
|
||||
const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
||||
{
|
||||
path: ["routing", "allowFrom"],
|
||||
message:
|
||||
"routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdis doctor` to migrate).",
|
||||
},
|
||||
];
|
||||
|
||||
const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
|
||||
{
|
||||
id: "routing.allowFrom->whatsapp.allowFrom",
|
||||
describe: "Move routing.allowFrom to whatsapp.allowFrom",
|
||||
apply: (raw, changes) => {
|
||||
const routing = raw.routing;
|
||||
if (!routing || typeof routing !== "object") return;
|
||||
const allowFrom = (routing as Record<string, unknown>).allowFrom;
|
||||
if (allowFrom === undefined) return;
|
||||
|
||||
const whatsapp =
|
||||
raw.whatsapp && typeof raw.whatsapp === "object"
|
||||
? (raw.whatsapp as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
if (whatsapp.allowFrom === undefined) {
|
||||
whatsapp.allowFrom = allowFrom;
|
||||
changes.push("Moved routing.allowFrom → whatsapp.allowFrom.");
|
||||
} else {
|
||||
changes.push("Removed routing.allowFrom (whatsapp.allowFrom already set).");
|
||||
}
|
||||
|
||||
delete (routing as Record<string, unknown>).allowFrom;
|
||||
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
||||
delete raw.routing;
|
||||
}
|
||||
raw.whatsapp = whatsapp;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
|
||||
if (!raw || typeof raw !== "object") return [];
|
||||
const root = raw as Record<string, unknown>;
|
||||
const issues: LegacyConfigIssue[] = [];
|
||||
for (const rule of LEGACY_CONFIG_RULES) {
|
||||
let cursor: unknown = root;
|
||||
for (const key of rule.path) {
|
||||
if (!cursor || typeof cursor !== "object") {
|
||||
cursor = undefined;
|
||||
break;
|
||||
}
|
||||
cursor = (cursor as Record<string, unknown>)[key];
|
||||
}
|
||||
if (cursor !== undefined) {
|
||||
issues.push({ path: rule.path.join("."), message: rule.message });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function migrateLegacyConfig(raw: unknown): {
|
||||
config: ClawdisConfig | null;
|
||||
changes: string[];
|
||||
} {
|
||||
if (!raw || typeof raw !== "object") return { config: null, changes: [] };
|
||||
const next = structuredClone(raw) as Record<string, unknown>;
|
||||
const changes: string[] = [];
|
||||
for (const migration of LEGACY_CONFIG_MIGRATIONS) {
|
||||
migration.apply(next, changes);
|
||||
}
|
||||
if (changes.length === 0) return { config: null, changes: [] };
|
||||
const validated = validateConfigObject(next);
|
||||
if (!validated.ok) {
|
||||
changes.push(
|
||||
"Migration applied, but config still invalid; fix remaining issues manually.",
|
||||
);
|
||||
return { config: null, changes };
|
||||
}
|
||||
return { config: validated.config, changes };
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -1144,6 +1328,16 @@ export function validateConfigObject(
|
||||
):
|
||||
| { ok: true; config: ClawdisConfig }
|
||||
| { ok: false; issues: ConfigValidationIssue[] } {
|
||||
const legacyIssues = findLegacyConfigIssues(raw);
|
||||
if (legacyIssues.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
issues: legacyIssues.map((iss) => ({
|
||||
path: iss.path,
|
||||
message: iss.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const validated = ClawdisSchema.safeParse(raw);
|
||||
if (!validated.success) {
|
||||
return {
|
||||
@@ -1216,6 +1410,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
const exists = fs.existsSync(configPath);
|
||||
if (!exists) {
|
||||
const config = applyTalkApiKey({});
|
||||
const legacyIssues: LegacyConfigIssue[] = [];
|
||||
return {
|
||||
path: configPath,
|
||||
exists: false,
|
||||
@@ -1224,6 +1419,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
valid: true,
|
||||
config,
|
||||
issues: [],
|
||||
legacyIssues,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1241,9 +1437,12 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
issues: [
|
||||
{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` },
|
||||
],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
|
||||
const legacyIssues = findLegacyConfigIssues(parsedRes.parsed);
|
||||
|
||||
const validated = validateConfigObject(parsedRes.parsed);
|
||||
if (!validated.ok) {
|
||||
return {
|
||||
@@ -1254,6 +1453,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: validated.issues,
|
||||
legacyIssues,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1265,6 +1465,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
valid: true,
|
||||
config: applyTalkApiKey(validated.config),
|
||||
issues: [],
|
||||
legacyIssues,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -1275,6 +1476,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildGroupDisplayName,
|
||||
deriveSessionKey,
|
||||
loadSessionStore,
|
||||
resolveSessionKey,
|
||||
@@ -31,6 +32,38 @@ describe("sessions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("prefixes group keys with surface when available", () => {
|
||||
expect(
|
||||
deriveSessionKey("per-sender", {
|
||||
From: "12345-678@g.us",
|
||||
ChatType: "group",
|
||||
Surface: "whatsapp",
|
||||
}),
|
||||
).toBe("whatsapp:group:12345-678@g.us");
|
||||
});
|
||||
|
||||
it("keeps explicit surface when provided in group key", () => {
|
||||
expect(
|
||||
resolveSessionKey(
|
||||
"per-sender",
|
||||
{ From: "group:discord:12345", ChatType: "group" },
|
||||
"main",
|
||||
),
|
||||
).toBe("discord:group:12345");
|
||||
});
|
||||
|
||||
it("builds discord display name with guild+channel slugs", () => {
|
||||
expect(
|
||||
buildGroupDisplayName({
|
||||
surface: "discord",
|
||||
room: "#general",
|
||||
space: "friends-of-clawd",
|
||||
id: "123",
|
||||
key: "discord:group:123",
|
||||
}),
|
||||
).toBe("discord:friends-of-clawd#general");
|
||||
});
|
||||
|
||||
it("collapses direct chats to main by default", () => {
|
||||
expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main");
|
||||
});
|
||||
|
||||
@@ -10,11 +10,24 @@ import { normalizeE164 } from "../utils.js";
|
||||
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
|
||||
const GROUP_SURFACES = new Set([
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
"discord",
|
||||
"signal",
|
||||
"imessage",
|
||||
"webchat",
|
||||
"slack",
|
||||
]);
|
||||
|
||||
export type SessionChatType = "direct" | "group" | "room";
|
||||
|
||||
export type SessionEntry = {
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
systemSent?: boolean;
|
||||
abortedLastRun?: boolean;
|
||||
chatType?: SessionChatType;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
providerOverride?: string;
|
||||
@@ -27,6 +40,11 @@ export type SessionEntry = {
|
||||
totalTokens?: number;
|
||||
model?: string;
|
||||
contextTokens?: number;
|
||||
displayName?: string;
|
||||
surface?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
lastChannel?:
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
@@ -38,6 +56,14 @@ export type SessionEntry = {
|
||||
skillsSnapshot?: SessionSkillSnapshot;
|
||||
};
|
||||
|
||||
export type GroupKeyResolution = {
|
||||
key: string;
|
||||
legacyKey?: string;
|
||||
surface?: string;
|
||||
id?: string;
|
||||
chatType?: SessionChatType;
|
||||
};
|
||||
|
||||
export type SessionSkillSnapshot = {
|
||||
prompt: string;
|
||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||
@@ -66,6 +92,151 @@ export function resolveStorePath(store?: string) {
|
||||
return path.resolve(store);
|
||||
}
|
||||
|
||||
function normalizeGroupLabel(raw?: string) {
|
||||
const trimmed = raw?.trim().toLowerCase() ?? "";
|
||||
if (!trimmed) return "";
|
||||
const dashed = trimmed.replace(/\s+/g, "-");
|
||||
const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-");
|
||||
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
||||
}
|
||||
|
||||
function shortenGroupId(value?: string) {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.length <= 14) return trimmed;
|
||||
return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function buildGroupDisplayName(params: {
|
||||
surface?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
id?: string;
|
||||
key: string;
|
||||
}) {
|
||||
const surfaceKey = (params.surface?.trim().toLowerCase() || "group").trim();
|
||||
const room = params.room?.trim();
|
||||
const space = params.space?.trim();
|
||||
const subject = params.subject?.trim();
|
||||
const detail =
|
||||
(room && space
|
||||
? `${space}${room.startsWith("#") ? "" : "#"}${room}`
|
||||
: room || subject || space || "") || "";
|
||||
const fallbackId = params.id?.trim() || params.key.replace(/^group:/, "");
|
||||
const rawLabel = detail || fallbackId;
|
||||
let token = normalizeGroupLabel(rawLabel);
|
||||
if (!token) {
|
||||
token = normalizeGroupLabel(shortenGroupId(rawLabel));
|
||||
}
|
||||
if (!params.room && token.startsWith("#")) {
|
||||
token = token.replace(/^#+/, "");
|
||||
}
|
||||
if (
|
||||
token &&
|
||||
!/^[@#]/.test(token) &&
|
||||
!token.startsWith("g-") &&
|
||||
!token.includes("#")
|
||||
) {
|
||||
token = `g-${token}`;
|
||||
}
|
||||
return token ? `${surfaceKey}:${token}` : surfaceKey;
|
||||
}
|
||||
|
||||
export function resolveGroupSessionKey(
|
||||
ctx: MsgContext,
|
||||
): GroupKeyResolution | null {
|
||||
const from = typeof ctx.From === "string" ? ctx.From.trim() : "";
|
||||
if (!from) return null;
|
||||
const chatType = ctx.ChatType?.trim().toLowerCase();
|
||||
const isGroup =
|
||||
chatType === "group" ||
|
||||
from.startsWith("group:") ||
|
||||
from.includes("@g.us") ||
|
||||
from.includes(":group:") ||
|
||||
from.includes(":channel:");
|
||||
if (!isGroup) return null;
|
||||
|
||||
const surfaceHint = ctx.Surface?.trim().toLowerCase();
|
||||
const hasLegacyGroupPrefix = from.startsWith("group:");
|
||||
const raw = (
|
||||
hasLegacyGroupPrefix ? from.slice("group:".length) : from
|
||||
).trim();
|
||||
|
||||
let surface: string | undefined;
|
||||
let kind: "group" | "channel" | undefined;
|
||||
let id = "";
|
||||
|
||||
const parseKind = (value: string) => {
|
||||
if (value === "channel") return "channel";
|
||||
return "group";
|
||||
};
|
||||
|
||||
const parseParts = (parts: string[]) => {
|
||||
if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) {
|
||||
surface = parts[0];
|
||||
if (parts.length >= 3) {
|
||||
const kindCandidate = parts[1];
|
||||
if (["group", "channel"].includes(kindCandidate)) {
|
||||
kind = parseKind(kindCandidate);
|
||||
id = parts.slice(2).join(":");
|
||||
} else {
|
||||
id = parts.slice(1).join(":");
|
||||
}
|
||||
} else {
|
||||
id = parts[1];
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (parts.length >= 2 && ["group", "channel"].includes(parts[0])) {
|
||||
kind = parseKind(parts[0]);
|
||||
id = parts.slice(1).join(":");
|
||||
}
|
||||
};
|
||||
|
||||
if (hasLegacyGroupPrefix) {
|
||||
const legacyParts = raw.split(":").filter(Boolean);
|
||||
if (legacyParts.length > 1) {
|
||||
parseParts(legacyParts);
|
||||
} else {
|
||||
id = raw;
|
||||
}
|
||||
} else if (from.includes("@g.us") && !from.includes(":")) {
|
||||
id = from;
|
||||
} else {
|
||||
parseParts(from.split(":").filter(Boolean));
|
||||
if (!id) {
|
||||
id = raw || from;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedSurface = surface ?? surfaceHint;
|
||||
if (!resolvedSurface) {
|
||||
const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`;
|
||||
return {
|
||||
key: legacy,
|
||||
id: raw || from,
|
||||
legacyKey: legacy,
|
||||
chatType: "group",
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedKind = kind === "channel" ? "channel" : "group";
|
||||
const key = `${resolvedSurface}:${resolvedKind}:${id || raw || from}`;
|
||||
let legacyKey: string | undefined;
|
||||
if (hasLegacyGroupPrefix || from.includes("@g.us")) {
|
||||
legacyKey = `group:${id || raw || from}`;
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
legacyKey,
|
||||
surface: resolvedSurface,
|
||||
id: id || raw || from,
|
||||
chatType: resolvedKind === "channel" ? "room" : "group",
|
||||
};
|
||||
}
|
||||
|
||||
export function loadSessionStore(
|
||||
storePath: string,
|
||||
): Record<string, SessionEntry> {
|
||||
@@ -145,6 +316,12 @@ export async function updateLastRoute(params: {
|
||||
totalTokens: existing?.totalTokens,
|
||||
model: existing?.model,
|
||||
contextTokens: existing?.contextTokens,
|
||||
displayName: existing?.displayName,
|
||||
chatType: existing?.chatType,
|
||||
surface: existing?.surface,
|
||||
subject: existing?.subject,
|
||||
room: existing?.room,
|
||||
space: existing?.space,
|
||||
skillsSnapshot: existing?.skillsSnapshot,
|
||||
lastChannel: channel,
|
||||
lastTo: to?.trim() ? to.trim() : undefined,
|
||||
@@ -157,14 +334,9 @@ export async function updateLastRoute(params: {
|
||||
// Decide which session bucket to use (per-sender vs global).
|
||||
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
||||
if (scope === "global") return "global";
|
||||
const resolvedGroup = resolveGroupSessionKey(ctx);
|
||||
if (resolvedGroup) return resolvedGroup.key;
|
||||
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
||||
// Preserve group conversations as distinct buckets
|
||||
if (typeof ctx.From === "string" && ctx.From.includes("@g.us")) {
|
||||
return `group:${ctx.From}`;
|
||||
}
|
||||
if (typeof ctx.From === "string" && ctx.From.startsWith("group:")) {
|
||||
return ctx.From;
|
||||
}
|
||||
return from || "unknown";
|
||||
}
|
||||
|
||||
@@ -177,11 +349,16 @@ export function resolveSessionKey(
|
||||
ctx: MsgContext,
|
||||
mainKey?: string,
|
||||
) {
|
||||
const explicit = ctx.SessionKey?.trim();
|
||||
if (explicit) return explicit;
|
||||
const raw = deriveSessionKey(scope, ctx);
|
||||
if (scope === "global") return raw;
|
||||
// Default to a single shared direct-chat session called "main"; groups stay isolated.
|
||||
const canonical = (mainKey ?? "main").trim() || "main";
|
||||
const isGroup = raw.startsWith("group:") || raw.includes("@g.us");
|
||||
const isGroup =
|
||||
raw.startsWith("group:") ||
|
||||
raw.includes(":group:") ||
|
||||
raw.includes(":channel:");
|
||||
if (!isGroup) return canonical;
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ function resolveDeliveryTarget(
|
||||
|
||||
const sanitizedWhatsappTo = (() => {
|
||||
if (channel !== "whatsapp") return to;
|
||||
const rawAllow = cfg.routing?.allowFrom ?? [];
|
||||
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||
if (rawAllow.includes("*")) return to;
|
||||
const allowFrom = rawAllow
|
||||
.map((val) => normalizeE164(val))
|
||||
|
||||
@@ -23,4 +23,21 @@ describe("cron schedule", () => {
|
||||
);
|
||||
expect(next).toBe(anchor + 30_000);
|
||||
});
|
||||
|
||||
it("computes next run for every schedule when anchorMs is not provided", () => {
|
||||
const now = Date.parse("2025-12-13T00:00:00.000Z");
|
||||
const next = computeNextRunAtMs({ kind: "every", everyMs: 30_000 }, now);
|
||||
|
||||
// Should return nowMs + everyMs, not nowMs (which would cause infinite loop)
|
||||
expect(next).toBe(now + 30_000);
|
||||
});
|
||||
|
||||
it("advances when now matches anchor for every schedule", () => {
|
||||
const anchor = Date.parse("2025-12-13T00:00:00.000Z");
|
||||
const next = computeNextRunAtMs(
|
||||
{ kind: "every", everyMs: 30_000, anchorMs: anchor },
|
||||
anchor,
|
||||
);
|
||||
expect(next).toBe(anchor + 30_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,9 +12,9 @@ export function computeNextRunAtMs(
|
||||
if (schedule.kind === "every") {
|
||||
const everyMs = Math.max(1, Math.floor(schedule.everyMs));
|
||||
const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
|
||||
if (nowMs <= anchor) return anchor;
|
||||
if (nowMs < anchor) return anchor;
|
||||
const elapsed = nowMs - anchor;
|
||||
const steps = Math.floor((elapsed + everyMs - 1) / everyMs);
|
||||
const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs));
|
||||
return anchor + steps * everyMs;
|
||||
}
|
||||
|
||||
|
||||
150
src/discord/monitor.test.ts
Normal file
150
src/discord/monitor.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
allowListMatches,
|
||||
type DiscordGuildEntryResolved,
|
||||
normalizeDiscordAllowList,
|
||||
normalizeDiscordSlug,
|
||||
resolveDiscordChannelConfig,
|
||||
resolveDiscordGuildEntry,
|
||||
resolveGroupDmAllow,
|
||||
} from "./monitor.js";
|
||||
|
||||
const fakeGuild = (id: string, name: string) =>
|
||||
({ id, name }) as unknown as import("discord.js").Guild;
|
||||
|
||||
const makeEntries = (
|
||||
entries: Record<string, Partial<DiscordGuildEntryResolved>>,
|
||||
): Record<string, DiscordGuildEntryResolved> => {
|
||||
const out: Record<string, DiscordGuildEntryResolved> = {};
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
out[key] = {
|
||||
slug: value.slug,
|
||||
requireMention: value.requireMention,
|
||||
users: value.users,
|
||||
channels: value.channels,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
describe("discord allowlist helpers", () => {
|
||||
it("normalizes slugs", () => {
|
||||
expect(normalizeDiscordSlug("Friends of Clawd")).toBe("friends-of-clawd");
|
||||
expect(normalizeDiscordSlug("#General")).toBe("general");
|
||||
expect(normalizeDiscordSlug("Dev__Chat")).toBe("dev-chat");
|
||||
});
|
||||
|
||||
it("matches ids or names", () => {
|
||||
const allow = normalizeDiscordAllowList(
|
||||
["123", "steipete", "Friends of Clawd"],
|
||||
["discord:", "user:", "guild:", "channel:"],
|
||||
);
|
||||
expect(allow).not.toBeNull();
|
||||
if (!allow) {
|
||||
throw new Error("Expected allow list to be normalized");
|
||||
}
|
||||
expect(allowListMatches(allow, { id: "123" })).toBe(true);
|
||||
expect(allowListMatches(allow, { name: "steipete" })).toBe(true);
|
||||
expect(allowListMatches(allow, { name: "friends-of-clawd" })).toBe(true);
|
||||
expect(allowListMatches(allow, { name: "other" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discord guild/channel resolution", () => {
|
||||
it("resolves guild entry by id", () => {
|
||||
const guildEntries = makeEntries({
|
||||
"123": { slug: "friends-of-clawd" },
|
||||
});
|
||||
const resolved = resolveDiscordGuildEntry({
|
||||
guild: fakeGuild("123", "Friends of Clawd"),
|
||||
guildEntries,
|
||||
});
|
||||
expect(resolved?.id).toBe("123");
|
||||
expect(resolved?.slug).toBe("friends-of-clawd");
|
||||
});
|
||||
|
||||
it("resolves guild entry by slug key", () => {
|
||||
const guildEntries = makeEntries({
|
||||
"friends-of-clawd": { slug: "friends-of-clawd" },
|
||||
});
|
||||
const resolved = resolveDiscordGuildEntry({
|
||||
guild: fakeGuild("123", "Friends of Clawd"),
|
||||
guildEntries,
|
||||
});
|
||||
expect(resolved?.id).toBe("123");
|
||||
expect(resolved?.slug).toBe("friends-of-clawd");
|
||||
});
|
||||
|
||||
it("resolves channel config by slug", () => {
|
||||
const guildInfo: DiscordGuildEntryResolved = {
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
help: { allow: true, requireMention: true },
|
||||
},
|
||||
};
|
||||
const channel = resolveDiscordChannelConfig({
|
||||
guildInfo,
|
||||
channelId: "456",
|
||||
channelName: "General",
|
||||
channelSlug: "general",
|
||||
});
|
||||
expect(channel?.allowed).toBe(true);
|
||||
expect(channel?.requireMention).toBeUndefined();
|
||||
|
||||
const help = resolveDiscordChannelConfig({
|
||||
guildInfo,
|
||||
channelId: "789",
|
||||
channelName: "Help",
|
||||
channelSlug: "help",
|
||||
});
|
||||
expect(help?.allowed).toBe(true);
|
||||
expect(help?.requireMention).toBe(true);
|
||||
});
|
||||
|
||||
it("denies channel when config present but no match", () => {
|
||||
const guildInfo: DiscordGuildEntryResolved = {
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
},
|
||||
};
|
||||
const channel = resolveDiscordChannelConfig({
|
||||
guildInfo,
|
||||
channelId: "999",
|
||||
channelName: "random",
|
||||
channelSlug: "random",
|
||||
});
|
||||
expect(channel?.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discord group DM gating", () => {
|
||||
it("allows all when no allowlist", () => {
|
||||
expect(
|
||||
resolveGroupDmAllow({
|
||||
channels: undefined,
|
||||
channelId: "1",
|
||||
channelName: "dm",
|
||||
channelSlug: "dm",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches group DM allowlist", () => {
|
||||
expect(
|
||||
resolveGroupDmAllow({
|
||||
channels: ["clawd-dm"],
|
||||
channelId: "1",
|
||||
channelName: "Clawd DM",
|
||||
channelSlug: "clawd-dm",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveGroupDmAllow({
|
||||
channels: ["clawd-dm"],
|
||||
channelId: "1",
|
||||
channelName: "Other",
|
||||
channelSlug: "other",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
ApplicationCommandOptionType,
|
||||
ChannelType,
|
||||
Client,
|
||||
type CommandInteractionOption,
|
||||
Events,
|
||||
GatewayIntentBits,
|
||||
type Message,
|
||||
@@ -9,10 +12,12 @@ import {
|
||||
import { chunkText } from "../auto-reply/chunk.js";
|
||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { DiscordSlashCommandConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
||||
import { danger, isVerbose, logVerbose } from "../globals.js";
|
||||
import { danger, isVerbose, logVerbose, warn } from "../globals.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { detectMime } from "../media/mime.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
@@ -24,12 +29,7 @@ export type MonitorDiscordOpts = {
|
||||
token?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
allowFrom?: Array<string | number>;
|
||||
guildAllowFrom?: {
|
||||
guilds?: Array<string | number>;
|
||||
users?: Array<string | number>;
|
||||
};
|
||||
requireMention?: boolean;
|
||||
slashCommand?: DiscordSlashCommandConfig;
|
||||
mediaMaxMb?: number;
|
||||
historyLimit?: number;
|
||||
};
|
||||
@@ -47,6 +47,25 @@ type DiscordHistoryEntry = {
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
export type DiscordAllowList = {
|
||||
allowAll: boolean;
|
||||
ids: Set<string>;
|
||||
names: Set<string>;
|
||||
};
|
||||
|
||||
export type DiscordGuildEntryResolved = {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
requireMention?: boolean;
|
||||
users?: Array<string | number>;
|
||||
channels?: Record<string, { allow?: boolean; requireMention?: boolean }>;
|
||||
};
|
||||
|
||||
export type DiscordChannelConfigResolved = {
|
||||
allowed: boolean;
|
||||
requireMention?: boolean;
|
||||
};
|
||||
|
||||
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const cfg = loadConfig();
|
||||
const token = normalizeDiscordToken(
|
||||
@@ -69,16 +88,21 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
},
|
||||
};
|
||||
|
||||
const allowFrom = opts.allowFrom ?? cfg.discord?.allowFrom;
|
||||
const guildAllowFrom = opts.guildAllowFrom ?? cfg.discord?.guildAllowFrom;
|
||||
const requireMention =
|
||||
opts.requireMention ?? cfg.discord?.requireMention ?? true;
|
||||
const dmConfig = cfg.discord?.dm;
|
||||
const guildEntries = cfg.discord?.guilds;
|
||||
const allowFrom = dmConfig?.allowFrom;
|
||||
const slashCommand = resolveSlashCommandConfig(
|
||||
opts.slashCommand ?? cfg.discord?.slashCommand,
|
||||
);
|
||||
const mediaMaxBytes =
|
||||
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
|
||||
);
|
||||
const dmEnabled = dmConfig?.enabled ?? true;
|
||||
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
|
||||
const groupDmChannels = dmConfig?.groupChannels;
|
||||
|
||||
const client = new Client({
|
||||
intents: [
|
||||
@@ -95,6 +119,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
|
||||
client.once(Events.ClientReady, () => {
|
||||
runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`);
|
||||
if (slashCommand.enabled) {
|
||||
void ensureSlashCommand(client, slashCommand, runtime);
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.Error, (err) => {
|
||||
@@ -106,7 +133,13 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
if (message.author?.bot) return;
|
||||
if (!message.author) return;
|
||||
|
||||
const isDirectMessage = !message.guild;
|
||||
// Discord.js typing excludes GroupDM for message.channel.type; widen for runtime check.
|
||||
const channelType = message.channel.type as ChannelType;
|
||||
const isGroupDm = channelType === ChannelType.GroupDM;
|
||||
const isDirectMessage = channelType === ChannelType.DM;
|
||||
const isGuildMessage = Boolean(message.guild);
|
||||
if (isGroupDm && !groupDmEnabled) return;
|
||||
if (isDirectMessage && !dmEnabled) return;
|
||||
const botId = client.user?.id;
|
||||
const wasMentioned =
|
||||
!isDirectMessage && Boolean(botId && message.mentions.has(botId));
|
||||
@@ -117,7 +150,59 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
message.embeds[0]?.description ||
|
||||
"";
|
||||
|
||||
if (!isDirectMessage && historyLimit > 0 && baseText) {
|
||||
const guildInfo = isGuildMessage
|
||||
? resolveDiscordGuildEntry({
|
||||
guild: message.guild,
|
||||
guildEntries,
|
||||
})
|
||||
: null;
|
||||
if (
|
||||
isGuildMessage &&
|
||||
guildEntries &&
|
||||
Object.keys(guildEntries).length > 0 &&
|
||||
!guildInfo
|
||||
) {
|
||||
logVerbose(
|
||||
`Blocked discord guild ${message.guild?.id ?? "unknown"} (not in discord.guilds)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const channelName =
|
||||
(isGuildMessage || isGroupDm) && "name" in message.channel
|
||||
? message.channel.name
|
||||
: undefined;
|
||||
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
||||
const guildSlug =
|
||||
guildInfo?.slug ||
|
||||
(message.guild?.name ? normalizeDiscordSlug(message.guild.name) : "");
|
||||
const channelConfig = isGuildMessage
|
||||
? resolveDiscordChannelConfig({
|
||||
guildInfo,
|
||||
channelId: message.channelId,
|
||||
channelName,
|
||||
channelSlug,
|
||||
})
|
||||
: null;
|
||||
|
||||
const groupDmAllowed =
|
||||
isGroupDm &&
|
||||
resolveGroupDmAllow({
|
||||
channels: groupDmChannels,
|
||||
channelId: message.channelId,
|
||||
channelName,
|
||||
channelSlug,
|
||||
});
|
||||
if (isGroupDm && !groupDmAllowed) return;
|
||||
|
||||
if (isGuildMessage && channelConfig?.allowed === false) {
|
||||
logVerbose(
|
||||
`Blocked discord channel ${message.channelId} not in guild channel allowlist`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGuildMessage && historyLimit > 0 && baseText) {
|
||||
const history = guildHistories.get(message.channelId) ?? [];
|
||||
history.push({
|
||||
sender: message.member?.displayName ?? message.author.tag,
|
||||
@@ -129,7 +214,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
guildHistories.set(message.channelId, history);
|
||||
}
|
||||
|
||||
if (!isDirectMessage && requireMention) {
|
||||
const resolvedRequireMention =
|
||||
channelConfig?.requireMention ?? guildInfo?.requireMention ?? true;
|
||||
if (isGuildMessage && resolvedRequireMention) {
|
||||
if (botId && !wasMentioned) {
|
||||
logger.info(
|
||||
{
|
||||
@@ -142,23 +229,23 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDirectMessage && guildAllowFrom) {
|
||||
const guilds = normalizeDiscordAllowList(guildAllowFrom.guilds, [
|
||||
"guild:",
|
||||
]);
|
||||
const users = normalizeDiscordAllowList(guildAllowFrom.users, [
|
||||
"discord:",
|
||||
"user:",
|
||||
]);
|
||||
if (guilds || users) {
|
||||
const guildId = message.guild?.id ?? "";
|
||||
const userId = message.author.id;
|
||||
const guildOk =
|
||||
!guilds || guilds.allowAll || (guildId && guilds.ids.has(guildId));
|
||||
const userOk = !users || users.allowAll || users.ids.has(userId);
|
||||
if (!guildOk || !userOk) {
|
||||
if (isGuildMessage) {
|
||||
const userAllow = guildInfo?.users;
|
||||
if (Array.isArray(userAllow) && userAllow.length > 0) {
|
||||
const users = normalizeDiscordAllowList(userAllow, [
|
||||
"discord:",
|
||||
"user:",
|
||||
]);
|
||||
const userOk =
|
||||
!users ||
|
||||
allowListMatches(users, {
|
||||
id: message.author.id,
|
||||
name: message.author.username,
|
||||
tag: message.author.tag,
|
||||
});
|
||||
if (!userOk) {
|
||||
logVerbose(
|
||||
`Blocked discord guild sender ${userId} (guild ${guildId || "unknown"}) not in guildAllowFrom`,
|
||||
`Blocked discord guild sender ${message.author.id} (not in guild users allowlist)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -166,22 +253,20 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}
|
||||
|
||||
if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||
const allowed = allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
const candidate = message.author.id;
|
||||
const normalized = new Set(
|
||||
allowed
|
||||
.filter((entry) => entry !== "*")
|
||||
.map((entry) => entry.replace(/^discord:/i, "")),
|
||||
);
|
||||
const allowList = normalizeDiscordAllowList(allowFrom, [
|
||||
"discord:",
|
||||
"user:",
|
||||
]);
|
||||
const permitted =
|
||||
allowed.includes("*") ||
|
||||
normalized.has(candidate) ||
|
||||
allowed.includes(candidate);
|
||||
allowList &&
|
||||
allowListMatches(allowList, {
|
||||
id: message.author.id,
|
||||
name: message.author.username,
|
||||
tag: message.author.tag,
|
||||
});
|
||||
if (!permitted) {
|
||||
logVerbose(
|
||||
`Blocked unauthorized discord sender ${candidate} (not in allowFrom)`,
|
||||
`Blocked unauthorized discord sender ${message.author.id} (not in allowFrom)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -198,6 +283,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const fromLabel = isDirectMessage
|
||||
? buildDirectLabel(message)
|
||||
: buildGuildLabel(message);
|
||||
const groupRoom =
|
||||
isGuildMessage && channelSlug ? `#${channelSlug}` : undefined;
|
||||
const groupSubject = isDirectMessage ? undefined : groupRoom;
|
||||
const textWithId = `${text}\n[discord message id: ${message.id} channel: ${message.channelId}]`;
|
||||
let combinedBody = formatAgentEnvelope({
|
||||
surface: "Discord",
|
||||
@@ -224,7 +312,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
.join("\n");
|
||||
combinedBody = `[Chat messages since your last reply - for context]\n${historyText}\n\n[Current message - respond to this]\n${combinedBody}`;
|
||||
}
|
||||
combinedBody = `${combinedBody}\n[from: ${message.member?.displayName ?? message.author.tag}]`;
|
||||
const name = message.author.tag;
|
||||
const id = message.author.id;
|
||||
combinedBody = `${combinedBody}\n[from: ${name} id:${id}]`;
|
||||
shouldClearHistory = true;
|
||||
}
|
||||
|
||||
@@ -238,10 +328,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
: `channel:${message.channelId}`,
|
||||
ChatType: isDirectMessage ? "direct" : "group",
|
||||
SenderName: message.member?.displayName ?? message.author.tag,
|
||||
GroupSubject:
|
||||
!isDirectMessage && "name" in message.channel
|
||||
? message.channel.name
|
||||
: undefined,
|
||||
GroupSubject: groupSubject,
|
||||
GroupRoom: groupRoom,
|
||||
GroupSpace: isGuildMessage ? guildSlug || undefined : undefined,
|
||||
Surface: "discord" as const,
|
||||
WasMentioned: wasMentioned,
|
||||
MessageSid: message.id,
|
||||
@@ -290,7 +379,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
token,
|
||||
runtime,
|
||||
});
|
||||
if (!isDirectMessage && shouldClearHistory && historyLimit > 0) {
|
||||
if (isGuildMessage && shouldClearHistory && historyLimit > 0) {
|
||||
guildHistories.set(message.channelId, []);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -298,6 +387,163 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.InteractionCreate, async (interaction) => {
|
||||
try {
|
||||
if (!slashCommand.enabled) return;
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
if (interaction.commandName !== slashCommand.name) return;
|
||||
if (interaction.user?.bot) return;
|
||||
|
||||
const channelType = interaction.channel?.type as ChannelType | undefined;
|
||||
const isGroupDm = channelType === ChannelType.GroupDM;
|
||||
const isDirectMessage =
|
||||
!interaction.inGuild() && channelType === ChannelType.DM;
|
||||
const isGuildMessage = interaction.inGuild();
|
||||
|
||||
if (isGroupDm && !groupDmEnabled) return;
|
||||
if (isDirectMessage && !dmEnabled) return;
|
||||
|
||||
if (isGuildMessage) {
|
||||
const guildInfo = resolveDiscordGuildEntry({
|
||||
guild: interaction.guild ?? null,
|
||||
guildEntries,
|
||||
});
|
||||
if (
|
||||
guildEntries &&
|
||||
Object.keys(guildEntries).length > 0 &&
|
||||
!guildInfo
|
||||
) {
|
||||
logVerbose(
|
||||
`Blocked discord guild ${interaction.guildId ?? "unknown"} (not in discord.guilds)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const channelName =
|
||||
interaction.channel && "name" in interaction.channel
|
||||
? interaction.channel.name
|
||||
: undefined;
|
||||
const channelSlug = channelName
|
||||
? normalizeDiscordSlug(channelName)
|
||||
: "";
|
||||
const channelConfig = resolveDiscordChannelConfig({
|
||||
guildInfo,
|
||||
channelId: interaction.channelId,
|
||||
channelName,
|
||||
channelSlug,
|
||||
});
|
||||
if (channelConfig?.allowed === false) {
|
||||
logVerbose(
|
||||
`Blocked discord channel ${interaction.channelId} not in guild channel allowlist`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const userAllow = guildInfo?.users;
|
||||
if (Array.isArray(userAllow) && userAllow.length > 0) {
|
||||
const users = normalizeDiscordAllowList(userAllow, [
|
||||
"discord:",
|
||||
"user:",
|
||||
]);
|
||||
const userOk =
|
||||
!users ||
|
||||
allowListMatches(users, {
|
||||
id: interaction.user.id,
|
||||
name: interaction.user.username,
|
||||
tag: interaction.user.tag,
|
||||
});
|
||||
if (!userOk) {
|
||||
logVerbose(
|
||||
`Blocked discord guild sender ${interaction.user.id} (not in guild users allowlist)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (isGroupDm) {
|
||||
const channelName =
|
||||
interaction.channel && "name" in interaction.channel
|
||||
? interaction.channel.name
|
||||
: undefined;
|
||||
const channelSlug = channelName
|
||||
? normalizeDiscordSlug(channelName)
|
||||
: "";
|
||||
const groupDmAllowed = resolveGroupDmAllow({
|
||||
channels: groupDmChannels,
|
||||
channelId: interaction.channelId,
|
||||
channelName,
|
||||
channelSlug,
|
||||
});
|
||||
if (!groupDmAllowed) return;
|
||||
} else if (isDirectMessage) {
|
||||
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||
const allowList = normalizeDiscordAllowList(allowFrom, [
|
||||
"discord:",
|
||||
"user:",
|
||||
]);
|
||||
const permitted =
|
||||
allowList &&
|
||||
allowListMatches(allowList, {
|
||||
id: interaction.user.id,
|
||||
name: interaction.user.username,
|
||||
tag: interaction.user.tag,
|
||||
});
|
||||
if (!permitted) {
|
||||
logVerbose(
|
||||
`Blocked unauthorized discord sender ${interaction.user.id} (not in allowFrom)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = resolveSlashPrompt(interaction.options.data);
|
||||
if (!prompt) {
|
||||
await interaction.reply({
|
||||
content: "Message required.",
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: slashCommand.ephemeral });
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const ctxPayload = {
|
||||
Body: prompt,
|
||||
From: `discord:${userId}`,
|
||||
To: `slash:${userId}`,
|
||||
ChatType: "direct",
|
||||
SenderName: interaction.user.username,
|
||||
Surface: "discord" as const,
|
||||
WasMentioned: true,
|
||||
MessageSid: interaction.id,
|
||||
Timestamp: interaction.createdTimestamp,
|
||||
SessionKey: `${slashCommand.sessionPrefix}:${userId}`,
|
||||
};
|
||||
|
||||
const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg);
|
||||
const replies = replyResult
|
||||
? Array.isArray(replyResult)
|
||||
? replyResult
|
||||
: [replyResult]
|
||||
: [];
|
||||
|
||||
await deliverSlashReplies({
|
||||
replies,
|
||||
interaction,
|
||||
ephemeral: slashCommand.ephemeral,
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`slash handler failed: ${String(err)}`));
|
||||
if (interaction.isRepliable()) {
|
||||
const content = "Sorry, something went wrong handling that command.";
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.followUp({ content, ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ content, ephemeral: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await client.login(token);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -364,25 +610,256 @@ function buildGuildLabel(message: import("discord.js").Message) {
|
||||
return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`;
|
||||
}
|
||||
|
||||
function normalizeDiscordAllowList(
|
||||
export function normalizeDiscordAllowList(
|
||||
raw: Array<string | number> | undefined,
|
||||
prefixes: string[],
|
||||
): { allowAll: boolean; ids: Set<string> } | null {
|
||||
): DiscordAllowList | null {
|
||||
if (!raw || raw.length === 0) return null;
|
||||
const cleaned = raw
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
for (const prefix of prefixes) {
|
||||
if (entry.toLowerCase().startsWith(prefix)) {
|
||||
return entry.slice(prefix.length);
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
const names = new Set<string>();
|
||||
let allowAll = false;
|
||||
|
||||
for (const rawEntry of raw) {
|
||||
let entry = String(rawEntry).trim();
|
||||
if (!entry) continue;
|
||||
if (entry === "*") {
|
||||
allowAll = true;
|
||||
continue;
|
||||
}
|
||||
for (const prefix of prefixes) {
|
||||
if (entry.toLowerCase().startsWith(prefix)) {
|
||||
entry = entry.slice(prefix.length);
|
||||
break;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
const mentionMatch = entry.match(/^<[@#][!]?(\d+)>$/);
|
||||
if (mentionMatch?.[1]) {
|
||||
ids.add(mentionMatch[1]);
|
||||
continue;
|
||||
}
|
||||
entry = entry.trim();
|
||||
if (entry.startsWith("@") || entry.startsWith("#")) {
|
||||
entry = entry.slice(1);
|
||||
}
|
||||
if (/^\d+$/.test(entry)) {
|
||||
ids.add(entry);
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeDiscordName(entry);
|
||||
if (normalized) names.add(normalized);
|
||||
const slugged = normalizeDiscordSlug(entry);
|
||||
if (slugged) names.add(slugged);
|
||||
}
|
||||
|
||||
if (!allowAll && ids.size === 0 && names.size === 0) return null;
|
||||
return { allowAll, ids, names };
|
||||
}
|
||||
|
||||
function normalizeDiscordName(value?: string | null) {
|
||||
if (!value) return "";
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function normalizeDiscordSlug(value?: string | null) {
|
||||
if (!value) return "";
|
||||
let text = value.trim().toLowerCase();
|
||||
if (!text) return "";
|
||||
text = text.replace(/^[@#]+/, "");
|
||||
text = text.replace(/[\s_]+/g, "-");
|
||||
text = text.replace(/[^a-z0-9-]+/g, "-");
|
||||
text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
|
||||
return text;
|
||||
}
|
||||
|
||||
export function allowListMatches(
|
||||
allowList: DiscordAllowList,
|
||||
candidates: {
|
||||
id?: string;
|
||||
name?: string | null;
|
||||
tag?: string | null;
|
||||
},
|
||||
) {
|
||||
if (allowList.allowAll) return true;
|
||||
const { id, name, tag } = candidates;
|
||||
if (id && allowList.ids.has(id)) return true;
|
||||
const normalizedName = normalizeDiscordName(name);
|
||||
if (normalizedName && allowList.names.has(normalizedName)) return true;
|
||||
const normalizedTag = normalizeDiscordName(tag);
|
||||
if (normalizedTag && allowList.names.has(normalizedTag)) return true;
|
||||
const slugName = normalizeDiscordSlug(name);
|
||||
if (slugName && allowList.names.has(slugName)) return true;
|
||||
const slugTag = normalizeDiscordSlug(tag);
|
||||
if (slugTag && allowList.names.has(slugTag)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resolveDiscordGuildEntry(params: {
|
||||
guild: import("discord.js").Guild | null;
|
||||
guildEntries: Record<string, DiscordGuildEntryResolved> | undefined;
|
||||
}): DiscordGuildEntryResolved | null {
|
||||
const { guild, guildEntries } = params;
|
||||
if (!guild || !guildEntries || Object.keys(guildEntries).length === 0) {
|
||||
return null;
|
||||
}
|
||||
const guildId = guild.id;
|
||||
const guildSlug = normalizeDiscordSlug(guild.name);
|
||||
const direct = guildEntries[guildId];
|
||||
if (direct) {
|
||||
return {
|
||||
id: guildId,
|
||||
slug: direct.slug ?? guildSlug,
|
||||
requireMention: direct.requireMention,
|
||||
users: direct.users,
|
||||
channels: direct.channels,
|
||||
};
|
||||
}
|
||||
if (guildSlug && guildEntries[guildSlug]) {
|
||||
const entry = guildEntries[guildSlug];
|
||||
return {
|
||||
id: guildId,
|
||||
slug: entry.slug ?? guildSlug,
|
||||
requireMention: entry.requireMention,
|
||||
users: entry.users,
|
||||
channels: entry.channels,
|
||||
};
|
||||
}
|
||||
const matchBySlug = Object.entries(guildEntries).find(([, entry]) => {
|
||||
const entrySlug = normalizeDiscordSlug(entry.slug);
|
||||
return entrySlug && entrySlug === guildSlug;
|
||||
});
|
||||
if (matchBySlug) {
|
||||
const entry = matchBySlug[1];
|
||||
return {
|
||||
id: guildId,
|
||||
slug: entry.slug ?? guildSlug,
|
||||
requireMention: entry.requireMention,
|
||||
users: entry.users,
|
||||
channels: entry.channels,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveDiscordChannelConfig(params: {
|
||||
guildInfo: DiscordGuildEntryResolved | null;
|
||||
channelId: string;
|
||||
channelName?: string;
|
||||
channelSlug?: string;
|
||||
}): DiscordChannelConfigResolved | null {
|
||||
const { guildInfo, channelId, channelName, channelSlug } = params;
|
||||
const channelEntries = guildInfo?.channels;
|
||||
if (channelEntries && Object.keys(channelEntries).length > 0) {
|
||||
const entry =
|
||||
channelEntries[channelId] ??
|
||||
(channelSlug
|
||||
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
|
||||
: undefined) ??
|
||||
(channelName
|
||||
? channelEntries[normalizeDiscordSlug(channelName)]
|
||||
: undefined);
|
||||
if (!entry) return { allowed: false };
|
||||
return {
|
||||
allowed: entry.allow !== false,
|
||||
requireMention: entry.requireMention,
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
export function resolveGroupDmAllow(params: {
|
||||
channels: Array<string | number> | undefined;
|
||||
channelId: string;
|
||||
channelName?: string;
|
||||
channelSlug?: string;
|
||||
}) {
|
||||
const { channels, channelId, channelName, channelSlug } = params;
|
||||
if (!channels || channels.length === 0) return true;
|
||||
const allowList = normalizeDiscordAllowList(channels, ["channel:"]);
|
||||
if (!allowList) return true;
|
||||
return allowListMatches(allowList, {
|
||||
id: channelId,
|
||||
name: channelSlug || channelName,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureSlashCommand(
|
||||
client: Client,
|
||||
slashCommand: Required<DiscordSlashCommandConfig>,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
try {
|
||||
const appCommands = client.application?.commands;
|
||||
if (!appCommands) {
|
||||
runtime.error?.(danger("discord slash commands unavailable"));
|
||||
return;
|
||||
}
|
||||
const existing = await appCommands.fetch();
|
||||
const hasCommand = Array.from(existing.values()).some(
|
||||
(entry) => entry.name === slashCommand.name,
|
||||
);
|
||||
if (hasCommand) return;
|
||||
await appCommands.create({
|
||||
name: slashCommand.name,
|
||||
description: "Ask Clawdis a question",
|
||||
options: [
|
||||
{
|
||||
name: "prompt",
|
||||
description: "What should Clawdis help with?",
|
||||
type: ApplicationCommandOptionType.String,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const allowAll = cleaned.includes("*");
|
||||
const ids = new Set(cleaned.filter((entry) => entry !== "*"));
|
||||
return { allowAll, ids };
|
||||
runtime.log?.(`registered discord slash command /${slashCommand.name}`);
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number | string })?.status;
|
||||
const code = (err as { code?: number | string })?.code;
|
||||
const message = String(err);
|
||||
const isRateLimit =
|
||||
status === 429 || code === 429 || /rate ?limit/i.test(message);
|
||||
const text = `discord slash command setup failed: ${message}`;
|
||||
if (isRateLimit) {
|
||||
logVerbose(text);
|
||||
runtime.error?.(warn(text));
|
||||
} else {
|
||||
runtime.error?.(danger(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSlashCommandConfig(
|
||||
raw: DiscordSlashCommandConfig | undefined,
|
||||
): Required<DiscordSlashCommandConfig> {
|
||||
return {
|
||||
enabled: raw ? raw.enabled !== false : false,
|
||||
name: raw?.name?.trim() || "clawd",
|
||||
sessionPrefix: raw?.sessionPrefix?.trim() || "discord:slash",
|
||||
ephemeral: raw?.ephemeral !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSlashPrompt(
|
||||
options: readonly CommandInteractionOption[],
|
||||
): string | undefined {
|
||||
const direct = findFirstStringOption(options);
|
||||
if (direct) return direct;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findFirstStringOption(
|
||||
options: readonly CommandInteractionOption[],
|
||||
): string | undefined {
|
||||
for (const option of options) {
|
||||
if (typeof option.value === "string") {
|
||||
const trimmed = option.value.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
if (option.options && option.options.length > 0) {
|
||||
const nested = findFirstStringOption(option.options);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function sendTyping(message: Message) {
|
||||
@@ -430,3 +907,45 @@ async function deliverReplies({
|
||||
runtime.log?.(`delivered reply to ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deliverSlashReplies({
|
||||
replies,
|
||||
interaction,
|
||||
ephemeral,
|
||||
}: {
|
||||
replies: ReplyPayload[];
|
||||
interaction: import("discord.js").ChatInputCommandInteraction;
|
||||
ephemeral: boolean;
|
||||
}) {
|
||||
const messages: string[] = [];
|
||||
for (const payload of replies) {
|
||||
const textRaw = payload.text?.trim() ?? "";
|
||||
const text =
|
||||
textRaw && textRaw !== SILENT_REPLY_TOKEN ? textRaw : undefined;
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const combined = [
|
||||
text ?? "",
|
||||
...mediaList.map((url) => url.trim()).filter(Boolean),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (!combined) continue;
|
||||
for (const chunk of chunkText(combined, 2000)) {
|
||||
messages.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
await interaction.editReply({
|
||||
content: "No response was generated for that command.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const [first, ...rest] = messages;
|
||||
await interaction.editReply({ content: first });
|
||||
for (const message of rest) {
|
||||
await interaction.followUp({ content: message, ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
|
||||
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
|
||||
if (auth.mode === "token" && !auth.token) {
|
||||
throw new Error(
|
||||
"gateway auth mode is token, but CLAWDIS_GATEWAY_TOKEN is not set",
|
||||
"gateway auth mode is token, but no token was configured (set gateway.auth.token or CLAWDIS_GATEWAY_TOKEN)",
|
||||
);
|
||||
}
|
||||
if (auth.mode === "password" && !auth.password) {
|
||||
|
||||
@@ -25,8 +25,8 @@ export async function callGateway<T = unknown>(
|
||||
): Promise<T> {
|
||||
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||
const config = loadConfig();
|
||||
const remote =
|
||||
config.gateway?.mode === "remote" ? config.gateway.remote : undefined;
|
||||
const isRemoteMode = config.gateway?.mode === "remote";
|
||||
const remote = isRemoteMode ? config.gateway.remote : undefined;
|
||||
const url =
|
||||
(typeof opts.url === "string" && opts.url.trim().length > 0
|
||||
? opts.url.trim()
|
||||
@@ -39,9 +39,15 @@ export async function callGateway<T = unknown>(
|
||||
(typeof opts.token === "string" && opts.token.trim().length > 0
|
||||
? opts.token.trim()
|
||||
: undefined) ||
|
||||
(typeof remote?.token === "string" && remote.token.trim().length > 0
|
||||
? remote.token.trim()
|
||||
: undefined);
|
||||
(isRemoteMode
|
||||
? typeof remote?.token === "string" && remote.token.trim().length > 0
|
||||
? remote.token.trim()
|
||||
: undefined
|
||||
: process.env.CLAWDIS_GATEWAY_TOKEN?.trim() ||
|
||||
(typeof config.gateway?.auth?.token === "string" &&
|
||||
config.gateway.auth.token.trim().length > 0
|
||||
? config.gateway.auth.token.trim()
|
||||
: undefined));
|
||||
const password =
|
||||
(typeof opts.password === "string" && opts.password.trim().length > 0
|
||||
? opts.password.trim()
|
||||
|
||||
@@ -130,6 +130,11 @@ let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
|
||||
let testGatewayAuth: Record<string, unknown> | undefined;
|
||||
let testHooksConfig: Record<string, unknown> | undefined;
|
||||
let testCanvasHostPort: number | undefined;
|
||||
let testLegacyIssues: Array<{ path: string; message: string }> = [];
|
||||
let testLegacyParsed: Record<string, unknown> = {};
|
||||
let testMigrationConfig: Record<string, unknown> | null = null;
|
||||
let testMigrationChanges: string[] = [];
|
||||
let testIsNixMode = false;
|
||||
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
|
||||
vi.mock("../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
|
||||
@@ -151,6 +156,21 @@ vi.mock("../config/config.js", () => {
|
||||
path.join(os.homedir(), ".clawdis", "clawdis.json");
|
||||
|
||||
const readConfigFileSnapshot = async () => {
|
||||
if (testLegacyIssues.length > 0) {
|
||||
return {
|
||||
path: resolveConfigPath(),
|
||||
exists: true,
|
||||
raw: JSON.stringify(testLegacyParsed ?? {}),
|
||||
parsed: testLegacyParsed ?? {},
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: testLegacyIssues.map((issue) => ({
|
||||
path: issue.path,
|
||||
message: issue.message,
|
||||
})),
|
||||
legacyIssues: testLegacyIssues,
|
||||
};
|
||||
}
|
||||
const configPath = resolveConfigPath();
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
@@ -163,6 +183,7 @@ vi.mock("../config/config.js", () => {
|
||||
valid: true,
|
||||
config: {},
|
||||
issues: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
try {
|
||||
@@ -176,6 +197,7 @@ vi.mock("../config/config.js", () => {
|
||||
valid: true,
|
||||
config: parsed,
|
||||
issues: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -186,27 +208,32 @@ vi.mock("../config/config.js", () => {
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const writeConfigFile = async (cfg: Record<string, unknown>) => {
|
||||
const writeConfigFile = vi.fn(async (cfg: Record<string, unknown>) => {
|
||||
const configPath = resolveConfigPath();
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n");
|
||||
await fs.writeFile(configPath, raw, "utf-8");
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
CONFIG_PATH_CLAWDIS: resolveConfigPath(),
|
||||
STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()),
|
||||
isNixMode: false,
|
||||
isNixMode: testIsNixMode,
|
||||
migrateLegacyConfig: (raw: unknown) => ({
|
||||
config: testMigrationConfig ?? (raw as Record<string, unknown>),
|
||||
changes: testMigrationChanges,
|
||||
}),
|
||||
loadConfig: () => ({
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: testAllowFrom,
|
||||
},
|
||||
session: { mainKey: "main", store: testSessionStorePath },
|
||||
@@ -279,6 +306,11 @@ beforeEach(async () => {
|
||||
testGatewayAuth = undefined;
|
||||
testHooksConfig = undefined;
|
||||
testCanvasHostPort = undefined;
|
||||
testLegacyIssues = [];
|
||||
testLegacyParsed = {};
|
||||
testMigrationConfig = null;
|
||||
testMigrationChanges = [];
|
||||
testIsNixMode = false;
|
||||
cronIsolatedRun.mockClear();
|
||||
drainSystemEvents();
|
||||
resetAgentRunContextForTest();
|
||||
@@ -516,6 +548,40 @@ describe("gateway server", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test("auto-migrates legacy config on startup", async () => {
|
||||
(writeConfigFile as unknown as { mockClear?: () => void })?.mockClear?.();
|
||||
testLegacyIssues = [
|
||||
{
|
||||
path: "routing.allowFrom",
|
||||
message: "legacy",
|
||||
},
|
||||
];
|
||||
testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } };
|
||||
testMigrationConfig = { whatsapp: { allowFrom: ["+15555550123"] } };
|
||||
testMigrationChanges = ["Moved routing.allowFrom → whatsapp.allowFrom."];
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(testMigrationConfig);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("fails in Nix mode when legacy config is present", async () => {
|
||||
testLegacyIssues = [
|
||||
{
|
||||
path: "routing.allowFrom",
|
||||
message: "legacy",
|
||||
},
|
||||
];
|
||||
testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } };
|
||||
testIsNixMode = true;
|
||||
|
||||
const port = await getFreePort();
|
||||
await expect(startGatewayServer(port)).rejects.toThrow(
|
||||
"Legacy config entries detected while running in Nix mode",
|
||||
);
|
||||
});
|
||||
|
||||
test("models.list returns model catalog", async () => {
|
||||
piSdkMock.enabled = true;
|
||||
piSdkMock.models = [
|
||||
@@ -3865,7 +3931,7 @@ describe("gateway server", () => {
|
||||
thinkingLevel: "low",
|
||||
verboseLevel: "on",
|
||||
},
|
||||
"group:dev": {
|
||||
"discord:group:dev": {
|
||||
sessionId: "sess-group",
|
||||
updatedAt: now - 120_000,
|
||||
totalTokens: 50,
|
||||
@@ -3977,7 +4043,7 @@ describe("gateway server", () => {
|
||||
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
|
||||
ws,
|
||||
"sessions.delete",
|
||||
{ key: "group:dev" },
|
||||
{ key: "discord:group:dev" },
|
||||
);
|
||||
expect(deleted.ok).toBe(true);
|
||||
expect(deleted.payload?.deleted).toBe(true);
|
||||
@@ -3986,7 +4052,9 @@ describe("gateway server", () => {
|
||||
}>(ws, "sessions.list", {});
|
||||
expect(listAfterDelete.ok).toBe(true);
|
||||
expect(
|
||||
listAfterDelete.payload?.sessions.some((s) => s.key === "group:dev"),
|
||||
listAfterDelete.payload?.sessions.some(
|
||||
(s) => s.key === "discord:group:dev",
|
||||
),
|
||||
).toBe(false);
|
||||
const filesAfterDelete = await fs.readdir(dir);
|
||||
expect(
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
CONFIG_PATH_CLAWDIS,
|
||||
isNixMode,
|
||||
loadConfig,
|
||||
migrateLegacyConfig,
|
||||
parseConfigJson5,
|
||||
readConfigFileSnapshot,
|
||||
STATE_DIR_CLAWDIS,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import {
|
||||
buildGroupDisplayName,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
@@ -455,6 +457,11 @@ type GatewaySessionsDefaults = {
|
||||
type GatewaySessionRow = {
|
||||
key: string;
|
||||
kind: "direct" | "group" | "global" | "unknown";
|
||||
displayName?: string;
|
||||
surface?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
updatedAt: number | null;
|
||||
sessionId?: string;
|
||||
systemSent?: boolean;
|
||||
@@ -653,7 +660,6 @@ type DedupeEntry = {
|
||||
error?: ErrorShape;
|
||||
};
|
||||
|
||||
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||
|
||||
function formatForLog(value: unknown): string {
|
||||
try {
|
||||
@@ -862,13 +868,41 @@ function loadSessionEntry(sessionKey: string) {
|
||||
return { cfg, storePath, store, entry };
|
||||
}
|
||||
|
||||
function classifySessionKey(key: string): GatewaySessionRow["kind"] {
|
||||
function classifySessionKey(
|
||||
key: string,
|
||||
entry?: SessionEntry,
|
||||
): GatewaySessionRow["kind"] {
|
||||
if (key === "global") return "global";
|
||||
if (key.startsWith("group:")) return "group";
|
||||
if (key === "unknown") return "unknown";
|
||||
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
|
||||
if (
|
||||
key.startsWith("group:") ||
|
||||
key.includes(":group:") ||
|
||||
key.includes(":channel:")
|
||||
) {
|
||||
return "group";
|
||||
}
|
||||
return "direct";
|
||||
}
|
||||
|
||||
function parseGroupKey(
|
||||
key: string,
|
||||
): { surface?: string; kind?: "group" | "channel"; id?: string } | null {
|
||||
if (key.startsWith("group:")) {
|
||||
const raw = key.slice("group:".length);
|
||||
return raw ? { id: raw } : null;
|
||||
}
|
||||
const parts = key.split(":").filter(Boolean);
|
||||
if (parts.length >= 3) {
|
||||
const [surface, kind, ...rest] = parts;
|
||||
if (kind === "group" || kind === "channel") {
|
||||
const id = rest.join(":");
|
||||
return { surface, kind, id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSessionDefaults(cfg: ClawdisConfig): GatewaySessionsDefaults {
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
@@ -913,9 +947,32 @@ function listSessionsFromStore(params: {
|
||||
const input = entry?.inputTokens ?? 0;
|
||||
const output = entry?.outputTokens ?? 0;
|
||||
const total = entry?.totalTokens ?? input + output;
|
||||
const parsed = parseGroupKey(key);
|
||||
const surface = entry?.surface ?? parsed?.surface;
|
||||
const subject = entry?.subject;
|
||||
const room = entry?.room;
|
||||
const space = entry?.space;
|
||||
const id = parsed?.id;
|
||||
const displayName =
|
||||
entry?.displayName ??
|
||||
(surface
|
||||
? buildGroupDisplayName({
|
||||
surface,
|
||||
subject,
|
||||
room,
|
||||
space,
|
||||
id,
|
||||
key,
|
||||
})
|
||||
: undefined);
|
||||
return {
|
||||
key,
|
||||
kind: classifySessionKey(key),
|
||||
kind: classifySessionKey(key, entry),
|
||||
displayName,
|
||||
surface,
|
||||
subject,
|
||||
room,
|
||||
space,
|
||||
updatedAt,
|
||||
sessionId: entry?.sessionId,
|
||||
systemSent: entry?.systemSent,
|
||||
@@ -1265,6 +1322,31 @@ export async function startGatewayServer(
|
||||
port = 18789,
|
||||
opts: GatewayServerOptions = {},
|
||||
): Promise<GatewayServer> {
|
||||
const configSnapshot = await readConfigFileSnapshot();
|
||||
if (configSnapshot.legacyIssues.length > 0) {
|
||||
if (isNixMode) {
|
||||
throw new Error(
|
||||
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
|
||||
);
|
||||
}
|
||||
const { config: migrated, changes } = migrateLegacyConfig(
|
||||
configSnapshot.parsed,
|
||||
);
|
||||
if (!migrated) {
|
||||
throw new Error(
|
||||
"Legacy config entries detected but auto-migration failed. Run \"clawdis doctor\" to migrate.",
|
||||
);
|
||||
}
|
||||
await writeConfigFile(migrated);
|
||||
if (changes.length > 0) {
|
||||
log.info(
|
||||
`gateway: migrated legacy config entries:\n${changes
|
||||
.map((entry) => `- ${entry}`)
|
||||
.join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cfgAtStart = loadConfig();
|
||||
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
|
||||
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
|
||||
@@ -1288,7 +1370,8 @@ export async function startGatewayServer(
|
||||
...tailscaleOverrides,
|
||||
};
|
||||
const tailscaleMode = tailscaleConfig.mode ?? "off";
|
||||
const token = getGatewayToken();
|
||||
const token =
|
||||
authConfig.token ?? process.env.CLAWDIS_GATEWAY_TOKEN ?? undefined;
|
||||
const password =
|
||||
authConfig.password ?? process.env.CLAWDIS_GATEWAY_PASSWORD ?? undefined;
|
||||
const authMode: ResolvedGatewayAuth["mode"] =
|
||||
@@ -2017,6 +2100,15 @@ export async function startGatewayServer(
|
||||
const startTelegramProvider = async () => {
|
||||
if (telegramTask) return;
|
||||
const cfg = loadConfig();
|
||||
if (!cfg.telegram) {
|
||||
telegramRuntime = {
|
||||
...telegramRuntime,
|
||||
running: false,
|
||||
lastError: "not configured",
|
||||
};
|
||||
logTelegram.info("skipping provider start (telegram not configured)");
|
||||
return;
|
||||
}
|
||||
if (cfg.telegram?.enabled === false) {
|
||||
telegramRuntime = {
|
||||
...telegramRuntime,
|
||||
@@ -2111,6 +2203,15 @@ export async function startGatewayServer(
|
||||
const startDiscordProvider = async () => {
|
||||
if (discordTask) return;
|
||||
const cfg = loadConfig();
|
||||
if (!cfg.discord) {
|
||||
discordRuntime = {
|
||||
...discordRuntime,
|
||||
running: false,
|
||||
lastError: "not configured",
|
||||
};
|
||||
logDiscord.info("skipping provider start (discord not configured)");
|
||||
return;
|
||||
}
|
||||
if (cfg.discord?.enabled === false) {
|
||||
discordRuntime = {
|
||||
...discordRuntime,
|
||||
@@ -2153,9 +2254,7 @@ export async function startGatewayServer(
|
||||
token: discordToken.trim(),
|
||||
runtime: discordRuntimeEnv,
|
||||
abortSignal: discordAbort.signal,
|
||||
allowFrom: cfg.discord?.allowFrom,
|
||||
guildAllowFrom: cfg.discord?.guildAllowFrom,
|
||||
requireMention: cfg.discord?.requireMention,
|
||||
slashCommand: cfg.discord?.slashCommand,
|
||||
mediaMaxMb: cfg.discord?.mediaMaxMb,
|
||||
historyLimit: cfg.discord?.historyLimit,
|
||||
})
|
||||
@@ -2216,6 +2315,26 @@ export async function startGatewayServer(
|
||||
logSignal.info("skipping provider start (signal.enabled=false)");
|
||||
return;
|
||||
}
|
||||
const signalCfg = cfg.signal;
|
||||
const signalMeaningfullyConfigured = Boolean(
|
||||
signalCfg.account?.trim() ||
|
||||
signalCfg.httpUrl?.trim() ||
|
||||
signalCfg.cliPath?.trim() ||
|
||||
signalCfg.httpHost?.trim() ||
|
||||
typeof signalCfg.httpPort === "number" ||
|
||||
typeof signalCfg.autoStart === "boolean",
|
||||
);
|
||||
if (!signalMeaningfullyConfigured) {
|
||||
signalRuntime = {
|
||||
...signalRuntime,
|
||||
running: false,
|
||||
lastError: "not configured",
|
||||
};
|
||||
logSignal.info(
|
||||
"skipping provider start (signal config present but missing required fields)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const host = cfg.signal?.httpHost?.trim() || "127.0.0.1";
|
||||
const port = cfg.signal?.httpPort ?? 8080;
|
||||
const baseUrl = cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`;
|
||||
@@ -2881,6 +3000,12 @@ export async function startGatewayServer(
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
displayName: entry?.displayName,
|
||||
chatType: entry?.chatType,
|
||||
surface: entry?.surface,
|
||||
subject: entry?.subject,
|
||||
room: entry?.room,
|
||||
space: entry?.space,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
skillsSnapshot: entry?.skillsSnapshot,
|
||||
@@ -4285,21 +4410,33 @@ export async function startGatewayServer(
|
||||
? Math.max(1000, timeoutMsRaw)
|
||||
: 10_000;
|
||||
const cfg = loadConfig();
|
||||
const telegramCfg = cfg.telegram;
|
||||
const telegramEnabled =
|
||||
Boolean(telegramCfg) && telegramCfg?.enabled !== false;
|
||||
const { token: telegramToken, source: tokenSource } =
|
||||
resolveTelegramToken(cfg);
|
||||
telegramEnabled
|
||||
? resolveTelegramToken(cfg)
|
||||
: { token: "", source: "none" as const };
|
||||
let telegramProbe: TelegramProbe | undefined;
|
||||
let lastProbeAt: number | null = null;
|
||||
if (probe && telegramToken) {
|
||||
if (probe && telegramToken && telegramEnabled) {
|
||||
telegramProbe = await probeTelegram(
|
||||
telegramToken,
|
||||
timeoutMs,
|
||||
cfg.telegram?.proxy,
|
||||
telegramCfg?.proxy,
|
||||
);
|
||||
lastProbeAt = Date.now();
|
||||
}
|
||||
|
||||
const discordEnvToken = process.env.DISCORD_BOT_TOKEN?.trim();
|
||||
const discordConfigToken = cfg.discord?.token?.trim();
|
||||
const discordCfg = cfg.discord;
|
||||
const discordEnabled =
|
||||
Boolean(discordCfg) && discordCfg?.enabled !== false;
|
||||
const discordEnvToken = discordEnabled
|
||||
? process.env.DISCORD_BOT_TOKEN?.trim()
|
||||
: "";
|
||||
const discordConfigToken = discordEnabled
|
||||
? discordCfg?.token?.trim()
|
||||
: "";
|
||||
const discordToken = discordEnvToken || discordConfigToken || "";
|
||||
const discordTokenSource = discordEnvToken
|
||||
? "env"
|
||||
@@ -4308,7 +4445,7 @@ export async function startGatewayServer(
|
||||
: "none";
|
||||
let discordProbe: DiscordProbe | undefined;
|
||||
let discordLastProbeAt: number | null = null;
|
||||
if (probe && discordToken) {
|
||||
if (probe && discordToken && discordEnabled) {
|
||||
discordProbe = await probeDiscord(discordToken, timeoutMs);
|
||||
discordLastProbeAt = Date.now();
|
||||
}
|
||||
@@ -4320,7 +4457,17 @@ export async function startGatewayServer(
|
||||
const signalBaseUrl =
|
||||
signalCfg?.httpUrl?.trim() ||
|
||||
`http://${signalHost}:${signalPort}`;
|
||||
const signalConfigured = Boolean(signalCfg) && signalEnabled;
|
||||
const signalConfigured =
|
||||
Boolean(signalCfg) &&
|
||||
signalEnabled &&
|
||||
Boolean(
|
||||
signalCfg?.account?.trim() ||
|
||||
signalCfg?.httpUrl?.trim() ||
|
||||
signalCfg?.cliPath?.trim() ||
|
||||
signalCfg?.httpHost?.trim() ||
|
||||
typeof signalCfg?.httpPort === "number" ||
|
||||
typeof signalCfg?.autoStart === "boolean",
|
||||
);
|
||||
let signalProbe: SignalProbe | undefined;
|
||||
let signalLastProbeAt: number | null = null;
|
||||
if (probe && signalConfigured) {
|
||||
@@ -4362,7 +4509,7 @@ export async function startGatewayServer(
|
||||
lastError: whatsappRuntime.lastError ?? null,
|
||||
},
|
||||
telegram: {
|
||||
configured: Boolean(telegramToken),
|
||||
configured: telegramEnabled && Boolean(telegramToken),
|
||||
tokenSource,
|
||||
running: telegramRuntime.running,
|
||||
mode: telegramRuntime.mode ?? null,
|
||||
@@ -4373,7 +4520,7 @@ export async function startGatewayServer(
|
||||
lastProbeAt,
|
||||
},
|
||||
discord: {
|
||||
configured: Boolean(discordToken),
|
||||
configured: discordEnabled && Boolean(discordToken),
|
||||
tokenSource: discordTokenSource,
|
||||
running: discordRuntime.running,
|
||||
lastStartAt: discordRuntime.lastStartAt ?? null,
|
||||
@@ -6521,7 +6668,7 @@ export async function startGatewayServer(
|
||||
if (explicit) return resolvedTo;
|
||||
|
||||
const cfg = cfgForAgent ?? loadConfig();
|
||||
const rawAllow = cfg.routing?.allowFrom ?? [];
|
||||
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||
if (rawAllow.includes("*")) return resolvedTo;
|
||||
const allowFrom = rawAllow
|
||||
.map((val) => normalizeE164(val))
|
||||
|
||||
@@ -168,8 +168,9 @@ export class IMessageRpcClient {
|
||||
let parsed: IMessageRpcResponse<unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(line) as IMessageRpcResponse<unknown>;
|
||||
} catch (_err) {
|
||||
this.runtime?.error?.(`imsg rpc: failed to parse ${line}`);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
this.runtime?.error?.(`imsg rpc: failed to parse ${line}: ${detail}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,8 +61,7 @@ function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv {
|
||||
|
||||
function resolveAllowFrom(opts: MonitorIMessageOpts): string[] {
|
||||
const cfg = loadConfig();
|
||||
const raw =
|
||||
opts.allowFrom ?? cfg.imessage?.allowFrom ?? cfg.routing?.allowFrom ?? [];
|
||||
const raw = opts.allowFrom ?? cfg.imessage?.allowFrom ?? [];
|
||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,23 @@ export function parseIMessageTarget(raw: string): IMessageTarget {
|
||||
if (!trimmed) throw new Error("iMessage target is required");
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
for (const { prefix, service } of SERVICE_PREFIXES) {
|
||||
if (lower.startsWith(prefix)) {
|
||||
const remainder = stripPrefix(trimmed, prefix);
|
||||
if (!remainder) throw new Error(`${prefix} target is required`);
|
||||
const remainderLower = remainder.toLowerCase();
|
||||
const isChatTarget =
|
||||
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
||||
CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
||||
CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
||||
remainderLower.startsWith("group:");
|
||||
if (isChatTarget) {
|
||||
return parseIMessageTarget(remainder);
|
||||
}
|
||||
return { kind: "handle", to: remainder, service };
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of CHAT_ID_PREFIXES) {
|
||||
if (lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(trimmed, prefix);
|
||||
@@ -89,14 +106,6 @@ export function parseIMessageTarget(raw: string): IMessageTarget {
|
||||
return { kind: "chat_guid", chatGuid: value };
|
||||
}
|
||||
|
||||
for (const { prefix, service } of SERVICE_PREFIXES) {
|
||||
if (lower.startsWith(prefix)) {
|
||||
const to = stripPrefix(trimmed, prefix);
|
||||
if (!to) throw new Error(`${prefix} target is required`);
|
||||
return { kind: "handle", to, service };
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "handle", to: trimmed, service: "auto" };
|
||||
}
|
||||
|
||||
@@ -105,6 +114,14 @@ export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget {
|
||||
if (!trimmed) return { kind: "handle", handle: "" };
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
for (const { prefix } of SERVICE_PREFIXES) {
|
||||
if (lower.startsWith(prefix)) {
|
||||
const remainder = stripPrefix(trimmed, prefix);
|
||||
if (!remainder) return { kind: "handle", handle: "" };
|
||||
return parseIMessageAllowTarget(remainder);
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of CHAT_ID_PREFIXES) {
|
||||
if (lower.startsWith(prefix)) {
|
||||
const value = stripPrefix(trimmed, prefix);
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
it("applies allowFrom fallback for WhatsApp targets", () => {
|
||||
const cfg: ClawdisConfig = {
|
||||
agent: { heartbeat: { target: "whatsapp", to: "+1999" } },
|
||||
routing: { allowFrom: ["+1555", "+1666"] },
|
||||
whatsapp: { allowFrom: ["+1555", "+1666"] },
|
||||
};
|
||||
const entry = {
|
||||
...baseEntry,
|
||||
@@ -145,7 +145,7 @@ describe("runHeartbeatOnce", () => {
|
||||
agent: {
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||
},
|
||||
routing: { allowFrom: ["*"] },
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
@@ -206,7 +206,7 @@ describe("runHeartbeatOnce", () => {
|
||||
agent: {
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||
},
|
||||
routing: { allowFrom: ["*"] },
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
||||
return { channel, to };
|
||||
}
|
||||
|
||||
const rawAllow = cfg.routing?.allowFrom ?? [];
|
||||
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||
if (rawAllow.includes("*")) return { channel, to };
|
||||
const allowFrom = rawAllow
|
||||
.map((val) => normalizeE164(val))
|
||||
@@ -401,7 +401,7 @@ export async function runHeartbeatOnce(opts: {
|
||||
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
|
||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg);
|
||||
const previousUpdatedAt = entry?.updatedAt;
|
||||
const allowFrom = cfg.routing?.allowFrom ?? [];
|
||||
const allowFrom = cfg.whatsapp?.allowFrom ?? [];
|
||||
const sender = resolveHeartbeatSender({
|
||||
allowFrom,
|
||||
lastTo: entry?.lastTo,
|
||||
|
||||
@@ -35,8 +35,11 @@ export async function buildProviderSummary(
|
||||
if (!telegramEnabled) {
|
||||
lines.push(chalk.cyan("Telegram: disabled"));
|
||||
} else {
|
||||
const { token: telegramToken } = resolveTelegramToken(effective);
|
||||
const telegramConfigured = Boolean(telegramToken);
|
||||
const { token: telegramToken } = effective.telegram
|
||||
? resolveTelegramToken(effective)
|
||||
: { token: "" };
|
||||
const telegramConfigured =
|
||||
Boolean(effective.telegram) && Boolean(telegramToken);
|
||||
lines.push(
|
||||
telegramConfigured
|
||||
? chalk.green("Telegram: configured")
|
||||
@@ -48,11 +51,16 @@ export async function buildProviderSummary(
|
||||
if (!signalEnabled) {
|
||||
lines.push(chalk.cyan("Signal: disabled"));
|
||||
} else {
|
||||
const signalConfigured = Boolean(
|
||||
effective.signal?.httpUrl ||
|
||||
effective.signal?.cliPath ||
|
||||
effective.signal?.account,
|
||||
);
|
||||
const signalConfigured =
|
||||
Boolean(effective.signal) &&
|
||||
Boolean(
|
||||
effective.signal?.account?.trim() ||
|
||||
effective.signal?.httpUrl?.trim() ||
|
||||
effective.signal?.cliPath?.trim() ||
|
||||
effective.signal?.httpHost?.trim() ||
|
||||
typeof effective.signal?.httpPort === "number" ||
|
||||
typeof effective.signal?.autoStart === "boolean",
|
||||
);
|
||||
lines.push(
|
||||
signalConfigured
|
||||
? chalk.green("Signal: configured")
|
||||
@@ -72,8 +80,8 @@ export async function buildProviderSummary(
|
||||
);
|
||||
}
|
||||
|
||||
const allowFrom = effective.routing?.allowFrom?.length
|
||||
? effective.routing.allowFrom.map(normalizeE164).filter(Boolean)
|
||||
const allowFrom = effective.whatsapp?.allowFrom?.length
|
||||
? effective.whatsapp.allowFrom.map(normalizeE164).filter(Boolean)
|
||||
: [];
|
||||
if (allowFrom.length) {
|
||||
lines.push(chalk.cyan(`AllowFrom: ${allowFrom.join(", ")}`));
|
||||
|
||||
@@ -16,6 +16,15 @@ describe("classifySignalCliLogLine", () => {
|
||||
expect(classifySignalCliLogLine("ERROR Something")).toBe("error");
|
||||
});
|
||||
|
||||
it("treats failures without explicit severity as error", () => {
|
||||
expect(
|
||||
classifySignalCliLogLine("Failed to initialize HTTP Server - oops"),
|
||||
).toBe("error");
|
||||
expect(classifySignalCliLogLine('Exception in thread "main"')).toBe(
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for empty lines", () => {
|
||||
expect(classifySignalCliLogLine("")).toBe(null);
|
||||
expect(classifySignalCliLogLine(" ")).toBe(null);
|
||||
|
||||
@@ -23,6 +23,8 @@ export function classifySignalCliLogLine(line: string): "log" | "error" | null {
|
||||
if (!trimmed) return null;
|
||||
// signal-cli commonly writes all logs to stderr; treat severity explicitly.
|
||||
if (/\b(ERROR|WARN|WARNING)\b/.test(trimmed)) return "error";
|
||||
// Some signal-cli failures are not tagged with WARN/ERROR but should still be surfaced loudly.
|
||||
if (/\b(FAILED|SEVERE|EXCEPTION)\b/i.test(trimmed)) return "error";
|
||||
return "log";
|
||||
}
|
||||
|
||||
|
||||
@@ -92,8 +92,7 @@ function resolveAccount(opts: MonitorSignalOpts): string | undefined {
|
||||
|
||||
function resolveAllowFrom(opts: MonitorSignalOpts): string[] {
|
||||
const cfg = loadConfig();
|
||||
const raw =
|
||||
opts.allowFrom ?? cfg.signal?.allowFrom ?? cfg.routing?.allowFrom ?? [];
|
||||
const raw = opts.allowFrom ?? cfg.signal?.allowFrom ?? [];
|
||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,19 +43,20 @@ function parseTarget(raw: string): SignalTarget {
|
||||
let value = raw.trim();
|
||||
if (!value) throw new Error("Signal recipient is required");
|
||||
const lower = value.toLowerCase();
|
||||
if (lower.startsWith("group:")) {
|
||||
return { type: "group", groupId: value.slice("group:".length).trim() };
|
||||
}
|
||||
if (lower.startsWith("signal:")) {
|
||||
value = value.slice("signal:".length).trim();
|
||||
}
|
||||
if (lower.startsWith("username:")) {
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized.startsWith("group:")) {
|
||||
return { type: "group", groupId: value.slice("group:".length).trim() };
|
||||
}
|
||||
if (normalized.startsWith("username:")) {
|
||||
return {
|
||||
type: "username",
|
||||
username: value.slice("username:".length).trim(),
|
||||
};
|
||||
}
|
||||
if (lower.startsWith("u:")) {
|
||||
if (normalized.startsWith("u:")) {
|
||||
return { type: "username", username: value.trim() };
|
||||
}
|
||||
return { type: "recipient", recipient: value };
|
||||
|
||||
@@ -36,7 +36,7 @@ function normalizeChatId(to: string): string {
|
||||
|
||||
// Common internal prefixes that sometimes leak into outbound sends.
|
||||
// - ctx.To uses `telegram:<id>`
|
||||
// - group sessions often use `group:<id>`
|
||||
// - group sessions often use `telegram:group:<id>`
|
||||
let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
|
||||
|
||||
// Accept t.me links for public chats/channels.
|
||||
|
||||
@@ -33,7 +33,7 @@ export function normalizeE164(number: string): string {
|
||||
|
||||
/**
|
||||
* "Self-chat mode" heuristic (single phone): the gateway is logged in as the owner's own WhatsApp account,
|
||||
* and `routing.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the
|
||||
* and `whatsapp.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the
|
||||
* "bot" and the human are the same WhatsApp identity (e.g. auto read receipts, @mention JID triggers).
|
||||
*/
|
||||
export function isSelfChatMode(
|
||||
|
||||
@@ -111,7 +111,7 @@ describe("partial reply gating", () => {
|
||||
const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" });
|
||||
|
||||
const mockConfig: ClawdisConfig = {
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
};
|
||||
@@ -158,7 +158,7 @@ describe("partial reply gating", () => {
|
||||
const replyResolver = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const mockConfig: ClawdisConfig = {
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: store.storePath, mainKey: "main" },
|
||||
@@ -1015,7 +1015,7 @@ describe("web auto-reply", () => {
|
||||
.mockResolvedValueOnce({ text: "ok" });
|
||||
|
||||
const { storePath, cleanup } = await makeSessionStore({
|
||||
"group:123@g.us": {
|
||||
"whatsapp:group:123@g.us": {
|
||||
sessionId: "g-1",
|
||||
updatedAt: Date.now(),
|
||||
groupActivation: "always",
|
||||
@@ -1097,9 +1097,11 @@ describe("web auto-reply", () => {
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
// Self-chat heuristic: allowFrom includes selfE164.
|
||||
allowFrom: ["+999"],
|
||||
},
|
||||
routing: {
|
||||
groupChat: {
|
||||
requireMention: true,
|
||||
mentionPatterns: ["\\bclawd\\b"],
|
||||
@@ -1247,7 +1249,7 @@ describe("web auto-reply", () => {
|
||||
it("prefixes body with same-phone marker when from === to", async () => {
|
||||
// Enable messagePrefix for same-phone mode testing
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
@@ -1372,7 +1374,7 @@ describe("web auto-reply", () => {
|
||||
|
||||
it("applies responsePrefix to regular replies", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
@@ -1417,7 +1419,7 @@ describe("web auto-reply", () => {
|
||||
|
||||
it("does not deliver HEARTBEAT_OK responses", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
@@ -1462,7 +1464,7 @@ describe("web auto-reply", () => {
|
||||
|
||||
it("does not double-prefix if responsePrefix already present", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
@@ -1508,7 +1510,7 @@ describe("web auto-reply", () => {
|
||||
|
||||
it("sends tool summaries immediately with responsePrefix", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
|
||||
@@ -116,7 +116,7 @@ function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
|
||||
}
|
||||
})
|
||||
.filter((r): r is RegExp => Boolean(r)) ?? [];
|
||||
return { mentionRegexes, allowFrom: cfg.routing?.allowFrom };
|
||||
return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom };
|
||||
}
|
||||
|
||||
function isBotMentioned(
|
||||
@@ -412,7 +412,10 @@ function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
|
||||
const storePath = resolveStorePath(cfg.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const isGroupKey = (key: string) =>
|
||||
key.startsWith("group:") || key.includes("@g.us");
|
||||
key.startsWith("group:") ||
|
||||
key.includes(":group:") ||
|
||||
key.includes(":channel:") ||
|
||||
key.includes("@g.us");
|
||||
const isCronKey = (key: string) => key.startsWith("cron:");
|
||||
|
||||
const recipients = Object.entries(store)
|
||||
@@ -445,8 +448,8 @@ export function resolveHeartbeatRecipients(
|
||||
|
||||
const sessionRecipients = getSessionRecipients(cfg);
|
||||
const allowFrom =
|
||||
Array.isArray(cfg.routing?.allowFrom) && cfg.routing.allowFrom.length > 0
|
||||
? cfg.routing.allowFrom.filter((v) => v !== "*").map(normalizeE164)
|
||||
Array.isArray(cfg.whatsapp?.allowFrom) && cfg.whatsapp.allowFrom.length > 0
|
||||
? cfg.whatsapp.allowFrom.filter((v) => v !== "*").map(normalizeE164)
|
||||
: [];
|
||||
|
||||
const unique = (list: string[]) => [...new Set(list.filter(Boolean))];
|
||||
@@ -812,7 +815,7 @@ export async function monitorWebProvider(
|
||||
const resolveGroupActivationFor = (conversationId: string) => {
|
||||
const key = conversationId.startsWith("group:")
|
||||
? conversationId
|
||||
: `group:${conversationId}`;
|
||||
: `whatsapp:group:${conversationId}`;
|
||||
const store = loadSessionStore(sessionStorePath);
|
||||
const entry = store[key];
|
||||
const requireMention = cfg.routing?.groupChat?.requireMention;
|
||||
@@ -915,7 +918,7 @@ export async function monitorWebProvider(
|
||||
// Build message prefix: explicit config > default based on allowFrom
|
||||
let messagePrefix = cfg.messages?.messagePrefix;
|
||||
if (messagePrefix === undefined) {
|
||||
const hasAllowFrom = (cfg.routing?.allowFrom?.length ?? 0) > 0;
|
||||
const hasAllowFrom = (cfg.whatsapp?.allowFrom?.length ?? 0) > 0;
|
||||
messagePrefix = hasAllowFrom ? "" : "[clawdis]";
|
||||
}
|
||||
const prefixStr = messagePrefix ? `${messagePrefix} ` : "";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: vi.fn().mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"], // Allow all in tests
|
||||
},
|
||||
messages: {
|
||||
|
||||
@@ -157,7 +157,7 @@ export async function monitorWebInbox(options: {
|
||||
// Filter unauthorized senders early to prevent wasted processing
|
||||
// and potential session corruption from Bad MAC errors
|
||||
const cfg = loadConfig();
|
||||
const configuredAllowFrom = cfg.routing?.allowFrom;
|
||||
const configuredAllowFrom = cfg.whatsapp?.allowFrom;
|
||||
// Without user config, default to self-only DM access so the owner can talk to themselves
|
||||
const defaultAllowFrom =
|
||||
(!configuredAllowFrom || configuredAllowFrom.length === 0) && selfE164
|
||||
|
||||
@@ -80,12 +80,34 @@ describe("web media loading", () => {
|
||||
// Create a minimal valid GIF (1x1 pixel)
|
||||
// GIF89a header + minimal image data
|
||||
const gifBuffer = Buffer.from([
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a
|
||||
0x01, 0x00, 0x01, 0x00, // 1x1 dimensions
|
||||
0x00, 0x00, 0x00, // no global color table
|
||||
0x2c, 0x00, 0x00, 0x00, 0x00, // image descriptor
|
||||
0x01, 0x00, 0x01, 0x00, 0x00, // 1x1 image
|
||||
0x02, 0x01, 0x44, 0x00, 0x3b, // minimal LZW data + trailer
|
||||
0x47,
|
||||
0x49,
|
||||
0x46,
|
||||
0x38,
|
||||
0x39,
|
||||
0x61, // GIF89a
|
||||
0x01,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00, // 1x1 dimensions
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // no global color table
|
||||
0x2c,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // image descriptor
|
||||
0x01,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00, // 1x1 image
|
||||
0x02,
|
||||
0x01,
|
||||
0x44,
|
||||
0x00,
|
||||
0x3b, // minimal LZW data + trailer
|
||||
]);
|
||||
|
||||
const file = path.join(os.tmpdir(), `clawdis-media-${Date.now()}.gif`);
|
||||
@@ -102,18 +124,19 @@ describe("web media loading", () => {
|
||||
|
||||
it("preserves GIF from URL without JPEG conversion", async () => {
|
||||
const gifBytes = new Uint8Array([
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||
0x01, 0x00, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00,
|
||||
0x2c, 0x00, 0x00, 0x00, 0x00,
|
||||
0x01, 0x00, 0x01, 0x00, 0x00,
|
||||
0x02, 0x01, 0x44, 0x00, 0x3b,
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00,
|
||||
0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02,
|
||||
0x01, 0x44, 0x00, 0x3b,
|
||||
]);
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () => gifBytes.buffer.slice(gifBytes.byteOffset, gifBytes.byteOffset + gifBytes.byteLength),
|
||||
arrayBuffer: async () =>
|
||||
gifBytes.buffer.slice(
|
||||
gifBytes.byteOffset,
|
||||
gifBytes.byteOffset + gifBytes.byteLength,
|
||||
),
|
||||
headers: { get: () => "image/gif" },
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock("../media/store.js", () => ({
|
||||
}));
|
||||
|
||||
const mockLoadConfig = vi.fn().mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"], // Allow all in tests by default
|
||||
},
|
||||
messages: {
|
||||
@@ -450,7 +450,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
it("still forwards group messages (with sender info) even when allowFrom is restrictive", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+111"], // does not include +777
|
||||
},
|
||||
messages: {
|
||||
@@ -506,7 +506,7 @@ describe("web monitor inbox", () => {
|
||||
// Test for auto-recovery fix: early allowFrom filtering prevents Bad MAC errors
|
||||
// from unauthorized senders corrupting sessions
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+111"], // Only allow +111
|
||||
},
|
||||
messages: {
|
||||
@@ -546,7 +546,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
// Reset mock for other tests
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
@@ -561,7 +561,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
it("skips read receipts in self-chat mode", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
// Self-chat heuristic: allowFrom includes selfE164 (+123).
|
||||
allowFrom: ["+123"],
|
||||
},
|
||||
@@ -598,7 +598,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
// Reset mock for other tests
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
@@ -613,7 +613,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
it("lets group messages through even when sender not in allowFrom", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+1234"],
|
||||
},
|
||||
messages: {
|
||||
@@ -655,7 +655,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
it("allows messages from senders in allowFrom list", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+111", "+999"], // Allow +999
|
||||
},
|
||||
messages: {
|
||||
@@ -690,7 +690,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
// Reset mock for other tests
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
@@ -707,7 +707,7 @@ describe("web monitor inbox", () => {
|
||||
// Same-phone mode: when from === selfJid, should always be allowed
|
||||
// This allows users to message themselves even with restrictive allowFrom
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+111"], // Only allow +111, but self is +123
|
||||
},
|
||||
messages: {
|
||||
@@ -810,7 +810,7 @@ it("defaults to self-only when no config is present", async () => {
|
||||
|
||||
// Reset mock for other tests
|
||||
mockLoadConfig.mockReturnValue({
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
messages: {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createMockBaileys } from "../../test/mocks/baileys.js";
|
||||
// Use globalThis to store the mock config so it survives vi.mock hoisting
|
||||
const CONFIG_KEY = Symbol.for("clawdis:testConfigMock");
|
||||
const DEFAULT_CONFIG = {
|
||||
routing: {
|
||||
whatsapp: {
|
||||
// Tests can override; default remains open to avoid surprising fixtures
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user