- Prefix unused test variables with underscore - Remove unused piSpec import and idleMs class member - Fix import ordering and code formatting
2330 lines
64 KiB
TypeScript
2330 lines
64 KiB
TypeScript
import crypto from "node:crypto";
|
||
import fs from "node:fs";
|
||
import net from "node:net";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { createMockTwilio } from "../test/mocks/twilio.js";
|
||
import * as exec from "./process/exec.js";
|
||
import * as tauRpc from "./process/tau-rpc.js";
|
||
import { withWhatsAppPrefix } from "./utils.js";
|
||
|
||
// Mock config to avoid loading real user config
|
||
vi.mock("../src/config/config.js", () => ({
|
||
loadConfig: vi.fn().mockReturnValue({
|
||
inbound: {
|
||
allowFrom: ["*"],
|
||
messagePrefix: undefined,
|
||
responsePrefix: undefined,
|
||
timestampPrefix: false,
|
||
},
|
||
}),
|
||
}));
|
||
|
||
// Twilio mock factory shared across tests
|
||
vi.mock("twilio", () => {
|
||
const { factory } = createMockTwilio();
|
||
return { default: factory };
|
||
});
|
||
|
||
type TwilioFactoryMock = ReturnType<typeof createMockTwilio>["factory"];
|
||
const twilioFactory = (await import("twilio")).default as TwilioFactoryMock;
|
||
|
||
import * as index from "./index.js";
|
||
import { splitMediaFromOutput } from "./media/parse.js";
|
||
|
||
const envBackup = { ...process.env } as Record<string, string | undefined>;
|
||
|
||
beforeEach(() => {
|
||
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
|
||
process.env.TWILIO_AUTH_TOKEN = "token";
|
||
delete process.env.TWILIO_API_KEY;
|
||
delete process.env.TWILIO_API_SECRET;
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
afterEach(() => {
|
||
Object.entries(envBackup).forEach(([k, v]) => {
|
||
if (v === undefined) {
|
||
delete process.env[k];
|
||
} else {
|
||
process.env[k] = v;
|
||
}
|
||
});
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe("command helpers", () => {
|
||
it("runCommandWithTimeout captures stdout and timeout", async () => {
|
||
const result = await index.runCommandWithTimeout(
|
||
[process.execPath, "-e", "console.log('ok')"],
|
||
500,
|
||
);
|
||
expect(result.stdout.trim()).toBe("ok");
|
||
|
||
const slow = index.runCommandWithTimeout(
|
||
[process.execPath, "-e", "setTimeout(()=>{}, 1000)"],
|
||
20,
|
||
);
|
||
const timedOut = await slow;
|
||
expect(timedOut.killed).toBe(true);
|
||
});
|
||
|
||
it("ensurePortAvailable rejects when in use", async () => {
|
||
const server = net.createServer();
|
||
await new Promise((resolve) => server.listen(0, resolve));
|
||
const port = (server.address() as net.AddressInfo).port;
|
||
await expect(index.ensurePortAvailable(port)).rejects.toBeInstanceOf(
|
||
index.PortInUseError,
|
||
);
|
||
server.close();
|
||
});
|
||
});
|
||
|
||
describe("config and templating", () => {
|
||
it("getReplyFromConfig returns text when allowlist passes", async () => {
|
||
const cfg = {
|
||
inbound: {
|
||
allowFrom: ["+1555"],
|
||
reply: {
|
||
mode: "text" as const,
|
||
text: "Hello {{From}} {{Body}}",
|
||
bodyPrefix: "[pfx] ",
|
||
},
|
||
},
|
||
};
|
||
|
||
const onReplyStart = vi.fn();
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "hi", From: "whatsapp:+1555", To: "x" },
|
||
{ onReplyStart },
|
||
cfg,
|
||
);
|
||
expect(result?.text).toBe("Hello whatsapp:+1555 [pfx] hi");
|
||
expect(onReplyStart).toHaveBeenCalled();
|
||
});
|
||
|
||
it("getReplyFromConfig allows same-phone mode (from === to) without allowFrom", async () => {
|
||
const cfg = {
|
||
inbound: {
|
||
// No allowFrom configured
|
||
reply: {
|
||
mode: "text" as const,
|
||
text: "Echo: {{Body}}",
|
||
},
|
||
},
|
||
};
|
||
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "hello", From: "+1555", To: "+1555" },
|
||
undefined,
|
||
cfg,
|
||
);
|
||
expect(result?.text).toBe("Echo: hello");
|
||
});
|
||
|
||
it("getReplyFromConfig allows same-phone mode even when not in allowFrom list", async () => {
|
||
const cfg = {
|
||
inbound: {
|
||
allowFrom: ["+9999"], // Different number
|
||
reply: {
|
||
mode: "text" as const,
|
||
text: "Reply: {{Body}}",
|
||
},
|
||
},
|
||
};
|
||
|
||
// Same-phone mode should bypass allowFrom check
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "test", From: "+1555", To: "+1555" },
|
||
undefined,
|
||
cfg,
|
||
);
|
||
expect(result?.text).toBe("Reply: test");
|
||
});
|
||
|
||
it("getReplyFromConfig allows group chats even when not in allowFrom", async () => {
|
||
const cfg = {
|
||
inbound: {
|
||
allowFrom: ["+9999"],
|
||
reply: {
|
||
mode: "text" as const,
|
||
text: "Group: {{From}}",
|
||
},
|
||
},
|
||
};
|
||
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "hello", From: "120363422899103675@g.us", To: "+4475" },
|
||
undefined,
|
||
cfg,
|
||
);
|
||
expect(result?.text).toBe("Group: 120363422899103675@g.us");
|
||
});
|
||
|
||
it("getReplyFromConfig rejects non-same-phone when not in allowFrom", async () => {
|
||
const cfg = {
|
||
inbound: {
|
||
allowFrom: ["+9999"],
|
||
reply: {
|
||
mode: "text" as const,
|
||
text: "Should not see this",
|
||
},
|
||
},
|
||
};
|
||
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "test", From: "+1555", To: "+2666" },
|
||
undefined,
|
||
cfg,
|
||
);
|
||
expect(result).toBeUndefined();
|
||
});
|
||
|
||
it("getReplyFromConfig templating includes media fields", async () => {
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
},
|
||
},
|
||
};
|
||
const result = await index.getReplyFromConfig(
|
||
{
|
||
Body: "",
|
||
From: "+1",
|
||
To: "+2",
|
||
MediaPath: "/tmp/a.jpg",
|
||
MediaType: "image/jpeg",
|
||
MediaUrl: "http://example.com/a.jpg",
|
||
},
|
||
undefined,
|
||
cfg,
|
||
);
|
||
expect(result?.text).toContain("/tmp/a.jpg");
|
||
expect(result?.text).toContain("image/jpeg");
|
||
expect(result?.text).toContain("http://example.com/a.jpg");
|
||
});
|
||
|
||
it("getReplyFromConfig runs audio transcription command when configured", async () => {
|
||
const cfg = {
|
||
inbound: {
|
||
transcribeAudio: {
|
||
command: ["echo", "voice transcript"],
|
||
},
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
},
|
||
},
|
||
};
|
||
|
||
const runExec = vi.spyOn(exec, "runExec").mockResolvedValue({
|
||
stdout: "voice transcript\n",
|
||
stderr: "",
|
||
});
|
||
const commandRunner = vi.fn().mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
|
||
const result = await index.getReplyFromConfig(
|
||
{
|
||
Body: "<media:audio>",
|
||
From: "+1",
|
||
To: "+2",
|
||
MediaPath: "/tmp/voice.ogg",
|
||
MediaType: "audio/ogg",
|
||
},
|
||
undefined,
|
||
cfg,
|
||
commandRunner,
|
||
);
|
||
|
||
expect(runExec).toHaveBeenCalled();
|
||
expect(commandRunner).toHaveBeenCalled();
|
||
const argv = commandRunner.mock.calls[0][0];
|
||
const prompt = argv[argv.length - 1] as string;
|
||
expect(prompt).toContain("/tmp/voice.ogg");
|
||
expect(prompt).toContain("Transcript:");
|
||
expect(prompt).toContain("voice transcript");
|
||
expect(result?.text).toBe("ok");
|
||
});
|
||
|
||
it("getReplyFromConfig skips transcription when not configured", async () => {
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "text" as const,
|
||
text: "{{Body}}",
|
||
},
|
||
},
|
||
};
|
||
|
||
const runExec = vi.spyOn(exec, "runExec");
|
||
const result = await index.getReplyFromConfig(
|
||
{
|
||
Body: "<media:audio>",
|
||
From: "+1",
|
||
To: "+2",
|
||
MediaPath: "/tmp/voice.ogg",
|
||
MediaType: "audio/ogg",
|
||
},
|
||
undefined,
|
||
cfg,
|
||
);
|
||
|
||
expect(runExec).not.toHaveBeenCalled();
|
||
expect(result?.text).toContain("/tmp/voice.ogg");
|
||
expect(result?.text).toContain("<media:audio>");
|
||
});
|
||
|
||
it("getReplyFromConfig extracts media URL from command stdout", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "hello\nMEDIA: https://example.com/img.jpg\n",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
},
|
||
},
|
||
};
|
||
const result = await index.getReplyFromConfig(
|
||
{
|
||
Body: "hi",
|
||
From: "+1",
|
||
To: "+2",
|
||
},
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(result?.text).toBe("hello");
|
||
expect(result?.mediaUrl).toBe("https://example.com/img.jpg");
|
||
});
|
||
|
||
it("extracts first MEDIA token even with trailing text", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "hello\nMEDIA:/tmp/pic.png extra words here\n",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
},
|
||
},
|
||
};
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(result?.mediaUrl).toBe("/tmp/pic.png");
|
||
});
|
||
|
||
it("extracts MEDIA token inline within a sentence", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "caption before MEDIA:/tmp/pic.png caption after",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
},
|
||
},
|
||
};
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(result?.mediaUrl).toBe("/tmp/pic.png");
|
||
expect(result?.text).toBe("caption before caption after");
|
||
});
|
||
|
||
it("uses heartbeatCommand only for heartbeat polls", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "normal {{Body}}"],
|
||
heartbeatCommand: ["echo", "heartbeat {{Body}}"],
|
||
},
|
||
},
|
||
};
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "PING", From: "+1", To: "+2" },
|
||
{ isHeartbeat: true },
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(runSpy).toHaveBeenCalledWith(
|
||
["echo", "heartbeat PING"],
|
||
expect.any(Object),
|
||
);
|
||
});
|
||
|
||
it("falls back to default command for non-heartbeat calls", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "normal {{Body}}"],
|
||
heartbeatCommand: ["echo", "heartbeat {{Body}}"],
|
||
},
|
||
},
|
||
};
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "PING", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(runSpy).toHaveBeenCalledWith(
|
||
["echo", "normal PING"],
|
||
expect.any(Object),
|
||
);
|
||
});
|
||
|
||
it("captures MEDIA wrapped in backticks", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "MEDIA:`/tmp/pic.png` cool",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
},
|
||
},
|
||
};
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(result?.mediaUrl).toBe("/tmp/pic.png");
|
||
});
|
||
|
||
it("captures MEDIA token with trailing JSON characters", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: 'MEDIA:/tmp/pic.png"} trailing',
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
},
|
||
},
|
||
};
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(result?.mediaUrl).toBe("/tmp/pic.png");
|
||
});
|
||
|
||
it("injects --thinking for pi when /think directive is present", async () => {
|
||
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["pi", "--mode", "json", "{{Body}}"],
|
||
agent: { kind: "pi" },
|
||
},
|
||
},
|
||
};
|
||
await index.getReplyFromConfig(
|
||
{ Body: "/think:high hello", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
);
|
||
expect(rpcSpy).toHaveBeenCalled();
|
||
const args = rpcSpy.mock.calls[0][0].argv;
|
||
expect(args).toContain("--thinking");
|
||
expect(args).toContain("high");
|
||
expect(rpcSpy.mock.calls[0][0].prompt).toBe("hello");
|
||
});
|
||
|
||
it("rewrites /think directive to textual cue for non-pi agents", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
},
|
||
},
|
||
};
|
||
await index.getReplyFromConfig(
|
||
{ Body: "/think:medium hi there", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(runSpy).toHaveBeenCalled();
|
||
const args = runSpy.mock.calls[0][0] as string[];
|
||
expect(args[1]).toBe("hi there think harder");
|
||
});
|
||
|
||
it("treats /think:off as no-op for non-pi agents", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
},
|
||
},
|
||
};
|
||
await index.getReplyFromConfig(
|
||
{ Body: "/think:off hi there", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(runSpy).toHaveBeenCalled();
|
||
const args = runSpy.mock.calls[0][0] as string[];
|
||
expect(args[1]).toBe("hi there");
|
||
});
|
||
|
||
it("treats /think:off as no-op for pi (no --thinking injected)", async () => {
|
||
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["pi", "--mode", "json", "{{Body}}"],
|
||
agent: { kind: "pi" },
|
||
},
|
||
},
|
||
};
|
||
await index.getReplyFromConfig(
|
||
{ Body: "/think:off hello", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
);
|
||
expect(rpcSpy).toHaveBeenCalled();
|
||
const args = rpcSpy.mock.calls[0][0].argv;
|
||
expect(args).not.toContain("--thinking");
|
||
expect(rpcSpy.mock.calls[0][0].prompt).toBe("hello");
|
||
});
|
||
|
||
it("persists session thinking level when directive-only message is sent", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const storeDir = await fs.promises.mkdtemp(
|
||
path.join(os.tmpdir(), "warelay-session-"),
|
||
);
|
||
const storePath = path.join(storeDir, "sessions.json");
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
session: { store: storePath },
|
||
},
|
||
},
|
||
};
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "/think:medium", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "hi there", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
expect(runSpy).toHaveBeenCalledTimes(1);
|
||
const args = runSpy.mock.calls[0][0] as string[];
|
||
expect(args.join(" ")).toContain("hi there think harder");
|
||
});
|
||
|
||
it("confirms directive-only think level and skips command", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
},
|
||
},
|
||
};
|
||
|
||
const ack = await index.getReplyFromConfig(
|
||
{ Body: "/thinking high", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
expect(runSpy).not.toHaveBeenCalled();
|
||
expect(ack?.text).toBe("Thinking level set to high.");
|
||
});
|
||
|
||
it("enables verbose via directive-only and skips command", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
},
|
||
},
|
||
};
|
||
|
||
const _ack = await index.getReplyFromConfig(
|
||
{ Body: "/v:on", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
// Directive may short-circuit or proceed; any behavior is fine as long as thinking persists.
|
||
});
|
||
|
||
it("rejects invalid verbose directive-only and preserves state", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const storeDir = await fs.promises.mkdtemp(
|
||
path.join(os.tmpdir(), "warelay-session-"),
|
||
);
|
||
const storePath = path.join(storeDir, "sessions.json");
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
session: { store: storePath },
|
||
},
|
||
},
|
||
};
|
||
|
||
const ack = await index.getReplyFromConfig(
|
||
{ Body: "/verbose maybe", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
expect(runSpy).not.toHaveBeenCalled();
|
||
expect(ack?.text).toContain("Unrecognized verbose level");
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(runSpy).toHaveBeenCalledTimes(1);
|
||
const args = runSpy.mock.calls[0][0] as string[];
|
||
const bodyArg = args[args.length - 1];
|
||
expect(bodyArg).toBe("hi");
|
||
});
|
||
|
||
it("shows tool results when verbose is on for pi", async () => {
|
||
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
|
||
stdout:
|
||
'{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"summary"}]}}\n' +
|
||
'{"type":"message_end","message":{"role":"tool_result","name":"bash","details":{"command":"ls"},"content":[{"type":"text","text":"ls output"}]}}',
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["pi", "--mode", "json", "{{Body}}"],
|
||
agent: { kind: "pi" },
|
||
},
|
||
},
|
||
};
|
||
|
||
const res = await index.getReplyFromConfig(
|
||
{ Body: "/v on hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
);
|
||
|
||
expect(rpcSpy).toHaveBeenCalled();
|
||
const payloads = Array.isArray(res) ? res : res ? [res] : [];
|
||
expect(payloads.length).toBeGreaterThanOrEqual(2);
|
||
expect(payloads[0]?.text).toBe("💻 ls — “ls output”");
|
||
expect(payloads[1]?.text).toContain("summary");
|
||
});
|
||
|
||
it("prepends session hint when new session and verbose on", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
vi.spyOn(crypto, "randomUUID").mockReturnValue("sess-uuid");
|
||
const storeDir = await fs.promises.mkdtemp(
|
||
path.join(os.tmpdir(), "warelay-session-"),
|
||
);
|
||
const storePath = path.join(storeDir, "sessions.json");
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
session: { store: storePath },
|
||
},
|
||
},
|
||
};
|
||
|
||
const res = await index.getReplyFromConfig(
|
||
{ Body: "/new /v on hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
const payloads = Array.isArray(res) ? res : res ? [res] : [];
|
||
expect(payloads[0]?.text).toBe("🧭 New session: sess-uuid");
|
||
expect(payloads[1]?.text).toBe("ok");
|
||
});
|
||
|
||
it("treats directive-only even when bracket prefixes are present", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const storeDir = await fs.promises.mkdtemp(
|
||
path.join(os.tmpdir(), "warelay-session-"),
|
||
);
|
||
const storePath = path.join(storeDir, "sessions.json");
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
session: { store: storePath },
|
||
},
|
||
},
|
||
};
|
||
|
||
const ack = await index.getReplyFromConfig(
|
||
{
|
||
Body: "[Dec 1 00:00] [🦞 same-phone] /think:high",
|
||
From: "+1",
|
||
To: "+2",
|
||
},
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
expect(runSpy).not.toHaveBeenCalled();
|
||
expect(ack?.text).toBe("Thinking level set to high.");
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "hello", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
expect(runSpy).toHaveBeenCalledTimes(1);
|
||
const args = runSpy.mock.calls[0][0] as string[];
|
||
const bodyArg = args[args.length - 1];
|
||
expect(bodyArg).toBe("hello ultrathink");
|
||
});
|
||
|
||
it("treats verbose directive-only inside group batch context", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const storeDir = await fs.promises.mkdtemp(
|
||
path.join(os.tmpdir(), "warelay-session-"),
|
||
);
|
||
const storePath = path.join(storeDir, "sessions.json");
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
session: { store: storePath },
|
||
},
|
||
},
|
||
};
|
||
|
||
const batchBody =
|
||
"[Chat messages since your last reply - for context]\nAlice: hi\n\n[Current message - respond to this]\nBob: /v on\n[from: Bob (+222)]";
|
||
|
||
const ack = await index.getReplyFromConfig(
|
||
{
|
||
Body: batchBody,
|
||
From: "group:123@g.us",
|
||
To: "+2",
|
||
},
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
// Combined directive may already persist and return ack; command should not be required,
|
||
// but if it runs, we still validate persistence on next turn.
|
||
expect(ack?.text).toBeDefined();
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "hello", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
expect(runSpy).toHaveBeenCalledTimes(1);
|
||
const args = runSpy.mock.calls[0][0] as string[];
|
||
const bodyArg = args[args.length - 1];
|
||
expect(bodyArg).toBe("hello");
|
||
});
|
||
|
||
it("treats think directive-only with mentions in group batch context", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const storeDir = await fs.promises.mkdtemp(
|
||
path.join(os.tmpdir(), "warelay-session-"),
|
||
);
|
||
const storePath = path.join(storeDir, "sessions.json");
|
||
const cfg = {
|
||
inbound: {
|
||
groupChat: {
|
||
mentionPatterns: ["@clawd", "\\\\+447511247203"],
|
||
},
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
session: { store: storePath },
|
||
},
|
||
},
|
||
};
|
||
|
||
const batchBody =
|
||
"[Current message - respond to this]\nPeter: @2350001479733 /thinking low";
|
||
|
||
const ack = await index.getReplyFromConfig(
|
||
{
|
||
Body: batchBody,
|
||
From: "group:123@g.us",
|
||
To: "+447511247203",
|
||
},
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
expect(runSpy).not.toHaveBeenCalled();
|
||
expect(ack?.text).toBe("Thinking level set to low.");
|
||
});
|
||
|
||
it("treats combined verbose+thinking directives with mention in group batch context", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const storeDir = await fs.promises.mkdtemp(
|
||
path.join(os.tmpdir(), "warelay-session-"),
|
||
);
|
||
const storePath = path.join(storeDir, "sessions.json");
|
||
const cfg = {
|
||
inbound: {
|
||
groupChat: {
|
||
mentionPatterns: ["@clawd", "\\\\+447511247203", "clawd\\s*uk"],
|
||
},
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
session: { store: storePath },
|
||
},
|
||
},
|
||
};
|
||
|
||
const batchBody =
|
||
"[Current message - respond to this]\nPeter: @Clawd UK /thinking medium /v on";
|
||
|
||
const _ack = await index.getReplyFromConfig(
|
||
{
|
||
Body: batchBody,
|
||
From: "group:456@g.us",
|
||
To: "+447511247203",
|
||
},
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
// Next message should inject persisted thinking=medium and verbose=on
|
||
await index.getReplyFromConfig(
|
||
{ Body: "hello", From: "group:456@g.us", To: "+447511247203" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
const persisted = JSON.parse(
|
||
await fs.promises.readFile(storePath, "utf-8"),
|
||
) as Record<string, { thinkingLevel?: string; verboseLevel?: string }>;
|
||
const _entry = Object.values(persisted)[0] as {
|
||
thinkingLevel?: string;
|
||
verboseLevel?: string;
|
||
};
|
||
});
|
||
|
||
it("ignores directive-only when mention pattern doesn’t match self", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const storeDir = await fs.promises.mkdtemp(
|
||
path.join(os.tmpdir(), "warelay-session-"),
|
||
);
|
||
const storePath = path.join(storeDir, "sessions.json");
|
||
const cfg = {
|
||
inbound: {
|
||
groupChat: {
|
||
mentionPatterns: ["@clawd"], // no match for @someoneelse
|
||
},
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
session: { store: storePath },
|
||
},
|
||
},
|
||
};
|
||
|
||
const batchBody =
|
||
"[Current message - respond to this]\nUser: @someoneelse /thinking high";
|
||
|
||
const res = await index.getReplyFromConfig(
|
||
{ Body: batchBody, From: "group:789@g.us", To: "+447511247203" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
// Because mention doesn’t match, it’s treated as normal text and forwarded.
|
||
expect(res?.text).toBe("ok");
|
||
expect(runSpy).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("rejects invalid directive-only think level without changing state", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const storeDir = await fs.promises.mkdtemp(
|
||
path.join(os.tmpdir(), "warelay-session-"),
|
||
);
|
||
const storePath = path.join(storeDir, "sessions.json");
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
session: { store: storePath },
|
||
},
|
||
},
|
||
};
|
||
|
||
const ack = await index.getReplyFromConfig(
|
||
{ Body: "/thinking big", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
expect(runSpy).not.toHaveBeenCalled();
|
||
expect(ack?.text).toContain('Unrecognized thinking level "big"');
|
||
|
||
// Send another message; state should not carry any level.
|
||
const second = await index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(runSpy).toHaveBeenCalledTimes(1);
|
||
const args = runSpy.mock.calls[0][0] as string[];
|
||
const bodyArg = args[args.length - 1];
|
||
expect(bodyArg).toBe("hi");
|
||
expect(second?.text).toBe("ok");
|
||
});
|
||
|
||
it("uses global thinkingDefault when no directive or session override", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
thinkingDefault: "low" as const,
|
||
},
|
||
},
|
||
};
|
||
await index.getReplyFromConfig(
|
||
{ Body: "hello", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(runSpy).toHaveBeenCalled();
|
||
const args = runSpy.mock.calls[0][0] as string[];
|
||
expect(args[1]).toBe("hello think hard");
|
||
});
|
||
|
||
it("accepts spaced directive form '/think high' and applies cue", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
},
|
||
},
|
||
};
|
||
await index.getReplyFromConfig(
|
||
{ Body: "/think high hello world", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(runSpy).toHaveBeenCalled();
|
||
const args = runSpy.mock.calls[0][0] as string[];
|
||
expect(args[1]).toBe("hello world ultrathink");
|
||
});
|
||
|
||
it("accepts shorthand '/t:medium' and applies cue", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
},
|
||
},
|
||
};
|
||
await index.getReplyFromConfig(
|
||
{ Body: "/t:medium greetings", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(runSpy).toHaveBeenCalled();
|
||
const args = runSpy.mock.calls[0][0] as string[];
|
||
expect(args[1]).toBe("greetings think harder");
|
||
});
|
||
|
||
it("stores session thinking for pi and injects on next message", async () => {
|
||
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const storeDir = await fs.promises.mkdtemp(
|
||
path.join(os.tmpdir(), "warelay-session-"),
|
||
);
|
||
const storePath = path.join(storeDir, "sessions.json");
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["pi", "--mode", "json", "{{Body}}"],
|
||
agent: { kind: "pi" },
|
||
session: { store: storePath },
|
||
},
|
||
},
|
||
};
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "/thinking max", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
);
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "next run", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
);
|
||
|
||
expect(rpcSpy).toHaveBeenCalled();
|
||
const args = rpcSpy.mock.calls[0][0].argv;
|
||
expect(args).toContain("--thinking");
|
||
expect(args).toContain("high");
|
||
});
|
||
|
||
it("clears stored thinking when directive-only /think:off is sent", async () => {
|
||
const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const storeDir = await fs.promises.mkdtemp(
|
||
path.join(os.tmpdir(), "warelay-session-"),
|
||
);
|
||
const storePath = path.join(storeDir, "sessions.json");
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["pi", "--mode", "json", "{{Body}}"],
|
||
agent: { kind: "pi" },
|
||
session: { store: storePath },
|
||
},
|
||
},
|
||
};
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "/think:medium", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
);
|
||
await index.getReplyFromConfig(
|
||
{ Body: "/think:off", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
);
|
||
rpcSpy.mockClear();
|
||
await index.getReplyFromConfig(
|
||
{ Body: "plain text", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
);
|
||
expect(rpcSpy).toHaveBeenCalled();
|
||
const args = rpcSpy.mock.calls[0][0].argv;
|
||
expect(args).not.toContain("--thinking");
|
||
});
|
||
|
||
it("ignores invalid MEDIA lines with whitespace", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "hello\nMEDIA: not a url with spaces\nrest\n",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
},
|
||
},
|
||
};
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(result?.text).toBe("hello\nrest");
|
||
expect(result?.mediaUrl).toBeUndefined();
|
||
});
|
||
|
||
it("injects fallback text when command returns nothing", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
},
|
||
},
|
||
};
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(result?.text).toContain("command produced no output");
|
||
expect(result?.mediaUrl).toBeUndefined();
|
||
});
|
||
|
||
it("returns timeout reply with partial stdout snippet", async () => {
|
||
const partial = "x".repeat(900);
|
||
const runSpy = vi.fn().mockRejectedValue({
|
||
killed: true,
|
||
signal: "SIGKILL",
|
||
stdout: partial,
|
||
stderr: "",
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
timeoutSeconds: 42,
|
||
},
|
||
},
|
||
};
|
||
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
expect(result?.text).toContain("Command timed out after 42s");
|
||
expect(result?.text).toContain("Partial output before timeout");
|
||
expect(result?.text).toContain(`${partial.slice(0, 800)}...`);
|
||
expect(result?.text).not.toContain(partial);
|
||
});
|
||
|
||
it("returns timeout reply without partial output when none is available", async () => {
|
||
const runSpy = vi.fn().mockRejectedValue({
|
||
killed: true,
|
||
signal: "SIGKILL",
|
||
stdout: "",
|
||
stderr: "",
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
timeoutSeconds: 5,
|
||
},
|
||
},
|
||
};
|
||
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
expect(result?.text).toBe(
|
||
"Command timed out after 5s. Try a shorter prompt or split the request.",
|
||
);
|
||
});
|
||
|
||
it("splitMediaFromOutput strips media token and preserves text", () => {
|
||
const { text, mediaUrl } = splitMediaFromOutput(
|
||
"line1\nMEDIA:https://x/y.png\nline2",
|
||
);
|
||
expect(mediaUrl).toBe("https://x/y.png");
|
||
expect(text).toBe("line1\nline2");
|
||
});
|
||
|
||
it("getReplyFromConfig runs command and manages session store", async () => {
|
||
const tmpStore = path.join(os.tmpdir(), `warelay-store-${Date.now()}.json`);
|
||
vi.spyOn(crypto, "randomUUID").mockReturnValue("session-123");
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "cmd output\n",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
template: "[tmpl]",
|
||
session: {
|
||
scope: "per-sender" as const,
|
||
resetTriggers: ["/new"],
|
||
store: tmpStore,
|
||
sessionArgNew: ["--sid", "{{SessionId}}"],
|
||
sessionArgResume: ["--resume", "{{SessionId}}"],
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
const first = await index.getReplyFromConfig(
|
||
{ Body: "/new hello", From: "+1555", To: "+1666" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(first?.text).toBe("cmd output");
|
||
const argvFirst = runSpy.mock.calls[0][0];
|
||
expect(argvFirst).toEqual([
|
||
"echo",
|
||
"[tmpl]",
|
||
"--sid",
|
||
"session-123",
|
||
"hello",
|
||
]);
|
||
|
||
const second = await index.getReplyFromConfig(
|
||
{ Body: "next", From: "+1555", To: "+1666" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
expect(second?.text).toBe("cmd output");
|
||
const argvSecond = runSpy.mock.calls[1][0];
|
||
expect(argvSecond[2]).toBe("--resume");
|
||
});
|
||
|
||
it("only sends system prompt once per session when configured", async () => {
|
||
const tmpStore = path.join(os.tmpdir(), `warelay-store-${Date.now()}.json`);
|
||
vi.spyOn(crypto, "randomUUID").mockReturnValue("sid-1");
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok\n",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
template: "[tmpl]",
|
||
bodyPrefix: "[pfx] ",
|
||
session: {
|
||
sendSystemOnce: true,
|
||
sessionIntro: "SYS",
|
||
store: tmpStore,
|
||
sessionArgNew: ["--sid", "{{SessionId}}"],
|
||
sessionArgResume: ["--resume", "{{SessionId}}"],
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "/new hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
await index.getReplyFromConfig(
|
||
{ Body: "next", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
const firstArgv = runSpy.mock.calls[0][0];
|
||
expect(firstArgv).toEqual([
|
||
"echo",
|
||
"[tmpl]",
|
||
"--sid",
|
||
"sid-1",
|
||
"SYS\n\n[pfx] hi",
|
||
]);
|
||
|
||
const secondArgv = runSpy.mock.calls[1][0];
|
||
expect(secondArgv).toEqual(["echo", "--resume", "sid-1", "next"]);
|
||
|
||
const persisted = JSON.parse(fs.readFileSync(tmpStore, "utf-8"));
|
||
const firstEntry = Object.values(persisted)[0] as { systemSent?: boolean };
|
||
expect(typeof firstEntry.systemSent).toBe("boolean");
|
||
});
|
||
|
||
it("keeps sending system prompt when sendSystemOnce is disabled (default)", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok\n",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
bodyPrefix: "[sys] ",
|
||
session: {
|
||
scope: "per-sender" as const,
|
||
resetTriggers: ["/new"],
|
||
idleMinutes: 60,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "/new hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
await index.getReplyFromConfig(
|
||
{ Body: "next", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
const firstArgv = runSpy.mock.calls[0][0];
|
||
expect(firstArgv[firstArgv.length - 1]).toBe("[sys] hi");
|
||
|
||
const secondArgv = runSpy.mock.calls[1][0];
|
||
expect(secondArgv[secondArgv.length - 1]).toBe("[sys] next");
|
||
});
|
||
|
||
it("stores session id returned by agent meta when it differs", async () => {
|
||
const tmpStore = path.join(
|
||
os.tmpdir(),
|
||
`warelay-store-${Date.now()}-sessionid.json`,
|
||
);
|
||
vi.spyOn(crypto, "randomUUID").mockReturnValue("initial-sid");
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: '{"text":"hi","session_id":"agent-sid-123"}\n',
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["claude", "{{Body}}"],
|
||
agent: { kind: "claude", format: "json" as const },
|
||
session: { store: tmpStore },
|
||
},
|
||
},
|
||
};
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "/new hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
const persisted = JSON.parse(fs.readFileSync(tmpStore, "utf-8"));
|
||
const entry = Object.values(persisted)[0] as { sessionId?: string };
|
||
expect(entry.sessionId).toBe("agent-sid-123");
|
||
});
|
||
|
||
it("aborts command when stop word is received and skips command runner", async () => {
|
||
const tmpStore = path.join(
|
||
os.tmpdir(),
|
||
`warelay-store-${Date.now()}-abort.json`,
|
||
);
|
||
const runSpy = vi.fn().mockResolvedValue({
|
||
stdout: "should-not-run",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
session: { store: tmpStore },
|
||
},
|
||
},
|
||
};
|
||
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "stop", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
expect(result?.text).toMatch(/aborted/i);
|
||
expect(runSpy).not.toHaveBeenCalled();
|
||
const persisted = JSON.parse(fs.readFileSync(tmpStore, "utf-8"));
|
||
const entry = Object.values(persisted)[0] as { abortedLastRun?: boolean };
|
||
expect(entry.abortedLastRun).toBe(true);
|
||
});
|
||
|
||
it("adds an abort hint to the next prompt and then clears the flag", async () => {
|
||
const tmpStore = path.join(
|
||
os.tmpdir(),
|
||
`warelay-store-${Date.now()}-aborthint.json`,
|
||
);
|
||
const runSpy = vi.fn().mockResolvedValue({
|
||
stdout: "ok\n",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
session: { store: tmpStore },
|
||
},
|
||
},
|
||
};
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "abort", From: "+1555", To: "+2666" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "continue", From: "+1555", To: "+2666" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
const argv = runSpy.mock.calls[0][0];
|
||
const prompt = argv.at(-1) as string;
|
||
expect(prompt).toMatch(/previous agent run was aborted/i);
|
||
expect(prompt).toMatch(/continue/);
|
||
const persisted = JSON.parse(fs.readFileSync(tmpStore, "utf-8"));
|
||
const entry = Object.values(persisted)[0] as { abortedLastRun?: boolean };
|
||
expect(entry.abortedLastRun).toBe(false);
|
||
expect(result?.text).toBe("ok");
|
||
});
|
||
|
||
it("refreshes typing indicator while command runs", async () => {
|
||
const onReplyStart = vi.fn();
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockImplementation(
|
||
() =>
|
||
new Promise((resolve) =>
|
||
setTimeout(
|
||
() =>
|
||
resolve({
|
||
stdout: "done\n",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
}),
|
||
120,
|
||
),
|
||
),
|
||
);
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
typingIntervalSeconds: 0.02,
|
||
},
|
||
},
|
||
};
|
||
|
||
const promise = index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1", To: "+2" },
|
||
{ onReplyStart },
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
await new Promise((r) => setTimeout(r, 200));
|
||
await promise;
|
||
expect(onReplyStart.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||
});
|
||
|
||
it("uses session typing interval override", async () => {
|
||
const onReplyStart = vi.fn();
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockImplementation(
|
||
() =>
|
||
new Promise((resolve) =>
|
||
setTimeout(
|
||
() =>
|
||
resolve({
|
||
stdout: "done\n",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
}),
|
||
120,
|
||
),
|
||
),
|
||
);
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
session: { typingIntervalSeconds: 0.02 },
|
||
},
|
||
},
|
||
};
|
||
|
||
const promise = index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1", To: "+2" },
|
||
{ onReplyStart },
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
await new Promise((r) => setTimeout(r, 200));
|
||
await promise;
|
||
expect(onReplyStart.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||
});
|
||
|
||
it("injects Claude output format + print flag when configured", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["claude", "{{Body}}"],
|
||
agent: { kind: "claude", format: "text" as const },
|
||
},
|
||
},
|
||
};
|
||
|
||
await index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1555", To: "+1666" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
const argv = runSpy.mock.calls[0][0];
|
||
expect(argv[0]).toBe("claude");
|
||
expect(argv.at(-1)).toContain("You are Clawd (Claude)");
|
||
expect(argv.at(-1)).toContain("scratchpad");
|
||
expect(argv.at(-1)).toMatch(/hi$/);
|
||
// The helper should auto-add print and output format flags without disturbing the prompt position.
|
||
expect(argv.includes("-p") || argv.includes("--print")).toBe(true);
|
||
const outputIdx = argv.findIndex(
|
||
(part) =>
|
||
part === "--output-format" || part.startsWith("--output-format="),
|
||
);
|
||
expect(outputIdx).toBeGreaterThan(-1);
|
||
expect(argv[outputIdx + 1]).toBe("text");
|
||
});
|
||
|
||
it("parses Claude JSON output and returns text content", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: '{"text":"hello world"}\n',
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["claude", "{{Body}}"],
|
||
agent: { kind: "claude", format: "json" as const },
|
||
},
|
||
},
|
||
};
|
||
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
expect(result?.text).toBe("hello world");
|
||
});
|
||
|
||
it("parses Claude JSON output even without explicit claudeOutputFormat when using claude bin", async () => {
|
||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||
stdout: '{"result":"Sure! What\'s up?"}\n',
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
});
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["claude", "{{Body}}"],
|
||
agent: { kind: "claude" },
|
||
},
|
||
},
|
||
};
|
||
|
||
const result = await index.getReplyFromConfig(
|
||
{ Body: "hi", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
);
|
||
|
||
expect(result?.text).toBe("Sure! What's up?");
|
||
const argv = runSpy.mock.calls[0][0];
|
||
expect(argv.at(-1)).toContain("You are Clawd (Claude)");
|
||
expect(argv.at(-1)).toContain("scratchpad");
|
||
});
|
||
|
||
it("serializes command auto-replies via the queue", async () => {
|
||
let active = 0;
|
||
let maxActive = 0;
|
||
const runSpy = vi.fn(async () => {
|
||
active += 1;
|
||
maxActive = Math.max(maxActive, active);
|
||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||
active -= 1;
|
||
return {
|
||
stdout: "ok",
|
||
stderr: "",
|
||
code: 0,
|
||
signal: null,
|
||
killed: false,
|
||
};
|
||
});
|
||
|
||
const cfg = {
|
||
inbound: {
|
||
reply: {
|
||
mode: "command" as const,
|
||
command: ["echo", "{{Body}}"],
|
||
},
|
||
},
|
||
};
|
||
|
||
await Promise.all([
|
||
index.getReplyFromConfig(
|
||
{ Body: "first", From: "+1", To: "+2" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
),
|
||
index.getReplyFromConfig(
|
||
{ Body: "second", From: "+3", To: "+4" },
|
||
undefined,
|
||
cfg,
|
||
runSpy,
|
||
),
|
||
]);
|
||
|
||
expect(runSpy).toHaveBeenCalledTimes(2);
|
||
expect(maxActive).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe("twilio interactions", () => {
|
||
it("autoReplyIfConfigured sends message when configured", async () => {
|
||
const client = twilioFactory._createClient();
|
||
client.messages.create.mockResolvedValue({});
|
||
await index.autoReplyIfConfigured(
|
||
client,
|
||
{
|
||
from: "whatsapp:+1",
|
||
to: "whatsapp:+2",
|
||
body: "hi",
|
||
sid: "SM1",
|
||
} as unknown as MessageInstance,
|
||
{
|
||
inbound: {
|
||
reply: { mode: "text", text: "auto-text" },
|
||
},
|
||
},
|
||
);
|
||
|
||
expect(client.messages.create).toHaveBeenCalledWith({
|
||
from: "whatsapp:+2",
|
||
to: "whatsapp:+1",
|
||
body: "auto-text",
|
||
});
|
||
});
|
||
|
||
it("sendTypingIndicator skips missing messageSid and sends when present", async () => {
|
||
const client = twilioFactory._createClient();
|
||
await index.sendTypingIndicator(client, index.defaultRuntime, undefined);
|
||
expect(client.request).not.toHaveBeenCalled();
|
||
|
||
await index.sendTypingIndicator(client, index.defaultRuntime, "SM123");
|
||
expect(client.request).toHaveBeenCalledWith(
|
||
expect.objectContaining({ method: "post" }),
|
||
);
|
||
});
|
||
|
||
it("sendMessage wraps Twilio client and returns sid", async () => {
|
||
const client = twilioFactory._createClient();
|
||
client.messages.create.mockResolvedValue({ sid: "SM999" });
|
||
twilioFactory.mockReturnValue(client);
|
||
|
||
const result = await index.sendMessage("+1555", "hi");
|
||
expect(client.messages.create).toHaveBeenCalledWith({
|
||
from: withWhatsAppPrefix("whatsapp:+15551234567"),
|
||
to: withWhatsAppPrefix("+1555"),
|
||
body: "hi",
|
||
});
|
||
expect(result?.sid).toBe("SM999");
|
||
});
|
||
|
||
it("waitForFinalStatus resolves on delivered", async () => {
|
||
const fetch = vi
|
||
.fn()
|
||
.mockResolvedValueOnce({ status: "sent" })
|
||
.mockResolvedValueOnce({ status: "delivered" });
|
||
const client = {
|
||
messages: vi.fn(() => ({ fetch })),
|
||
};
|
||
await index.waitForFinalStatus(
|
||
client as unknown as ReturnType<typeof index.createClient>,
|
||
"SM1",
|
||
1,
|
||
0,
|
||
);
|
||
expect(fetch).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it("waitForFinalStatus exits on failure", async () => {
|
||
const runtime: index.RuntimeEnv = {
|
||
error: vi.fn(),
|
||
exit: vi.fn() as unknown as (code: number) => never,
|
||
log: console.log,
|
||
};
|
||
const fetch = vi.fn().mockResolvedValue({ status: "failed" });
|
||
const client = {
|
||
messages: vi.fn(() => ({ fetch })),
|
||
};
|
||
await index
|
||
.waitForFinalStatus(
|
||
client as unknown as ReturnType<typeof index.createClient>,
|
||
"SM2",
|
||
1,
|
||
0,
|
||
runtime,
|
||
)
|
||
.catch(() => {});
|
||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||
});
|
||
});
|
||
|
||
describe("webhook and messaging", () => {
|
||
it("startWebhook responds and auto-replies", async () => {
|
||
const client = twilioFactory._createClient();
|
||
client.messages.create.mockResolvedValue({});
|
||
twilioFactory.mockReturnValue(client);
|
||
vi.spyOn(index, "getReplyFromConfig").mockResolvedValue({ text: "Auto" });
|
||
|
||
const server = await index.startWebhook(0, "/hook", undefined, false);
|
||
const address = server.address() as net.AddressInfo;
|
||
const url = `http://127.0.0.1:${address.port}/hook`;
|
||
const res = await fetch(url, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||
body: "From=whatsapp%3A%2B1555&To=whatsapp%3A%2B1666&Body=Hello&MessageSid=SM2",
|
||
});
|
||
expect(res.status).toBe(200);
|
||
await new Promise((resolve) => server.close(resolve));
|
||
});
|
||
|
||
it("hosts local media before replying via webhook", async () => {
|
||
const client = twilioFactory._createClient();
|
||
client.messages.create.mockResolvedValue({});
|
||
twilioFactory.mockReturnValue(client);
|
||
const replies = await import("./auto-reply/reply.js");
|
||
const hostModule = await import("./media/host.js");
|
||
const hostSpy = vi
|
||
.spyOn(hostModule, "ensureMediaHosted")
|
||
.mockResolvedValue({
|
||
url: "https://ts.net/media/abc",
|
||
id: "abc",
|
||
size: 123,
|
||
});
|
||
vi.spyOn(replies, "getReplyFromConfig").mockResolvedValue({
|
||
text: "Auto",
|
||
mediaUrl: "/tmp/pic.png",
|
||
});
|
||
|
||
const server = await index.startWebhook(0, "/hook", undefined, false);
|
||
const address = server.address() as net.AddressInfo;
|
||
const url = `http://127.0.0.1:${address.port}/hook`;
|
||
await fetch(url, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||
body: "From=whatsapp%3A%2B1555&To=whatsapp%3A%2B1666&Body=Hello&MessageSid=SM2",
|
||
});
|
||
|
||
expect(hostSpy).toHaveBeenCalledWith("/tmp/pic.png");
|
||
expect(client.messages.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
mediaUrl: ["https://ts.net/media/abc"],
|
||
}),
|
||
);
|
||
hostSpy.mockRestore();
|
||
await new Promise((resolve) => server.close(resolve));
|
||
});
|
||
|
||
it("listRecentMessages merges and sorts", async () => {
|
||
const inbound = [
|
||
{
|
||
sid: "1",
|
||
status: "delivered",
|
||
direction: "inbound",
|
||
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
||
from: "a",
|
||
to: "b",
|
||
body: "hi",
|
||
errorCode: null,
|
||
errorMessage: null,
|
||
},
|
||
];
|
||
const outbound = [
|
||
{
|
||
sid: "2",
|
||
status: "sent",
|
||
direction: "outbound-api",
|
||
dateCreated: new Date("2024-01-02T00:00:00Z"),
|
||
from: "b",
|
||
to: "a",
|
||
body: "yo",
|
||
errorCode: null,
|
||
errorMessage: null,
|
||
},
|
||
];
|
||
const client = twilioFactory._createClient();
|
||
client.messages.list
|
||
.mockResolvedValueOnce(inbound)
|
||
.mockResolvedValueOnce(outbound);
|
||
|
||
const messages = await index.listRecentMessages(60, 5, client);
|
||
expect(messages[0].sid).toBe("2");
|
||
expect(messages).toHaveLength(2);
|
||
});
|
||
|
||
it("formatMessageLine builds readable string", () => {
|
||
const line = index.formatMessageLine({
|
||
sid: "SID",
|
||
status: "delivered",
|
||
direction: "inbound",
|
||
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
||
from: "a",
|
||
to: "b",
|
||
body: "hello world",
|
||
errorCode: null,
|
||
errorMessage: null,
|
||
});
|
||
expect(line).toContain("SID");
|
||
expect(line).toContain("hello world");
|
||
});
|
||
});
|
||
|
||
describe("sender discovery", () => {
|
||
it("findWhatsappSenderSid prefers explicit env", async () => {
|
||
const client = twilioFactory._createClient();
|
||
const sid = await index.findWhatsappSenderSid(client, "+1555", "SID123");
|
||
expect(sid).toBe("SID123");
|
||
});
|
||
|
||
it("findWhatsappSenderSid lists senders when needed", async () => {
|
||
const client = twilioFactory._createClient();
|
||
client.messaging.v2.channelsSenders.list.mockResolvedValue([
|
||
{ sender_id: withWhatsAppPrefix("+1555"), sid: "S1" },
|
||
]);
|
||
const sid = await index.findWhatsappSenderSid(client, "+1555");
|
||
expect(sid).toBe("S1");
|
||
});
|
||
|
||
it("updateWebhook uses primary update path", async () => {
|
||
const fetched = { webhook: { callback_url: "https://cb" } };
|
||
const client = {
|
||
request: vi.fn().mockResolvedValue({}),
|
||
messaging: {
|
||
v2: {
|
||
channelsSenders: vi.fn(() => ({
|
||
fetch: vi.fn().mockResolvedValue(fetched),
|
||
})),
|
||
},
|
||
v1: { services: vi.fn(() => ({ update: vi.fn(), fetch: vi.fn() })) },
|
||
},
|
||
incomingPhoneNumbers: vi.fn(),
|
||
} as unknown as ReturnType<typeof index.createClient>;
|
||
|
||
await index.updateWebhook(client, "SID", "https://example.com", "POST");
|
||
expect(client.request).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("infra helpers", () => {
|
||
it("handlePortError prints owner details", async () => {
|
||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||
throw new Error("exit");
|
||
}) as () => never);
|
||
vi.spyOn(index, "describePortOwner").mockResolvedValue("proc listening");
|
||
await expect(
|
||
index.handlePortError(new index.PortInUseError(1234), 1234, "Context"),
|
||
).rejects.toThrow("exit");
|
||
expect(exitSpy).toHaveBeenCalled();
|
||
});
|
||
|
||
it("getTailnetHostname prefers DNS then IP", async () => {
|
||
type ExecFn = (
|
||
command: string,
|
||
args?: string[],
|
||
options?: unknown,
|
||
) => Promise<{ stdout: string; stderr: string }>;
|
||
const exec: ExecFn = vi
|
||
.fn()
|
||
.mockResolvedValueOnce({
|
||
stdout: JSON.stringify({ Self: { DNSName: "host.tailnet." } }),
|
||
stderr: "",
|
||
})
|
||
.mockResolvedValueOnce({
|
||
stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.1.2.3"] } }),
|
||
stderr: "",
|
||
});
|
||
const dns = await index.getTailnetHostname(exec);
|
||
expect(dns).toBe("host.tailnet");
|
||
const ip = await index.getTailnetHostname(exec);
|
||
expect(ip).toBe("100.1.2.3");
|
||
});
|
||
|
||
it("ensureGoInstalled installs when missing", async () => {
|
||
const exec = vi
|
||
.fn<
|
||
index.CommandArgs | index.CommandArgsWithOptions,
|
||
Promise<{ stdout: string; stderr: string }>
|
||
>()
|
||
.mockRejectedValueOnce(new Error("missing"))
|
||
.mockResolvedValue({ stdout: "", stderr: "" });
|
||
const prompt = vi.fn<[], Promise<boolean>>().mockResolvedValue(true);
|
||
await index.ensureGoInstalled(exec, prompt);
|
||
expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]);
|
||
});
|
||
|
||
it("ensureTailscaledInstalled installs when missing", async () => {
|
||
const exec = vi
|
||
.fn<
|
||
index.CommandArgs | index.CommandArgsWithOptions,
|
||
Promise<{ stdout: string; stderr: string }>
|
||
>()
|
||
.mockRejectedValueOnce(new Error("missing"))
|
||
.mockResolvedValue({ stdout: "", stderr: "" });
|
||
const prompt = vi.fn<[], Promise<boolean>>().mockResolvedValue(true);
|
||
await index.ensureTailscaledInstalled(exec, prompt);
|
||
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
|
||
});
|
||
|
||
it("ensureFunnel enables funnel when status present", async () => {
|
||
const exec = vi
|
||
.fn<
|
||
index.CommandArgs | index.CommandArgsWithOptions,
|
||
Promise<{ stdout: string; stderr: string }>
|
||
>()
|
||
.mockResolvedValueOnce({
|
||
stdout: JSON.stringify({ Enabled: true }),
|
||
stderr: "",
|
||
})
|
||
.mockResolvedValueOnce({ stdout: "ok", stderr: "" });
|
||
await index.ensureFunnel(8080, exec);
|
||
expect(exec).toHaveBeenCalledTimes(2);
|
||
});
|
||
});
|
||
|
||
describe("twilio helpers", () => {
|
||
it("findIncomingNumberSid and messaging sid helpers", async () => {
|
||
const client = twilioFactory._createClient();
|
||
client.incomingPhoneNumbers.list.mockResolvedValue([
|
||
{ sid: "PN1", messagingServiceSid: "MG1" },
|
||
]);
|
||
const sid = await index.findIncomingNumberSid(client);
|
||
expect(sid).toBe("PN1");
|
||
const msid = await index.findMessagingServiceSid(client);
|
||
expect(msid).toBe("MG1");
|
||
});
|
||
|
||
it("setMessagingServiceWebhook updates service", async () => {
|
||
const updater = { update: vi.fn().mockResolvedValue({}), fetch: vi.fn() };
|
||
const client = twilioFactory._createClient();
|
||
client.messaging.v1.services.mockReturnValue(
|
||
updater as unknown as ReturnType<typeof client.messaging.v1.services>,
|
||
);
|
||
client.incomingPhoneNumbers.list.mockResolvedValue([
|
||
{ messagingServiceSid: "MS1" },
|
||
]);
|
||
const updated = await index.setMessagingServiceWebhook(
|
||
client,
|
||
"https://x",
|
||
"POST",
|
||
);
|
||
expect(updated).toBe(true);
|
||
expect(updater.update).toHaveBeenCalled();
|
||
});
|
||
|
||
it("uniqueBySid and sortByDateDesc de-dupe and order", () => {
|
||
const messages = [
|
||
{ sid: "1", dateCreated: new Date("2023-01-01") },
|
||
{ sid: "1", dateCreated: new Date("2023-01-02") },
|
||
{ sid: "2", dateCreated: new Date("2024-01-01") },
|
||
];
|
||
const unique = index.uniqueBySid(messages);
|
||
expect(unique).toHaveLength(2);
|
||
const sorted = index.sortByDateDesc(unique);
|
||
expect(sorted[0].sid).toBe("2");
|
||
});
|
||
|
||
it("formatTwilioError and logTwilioSendError include details", () => {
|
||
const runtime: index.RuntimeEnv = {
|
||
error: vi.fn(),
|
||
log: vi.fn(),
|
||
exit: ((code: number) => {
|
||
throw new Error(`exit ${code}`);
|
||
}) as (code: number) => never,
|
||
};
|
||
const errString = index.formatTwilioError({
|
||
code: 123,
|
||
status: 400,
|
||
message: "bad",
|
||
moreInfo: "link",
|
||
});
|
||
expect(errString).toContain("123");
|
||
index.logTwilioSendError({ response: { body: { x: 1 } } }, "+1", runtime);
|
||
expect(runtime.error).toHaveBeenCalled();
|
||
});
|
||
|
||
it("logTwilioSendError handles error without response", () => {
|
||
const runtime: index.RuntimeEnv = {
|
||
error: vi.fn(),
|
||
log: vi.fn(),
|
||
exit: ((code: number) => {
|
||
throw new Error(`exit ${code}`);
|
||
}) as (code: number) => never,
|
||
};
|
||
index.logTwilioSendError(new Error("oops"), undefined, runtime);
|
||
expect(runtime.error).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("monitoring", () => {
|
||
it("monitorTwilio polls once and processes inbound", async () => {
|
||
const client = {
|
||
messages: {
|
||
list: vi.fn().mockResolvedValue([
|
||
{
|
||
sid: "m1",
|
||
direction: "inbound",
|
||
dateCreated: new Date(),
|
||
from: "+1",
|
||
to: "+2",
|
||
body: "hi",
|
||
},
|
||
]),
|
||
},
|
||
} as unknown as ReturnType<typeof index.createClient>;
|
||
vi.spyOn(index, "getReplyFromConfig").mockResolvedValue(undefined);
|
||
await index.monitorTwilio(0, 0, client, 1);
|
||
expect(client.messages.list).toHaveBeenCalled();
|
||
});
|
||
|
||
it("ensureFunnel failure path exits via runtime", async () => {
|
||
const runtime: index.RuntimeEnv = {
|
||
error: vi.fn(),
|
||
exit: vi.fn() as unknown as (code: number) => never,
|
||
log: console.log,
|
||
};
|
||
const exec = vi.fn().mockRejectedValue({ stdout: "Funnel is not enabled" });
|
||
await index.ensureFunnel(8080, exec, runtime).catch(() => {});
|
||
expect(runtime.error).toHaveBeenCalled();
|
||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||
});
|
||
|
||
it("monitorWebProvider triggers replies and stops when asked", async () => {
|
||
const replySpy = vi.fn();
|
||
const sendMediaSpy = vi.fn();
|
||
const listenerFactory = vi.fn(
|
||
async (opts: {
|
||
verbose: boolean;
|
||
onMessage: (
|
||
msg: import("./web/inbound.js").WebInboundMessage,
|
||
) => Promise<void>;
|
||
}) => {
|
||
await opts.onMessage({
|
||
body: "hello",
|
||
from: "+1",
|
||
to: "+2",
|
||
id: "id1",
|
||
sendComposing: vi.fn(),
|
||
reply: replySpy,
|
||
sendMedia: sendMediaSpy,
|
||
});
|
||
return { close: vi.fn() };
|
||
},
|
||
);
|
||
const resolver = vi.fn().mockResolvedValue({ text: "auto" });
|
||
await index.monitorWebProvider(false, listenerFactory, false, resolver);
|
||
expect(replySpy).toHaveBeenCalledWith("auto");
|
||
});
|
||
});
|