feat: embed pi agent runtime
This commit is contained in:
@@ -5,6 +5,12 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
@@ -26,6 +32,23 @@ import {
|
||||
setLoadConfigMock,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-web-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const makeSessionStore = async (
|
||||
entries: Record<string, unknown> = {},
|
||||
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
|
||||
@@ -89,27 +112,20 @@ describe("heartbeat helpers", () => {
|
||||
|
||||
it("resolves heartbeat minutes with default and overrides", () => {
|
||||
const cfgBase: ClawdisConfig = {
|
||||
inbound: {
|
||||
reply: { mode: "command" as const },
|
||||
},
|
||||
inbound: {},
|
||||
};
|
||||
expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30);
|
||||
expect(
|
||||
resolveReplyHeartbeatMinutes({
|
||||
inbound: { reply: { mode: "command", heartbeatMinutes: 5 } },
|
||||
inbound: { agent: { heartbeatMinutes: 5 } },
|
||||
}),
|
||||
).toBe(5);
|
||||
expect(
|
||||
resolveReplyHeartbeatMinutes({
|
||||
inbound: { reply: { mode: "command", heartbeatMinutes: 0 } },
|
||||
inbound: { agent: { heartbeatMinutes: 0 } },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7);
|
||||
expect(
|
||||
resolveReplyHeartbeatMinutes({
|
||||
inbound: { reply: { mode: "text" } },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,7 +138,7 @@ describe("resolveHeartbeatRecipients", () => {
|
||||
const cfg: ClawdisConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["+1999"],
|
||||
reply: { mode: "command", session: { store: store.storePath } },
|
||||
session: { store: store.storePath },
|
||||
},
|
||||
};
|
||||
const result = resolveHeartbeatRecipients(cfg);
|
||||
@@ -140,7 +156,7 @@ describe("resolveHeartbeatRecipients", () => {
|
||||
const cfg: ClawdisConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["+1999"],
|
||||
reply: { mode: "command", session: { store: store.storePath } },
|
||||
session: { store: store.storePath },
|
||||
},
|
||||
};
|
||||
const result = resolveHeartbeatRecipients(cfg);
|
||||
@@ -154,7 +170,7 @@ describe("resolveHeartbeatRecipients", () => {
|
||||
const cfg: ClawdisConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["*"],
|
||||
reply: { mode: "command", session: { store: store.storePath } },
|
||||
session: { store: store.storePath },
|
||||
},
|
||||
};
|
||||
const result = resolveHeartbeatRecipients(cfg);
|
||||
@@ -171,7 +187,7 @@ describe("resolveHeartbeatRecipients", () => {
|
||||
const cfg: ClawdisConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["+1999"],
|
||||
reply: { mode: "command", session: { store: store.storePath } },
|
||||
session: { store: store.storePath },
|
||||
},
|
||||
};
|
||||
const result = resolveHeartbeatRecipients(cfg, { all: true });
|
||||
@@ -191,7 +207,6 @@ describe("partial reply gating", () => {
|
||||
|
||||
const mockConfig: ClawdisConfig = {
|
||||
inbound: {
|
||||
reply: { mode: "command" },
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
};
|
||||
@@ -240,10 +255,7 @@ describe("partial reply gating", () => {
|
||||
const mockConfig: ClawdisConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["*"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
session: { store: store.storePath, mainKey: "main" },
|
||||
},
|
||||
session: { store: store.storePath, mainKey: "main" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -289,12 +301,13 @@ describe("partial reply gating", () => {
|
||||
});
|
||||
|
||||
it("defaults to self-only when no config is present", async () => {
|
||||
const cfg: ClawdisConfig = {
|
||||
inbound: {
|
||||
// No allowFrom provided; this simulates zero config file while keeping reply simple
|
||||
reply: { mode: "text", text: "ok" },
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Not self: should be blocked
|
||||
const blocked = await getReplyFromConfig(
|
||||
@@ -304,9 +317,10 @@ describe("partial reply gating", () => {
|
||||
To: "whatsapp:+123",
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
{},
|
||||
);
|
||||
expect(blocked).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
|
||||
// Self: should be allowed
|
||||
const allowed = await getReplyFromConfig(
|
||||
@@ -316,9 +330,10 @@ describe("partial reply gating", () => {
|
||||
To: "whatsapp:+123",
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
{},
|
||||
);
|
||||
expect(allowed).toEqual({ text: "ok" });
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,7 +346,7 @@ describe("runWebHeartbeatOnce", () => {
|
||||
cfg: {
|
||||
inbound: {
|
||||
allowFrom: ["+1555"],
|
||||
reply: { mode: "command", session: { store: store.storePath } },
|
||||
session: { store: store.storePath },
|
||||
},
|
||||
},
|
||||
to: "+1555",
|
||||
@@ -354,7 +369,7 @@ describe("runWebHeartbeatOnce", () => {
|
||||
cfg: {
|
||||
inbound: {
|
||||
allowFrom: ["+1555"],
|
||||
reply: { mode: "command", session: { store: store.storePath } },
|
||||
session: { store: store.storePath },
|
||||
},
|
||||
},
|
||||
to: "+1555",
|
||||
@@ -383,7 +398,7 @@ describe("runWebHeartbeatOnce", () => {
|
||||
cfg: {
|
||||
inbound: {
|
||||
allowFrom: ["+1999"],
|
||||
reply: { mode: "command", session: { store: storePath } },
|
||||
session: { store: storePath },
|
||||
},
|
||||
},
|
||||
to: "+1999",
|
||||
@@ -412,13 +427,10 @@ describe("runWebHeartbeatOnce", () => {
|
||||
setLoadConfigMock({
|
||||
inbound: {
|
||||
allowFrom: ["+1555"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
session: {
|
||||
store: storePath,
|
||||
idleMinutes: 60,
|
||||
heartbeatIdleMinutes: 10,
|
||||
},
|
||||
session: {
|
||||
store: storePath,
|
||||
idleMinutes: 60,
|
||||
heartbeatIdleMinutes: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -451,11 +463,8 @@ describe("runWebHeartbeatOnce", () => {
|
||||
setLoadConfigMock(() => ({
|
||||
inbound: {
|
||||
allowFrom: ["+4367"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
heartbeatMinutes: 0.001,
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
},
|
||||
agent: { heartbeatMinutes: 0.001 },
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -464,10 +473,7 @@ describe("runWebHeartbeatOnce", () => {
|
||||
const cfg: ClawdisConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["+4367"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
},
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -496,10 +502,7 @@ describe("runWebHeartbeatOnce", () => {
|
||||
setLoadConfigMock(() => ({
|
||||
inbound: {
|
||||
allowFrom: ["+1999"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
},
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -507,10 +510,7 @@ describe("runWebHeartbeatOnce", () => {
|
||||
const cfg: ClawdisConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["+1999"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
},
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
},
|
||||
};
|
||||
await runWebHeartbeatOnce({
|
||||
@@ -541,7 +541,7 @@ describe("runWebHeartbeatOnce", () => {
|
||||
cfg: {
|
||||
inbound: {
|
||||
allowFrom: ["+1555"],
|
||||
reply: { mode: "command", session: { store: store.storePath } },
|
||||
session: { store: store.storePath },
|
||||
},
|
||||
},
|
||||
to: "+1555",
|
||||
@@ -565,7 +565,7 @@ describe("runWebHeartbeatOnce", () => {
|
||||
cfg: {
|
||||
inbound: {
|
||||
allowFrom: ["+1555"],
|
||||
reply: { mode: "command", session: { store: store.storePath } },
|
||||
session: { store: store.storePath },
|
||||
},
|
||||
},
|
||||
to: "+1555",
|
||||
@@ -717,7 +717,7 @@ describe("web auto-reply", () => {
|
||||
setLoadConfigMock(() => ({
|
||||
inbound: {
|
||||
allowFrom: ["+1555"],
|
||||
reply: { mode: "command", session: { store: storePath } },
|
||||
session: { store: storePath },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -776,7 +776,7 @@ describe("web auto-reply", () => {
|
||||
inbound: {
|
||||
allowFrom: ["+1555"],
|
||||
groupChat: { requireMention: true, mentionPatterns: ["@clawd"] },
|
||||
reply: { mode: "command", session: { store: store.storePath } },
|
||||
session: { store: store.storePath },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -879,7 +879,7 @@ describe("web auto-reply", () => {
|
||||
setLoadConfigMock(() => ({
|
||||
inbound: {
|
||||
timestampPrefix: "UTC",
|
||||
reply: { mode: "command", session: { store: store.storePath } },
|
||||
session: { store: store.storePath },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1155,7 +1155,7 @@ describe("web auto-reply", () => {
|
||||
|
||||
for (const fmt of formats) {
|
||||
// Force a small cap to ensure compression is exercised for every format.
|
||||
setLoadConfigMock(() => ({ inbound: { reply: { mediaMaxMb: 1 } } }));
|
||||
setLoadConfigMock(() => ({ inbound: { agent: { mediaMaxMb: 1 } } }));
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
@@ -1220,7 +1220,7 @@ describe("web auto-reply", () => {
|
||||
);
|
||||
|
||||
it("honors mediaMaxMb from config", async () => {
|
||||
setLoadConfigMock(() => ({ inbound: { reply: { mediaMaxMb: 1 } } }));
|
||||
setLoadConfigMock(() => ({ inbound: { agent: { mediaMaxMb: 1 } } }));
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
|
||||
@@ -74,7 +74,7 @@ const formatDuration = (ms: number) =>
|
||||
|
||||
const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30;
|
||||
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
|
||||
export const HEARTBEAT_PROMPT = "HEARTBEAT /think:high";
|
||||
export const HEARTBEAT_PROMPT = "HEARTBEAT";
|
||||
|
||||
function elide(text?: string, limit = 400) {
|
||||
if (!text) return text;
|
||||
@@ -164,12 +164,10 @@ export function resolveReplyHeartbeatMinutes(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
overrideMinutes?: number,
|
||||
) {
|
||||
const raw = overrideMinutes ?? cfg.inbound?.reply?.heartbeatMinutes;
|
||||
const raw = overrideMinutes ?? cfg.inbound?.agent?.heartbeatMinutes;
|
||||
if (raw === 0) return null;
|
||||
if (typeof raw === "number" && raw > 0) return raw;
|
||||
return cfg.inbound?.reply?.mode === "command"
|
||||
? DEFAULT_REPLY_HEARTBEAT_MINUTES
|
||||
: null;
|
||||
return DEFAULT_REPLY_HEARTBEAT_MINUTES;
|
||||
}
|
||||
|
||||
export function stripHeartbeatToken(raw?: string) {
|
||||
@@ -214,12 +212,12 @@ export async function runWebHeartbeatOnce(opts: {
|
||||
});
|
||||
|
||||
const cfg = cfgOverride ?? loadConfig();
|
||||
const sessionCfg = cfg.inbound?.reply?.session;
|
||||
const sessionCfg = cfg.inbound?.session;
|
||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||
const mainKey = sessionCfg?.mainKey;
|
||||
const sessionKey = resolveSessionKey(sessionScope, { From: to }, mainKey);
|
||||
if (sessionId) {
|
||||
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
|
||||
const storePath = resolveStorePath(cfg.inbound?.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
store[sessionKey] = {
|
||||
...(store[sessionKey] ?? {}),
|
||||
@@ -319,7 +317,7 @@ export async function runWebHeartbeatOnce(opts: {
|
||||
const stripped = stripHeartbeatToken(replyPayload.text);
|
||||
if (stripped.shouldSkip && !hasMedia) {
|
||||
// Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works.
|
||||
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
|
||||
const storePath = resolveStorePath(cfg.inbound?.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
if (sessionSnapshot.entry && store[sessionSnapshot.key]) {
|
||||
store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt;
|
||||
@@ -381,7 +379,7 @@ export async function runWebHeartbeatOnce(opts: {
|
||||
}
|
||||
|
||||
function getFallbackRecipient(cfg: ReturnType<typeof loadConfig>) {
|
||||
const sessionCfg = cfg.inbound?.reply?.session;
|
||||
const sessionCfg = cfg.inbound?.session;
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
||||
@@ -402,10 +400,10 @@ function getFallbackRecipient(cfg: ReturnType<typeof loadConfig>) {
|
||||
}
|
||||
|
||||
function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
|
||||
const sessionCfg = cfg.inbound?.reply?.session;
|
||||
const sessionCfg = cfg.inbound?.session;
|
||||
const scope = sessionCfg?.scope ?? "per-sender";
|
||||
if (scope === "global") return [];
|
||||
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
|
||||
const storePath = resolveStorePath(cfg.inbound?.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const isGroupKey = (key: string) =>
|
||||
key.startsWith("group:") || key.includes("@g.us");
|
||||
@@ -470,7 +468,7 @@ function getSessionSnapshot(
|
||||
from: string,
|
||||
isHeartbeat = false,
|
||||
) {
|
||||
const sessionCfg = cfg.inbound?.reply?.session;
|
||||
const sessionCfg = cfg.inbound?.session;
|
||||
const scope = sessionCfg?.scope ?? "per-sender";
|
||||
const key = resolveSessionKey(
|
||||
scope,
|
||||
@@ -700,7 +698,7 @@ export async function monitorWebProvider(
|
||||
const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId });
|
||||
const reconnectLogger = getChildLogger({ module: "web-reconnect", runId });
|
||||
const cfg = loadConfig();
|
||||
const configuredMaxMb = cfg.inbound?.reply?.mediaMaxMb;
|
||||
const configuredMaxMb = cfg.inbound?.agent?.mediaMaxMb;
|
||||
const maxMediaBytes =
|
||||
typeof configuredMaxMb === "number" && configuredMaxMb > 0
|
||||
? configuredMaxMb * 1024 * 1024
|
||||
@@ -873,7 +871,7 @@ export async function monitorWebProvider(
|
||||
);
|
||||
|
||||
if (latest.chatType !== "group") {
|
||||
const sessionCfg = cfg.inbound?.reply?.session;
|
||||
const sessionCfg = cfg.inbound?.session;
|
||||
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
const to = (() => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@whiskeysockets/baileys";
|
||||
import qrcode from "qrcode-terminal";
|
||||
|
||||
import { SESSION_STORE_DEFAULT } from "../config/sessions.js";
|
||||
import { resolveDefaultSessionStorePath } from "../config/sessions.js";
|
||||
import { danger, info, success } from "../globals.js";
|
||||
import { getChildLogger, toPinoLikeLogger } from "../logging.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
@@ -153,7 +153,7 @@ export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) {
|
||||
}
|
||||
await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true });
|
||||
// Also drop session store to clear lingering per-sender state after logout.
|
||||
await fs.rm(SESSION_STORE_DEFAULT, { force: true });
|
||||
await fs.rm(resolveDefaultSessionStorePath(), { force: true });
|
||||
runtime.log(success("Cleared WhatsApp Web credentials."));
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user