chore: format to 2-space and bump changelog
This commit is contained in:
parent
a67f4db5e2
commit
e5f677803f
@ -1,6 +1,6 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [Unreleased] 1.0.5
|
## 1.1.0 — 2025-11-25
|
||||||
|
|
||||||
### Pending
|
### Pending
|
||||||
- Web auto-replies now resize/recompress media and honor `inbound.reply.mediaMaxMb` in `~/.warelay/warelay.json` (default 5 MB) to avoid provider/API limits.
|
- Web auto-replies now resize/recompress media and honor `inbound.reply.mediaMaxMb` in `~/.warelay/warelay.json` (default 5 MB) to avoid provider/API limits.
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
"$schema": "https://biomejs.dev/schemas/biome.json",
|
"$schema": "https://biomejs.dev/schemas/biome.json",
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentWidth": 2
|
"indentWidth": 2,
|
||||||
|
"indentStyle": "space"
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@ -3,37 +3,37 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { parseClaudeJson, parseClaudeJsonText } from "./claude.js";
|
import { parseClaudeJson, parseClaudeJsonText } from "./claude.js";
|
||||||
|
|
||||||
describe("claude JSON parsing", () => {
|
describe("claude JSON parsing", () => {
|
||||||
it("extracts text from single JSON object", () => {
|
it("extracts text from single JSON object", () => {
|
||||||
const out = parseClaudeJsonText('{"text":"hello"}');
|
const out = parseClaudeJsonText('{"text":"hello"}');
|
||||||
expect(out).toBe("hello");
|
expect(out).toBe("hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts from newline-delimited JSON", () => {
|
it("extracts from newline-delimited JSON", () => {
|
||||||
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
|
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
|
||||||
expect(out).toBe("there");
|
expect(out).toBe("there");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns undefined on invalid JSON", () => {
|
it("returns undefined on invalid JSON", () => {
|
||||||
expect(parseClaudeJsonText("not json")).toBeUndefined();
|
expect(parseClaudeJsonText("not json")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts text from Claude CLI result field and preserves metadata", () => {
|
it("extracts text from Claude CLI result field and preserves metadata", () => {
|
||||||
const sample = {
|
const sample = {
|
||||||
type: "result",
|
type: "result",
|
||||||
subtype: "success",
|
subtype: "success",
|
||||||
result: "hello from result field",
|
result: "hello from result field",
|
||||||
duration_ms: 1234,
|
duration_ms: 1234,
|
||||||
usage: { server_tool_use: { tool_a: 2 } },
|
usage: { server_tool_use: { tool_a: 2 } },
|
||||||
};
|
};
|
||||||
const parsed = parseClaudeJson(JSON.stringify(sample));
|
const parsed = parseClaudeJson(JSON.stringify(sample));
|
||||||
expect(parsed?.text).toBe("hello from result field");
|
expect(parsed?.text).toBe("hello from result field");
|
||||||
expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 });
|
expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 });
|
||||||
expect(parsed?.valid).toBe(true);
|
expect(parsed?.valid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks invalid Claude JSON as invalid but still attempts text extraction", () => {
|
it("marks invalid Claude JSON as invalid but still attempts text extraction", () => {
|
||||||
const parsed = parseClaudeJson('{"unexpected":1}');
|
const parsed = parseClaudeJson('{"unexpected":1}');
|
||||||
expect(parsed?.valid).toBe(false);
|
expect(parsed?.valid).toBe(false);
|
||||||
expect(parsed?.text).toBeUndefined();
|
expect(parsed?.text).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,159 +4,159 @@ import { z } from "zod";
|
|||||||
// Preferred binary name for Claude CLI invocations.
|
// Preferred binary name for Claude CLI invocations.
|
||||||
export const CLAUDE_BIN = "claude";
|
export const CLAUDE_BIN = "claude";
|
||||||
export const CLAUDE_IDENTITY_PREFIX =
|
export const CLAUDE_IDENTITY_PREFIX =
|
||||||
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present.";
|
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present.";
|
||||||
|
|
||||||
function extractClaudeText(payload: unknown): string | undefined {
|
function extractClaudeText(payload: unknown): string | undefined {
|
||||||
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
||||||
if (payload == null) return undefined;
|
if (payload == null) return undefined;
|
||||||
if (typeof payload === "string") return payload;
|
if (typeof payload === "string") return payload;
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
for (const item of payload) {
|
for (const item of payload) {
|
||||||
const found = extractClaudeText(item);
|
const found = extractClaudeText(item);
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (typeof payload === "object") {
|
if (typeof payload === "object") {
|
||||||
const obj = payload as Record<string, unknown>;
|
const obj = payload as Record<string, unknown>;
|
||||||
if (typeof obj.result === "string") return obj.result;
|
if (typeof obj.result === "string") return obj.result;
|
||||||
if (typeof obj.text === "string") return obj.text;
|
if (typeof obj.text === "string") return obj.text;
|
||||||
if (typeof obj.completion === "string") return obj.completion;
|
if (typeof obj.completion === "string") return obj.completion;
|
||||||
if (typeof obj.output === "string") return obj.output;
|
if (typeof obj.output === "string") return obj.output;
|
||||||
if (obj.message) {
|
if (obj.message) {
|
||||||
const inner = extractClaudeText(obj.message);
|
const inner = extractClaudeText(obj.message);
|
||||||
if (inner) return inner;
|
if (inner) return inner;
|
||||||
}
|
}
|
||||||
if (Array.isArray(obj.messages)) {
|
if (Array.isArray(obj.messages)) {
|
||||||
const inner = extractClaudeText(obj.messages);
|
const inner = extractClaudeText(obj.messages);
|
||||||
if (inner) return inner;
|
if (inner) return inner;
|
||||||
}
|
}
|
||||||
if (Array.isArray(obj.content)) {
|
if (Array.isArray(obj.content)) {
|
||||||
for (const block of obj.content) {
|
for (const block of obj.content) {
|
||||||
if (
|
if (
|
||||||
block &&
|
block &&
|
||||||
typeof block === "object" &&
|
typeof block === "object" &&
|
||||||
(block as { type?: string }).type === "text" &&
|
(block as { type?: string }).type === "text" &&
|
||||||
typeof (block as { text?: unknown }).text === "string"
|
typeof (block as { text?: unknown }).text === "string"
|
||||||
) {
|
) {
|
||||||
return (block as { text: string }).text;
|
return (block as { text: string }).text;
|
||||||
}
|
}
|
||||||
const inner = extractClaudeText(block);
|
const inner = extractClaudeText(block);
|
||||||
if (inner) return inner;
|
if (inner) return inner;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClaudeJsonParseResult = {
|
export type ClaudeJsonParseResult = {
|
||||||
text?: string;
|
text?: string;
|
||||||
parsed: unknown;
|
parsed: unknown;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ClaudeJsonSchema = z
|
const ClaudeJsonSchema = z
|
||||||
.object({
|
.object({
|
||||||
type: z.string().optional(),
|
type: z.string().optional(),
|
||||||
subtype: z.string().optional(),
|
subtype: z.string().optional(),
|
||||||
is_error: z.boolean().optional(),
|
is_error: z.boolean().optional(),
|
||||||
result: z.string().optional(),
|
result: z.string().optional(),
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
completion: z.string().optional(),
|
completion: z.string().optional(),
|
||||||
output: z.string().optional(),
|
output: z.string().optional(),
|
||||||
message: z.any().optional(),
|
message: z.any().optional(),
|
||||||
messages: z.any().optional(),
|
messages: z.any().optional(),
|
||||||
content: z.any().optional(),
|
content: z.any().optional(),
|
||||||
duration_ms: z.number().optional(),
|
duration_ms: z.number().optional(),
|
||||||
duration_api_ms: z.number().optional(),
|
duration_api_ms: z.number().optional(),
|
||||||
num_turns: z.number().optional(),
|
num_turns: z.number().optional(),
|
||||||
session_id: z.string().optional(),
|
session_id: z.string().optional(),
|
||||||
total_cost_usd: z.number().optional(),
|
total_cost_usd: z.number().optional(),
|
||||||
usage: z.record(z.string(), z.any()).optional(),
|
usage: z.record(z.string(), z.any()).optional(),
|
||||||
modelUsage: z.record(z.string(), z.any()).optional(),
|
modelUsage: z.record(z.string(), z.any()).optional(),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
.refine(
|
.refine(
|
||||||
(obj) =>
|
(obj) =>
|
||||||
typeof obj.result === "string" ||
|
typeof obj.result === "string" ||
|
||||||
typeof obj.text === "string" ||
|
typeof obj.text === "string" ||
|
||||||
typeof obj.completion === "string" ||
|
typeof obj.completion === "string" ||
|
||||||
typeof obj.output === "string" ||
|
typeof obj.output === "string" ||
|
||||||
obj.message !== undefined ||
|
obj.message !== undefined ||
|
||||||
obj.messages !== undefined ||
|
obj.messages !== undefined ||
|
||||||
obj.content !== undefined,
|
obj.content !== undefined,
|
||||||
{ message: "Not a Claude JSON payload" },
|
{ message: "Not a Claude JSON payload" },
|
||||||
);
|
);
|
||||||
|
|
||||||
type ClaudeSafeParse = ReturnType<typeof ClaudeJsonSchema.safeParse>;
|
type ClaudeSafeParse = ReturnType<typeof ClaudeJsonSchema.safeParse>;
|
||||||
|
|
||||||
export function parseClaudeJson(
|
export function parseClaudeJson(
|
||||||
raw: string,
|
raw: string,
|
||||||
): ClaudeJsonParseResult | undefined {
|
): ClaudeJsonParseResult | undefined {
|
||||||
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
|
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
|
||||||
let firstParsed: unknown;
|
let firstParsed: unknown;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
raw,
|
raw,
|
||||||
...raw
|
...raw
|
||||||
.split(/\n+/)
|
.split(/\n+/)
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
];
|
];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(candidate);
|
const parsed = JSON.parse(candidate);
|
||||||
if (firstParsed === undefined) firstParsed = parsed;
|
if (firstParsed === undefined) firstParsed = parsed;
|
||||||
let validation: ClaudeSafeParse | { success: false };
|
let validation: ClaudeSafeParse | { success: false };
|
||||||
try {
|
try {
|
||||||
validation = ClaudeJsonSchema.safeParse(parsed);
|
validation = ClaudeJsonSchema.safeParse(parsed);
|
||||||
} catch {
|
} catch {
|
||||||
validation = { success: false } as const;
|
validation = { success: false } as const;
|
||||||
}
|
}
|
||||||
const validated = validation.success ? validation.data : parsed;
|
const validated = validation.success ? validation.data : parsed;
|
||||||
const isLikelyClaude =
|
const isLikelyClaude =
|
||||||
typeof validated === "object" &&
|
typeof validated === "object" &&
|
||||||
validated !== null &&
|
validated !== null &&
|
||||||
("result" in validated ||
|
("result" in validated ||
|
||||||
"text" in validated ||
|
"text" in validated ||
|
||||||
"completion" in validated ||
|
"completion" in validated ||
|
||||||
"output" in validated);
|
"output" in validated);
|
||||||
const text = extractClaudeText(validated);
|
const text = extractClaudeText(validated);
|
||||||
if (text)
|
if (text)
|
||||||
return {
|
return {
|
||||||
parsed: validated,
|
parsed: validated,
|
||||||
text,
|
text,
|
||||||
// Treat parse as valid when schema passes or we still see Claude-like shape.
|
// Treat parse as valid when schema passes or we still see Claude-like shape.
|
||||||
valid: Boolean(validation?.success || isLikelyClaude),
|
valid: Boolean(validation?.success || isLikelyClaude),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors; try next candidate
|
// ignore parse errors; try next candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (firstParsed !== undefined) {
|
if (firstParsed !== undefined) {
|
||||||
let validation: ClaudeSafeParse | { success: false };
|
let validation: ClaudeSafeParse | { success: false };
|
||||||
try {
|
try {
|
||||||
validation = ClaudeJsonSchema.safeParse(firstParsed);
|
validation = ClaudeJsonSchema.safeParse(firstParsed);
|
||||||
} catch {
|
} catch {
|
||||||
validation = { success: false } as const;
|
validation = { success: false } as const;
|
||||||
}
|
}
|
||||||
const validated = validation.success ? validation.data : firstParsed;
|
const validated = validation.success ? validation.data : firstParsed;
|
||||||
const isLikelyClaude =
|
const isLikelyClaude =
|
||||||
typeof validated === "object" &&
|
typeof validated === "object" &&
|
||||||
validated !== null &&
|
validated !== null &&
|
||||||
("result" in validated ||
|
("result" in validated ||
|
||||||
"text" in validated ||
|
"text" in validated ||
|
||||||
"completion" in validated ||
|
"completion" in validated ||
|
||||||
"output" in validated);
|
"output" in validated);
|
||||||
return {
|
return {
|
||||||
parsed: validated,
|
parsed: validated,
|
||||||
text: extractClaudeText(validated),
|
text: extractClaudeText(validated),
|
||||||
valid: Boolean(validation?.success || isLikelyClaude),
|
valid: Boolean(validation?.success || isLikelyClaude),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseClaudeJsonText(raw: string): string | undefined {
|
export function parseClaudeJsonText(raw: string): string | undefined {
|
||||||
const parsed = parseClaudeJson(raw);
|
const parsed = parseClaudeJson(raw);
|
||||||
return parsed?.text;
|
return parsed?.text;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,24 +1,24 @@
|
|||||||
export type MsgContext = {
|
export type MsgContext = {
|
||||||
Body?: string;
|
Body?: string;
|
||||||
From?: string;
|
From?: string;
|
||||||
To?: string;
|
To?: string;
|
||||||
MessageSid?: string;
|
MessageSid?: string;
|
||||||
MediaPath?: string;
|
MediaPath?: string;
|
||||||
MediaUrl?: string;
|
MediaUrl?: string;
|
||||||
MediaType?: string;
|
MediaType?: string;
|
||||||
Transcript?: string;
|
Transcript?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TemplateContext = MsgContext & {
|
export type TemplateContext = MsgContext & {
|
||||||
BodyStripped?: string;
|
BodyStripped?: string;
|
||||||
SessionId?: string;
|
SessionId?: string;
|
||||||
IsNewSession?: string;
|
IsNewSession?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Simple {{Placeholder}} interpolation using inbound message context.
|
// Simple {{Placeholder}} interpolation using inbound message context.
|
||||||
export function applyTemplate(str: string, ctx: TemplateContext) {
|
export function applyTemplate(str: string, ctx: TemplateContext) {
|
||||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||||
const value = (ctx as Record<string, unknown>)[key];
|
const value = (ctx as Record<string, unknown>)[key];
|
||||||
return value == null ? "" : String(value);
|
return value == null ? "" : String(value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
152
src/cli/deps.ts
152
src/cli/deps.ts
@ -6,9 +6,9 @@ import { ensurePortAvailable, handlePortError } from "../infra/ports.js";
|
|||||||
import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js";
|
import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js";
|
||||||
import { ensureMediaHosted } from "../media/host.js";
|
import { ensureMediaHosted } from "../media/host.js";
|
||||||
import {
|
import {
|
||||||
logWebSelfId,
|
logWebSelfId,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
sendMessageWeb,
|
sendMessageWeb,
|
||||||
} from "../providers/web/index.js";
|
} from "../providers/web/index.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { createClient } from "../twilio/client.js";
|
import { createClient } from "../twilio/client.js";
|
||||||
@ -22,89 +22,89 @@ import { updateWebhook } from "../webhook/update.js";
|
|||||||
import { waitForever } from "./wait.js";
|
import { waitForever } from "./wait.js";
|
||||||
|
|
||||||
export type CliDeps = {
|
export type CliDeps = {
|
||||||
sendMessage: typeof sendMessage;
|
sendMessage: typeof sendMessage;
|
||||||
sendMessageWeb: typeof sendMessageWeb;
|
sendMessageWeb: typeof sendMessageWeb;
|
||||||
waitForFinalStatus: typeof waitForFinalStatus;
|
waitForFinalStatus: typeof waitForFinalStatus;
|
||||||
assertProvider: typeof assertProvider;
|
assertProvider: typeof assertProvider;
|
||||||
createClient?: typeof createClient;
|
createClient?: typeof createClient;
|
||||||
monitorTwilio: typeof monitorTwilio;
|
monitorTwilio: typeof monitorTwilio;
|
||||||
listRecentMessages: typeof listRecentMessages;
|
listRecentMessages: typeof listRecentMessages;
|
||||||
ensurePortAvailable: typeof ensurePortAvailable;
|
ensurePortAvailable: typeof ensurePortAvailable;
|
||||||
startWebhook: typeof startWebhook;
|
startWebhook: typeof startWebhook;
|
||||||
waitForever: typeof waitForever;
|
waitForever: typeof waitForever;
|
||||||
ensureBinary: typeof ensureBinary;
|
ensureBinary: typeof ensureBinary;
|
||||||
ensureFunnel: typeof ensureFunnel;
|
ensureFunnel: typeof ensureFunnel;
|
||||||
getTailnetHostname: typeof getTailnetHostname;
|
getTailnetHostname: typeof getTailnetHostname;
|
||||||
readEnv: typeof readEnv;
|
readEnv: typeof readEnv;
|
||||||
findWhatsappSenderSid: typeof findWhatsappSenderSid;
|
findWhatsappSenderSid: typeof findWhatsappSenderSid;
|
||||||
updateWebhook: typeof updateWebhook;
|
updateWebhook: typeof updateWebhook;
|
||||||
handlePortError: typeof handlePortError;
|
handlePortError: typeof handlePortError;
|
||||||
monitorWebProvider: typeof monitorWebProvider;
|
monitorWebProvider: typeof monitorWebProvider;
|
||||||
resolveTwilioMediaUrl: (
|
resolveTwilioMediaUrl: (
|
||||||
source: string,
|
source: string,
|
||||||
opts: { serveMedia: boolean; runtime: RuntimeEnv },
|
opts: { serveMedia: boolean; runtime: RuntimeEnv },
|
||||||
) => Promise<string>;
|
) => Promise<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function monitorTwilio(
|
export async function monitorTwilio(
|
||||||
intervalSeconds: number,
|
intervalSeconds: number,
|
||||||
lookbackMinutes: number,
|
lookbackMinutes: number,
|
||||||
clientOverride?: ReturnType<typeof createClient>,
|
clientOverride?: ReturnType<typeof createClient>,
|
||||||
maxIterations = Infinity,
|
maxIterations = Infinity,
|
||||||
) {
|
) {
|
||||||
// Adapter that wires default deps/runtime for the Twilio monitor loop.
|
// Adapter that wires default deps/runtime for the Twilio monitor loop.
|
||||||
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
|
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
|
||||||
client: clientOverride,
|
client: clientOverride,
|
||||||
maxIterations,
|
maxIterations,
|
||||||
deps: {
|
deps: {
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
readEnv,
|
readEnv,
|
||||||
createClient,
|
createClient,
|
||||||
sleep,
|
sleep,
|
||||||
},
|
},
|
||||||
runtime: defaultRuntime,
|
runtime: defaultRuntime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultDeps(): CliDeps {
|
export function createDefaultDeps(): CliDeps {
|
||||||
// Default dependency bundle used by CLI commands and tests.
|
// Default dependency bundle used by CLI commands and tests.
|
||||||
return {
|
return {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendMessageWeb,
|
sendMessageWeb,
|
||||||
waitForFinalStatus,
|
waitForFinalStatus,
|
||||||
assertProvider,
|
assertProvider,
|
||||||
createClient,
|
createClient,
|
||||||
monitorTwilio,
|
monitorTwilio,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
ensurePortAvailable,
|
ensurePortAvailable,
|
||||||
startWebhook,
|
startWebhook,
|
||||||
waitForever,
|
waitForever,
|
||||||
ensureBinary,
|
ensureBinary,
|
||||||
ensureFunnel,
|
ensureFunnel,
|
||||||
getTailnetHostname,
|
getTailnetHostname,
|
||||||
readEnv,
|
readEnv,
|
||||||
findWhatsappSenderSid,
|
findWhatsappSenderSid,
|
||||||
updateWebhook,
|
updateWebhook,
|
||||||
handlePortError,
|
handlePortError,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
|
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
|
||||||
if (/^https?:\/\//i.test(source)) return source;
|
if (/^https?:\/\//i.test(source)) return source;
|
||||||
const hosted = await ensureMediaHosted(source, {
|
const hosted = await ensureMediaHosted(source, {
|
||||||
startServer: serveMedia,
|
startServer: serveMedia,
|
||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
return hosted.url;
|
return hosted.url;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) {
|
export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) {
|
||||||
// Log the configured Twilio sender for clarity in CLI output.
|
// Log the configured Twilio sender for clarity in CLI output.
|
||||||
const env = readEnv(runtime);
|
const env = readEnv(runtime);
|
||||||
runtime.log(
|
runtime.log(
|
||||||
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
|
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { logWebSelfId };
|
export { logWebSelfId };
|
||||||
|
|||||||
@ -14,11 +14,11 @@ const waitForever = vi.fn();
|
|||||||
const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay");
|
const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay");
|
||||||
|
|
||||||
const runtime = {
|
const runtime = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("../commands/send.js", () => ({ sendCommand }));
|
vi.mock("../commands/send.js", () => ({ sendCommand }));
|
||||||
@ -27,63 +27,63 @@ vi.mock("../commands/webhook.js", () => ({ webhookCommand }));
|
|||||||
vi.mock("../env.js", () => ({ ensureTwilioEnv }));
|
vi.mock("../env.js", () => ({ ensureTwilioEnv }));
|
||||||
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
||||||
vi.mock("../provider-web.js", () => ({
|
vi.mock("../provider-web.js", () => ({
|
||||||
loginWeb,
|
loginWeb,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
pickProvider,
|
pickProvider,
|
||||||
}));
|
}));
|
||||||
vi.mock("./deps.js", () => ({
|
vi.mock("./deps.js", () => ({
|
||||||
createDefaultDeps: () => ({ waitForever }),
|
createDefaultDeps: () => ({ waitForever }),
|
||||||
logTwilioFrom,
|
logTwilioFrom,
|
||||||
logWebSelfId,
|
logWebSelfId,
|
||||||
monitorTwilio,
|
monitorTwilio,
|
||||||
}));
|
}));
|
||||||
vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux }));
|
vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux }));
|
||||||
|
|
||||||
const { buildProgram } = await import("./program.js");
|
const { buildProgram } = await import("./program.js");
|
||||||
|
|
||||||
describe("cli program", () => {
|
describe("cli program", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs send with required options", async () => {
|
it("runs send with required options", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
|
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
|
||||||
from: "user",
|
from: "user",
|
||||||
});
|
});
|
||||||
expect(sendCommand).toHaveBeenCalled();
|
expect(sendCommand).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid relay provider", async () => {
|
it("rejects invalid relay provider", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await expect(
|
await expect(
|
||||||
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
|
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
|
||||||
).rejects.toThrow("exit");
|
).rejects.toThrow("exit");
|
||||||
expect(runtime.error).toHaveBeenCalledWith(
|
expect(runtime.error).toHaveBeenCalledWith(
|
||||||
"--provider must be auto, web, or twilio",
|
"--provider must be auto, web, or twilio",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to twilio when web relay fails", async () => {
|
it("falls back to twilio when web relay fails", async () => {
|
||||||
pickProvider.mockResolvedValue("web");
|
pickProvider.mockResolvedValue("web");
|
||||||
monitorWebProvider.mockRejectedValue(new Error("no web"));
|
monitorWebProvider.mockRejectedValue(new Error("no web"));
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(
|
await program.parseAsync(
|
||||||
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
|
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
|
||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
);
|
);
|
||||||
expect(logWebSelfId).toHaveBeenCalled();
|
expect(logWebSelfId).toHaveBeenCalled();
|
||||||
expect(ensureTwilioEnv).toHaveBeenCalled();
|
expect(ensureTwilioEnv).toHaveBeenCalled();
|
||||||
expect(monitorTwilio).toHaveBeenCalledWith(2, 1);
|
expect(monitorTwilio).toHaveBeenCalledWith(2, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs relay tmux attach command", async () => {
|
it("runs relay tmux attach command", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
|
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
|
||||||
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
||||||
"pnpm warelay relay --verbose",
|
"pnpm warelay relay --verbose",
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,327 +10,327 @@ import { defaultRuntime } from "../runtime.js";
|
|||||||
import type { Provider } from "../utils.js";
|
import type { Provider } from "../utils.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import {
|
import {
|
||||||
createDefaultDeps,
|
createDefaultDeps,
|
||||||
logTwilioFrom,
|
logTwilioFrom,
|
||||||
logWebSelfId,
|
logWebSelfId,
|
||||||
monitorTwilio,
|
monitorTwilio,
|
||||||
} from "./deps.js";
|
} from "./deps.js";
|
||||||
import { spawnRelayTmux } from "./relay_tmux.js";
|
import { spawnRelayTmux } from "./relay_tmux.js";
|
||||||
|
|
||||||
export function buildProgram() {
|
export function buildProgram() {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
const PROGRAM_VERSION = VERSION;
|
const PROGRAM_VERSION = VERSION;
|
||||||
const TAGLINE =
|
const TAGLINE =
|
||||||
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
|
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("warelay")
|
.name("warelay")
|
||||||
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
|
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
|
||||||
.version(PROGRAM_VERSION);
|
.version(PROGRAM_VERSION);
|
||||||
|
|
||||||
const formatIntroLine = (version: string, rich = true) => {
|
const formatIntroLine = (version: string, rich = true) => {
|
||||||
const base = `📡 warelay ${version} — ${TAGLINE}`;
|
const base = `📡 warelay ${version} — ${TAGLINE}`;
|
||||||
return rich && chalk.level > 0
|
return rich && chalk.level > 0
|
||||||
? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}`
|
? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}`
|
||||||
: base;
|
: base;
|
||||||
};
|
};
|
||||||
|
|
||||||
program.configureHelp({
|
program.configureHelp({
|
||||||
optionTerm: (option) => chalk.yellow(option.flags),
|
optionTerm: (option) => chalk.yellow(option.flags),
|
||||||
subcommandTerm: (cmd) => chalk.green(cmd.name()),
|
subcommandTerm: (cmd) => chalk.green(cmd.name()),
|
||||||
});
|
});
|
||||||
|
|
||||||
program.configureOutput({
|
program.configureOutput({
|
||||||
writeOut: (str) => {
|
writeOut: (str) => {
|
||||||
const colored = str
|
const colored = str
|
||||||
.replace(/^Usage:/gm, chalk.bold.cyan("Usage:"))
|
.replace(/^Usage:/gm, chalk.bold.cyan("Usage:"))
|
||||||
.replace(/^Options:/gm, chalk.bold.cyan("Options:"))
|
.replace(/^Options:/gm, chalk.bold.cyan("Options:"))
|
||||||
.replace(/^Commands:/gm, chalk.bold.cyan("Commands:"));
|
.replace(/^Commands:/gm, chalk.bold.cyan("Commands:"));
|
||||||
process.stdout.write(colored);
|
process.stdout.write(colored);
|
||||||
},
|
},
|
||||||
writeErr: (str) => process.stderr.write(str),
|
writeErr: (str) => process.stderr.write(str),
|
||||||
outputError: (str, write) => write(chalk.red(str)),
|
outputError: (str, write) => write(chalk.red(str)),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (process.argv.includes("-V") || process.argv.includes("--version")) {
|
if (process.argv.includes("-V") || process.argv.includes("--version")) {
|
||||||
console.log(formatIntroLine(PROGRAM_VERSION));
|
console.log(formatIntroLine(PROGRAM_VERSION));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
||||||
const examples = [
|
const examples = [
|
||||||
[
|
[
|
||||||
"warelay login --verbose",
|
"warelay login --verbose",
|
||||||
"Link personal WhatsApp Web and show QR + connection logs.",
|
"Link personal WhatsApp Web and show QR + connection logs.",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'warelay send --to +15551234567 --message "Hi" --provider web --json',
|
'warelay send --to +15551234567 --message "Hi" --provider web --json',
|
||||||
"Send via your web session and print JSON result.",
|
"Send via your web session and print JSON result.",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"warelay relay --provider auto --interval 5 --lookback 15 --verbose",
|
"warelay relay --provider auto --interval 5 --lookback 15 --verbose",
|
||||||
"Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.",
|
"Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose",
|
"warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose",
|
||||||
"Start webhook + Tailscale Funnel and update Twilio callbacks.",
|
"Start webhook + Tailscale Funnel and update Twilio callbacks.",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"warelay status --limit 10 --lookback 60 --json",
|
"warelay status --limit 10 --lookback 60 --json",
|
||||||
"Show last 10 messages from the past hour as JSON.",
|
"Show last 10 messages from the past hour as JSON.",
|
||||||
],
|
],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const fmtExamples = examples
|
const fmtExamples = examples
|
||||||
.map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`)
|
.map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
program.addHelpText(
|
program.addHelpText(
|
||||||
"afterAll",
|
"afterAll",
|
||||||
`\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`,
|
`\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("login")
|
.command("login")
|
||||||
.description("Link your personal WhatsApp via QR (web provider)")
|
.description("Link your personal WhatsApp via QR (web provider)")
|
||||||
.option("--verbose", "Verbose connection logs", false)
|
.option("--verbose", "Verbose connection logs", false)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
try {
|
try {
|
||||||
await loginWeb(Boolean(opts.verbose));
|
await loginWeb(Boolean(opts.verbose));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("send")
|
.command("send")
|
||||||
.description("Send a WhatsApp message")
|
.description("Send a WhatsApp message")
|
||||||
.requiredOption(
|
.requiredOption(
|
||||||
"-t, --to <number>",
|
"-t, --to <number>",
|
||||||
"Recipient number in E.164 (e.g. +15551234567)",
|
"Recipient number in E.164 (e.g. +15551234567)",
|
||||||
)
|
)
|
||||||
.requiredOption("-m, --message <text>", "Message body")
|
.requiredOption("-m, --message <text>", "Message body")
|
||||||
.option(
|
.option(
|
||||||
"--media <path-or-url>",
|
"--media <path-or-url>",
|
||||||
"Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
|
"Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--serve-media",
|
"--serve-media",
|
||||||
"For Twilio: start a temporary media server if webhook is not running",
|
"For Twilio: start a temporary media server if webhook is not running",
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"-w, --wait <seconds>",
|
"-w, --wait <seconds>",
|
||||||
"Wait for delivery status (0 to skip)",
|
"Wait for delivery status (0 to skip)",
|
||||||
"20",
|
"20",
|
||||||
)
|
)
|
||||||
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
|
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
|
||||||
.option("--provider <provider>", "Provider: twilio | web", "twilio")
|
.option("--provider <provider>", "Provider: twilio | web", "twilio")
|
||||||
.option("--dry-run", "Print payload and skip sending", false)
|
.option("--dry-run", "Print payload and skip sending", false)
|
||||||
.option("--json", "Output result as JSON", false)
|
.option("--json", "Output result as JSON", false)
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option("--verbose", "Verbose logging", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
Examples:
|
Examples:
|
||||||
warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default)
|
warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default)
|
||||||
warelay send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget
|
warelay send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget
|
||||||
warelay send --to +15551234567 --message "Hi" --dry-run # print payload only
|
warelay send --to +15551234567 --message "Hi" --dry-run # print payload only
|
||||||
warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`,
|
warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const deps = createDefaultDeps();
|
const deps = createDefaultDeps();
|
||||||
try {
|
try {
|
||||||
await sendCommand(opts, deps, defaultRuntime);
|
await sendCommand(opts, deps, defaultRuntime);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(String(err));
|
defaultRuntime.error(String(err));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("relay")
|
.command("relay")
|
||||||
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
||||||
.option("--provider <provider>", "auto | web | twilio", "auto")
|
.option("--provider <provider>", "auto | web | twilio", "auto")
|
||||||
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
|
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
|
||||||
.option(
|
.option(
|
||||||
"-l, --lookback <minutes>",
|
"-l, --lookback <minutes>",
|
||||||
"Initial lookback window for twilio mode",
|
"Initial lookback window for twilio mode",
|
||||||
"5",
|
"5",
|
||||||
)
|
)
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option("--verbose", "Verbose logging", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
Examples:
|
Examples:
|
||||||
warelay relay # auto: web if logged-in, else twilio poll
|
warelay relay # auto: web if logged-in, else twilio poll
|
||||||
warelay relay --provider web # force personal web session
|
warelay relay --provider web # force personal web session
|
||||||
warelay relay --provider twilio # force twilio poll
|
warelay relay --provider twilio # force twilio poll
|
||||||
warelay relay --provider twilio --interval 2 --lookback 30
|
warelay relay --provider twilio --interval 2 --lookback 30
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const providerPref = String(opts.provider ?? "auto");
|
const providerPref = String(opts.provider ?? "auto");
|
||||||
if (!["auto", "web", "twilio"].includes(providerPref)) {
|
if (!["auto", "web", "twilio"].includes(providerPref)) {
|
||||||
defaultRuntime.error("--provider must be auto, web, or twilio");
|
defaultRuntime.error("--provider must be auto, web, or twilio");
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
const intervalSeconds = Number.parseInt(opts.interval, 10);
|
const intervalSeconds = Number.parseInt(opts.interval, 10);
|
||||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||||
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
|
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
|
||||||
defaultRuntime.error("Interval must be a positive integer");
|
defaultRuntime.error("Interval must be a positive integer");
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) {
|
if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) {
|
||||||
defaultRuntime.error("Lookback must be >= 0 minutes");
|
defaultRuntime.error("Lookback must be >= 0 minutes");
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = await pickProvider(providerPref as Provider | "auto");
|
const provider = await pickProvider(providerPref as Provider | "auto");
|
||||||
|
|
||||||
if (provider === "web") {
|
if (provider === "web") {
|
||||||
logWebSelfId(defaultRuntime, true);
|
logWebSelfId(defaultRuntime, true);
|
||||||
try {
|
try {
|
||||||
await monitorWebProvider(Boolean(opts.verbose));
|
await monitorWebProvider(Boolean(opts.verbose));
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (providerPref === "auto") {
|
if (providerPref === "auto") {
|
||||||
defaultRuntime.error(
|
defaultRuntime.error(
|
||||||
warn("Web session unavailable; falling back to twilio."),
|
warn("Web session unavailable; falling back to twilio."),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
defaultRuntime.error(danger(`Web relay failed: ${String(err)}`));
|
defaultRuntime.error(danger(`Web relay failed: ${String(err)}`));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureTwilioEnv();
|
ensureTwilioEnv();
|
||||||
logTwilioFrom();
|
logTwilioFrom();
|
||||||
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("status")
|
.command("status")
|
||||||
.description("Show recent WhatsApp messages (sent and received)")
|
.description("Show recent WhatsApp messages (sent and received)")
|
||||||
.option("-l, --limit <count>", "Number of messages to show", "20")
|
.option("-l, --limit <count>", "Number of messages to show", "20")
|
||||||
.option("-b, --lookback <minutes>", "How far back to fetch messages", "240")
|
.option("-b, --lookback <minutes>", "How far back to fetch messages", "240")
|
||||||
.option("--json", "Output JSON instead of text", false)
|
.option("--json", "Output JSON instead of text", false)
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option("--verbose", "Verbose logging", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
Examples:
|
Examples:
|
||||||
warelay status # last 20 msgs in past 4h
|
warelay status # last 20 msgs in past 4h
|
||||||
warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m
|
warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m
|
||||||
warelay status --json --limit 50 # machine-readable output`,
|
warelay status --json --limit 50 # machine-readable output`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const deps = createDefaultDeps();
|
const deps = createDefaultDeps();
|
||||||
try {
|
try {
|
||||||
await statusCommand(opts, deps, defaultRuntime);
|
await statusCommand(opts, deps, defaultRuntime);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(String(err));
|
defaultRuntime.error(String(err));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("webhook")
|
.command("webhook")
|
||||||
.description(
|
.description(
|
||||||
"Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.",
|
"Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.",
|
||||||
)
|
)
|
||||||
.option("-p, --port <port>", "Port to listen on", "42873")
|
.option("-p, --port <port>", "Port to listen on", "42873")
|
||||||
.option("-r, --reply <text>", "Optional auto-reply text")
|
.option("-r, --reply <text>", "Optional auto-reply text")
|
||||||
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
|
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
|
||||||
.option(
|
.option(
|
||||||
"--ingress <mode>",
|
"--ingress <mode>",
|
||||||
"Ingress: tailscale (funnel + Twilio update) | none (local only)",
|
"Ingress: tailscale (funnel + Twilio update) | none (local only)",
|
||||||
"tailscale",
|
"tailscale",
|
||||||
)
|
)
|
||||||
.option("--verbose", "Log inbound and auto-replies", false)
|
.option("--verbose", "Log inbound and auto-replies", false)
|
||||||
.option("-y, --yes", "Auto-confirm prompts when possible", false)
|
.option("-y, --yes", "Auto-confirm prompts when possible", false)
|
||||||
.option("--dry-run", "Print planned actions without starting server", false)
|
.option("--dry-run", "Print planned actions without starting server", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
Examples:
|
Examples:
|
||||||
warelay webhook # ingress=tailscale (funnel + Twilio update)
|
warelay webhook # ingress=tailscale (funnel + Twilio update)
|
||||||
warelay webhook --ingress none # local-only server (no funnel / no Twilio update)
|
warelay webhook --ingress none # local-only server (no funnel / no Twilio update)
|
||||||
warelay webhook --port 45000 # pick a high, less-colliding port
|
warelay webhook --port 45000 # pick a high, less-colliding port
|
||||||
warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file`,
|
warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file`,
|
||||||
)
|
)
|
||||||
// istanbul ignore next
|
// istanbul ignore next
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
setYes(Boolean(opts.yes));
|
setYes(Boolean(opts.yes));
|
||||||
const deps = createDefaultDeps();
|
const deps = createDefaultDeps();
|
||||||
try {
|
try {
|
||||||
const server = await webhookCommand(opts, deps, defaultRuntime);
|
const server = await webhookCommand(opts, deps, defaultRuntime);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
info("Webhook dry-run complete; no server started."),
|
info("Webhook dry-run complete; no server started."),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
console.log("\n👋 Webhook stopped");
|
console.log("\n👋 Webhook stopped");
|
||||||
defaultRuntime.exit(0);
|
defaultRuntime.exit(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await deps.waitForever();
|
await deps.waitForever();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(String(err));
|
defaultRuntime.error(String(err));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("relay:tmux")
|
.command("relay:tmux")
|
||||||
.description(
|
.description(
|
||||||
"Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach",
|
"Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach",
|
||||||
)
|
)
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
try {
|
try {
|
||||||
const session = await spawnRelayTmux(
|
const session = await spawnRelayTmux(
|
||||||
"pnpm warelay relay --verbose",
|
"pnpm warelay relay --verbose",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
info(
|
info(
|
||||||
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`,
|
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(
|
defaultRuntime.error(
|
||||||
danger(`Failed to start relay tmux session: ${String(err)}`),
|
danger(`Failed to start relay tmux session: ${String(err)}`),
|
||||||
);
|
);
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("relay:tmux:attach")
|
.command("relay:tmux:attach")
|
||||||
.description(
|
.description(
|
||||||
"Attach to the existing warelay-relay tmux session (no restart)",
|
"Attach to the existing warelay-relay tmux session (no restart)",
|
||||||
)
|
)
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
try {
|
try {
|
||||||
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
|
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
|
||||||
defaultRuntime.log(info("Attached to warelay-relay session."));
|
defaultRuntime.log(info("Attached to warelay-relay session."));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(
|
defaultRuntime.error(
|
||||||
danger(`Failed to attach to warelay-relay: ${String(err)}`),
|
danger(`Failed to attach to warelay-relay: ${String(err)}`),
|
||||||
);
|
);
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,47 +3,47 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { isYes, setVerbose, setYes } from "../globals.js";
|
import { isYes, setVerbose, setYes } from "../globals.js";
|
||||||
|
|
||||||
vi.mock("node:readline/promises", () => {
|
vi.mock("node:readline/promises", () => {
|
||||||
const question = vi.fn<[], Promise<string>>();
|
const question = vi.fn<[], Promise<string>>();
|
||||||
const close = vi.fn();
|
const close = vi.fn();
|
||||||
const createInterface = vi.fn(() => ({ question, close }));
|
const createInterface = vi.fn(() => ({ question, close }));
|
||||||
return { default: { createInterface } };
|
return { default: { createInterface } };
|
||||||
});
|
});
|
||||||
|
|
||||||
type ReadlineMock = {
|
type ReadlineMock = {
|
||||||
default: {
|
default: {
|
||||||
createInterface: () => {
|
createInterface: () => {
|
||||||
question: ReturnType<typeof vi.fn<[], Promise<string>>>;
|
question: ReturnType<typeof vi.fn<[], Promise<string>>>;
|
||||||
close: ReturnType<typeof vi.fn>;
|
close: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const { promptYesNo } = await import("./prompt.js");
|
const { promptYesNo } = await import("./prompt.js");
|
||||||
const readline = (await import("node:readline/promises")) as ReadlineMock;
|
const readline = (await import("node:readline/promises")) as ReadlineMock;
|
||||||
|
|
||||||
describe("promptYesNo", () => {
|
describe("promptYesNo", () => {
|
||||||
it("returns true when global --yes is set", async () => {
|
it("returns true when global --yes is set", async () => {
|
||||||
setYes(true);
|
setYes(true);
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
const result = await promptYesNo("Continue?");
|
const result = await promptYesNo("Continue?");
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(isYes()).toBe(true);
|
expect(isYes()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("asks the question and respects default", async () => {
|
it("asks the question and respects default", async () => {
|
||||||
setYes(false);
|
setYes(false);
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
const { question: questionMock } = readline.default.createInterface();
|
const { question: questionMock } = readline.default.createInterface();
|
||||||
questionMock.mockResolvedValueOnce("");
|
questionMock.mockResolvedValueOnce("");
|
||||||
const resultDefaultYes = await promptYesNo("Continue?", true);
|
const resultDefaultYes = await promptYesNo("Continue?", true);
|
||||||
expect(resultDefaultYes).toBe(true);
|
expect(resultDefaultYes).toBe(true);
|
||||||
|
|
||||||
questionMock.mockResolvedValueOnce("n");
|
questionMock.mockResolvedValueOnce("n");
|
||||||
const resultNo = await promptYesNo("Continue?", true);
|
const resultNo = await promptYesNo("Continue?", true);
|
||||||
expect(resultNo).toBe(false);
|
expect(resultNo).toBe(false);
|
||||||
|
|
||||||
questionMock.mockResolvedValueOnce("y");
|
questionMock.mockResolvedValueOnce("y");
|
||||||
const resultYes = await promptYesNo("Continue?", false);
|
const resultYes = await promptYesNo("Continue?", false);
|
||||||
expect(resultYes).toBe(true);
|
expect(resultYes).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,18 +4,18 @@ import readline from "node:readline/promises";
|
|||||||
import { isVerbose, isYes } from "../globals.js";
|
import { isVerbose, isYes } from "../globals.js";
|
||||||
|
|
||||||
export async function promptYesNo(
|
export async function promptYesNo(
|
||||||
question: string,
|
question: string,
|
||||||
defaultYes = false,
|
defaultYes = false,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// Simple Y/N prompt honoring global --yes and verbosity flags.
|
// Simple Y/N prompt honoring global --yes and verbosity flags.
|
||||||
if (isVerbose() && isYes()) return true; // redundant guard when both flags set
|
if (isVerbose() && isYes()) return true; // redundant guard when both flags set
|
||||||
if (isYes()) return true;
|
if (isYes()) return true;
|
||||||
const rl = readline.createInterface({ input, output });
|
const rl = readline.createInterface({ input, output });
|
||||||
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
||||||
const answer = (await rl.question(`${question}${suffix}`))
|
const answer = (await rl.question(`${question}${suffix}`))
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
rl.close();
|
rl.close();
|
||||||
if (!answer) return defaultYes;
|
if (!answer) return defaultYes;
|
||||||
return answer.startsWith("y");
|
return answer.startsWith("y");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,43 +2,43 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
// Mocks must be defined via vi.hoisted to avoid TDZ with ESM hoisting.
|
// Mocks must be defined via vi.hoisted to avoid TDZ with ESM hoisting.
|
||||||
const { monitorWebProvider, pickProvider, logWebSelfId, monitorTwilio } =
|
const { monitorWebProvider, pickProvider, logWebSelfId, monitorTwilio } =
|
||||||
vi.hoisted(() => {
|
vi.hoisted(() => {
|
||||||
return {
|
return {
|
||||||
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
|
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
|
||||||
pickProvider: vi.fn().mockResolvedValue("web"),
|
pickProvider: vi.fn().mockResolvedValue("web"),
|
||||||
logWebSelfId: vi.fn(),
|
logWebSelfId: vi.fn(),
|
||||||
monitorTwilio: vi.fn().mockResolvedValue(undefined),
|
monitorTwilio: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../provider-web.js", () => ({
|
vi.mock("../provider-web.js", () => ({
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
pickProvider,
|
pickProvider,
|
||||||
logWebSelfId,
|
logWebSelfId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../twilio/monitor.js", () => ({
|
vi.mock("../twilio/monitor.js", () => ({
|
||||||
monitorTwilio,
|
monitorTwilio,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { buildProgram } from "./program.js";
|
import { buildProgram } from "./program.js";
|
||||||
|
|
||||||
describe("CLI relay command (e2e-ish)", () => {
|
describe("CLI relay command (e2e-ish)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs relay in web mode without crashing", async () => {
|
it("runs relay in web mode without crashing", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
program.exitOverride(); // throw instead of exiting process on error
|
program.exitOverride(); // throw instead of exiting process on error
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
|
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
|
||||||
).resolves.toBeInstanceOf(Object);
|
).resolves.toBeInstanceOf(Object);
|
||||||
|
|
||||||
expect(pickProvider).toHaveBeenCalledWith("web");
|
expect(pickProvider).toHaveBeenCalledWith("web");
|
||||||
expect(logWebSelfId).toHaveBeenCalledTimes(1);
|
expect(logWebSelfId).toHaveBeenCalledTimes(1);
|
||||||
expect(monitorWebProvider).toHaveBeenCalledWith(false);
|
expect(monitorWebProvider).toHaveBeenCalledWith(false);
|
||||||
expect(monitorTwilio).not.toHaveBeenCalled();
|
expect(monitorTwilio).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,45 +3,45 @@ import { EventEmitter } from "node:events";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("node:child_process", () => {
|
vi.mock("node:child_process", () => {
|
||||||
const spawn = vi.fn((_cmd: string, _args: string[]) => {
|
const spawn = vi.fn((_cmd: string, _args: string[]) => {
|
||||||
const proc = new EventEmitter() as EventEmitter & {
|
const proc = new EventEmitter() as EventEmitter & {
|
||||||
kill: ReturnType<typeof vi.fn>;
|
kill: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
proc.emit("exit", 0);
|
proc.emit("exit", 0);
|
||||||
});
|
});
|
||||||
proc.kill = vi.fn();
|
proc.kill = vi.fn();
|
||||||
return proc;
|
return proc;
|
||||||
});
|
});
|
||||||
return { spawn };
|
return { spawn };
|
||||||
});
|
});
|
||||||
|
|
||||||
const { spawnRelayTmux } = await import("./relay_tmux.js");
|
const { spawnRelayTmux } = await import("./relay_tmux.js");
|
||||||
const { spawn } = await import("node:child_process");
|
const { spawn } = await import("node:child_process");
|
||||||
|
|
||||||
describe("spawnRelayTmux", () => {
|
describe("spawnRelayTmux", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("kills old session, starts new one, and attaches", async () => {
|
it("kills old session, starts new one, and attaches", async () => {
|
||||||
const session = await spawnRelayTmux("echo hi", true, true);
|
const session = await spawnRelayTmux("echo hi", true, true);
|
||||||
expect(session).toBe("warelay-relay");
|
expect(session).toBe("warelay-relay");
|
||||||
const spawnMock = spawn as unknown as vi.Mock;
|
const spawnMock = spawn as unknown as vi.Mock;
|
||||||
expect(spawnMock.mock.calls.length).toBe(3);
|
expect(spawnMock.mock.calls.length).toBe(3);
|
||||||
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;
|
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;
|
||||||
expect(calls[0][0]).toBe("tmux"); // kill-session
|
expect(calls[0][0]).toBe("tmux"); // kill-session
|
||||||
expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session
|
expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session
|
||||||
expect(calls[2][1][0]).toBe("attach-session");
|
expect(calls[2][1][0]).toBe("attach-session");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can skip attach", async () => {
|
it("can skip attach", async () => {
|
||||||
await spawnRelayTmux("echo hi", false, true);
|
await spawnRelayTmux("echo hi", false, true);
|
||||||
const spawnMock = spawn as unknown as vi.Mock;
|
const spawnMock = spawn as unknown as vi.Mock;
|
||||||
const hasAttach = spawnMock.mock.calls.some(
|
const hasAttach = spawnMock.mock.calls.some(
|
||||||
(c) =>
|
(c) =>
|
||||||
Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"),
|
Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"),
|
||||||
);
|
);
|
||||||
expect(hasAttach).toBe(false);
|
expect(hasAttach).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,48 +3,48 @@ import { spawn } from "node:child_process";
|
|||||||
const SESSION = "warelay-relay";
|
const SESSION = "warelay-relay";
|
||||||
|
|
||||||
export async function spawnRelayTmux(
|
export async function spawnRelayTmux(
|
||||||
cmd = "pnpm warelay relay --verbose",
|
cmd = "pnpm warelay relay --verbose",
|
||||||
attach = true,
|
attach = true,
|
||||||
restart = true,
|
restart = true,
|
||||||
) {
|
) {
|
||||||
if (restart) {
|
if (restart) {
|
||||||
await killSession(SESSION);
|
await killSession(SESSION);
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], {
|
const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
shell: false,
|
shell: false,
|
||||||
});
|
});
|
||||||
child.on("error", reject);
|
child.on("error", reject);
|
||||||
child.on("exit", (code) => {
|
child.on("exit", (code) => {
|
||||||
if (code === 0) resolve();
|
if (code === 0) resolve();
|
||||||
else reject(new Error(`tmux exited with code ${code}`));
|
else reject(new Error(`tmux exited with code ${code}`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attach) {
|
if (attach) {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const child = spawn("tmux", ["attach-session", "-t", SESSION], {
|
const child = spawn("tmux", ["attach-session", "-t", SESSION], {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
shell: false,
|
shell: false,
|
||||||
});
|
});
|
||||||
child.on("error", reject);
|
child.on("error", reject);
|
||||||
child.on("exit", (code) => {
|
child.on("exit", (code) => {
|
||||||
if (code === 0) resolve();
|
if (code === 0) resolve();
|
||||||
else reject(new Error(`tmux attach exited with code ${code}`));
|
else reject(new Error(`tmux attach exited with code ${code}`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return SESSION;
|
return SESSION;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function killSession(name: string) {
|
async function killSession(name: string) {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const child = spawn("tmux", ["kill-session", "-t", name], {
|
const child = spawn("tmux", ["kill-session", "-t", name], {
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
});
|
});
|
||||||
child.on("exit", () => resolve());
|
child.on("exit", () => resolve());
|
||||||
child.on("error", () => resolve());
|
child.on("error", () => resolve());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,14 +3,14 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { waitForever } from "./wait.js";
|
import { waitForever } from "./wait.js";
|
||||||
|
|
||||||
describe("waitForever", () => {
|
describe("waitForever", () => {
|
||||||
it("creates an unref'ed interval and returns a pending promise", () => {
|
it("creates an unref'ed interval and returns a pending promise", () => {
|
||||||
const setIntervalSpy = vi.spyOn(global, "setInterval");
|
const setIntervalSpy = vi.spyOn(global, "setInterval");
|
||||||
const promise = waitForever();
|
const promise = waitForever();
|
||||||
expect(setIntervalSpy).toHaveBeenCalledWith(
|
expect(setIntervalSpy).toHaveBeenCalledWith(
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
1_000_000,
|
1_000_000,
|
||||||
);
|
);
|
||||||
expect(promise).toBeInstanceOf(Promise);
|
expect(promise).toBeInstanceOf(Promise);
|
||||||
setIntervalSpy.mockRestore();
|
setIntervalSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
export function waitForever() {
|
export function waitForever() {
|
||||||
// Keep event loop alive via an unref'ed interval plus a pending promise.
|
// Keep event loop alive via an unref'ed interval plus a pending promise.
|
||||||
const interval = setInterval(() => {}, 1_000_000);
|
const interval = setInterval(() => {}, 1_000_000);
|
||||||
interval.unref();
|
interval.unref();
|
||||||
return new Promise<void>(() => {
|
return new Promise<void>(() => {
|
||||||
/* never resolve */
|
/* never resolve */
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,141 +5,141 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { sendCommand } from "./send.js";
|
import { sendCommand } from "./send.js";
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseDeps = {
|
const baseDeps = {
|
||||||
assertProvider: vi.fn(),
|
assertProvider: vi.fn(),
|
||||||
sendMessageWeb: vi.fn(),
|
sendMessageWeb: vi.fn(),
|
||||||
resolveTwilioMediaUrl: vi.fn(),
|
resolveTwilioMediaUrl: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
waitForFinalStatus: vi.fn(),
|
waitForFinalStatus: vi.fn(),
|
||||||
} as unknown as CliDeps;
|
} as unknown as CliDeps;
|
||||||
|
|
||||||
describe("sendCommand", () => {
|
describe("sendCommand", () => {
|
||||||
it("validates wait and poll", async () => {
|
it("validates wait and poll", async () => {
|
||||||
await expect(() =>
|
await expect(() =>
|
||||||
sendCommand(
|
sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "-1",
|
wait: "-1",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
},
|
},
|
||||||
baseDeps,
|
baseDeps,
|
||||||
runtime,
|
runtime,
|
||||||
),
|
),
|
||||||
).rejects.toThrow("Wait must be >= 0 seconds");
|
).rejects.toThrow("Wait must be >= 0 seconds");
|
||||||
|
|
||||||
await expect(() =>
|
await expect(() =>
|
||||||
sendCommand(
|
sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "0",
|
wait: "0",
|
||||||
poll: "0",
|
poll: "0",
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
},
|
},
|
||||||
baseDeps,
|
baseDeps,
|
||||||
runtime,
|
runtime,
|
||||||
),
|
),
|
||||||
).rejects.toThrow("Poll must be > 0 seconds");
|
).rejects.toThrow("Poll must be > 0 seconds");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles web dry-run and warns on wait", async () => {
|
it("handles web dry-run and warns on wait", async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
...baseDeps,
|
...baseDeps,
|
||||||
sendMessageWeb: vi.fn(),
|
sendMessageWeb: vi.fn(),
|
||||||
} as CliDeps;
|
} as CliDeps;
|
||||||
await sendCommand(
|
await sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "5",
|
wait: "5",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "web",
|
provider: "web",
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
media: "pic.jpg",
|
media: "pic.jpg",
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends via web and outputs JSON", async () => {
|
it("sends via web and outputs JSON", async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
...baseDeps,
|
...baseDeps,
|
||||||
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
|
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
|
||||||
} as CliDeps;
|
} as CliDeps;
|
||||||
await sendCommand(
|
await sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "1",
|
wait: "1",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "web",
|
provider: "web",
|
||||||
json: true,
|
json: true,
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.sendMessageWeb).toHaveBeenCalled();
|
expect(deps.sendMessageWeb).toHaveBeenCalled();
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('"provider": "web"'),
|
expect.stringContaining('"provider": "web"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports twilio dry-run", async () => {
|
it("supports twilio dry-run", async () => {
|
||||||
const deps = { ...baseDeps } as CliDeps;
|
const deps = { ...baseDeps } as CliDeps;
|
||||||
await sendCommand(
|
await sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "0",
|
wait: "0",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.sendMessage).not.toHaveBeenCalled();
|
expect(deps.sendMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends via twilio with media and skips wait when zero", async () => {
|
it("sends via twilio with media and skips wait when zero", async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
...baseDeps,
|
...baseDeps,
|
||||||
resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"),
|
resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"),
|
||||||
sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }),
|
sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }),
|
||||||
waitForFinalStatus: vi.fn(),
|
waitForFinalStatus: vi.fn(),
|
||||||
} as CliDeps;
|
} as CliDeps;
|
||||||
await sendCommand(
|
await sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "0",
|
wait: "0",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
media: "pic.jpg",
|
media: "pic.jpg",
|
||||||
serveMedia: true,
|
serveMedia: true,
|
||||||
json: true,
|
json: true,
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", {
|
expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", {
|
||||||
serveMedia: true,
|
serveMedia: true,
|
||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
expect(deps.waitForFinalStatus).not.toHaveBeenCalled();
|
expect(deps.waitForFinalStatus).not.toHaveBeenCalled();
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('"provider": "twilio"'),
|
expect.stringContaining('"provider": "twilio"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,109 +4,109 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import type { Provider } from "../utils.js";
|
import type { Provider } from "../utils.js";
|
||||||
|
|
||||||
export async function sendCommand(
|
export async function sendCommand(
|
||||||
opts: {
|
opts: {
|
||||||
to: string;
|
to: string;
|
||||||
message: string;
|
message: string;
|
||||||
wait: string;
|
wait: string;
|
||||||
poll: string;
|
poll: string;
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
media?: string;
|
media?: string;
|
||||||
serveMedia?: boolean;
|
serveMedia?: boolean;
|
||||||
},
|
},
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
deps.assertProvider(opts.provider);
|
deps.assertProvider(opts.provider);
|
||||||
const waitSeconds = Number.parseInt(opts.wait, 10);
|
const waitSeconds = Number.parseInt(opts.wait, 10);
|
||||||
const pollSeconds = Number.parseInt(opts.poll, 10);
|
const pollSeconds = Number.parseInt(opts.poll, 10);
|
||||||
|
|
||||||
if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
|
if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
|
||||||
throw new Error("Wait must be >= 0 seconds");
|
throw new Error("Wait must be >= 0 seconds");
|
||||||
}
|
}
|
||||||
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
|
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
|
||||||
throw new Error("Poll must be > 0 seconds");
|
throw new Error("Poll must be > 0 seconds");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.provider === "web") {
|
if (opts.provider === "web") {
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (waitSeconds !== 0) {
|
if (waitSeconds !== 0) {
|
||||||
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
||||||
}
|
}
|
||||||
const res = await deps
|
const res = await deps
|
||||||
.sendMessageWeb(opts.to, opts.message, {
|
.sendMessageWeb(opts.to, opts.message, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: opts.media,
|
mediaUrl: opts.media,
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
runtime.error(`❌ Web send failed: ${String(err)}`);
|
runtime.error(`❌ Web send failed: ${String(err)}`);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
provider: "web",
|
provider: "web",
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
messageId: res.messageId,
|
messageId: res.messageId,
|
||||||
mediaUrl: opts.media ?? null,
|
mediaUrl: opts.media ?? null,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mediaUrl: string | undefined;
|
let mediaUrl: string | undefined;
|
||||||
if (opts.media) {
|
if (opts.media) {
|
||||||
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
|
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
|
||||||
serveMedia: Boolean(opts.serveMedia),
|
serveMedia: Boolean(opts.serveMedia),
|
||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await deps.sendMessage(
|
const result = await deps.sendMessage(
|
||||||
opts.to,
|
opts.to,
|
||||||
opts.message,
|
opts.message,
|
||||||
{ mediaUrl },
|
{ mediaUrl },
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
sid: result?.sid ?? null,
|
sid: result?.sid ?? null,
|
||||||
mediaUrl: mediaUrl ?? null,
|
mediaUrl: mediaUrl ?? null,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
if (waitSeconds === 0) return;
|
if (waitSeconds === 0) return;
|
||||||
await deps.waitForFinalStatus(
|
await deps.waitForFinalStatus(
|
||||||
result.client,
|
result.client,
|
||||||
result.sid,
|
result.sid,
|
||||||
waitSeconds,
|
waitSeconds,
|
||||||
pollSeconds,
|
pollSeconds,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,46 +5,46 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { statusCommand } from "./status.js";
|
import { statusCommand } from "./status.js";
|
||||||
|
|
||||||
vi.mock("../twilio/messages.js", () => ({
|
vi.mock("../twilio/messages.js", () => ({
|
||||||
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
|
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const deps: CliDeps = {
|
const deps: CliDeps = {
|
||||||
listRecentMessages: vi.fn(),
|
listRecentMessages: vi.fn(),
|
||||||
} as unknown as CliDeps;
|
} as unknown as CliDeps;
|
||||||
|
|
||||||
describe("statusCommand", () => {
|
describe("statusCommand", () => {
|
||||||
it("validates limit and lookback", async () => {
|
it("validates limit and lookback", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
statusCommand({ limit: "0", lookback: "10" }, deps, runtime),
|
statusCommand({ limit: "0", lookback: "10" }, deps, runtime),
|
||||||
).rejects.toThrow("limit must be between 1 and 200");
|
).rejects.toThrow("limit must be between 1 and 200");
|
||||||
await expect(
|
await expect(
|
||||||
statusCommand({ limit: "10", lookback: "0" }, deps, runtime),
|
statusCommand({ limit: "10", lookback: "0" }, deps, runtime),
|
||||||
).rejects.toThrow("lookback must be > 0 minutes");
|
).rejects.toThrow("lookback must be > 0 minutes");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prints JSON when requested", async () => {
|
it("prints JSON when requested", async () => {
|
||||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]);
|
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]);
|
||||||
await statusCommand(
|
await statusCommand(
|
||||||
{ limit: "5", lookback: "10", json: true },
|
{ limit: "5", lookback: "10", json: true },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
JSON.stringify([{ sid: "1" }], null, 2),
|
JSON.stringify([{ sid: "1" }], null, 2),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prints formatted lines otherwise", async () => {
|
it("prints formatted lines otherwise", async () => {
|
||||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]);
|
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]);
|
||||||
await statusCommand({ limit: "1", lookback: "5" }, deps, runtime);
|
await statusCommand({ limit: "1", lookback: "5" }, deps, runtime);
|
||||||
expect(runtime.log).toHaveBeenCalledWith("LINE:123");
|
expect(runtime.log).toHaveBeenCalledWith("LINE:123");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,29 +3,29 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { formatMessageLine } from "../twilio/messages.js";
|
import { formatMessageLine } from "../twilio/messages.js";
|
||||||
|
|
||||||
export async function statusCommand(
|
export async function statusCommand(
|
||||||
opts: { limit: string; lookback: string; json?: boolean },
|
opts: { limit: string; lookback: string; json?: boolean },
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
const limit = Number.parseInt(opts.limit, 10);
|
const limit = Number.parseInt(opts.limit, 10);
|
||||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||||
if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
|
if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
|
||||||
throw new Error("limit must be between 1 and 200");
|
throw new Error("limit must be between 1 and 200");
|
||||||
}
|
}
|
||||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
|
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
|
||||||
throw new Error("lookback must be > 0 minutes");
|
throw new Error("lookback must be > 0 minutes");
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = await deps.listRecentMessages(lookbackMinutes, limit);
|
const messages = await deps.listRecentMessages(lookbackMinutes, limit);
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(JSON.stringify(messages, null, 2));
|
runtime.log(JSON.stringify(messages, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
runtime.log("No messages found in the requested window.");
|
runtime.log("No messages found in the requested window.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
runtime.log(formatMessageLine(m));
|
runtime.log(formatMessageLine(m));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,72 +5,72 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { upCommand } from "./up.js";
|
import { upCommand } from "./up.js";
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeDeps = (): CliDeps => ({
|
const makeDeps = (): CliDeps => ({
|
||||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||||
readEnv: vi.fn().mockReturnValue({
|
readEnv: vi.fn().mockReturnValue({
|
||||||
whatsappFrom: "whatsapp:+1555",
|
whatsappFrom: "whatsapp:+1555",
|
||||||
whatsappSenderSid: "WW",
|
whatsappSenderSid: "WW",
|
||||||
}),
|
}),
|
||||||
ensureBinary: vi.fn().mockResolvedValue(undefined),
|
ensureBinary: vi.fn().mockResolvedValue(undefined),
|
||||||
ensureFunnel: vi.fn().mockResolvedValue(undefined),
|
ensureFunnel: vi.fn().mockResolvedValue(undefined),
|
||||||
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
|
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
|
||||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||||
createClient: vi.fn().mockReturnValue({ client: true }),
|
createClient: vi.fn().mockReturnValue({ client: true }),
|
||||||
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
|
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
|
||||||
updateWebhook: vi.fn().mockResolvedValue(undefined),
|
updateWebhook: vi.fn().mockResolvedValue(undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("upCommand", () => {
|
describe("upCommand", () => {
|
||||||
it("throws on invalid port", async () => {
|
it("throws on invalid port", async () => {
|
||||||
await expect(() =>
|
await expect(() =>
|
||||||
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
|
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
|
||||||
).rejects.toThrow("Port must be between 1 and 65535");
|
).rejects.toThrow("Port must be between 1 and 65535");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("performs dry run and returns mock data", async () => {
|
it("performs dry run and returns mock data", async () => {
|
||||||
runtime.log.mockClear();
|
runtime.log.mockClear();
|
||||||
const result = await upCommand(
|
const result = await upCommand(
|
||||||
{ port: "42873", path: "/cb", dryRun: true },
|
{ port: "42873", path: "/cb", dryRun: true },
|
||||||
makeDeps(),
|
makeDeps(),
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
"[dry-run] would enable funnel on port 42873",
|
"[dry-run] would enable funnel on port 42873",
|
||||||
);
|
);
|
||||||
expect(result?.publicUrl).toBe("https://dry-run/cb");
|
expect(result?.publicUrl).toBe("https://dry-run/cb");
|
||||||
expect(result?.senderSid).toBeUndefined();
|
expect(result?.senderSid).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enables funnel, starts webhook, and updates Twilio", async () => {
|
it("enables funnel, starts webhook, and updates Twilio", async () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
const res = await upCommand(
|
const res = await upCommand(
|
||||||
{ port: "42873", path: "/hook", verbose: true },
|
{ port: "42873", path: "/hook", verbose: true },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.ensureBinary).toHaveBeenCalledWith(
|
expect(deps.ensureBinary).toHaveBeenCalledWith(
|
||||||
"tailscale",
|
"tailscale",
|
||||||
undefined,
|
undefined,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.ensureFunnel).toHaveBeenCalled();
|
expect(deps.ensureFunnel).toHaveBeenCalled();
|
||||||
expect(deps.startWebhook).toHaveBeenCalled();
|
expect(deps.startWebhook).toHaveBeenCalled();
|
||||||
expect(deps.updateWebhook).toHaveBeenCalledWith(
|
expect(deps.updateWebhook).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
"SID123",
|
"SID123",
|
||||||
"https://tailnet-host/hook",
|
"https://tailnet-host/hook",
|
||||||
"POST",
|
"POST",
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(res?.publicUrl).toBe("https://tailnet-host/hook");
|
expect(res?.publicUrl).toBe("https://tailnet-host/hook");
|
||||||
// waiter is returned to keep the process alive in real use.
|
// waiter is returned to keep the process alive in real use.
|
||||||
expect(typeof res?.waiter).toBe("function");
|
expect(typeof res?.waiter).toBe("function");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,65 +4,65 @@ import { retryAsync } from "../infra/retry.js";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
export async function upCommand(
|
export async function upCommand(
|
||||||
opts: {
|
opts: {
|
||||||
port: string;
|
port: string;
|
||||||
path: string;
|
path: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
},
|
},
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
waiter: typeof defaultWaitForever = defaultWaitForever,
|
waiter: typeof defaultWaitForever = defaultWaitForever,
|
||||||
) {
|
) {
|
||||||
const port = Number.parseInt(opts.port, 10);
|
const port = Number.parseInt(opts.port, 10);
|
||||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||||
throw new Error("Port must be between 1 and 65535");
|
throw new Error("Port must be between 1 and 65535");
|
||||||
}
|
}
|
||||||
|
|
||||||
await deps.ensurePortAvailable(port);
|
await deps.ensurePortAvailable(port);
|
||||||
const env = deps.readEnv(runtime);
|
const env = deps.readEnv(runtime);
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
runtime.log(`[dry-run] would enable funnel on port ${port}`);
|
runtime.log(`[dry-run] would enable funnel on port ${port}`);
|
||||||
runtime.log(`[dry-run] would start webhook at path ${opts.path}`);
|
runtime.log(`[dry-run] would start webhook at path ${opts.path}`);
|
||||||
runtime.log(`[dry-run] would update Twilio sender webhook`);
|
runtime.log(`[dry-run] would update Twilio sender webhook`);
|
||||||
const publicUrl = `https://dry-run${opts.path}`;
|
const publicUrl = `https://dry-run${opts.path}`;
|
||||||
return { server: undefined, publicUrl, senderSid: undefined, waiter };
|
return { server: undefined, publicUrl, senderSid: undefined, waiter };
|
||||||
}
|
}
|
||||||
await deps.ensureBinary("tailscale", undefined, runtime);
|
await deps.ensureBinary("tailscale", undefined, runtime);
|
||||||
await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
|
await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
|
||||||
const host = await deps.getTailnetHostname();
|
const host = await deps.getTailnetHostname();
|
||||||
const publicUrl = `https://${host}${opts.path}`;
|
const publicUrl = `https://${host}${opts.path}`;
|
||||||
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
|
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
|
||||||
|
|
||||||
const server = await retryAsync(
|
const server = await retryAsync(
|
||||||
() =>
|
() =>
|
||||||
deps.startWebhook(
|
deps.startWebhook(
|
||||||
port,
|
port,
|
||||||
opts.path,
|
opts.path,
|
||||||
undefined,
|
undefined,
|
||||||
Boolean(opts.verbose),
|
Boolean(opts.verbose),
|
||||||
runtime,
|
runtime,
|
||||||
),
|
),
|
||||||
3,
|
3,
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!deps.createClient) {
|
if (!deps.createClient) {
|
||||||
throw new Error("Twilio client dependency missing");
|
throw new Error("Twilio client dependency missing");
|
||||||
}
|
}
|
||||||
const twilioClient = deps.createClient(env);
|
const twilioClient = deps.createClient(env);
|
||||||
const senderSid = await deps.findWhatsappSenderSid(
|
const senderSid = await deps.findWhatsappSenderSid(
|
||||||
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
|
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
|
||||||
env.whatsappFrom,
|
env.whatsappFrom,
|
||||||
env.whatsappSenderSid,
|
env.whatsappSenderSid,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
|
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
|
||||||
|
|
||||||
runtime.log(
|
runtime.log(
|
||||||
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
||||||
);
|
);
|
||||||
|
|
||||||
return { server, publicUrl, senderSid, waiter };
|
return { server, publicUrl, senderSid, waiter };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,57 +6,57 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { webhookCommand } from "./webhook.js";
|
import { webhookCommand } from "./webhook.js";
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const deps: CliDeps = {
|
const deps: CliDeps = {
|
||||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("webhookCommand", () => {
|
describe("webhookCommand", () => {
|
||||||
it("throws on invalid port", async () => {
|
it("throws on invalid port", async () => {
|
||||||
await expect(() =>
|
await expect(() =>
|
||||||
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
|
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
|
||||||
).rejects.toThrow("Port must be between 1 and 65535");
|
).rejects.toThrow("Port must be between 1 and 65535");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs dry run instead of starting server", async () => {
|
it("logs dry run instead of starting server", async () => {
|
||||||
runtime.log.mockClear();
|
runtime.log.mockClear();
|
||||||
const res = await webhookCommand(
|
const res = await webhookCommand(
|
||||||
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
|
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(res).toBeUndefined();
|
expect(res).toBeUndefined();
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
"[dry-run] would start webhook on port 42873 path /hook",
|
"[dry-run] would start webhook on port 42873 path /hook",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts webhook when valid", async () => {
|
it("starts webhook when valid", async () => {
|
||||||
const res = await webhookCommand(
|
const res = await webhookCommand(
|
||||||
{
|
{
|
||||||
port: "42873",
|
port: "42873",
|
||||||
path: "/hook",
|
path: "/hook",
|
||||||
reply: "ok",
|
reply: "ok",
|
||||||
verbose: true,
|
verbose: true,
|
||||||
ingress: "none",
|
ingress: "none",
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.startWebhook).toHaveBeenCalledWith(
|
expect(deps.startWebhook).toHaveBeenCalledWith(
|
||||||
42873,
|
42873,
|
||||||
"/hook",
|
"/hook",
|
||||||
"ok",
|
"ok",
|
||||||
true,
|
true,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(res).toEqual({ server: true });
|
expect(res).toEqual({ server: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,60 +4,60 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { upCommand } from "./up.js";
|
import { upCommand } from "./up.js";
|
||||||
|
|
||||||
export async function webhookCommand(
|
export async function webhookCommand(
|
||||||
opts: {
|
opts: {
|
||||||
port: string;
|
port: string;
|
||||||
path: string;
|
path: string;
|
||||||
reply?: string;
|
reply?: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
ingress?: "tailscale" | "none";
|
ingress?: "tailscale" | "none";
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
},
|
},
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
const port = Number.parseInt(opts.port, 10);
|
const port = Number.parseInt(opts.port, 10);
|
||||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||||
throw new Error("Port must be between 1 and 65535");
|
throw new Error("Port must be between 1 and 65535");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ingress = opts.ingress ?? "tailscale";
|
const ingress = opts.ingress ?? "tailscale";
|
||||||
|
|
||||||
// Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update).
|
// Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update).
|
||||||
if (ingress === "tailscale") {
|
if (ingress === "tailscale") {
|
||||||
const result = await upCommand(
|
const result = await upCommand(
|
||||||
{
|
{
|
||||||
port: opts.port,
|
port: opts.port,
|
||||||
path: opts.path,
|
path: opts.path,
|
||||||
verbose: opts.verbose,
|
verbose: opts.verbose,
|
||||||
yes: opts.yes,
|
yes: opts.yes,
|
||||||
dryRun: opts.dryRun,
|
dryRun: opts.dryRun,
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
return result.server;
|
return result.server;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local-only webhook (no ingress / no Twilio update).
|
// Local-only webhook (no ingress / no Twilio update).
|
||||||
await deps.ensurePortAvailable(port);
|
await deps.ensurePortAvailable(port);
|
||||||
if (opts.reply === "dry-run" || opts.dryRun) {
|
if (opts.reply === "dry-run" || opts.dryRun) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`[dry-run] would start webhook on port ${port} path ${opts.path}`,
|
`[dry-run] would start webhook on port ${port} path ${opts.path}`,
|
||||||
);
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const server = await retryAsync(
|
const server = await retryAsync(
|
||||||
() =>
|
() =>
|
||||||
deps.startWebhook(
|
deps.startWebhook(
|
||||||
port,
|
port,
|
||||||
opts.path,
|
opts.path,
|
||||||
opts.reply,
|
opts.reply,
|
||||||
Boolean(opts.verbose),
|
Boolean(opts.verbose),
|
||||||
runtime,
|
runtime,
|
||||||
),
|
),
|
||||||
3,
|
3,
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,145 +10,145 @@ export type ClaudeOutputFormat = "text" | "json" | "stream-json";
|
|||||||
export type SessionScope = "per-sender" | "global";
|
export type SessionScope = "per-sender" | "global";
|
||||||
|
|
||||||
export type SessionConfig = {
|
export type SessionConfig = {
|
||||||
scope?: SessionScope;
|
scope?: SessionScope;
|
||||||
resetTriggers?: string[];
|
resetTriggers?: string[];
|
||||||
idleMinutes?: number;
|
idleMinutes?: number;
|
||||||
store?: string;
|
store?: string;
|
||||||
sessionArgNew?: string[];
|
sessionArgNew?: string[];
|
||||||
sessionArgResume?: string[];
|
sessionArgResume?: string[];
|
||||||
sessionArgBeforeBody?: boolean;
|
sessionArgBeforeBody?: boolean;
|
||||||
sendSystemOnce?: boolean;
|
sendSystemOnce?: boolean;
|
||||||
sessionIntro?: string;
|
sessionIntro?: string;
|
||||||
typingIntervalSeconds?: number;
|
typingIntervalSeconds?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoggingConfig = {
|
export type LoggingConfig = {
|
||||||
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
|
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
|
||||||
file?: string;
|
file?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WarelayConfig = {
|
export type WarelayConfig = {
|
||||||
logging?: LoggingConfig;
|
logging?: LoggingConfig;
|
||||||
inbound?: {
|
inbound?: {
|
||||||
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
||||||
transcribeAudio?: {
|
transcribeAudio?: {
|
||||||
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
||||||
command: string[];
|
command: string[];
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
};
|
};
|
||||||
reply?: {
|
reply?: {
|
||||||
mode: ReplyMode;
|
mode: ReplyMode;
|
||||||
text?: string; // for mode=text, can contain {{Body}}
|
text?: string; // for mode=text, can contain {{Body}}
|
||||||
command?: string[]; // for mode=command, argv with templates
|
command?: string[]; // for mode=command, argv with templates
|
||||||
cwd?: string; // working directory for command execution
|
cwd?: string; // working directory for command execution
|
||||||
template?: string; // prepend template string when building command/prompt
|
template?: string; // prepend template string when building command/prompt
|
||||||
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
||||||
bodyPrefix?: string; // optional string prepended to Body before templating
|
bodyPrefix?: string; // optional string prepended to Body before templating
|
||||||
mediaUrl?: string; // optional media attachment (path or URL)
|
mediaUrl?: string; // optional media attachment (path or URL)
|
||||||
session?: SessionConfig;
|
session?: SessionConfig;
|
||||||
claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format
|
claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format
|
||||||
mediaMaxMb?: number; // optional cap for outbound media (default 5MB)
|
mediaMaxMb?: number; // optional cap for outbound media (default 5MB)
|
||||||
typingIntervalSeconds?: number; // how often to refresh typing indicator while command runs
|
typingIntervalSeconds?: number; // how often to refresh typing indicator while command runs
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json");
|
export const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json");
|
||||||
|
|
||||||
const ReplySchema = z
|
const ReplySchema = z
|
||||||
.object({
|
.object({
|
||||||
mode: z.union([z.literal("text"), z.literal("command")]),
|
mode: z.union([z.literal("text"), z.literal("command")]),
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
command: z.array(z.string()).optional(),
|
command: z.array(z.string()).optional(),
|
||||||
cwd: z.string().optional(),
|
cwd: z.string().optional(),
|
||||||
template: z.string().optional(),
|
template: z.string().optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
bodyPrefix: z.string().optional(),
|
bodyPrefix: z.string().optional(),
|
||||||
mediaUrl: z.string().optional(),
|
mediaUrl: z.string().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||||
session: z
|
session: z
|
||||||
.object({
|
.object({
|
||||||
scope: z
|
scope: z
|
||||||
.union([z.literal("per-sender"), z.literal("global")])
|
.union([z.literal("per-sender"), z.literal("global")])
|
||||||
.optional(),
|
.optional(),
|
||||||
resetTriggers: z.array(z.string()).optional(),
|
resetTriggers: z.array(z.string()).optional(),
|
||||||
idleMinutes: z.number().int().positive().optional(),
|
idleMinutes: z.number().int().positive().optional(),
|
||||||
store: z.string().optional(),
|
store: z.string().optional(),
|
||||||
sessionArgNew: z.array(z.string()).optional(),
|
sessionArgNew: z.array(z.string()).optional(),
|
||||||
sessionArgResume: z.array(z.string()).optional(),
|
sessionArgResume: z.array(z.string()).optional(),
|
||||||
sessionArgBeforeBody: z.boolean().optional(),
|
sessionArgBeforeBody: z.boolean().optional(),
|
||||||
sendSystemOnce: z.boolean().optional(),
|
sendSystemOnce: z.boolean().optional(),
|
||||||
sessionIntro: z.string().optional(),
|
sessionIntro: z.string().optional(),
|
||||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
claudeOutputFormat: z
|
claudeOutputFormat: z
|
||||||
.union([
|
.union([
|
||||||
z.literal("text"),
|
z.literal("text"),
|
||||||
z.literal("json"),
|
z.literal("json"),
|
||||||
z.literal("stream-json"),
|
z.literal("stream-json"),
|
||||||
z.undefined(),
|
z.undefined(),
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
|
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
"reply.text is required for mode=text; reply.command is required for mode=command",
|
"reply.text is required for mode=text; reply.command is required for mode=command",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const WarelaySchema = z.object({
|
const WarelaySchema = z.object({
|
||||||
logging: z
|
logging: z
|
||||||
.object({
|
.object({
|
||||||
level: z
|
level: z
|
||||||
.union([
|
.union([
|
||||||
z.literal("silent"),
|
z.literal("silent"),
|
||||||
z.literal("fatal"),
|
z.literal("fatal"),
|
||||||
z.literal("error"),
|
z.literal("error"),
|
||||||
z.literal("warn"),
|
z.literal("warn"),
|
||||||
z.literal("info"),
|
z.literal("info"),
|
||||||
z.literal("debug"),
|
z.literal("debug"),
|
||||||
z.literal("trace"),
|
z.literal("trace"),
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
file: z.string().optional(),
|
file: z.string().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
inbound: z
|
inbound: z
|
||||||
.object({
|
.object({
|
||||||
allowFrom: z.array(z.string()).optional(),
|
allowFrom: z.array(z.string()).optional(),
|
||||||
transcribeAudio: z
|
transcribeAudio: z
|
||||||
.object({
|
.object({
|
||||||
command: z.array(z.string()),
|
command: z.array(z.string()),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
reply: ReplySchema.optional(),
|
reply: ReplySchema.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function loadConfig(): WarelayConfig {
|
export function loadConfig(): WarelayConfig {
|
||||||
// Read ~/.warelay/warelay.json (JSON5) if present.
|
// Read ~/.warelay/warelay.json (JSON5) if present.
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(CONFIG_PATH)) return {};
|
if (!fs.existsSync(CONFIG_PATH)) return {};
|
||||||
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
||||||
const parsed = JSON5.parse(raw);
|
const parsed = JSON5.parse(raw);
|
||||||
if (typeof parsed !== "object" || parsed === null) return {};
|
if (typeof parsed !== "object" || parsed === null) return {};
|
||||||
const validated = WarelaySchema.safeParse(parsed);
|
const validated = WarelaySchema.safeParse(parsed);
|
||||||
if (!validated.success) {
|
if (!validated.success) {
|
||||||
console.error("Invalid warelay config:");
|
console.error("Invalid warelay config:");
|
||||||
for (const iss of validated.error.issues) {
|
for (const iss of validated.error.issues) {
|
||||||
console.error(`- ${iss.path.join(".")}: ${iss.message}`);
|
console.error(`- ${iss.path.join(".")}: ${iss.message}`);
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return validated.data as WarelayConfig;
|
return validated.data as WarelayConfig;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to read config at ${CONFIG_PATH}`, err);
|
console.error(`Failed to read config at ${CONFIG_PATH}`, err);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,17 +3,17 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { deriveSessionKey } from "./sessions.js";
|
import { deriveSessionKey } from "./sessions.js";
|
||||||
|
|
||||||
describe("sessions", () => {
|
describe("sessions", () => {
|
||||||
it("returns normalized per-sender key", () => {
|
it("returns normalized per-sender key", () => {
|
||||||
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
|
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
|
||||||
"+1555",
|
"+1555",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to unknown when sender missing", () => {
|
it("falls back to unknown when sender missing", () => {
|
||||||
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
|
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("global scope returns global", () => {
|
it("global scope returns global", () => {
|
||||||
expect(deriveSessionKey("global", { From: "+1" })).toBe("global");
|
expect(deriveSessionKey("global", { From: "+1" })).toBe("global");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,9 +9,9 @@ import { CONFIG_DIR, normalizeE164 } from "../utils.js";
|
|||||||
export type SessionScope = "per-sender" | "global";
|
export type SessionScope = "per-sender" | "global";
|
||||||
|
|
||||||
export type SessionEntry = {
|
export type SessionEntry = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
systemSent?: boolean;
|
systemSent?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
|
export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
|
||||||
@ -19,42 +19,42 @@ export const DEFAULT_RESET_TRIGGER = "/new";
|
|||||||
export const DEFAULT_IDLE_MINUTES = 60;
|
export const DEFAULT_IDLE_MINUTES = 60;
|
||||||
|
|
||||||
export function resolveStorePath(store?: string) {
|
export function resolveStorePath(store?: string) {
|
||||||
if (!store) return SESSION_STORE_DEFAULT;
|
if (!store) return SESSION_STORE_DEFAULT;
|
||||||
if (store.startsWith("~"))
|
if (store.startsWith("~"))
|
||||||
return path.resolve(store.replace("~", os.homedir()));
|
return path.resolve(store.replace("~", os.homedir()));
|
||||||
return path.resolve(store);
|
return path.resolve(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadSessionStore(
|
export function loadSessionStore(
|
||||||
storePath: string,
|
storePath: string,
|
||||||
): Record<string, SessionEntry> {
|
): Record<string, SessionEntry> {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(storePath, "utf-8");
|
const raw = fs.readFileSync(storePath, "utf-8");
|
||||||
const parsed = JSON5.parse(raw);
|
const parsed = JSON5.parse(raw);
|
||||||
if (parsed && typeof parsed === "object") {
|
if (parsed && typeof parsed === "object") {
|
||||||
return parsed as Record<string, SessionEntry>;
|
return parsed as Record<string, SessionEntry>;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore missing/invalid store; we'll recreate it
|
// ignore missing/invalid store; we'll recreate it
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSessionStore(
|
export async function saveSessionStore(
|
||||||
storePath: string,
|
storePath: string,
|
||||||
store: Record<string, SessionEntry>,
|
store: Record<string, SessionEntry>,
|
||||||
) {
|
) {
|
||||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||||
await fs.promises.writeFile(
|
await fs.promises.writeFile(
|
||||||
storePath,
|
storePath,
|
||||||
JSON.stringify(store, null, 2),
|
JSON.stringify(store, null, 2),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide which session bucket to use (per-sender vs global).
|
// Decide which session bucket to use (per-sender vs global).
|
||||||
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
||||||
if (scope === "global") return "global";
|
if (scope === "global") return "global";
|
||||||
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
||||||
return from || "unknown";
|
return from || "unknown";
|
||||||
}
|
}
|
||||||
|
|||||||
160
src/env.test.ts
160
src/env.test.ts
@ -4,94 +4,94 @@ import { ensureTwilioEnv, readEnv } from "./env.js";
|
|||||||
import type { RuntimeEnv } from "./runtime.js";
|
import type { RuntimeEnv } from "./runtime.js";
|
||||||
|
|
||||||
const baseEnv = {
|
const baseEnv = {
|
||||||
TWILIO_ACCOUNT_SID: "AC123",
|
TWILIO_ACCOUNT_SID: "AC123",
|
||||||
TWILIO_WHATSAPP_FROM: "whatsapp:+1555",
|
TWILIO_WHATSAPP_FROM: "whatsapp:+1555",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("env helpers", () => {
|
describe("env helpers", () => {
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
process.env = {};
|
process.env = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
function setEnv(vars: Record<string, string | undefined>) {
|
function setEnv(vars: Record<string, string | undefined>) {
|
||||||
process.env = {};
|
process.env = {};
|
||||||
for (const [k, v] of Object.entries(vars)) {
|
for (const [k, v] of Object.entries(vars)) {
|
||||||
if (v === undefined) delete process.env[k];
|
if (v === undefined) delete process.env[k];
|
||||||
else process.env[k] = v;
|
else process.env[k] = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it("reads env with auth token", () => {
|
it("reads env with auth token", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
TWILIO_AUTH_TOKEN: "token",
|
TWILIO_AUTH_TOKEN: "token",
|
||||||
TWILIO_API_KEY: undefined,
|
TWILIO_API_KEY: undefined,
|
||||||
TWILIO_API_SECRET: undefined,
|
TWILIO_API_SECRET: undefined,
|
||||||
});
|
});
|
||||||
const cfg = readEnv(runtime);
|
const cfg = readEnv(runtime);
|
||||||
expect(cfg.accountSid).toBe("AC123");
|
expect(cfg.accountSid).toBe("AC123");
|
||||||
expect(cfg.whatsappFrom).toBe("whatsapp:+1555");
|
expect(cfg.whatsappFrom).toBe("whatsapp:+1555");
|
||||||
if ("authToken" in cfg.auth) {
|
if ("authToken" in cfg.auth) {
|
||||||
expect(cfg.auth.authToken).toBe("token");
|
expect(cfg.auth.authToken).toBe("token");
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Expected auth token");
|
throw new Error("Expected auth token");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads env with API key/secret", () => {
|
it("reads env with API key/secret", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
TWILIO_AUTH_TOKEN: undefined,
|
TWILIO_AUTH_TOKEN: undefined,
|
||||||
TWILIO_API_KEY: "key",
|
TWILIO_API_KEY: "key",
|
||||||
TWILIO_API_SECRET: "secret",
|
TWILIO_API_SECRET: "secret",
|
||||||
});
|
});
|
||||||
const cfg = readEnv(runtime);
|
const cfg = readEnv(runtime);
|
||||||
if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) {
|
if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) {
|
||||||
expect(cfg.auth.apiKey).toBe("key");
|
expect(cfg.auth.apiKey).toBe("key");
|
||||||
expect(cfg.auth.apiSecret).toBe("secret");
|
expect(cfg.auth.apiSecret).toBe("secret");
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Expected API key/secret");
|
throw new Error("Expected API key/secret");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails fast on invalid env", () => {
|
it("fails fast on invalid env", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
TWILIO_ACCOUNT_SID: "",
|
TWILIO_ACCOUNT_SID: "",
|
||||||
TWILIO_WHATSAPP_FROM: "",
|
TWILIO_WHATSAPP_FROM: "",
|
||||||
TWILIO_AUTH_TOKEN: undefined,
|
TWILIO_AUTH_TOKEN: undefined,
|
||||||
TWILIO_API_KEY: undefined,
|
TWILIO_API_KEY: undefined,
|
||||||
TWILIO_API_SECRET: undefined,
|
TWILIO_API_SECRET: undefined,
|
||||||
});
|
});
|
||||||
expect(() => readEnv(runtime)).toThrow("exit");
|
expect(() => readEnv(runtime)).toThrow("exit");
|
||||||
expect(runtime.error).toHaveBeenCalled();
|
expect(runtime.error).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ensureTwilioEnv passes when token present", () => {
|
it("ensureTwilioEnv passes when token present", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
TWILIO_AUTH_TOKEN: "token",
|
TWILIO_AUTH_TOKEN: "token",
|
||||||
TWILIO_API_KEY: undefined,
|
TWILIO_API_KEY: undefined,
|
||||||
TWILIO_API_SECRET: undefined,
|
TWILIO_API_SECRET: undefined,
|
||||||
});
|
});
|
||||||
expect(() => ensureTwilioEnv(runtime)).not.toThrow();
|
expect(() => ensureTwilioEnv(runtime)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ensureTwilioEnv fails when missing auth", () => {
|
it("ensureTwilioEnv fails when missing auth", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
TWILIO_AUTH_TOKEN: undefined,
|
TWILIO_AUTH_TOKEN: undefined,
|
||||||
TWILIO_API_KEY: undefined,
|
TWILIO_API_KEY: undefined,
|
||||||
TWILIO_API_SECRET: undefined,
|
TWILIO_API_SECRET: undefined,
|
||||||
});
|
});
|
||||||
expect(() => ensureTwilioEnv(runtime)).toThrow("exit");
|
expect(() => ensureTwilioEnv(runtime)).toThrow("exit");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
172
src/env.ts
172
src/env.ts
@ -4,103 +4,103 @@ import { danger } from "./globals.js";
|
|||||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||||
|
|
||||||
export type AuthMode =
|
export type AuthMode =
|
||||||
| { accountSid: string; authToken: string }
|
| { accountSid: string; authToken: string }
|
||||||
| { accountSid: string; apiKey: string; apiSecret: string };
|
| { accountSid: string; apiKey: string; apiSecret: string };
|
||||||
|
|
||||||
export type EnvConfig = {
|
export type EnvConfig = {
|
||||||
accountSid: string;
|
accountSid: string;
|
||||||
whatsappFrom: string;
|
whatsappFrom: string;
|
||||||
whatsappSenderSid?: string;
|
whatsappSenderSid?: string;
|
||||||
auth: AuthMode;
|
auth: AuthMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EnvSchema = z
|
const EnvSchema = z
|
||||||
.object({
|
.object({
|
||||||
TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"),
|
TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"),
|
||||||
TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"),
|
TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"),
|
||||||
TWILIO_SENDER_SID: z.string().optional(),
|
TWILIO_SENDER_SID: z.string().optional(),
|
||||||
TWILIO_AUTH_TOKEN: z.string().optional(),
|
TWILIO_AUTH_TOKEN: z.string().optional(),
|
||||||
TWILIO_API_KEY: z.string().optional(),
|
TWILIO_API_KEY: z.string().optional(),
|
||||||
TWILIO_API_SECRET: z.string().optional(),
|
TWILIO_API_SECRET: z.string().optional(),
|
||||||
})
|
})
|
||||||
.superRefine((val, ctx) => {
|
.superRefine((val, ctx) => {
|
||||||
if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) {
|
if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set",
|
message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) {
|
if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set",
|
message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!val.TWILIO_AUTH_TOKEN &&
|
!val.TWILIO_AUTH_TOKEN &&
|
||||||
!(val.TWILIO_API_KEY && val.TWILIO_API_SECRET)
|
!(val.TWILIO_API_KEY && val.TWILIO_API_SECRET)
|
||||||
) {
|
) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message:
|
message:
|
||||||
"Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET",
|
"Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig {
|
export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig {
|
||||||
// Load and validate Twilio auth + sender configuration from env.
|
// Load and validate Twilio auth + sender configuration from env.
|
||||||
const parsed = EnvSchema.safeParse(process.env);
|
const parsed = EnvSchema.safeParse(process.env);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
runtime.error("Invalid environment configuration:");
|
runtime.error("Invalid environment configuration:");
|
||||||
parsed.error.issues.forEach((iss) => {
|
parsed.error.issues.forEach((iss) => {
|
||||||
runtime.error(`- ${iss.message}`);
|
runtime.error(`- ${iss.message}`);
|
||||||
});
|
});
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
TWILIO_ACCOUNT_SID: accountSid,
|
TWILIO_ACCOUNT_SID: accountSid,
|
||||||
TWILIO_WHATSAPP_FROM: whatsappFrom,
|
TWILIO_WHATSAPP_FROM: whatsappFrom,
|
||||||
TWILIO_SENDER_SID: whatsappSenderSid,
|
TWILIO_SENDER_SID: whatsappSenderSid,
|
||||||
TWILIO_AUTH_TOKEN: authToken,
|
TWILIO_AUTH_TOKEN: authToken,
|
||||||
TWILIO_API_KEY: apiKey,
|
TWILIO_API_KEY: apiKey,
|
||||||
TWILIO_API_SECRET: apiSecret,
|
TWILIO_API_SECRET: apiSecret,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
|
|
||||||
let auth: AuthMode;
|
let auth: AuthMode;
|
||||||
if (apiKey && apiSecret) {
|
if (apiKey && apiSecret) {
|
||||||
auth = { accountSid, apiKey, apiSecret };
|
auth = { accountSid, apiKey, apiSecret };
|
||||||
} else if (authToken) {
|
} else if (authToken) {
|
||||||
auth = { accountSid, authToken };
|
auth = { accountSid, authToken };
|
||||||
} else {
|
} else {
|
||||||
runtime.error("Missing Twilio auth configuration");
|
runtime.error("Missing Twilio auth configuration");
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
throw new Error("unreachable");
|
throw new Error("unreachable");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accountSid,
|
accountSid,
|
||||||
whatsappFrom,
|
whatsappFrom,
|
||||||
whatsappSenderSid,
|
whatsappSenderSid,
|
||||||
auth,
|
auth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) {
|
export function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) {
|
||||||
// Guardrails: fail fast when Twilio env vars are missing or incomplete.
|
// Guardrails: fail fast when Twilio env vars are missing or incomplete.
|
||||||
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
||||||
const missing = required.filter((k) => !process.env[k]);
|
const missing = required.filter((k) => !process.env[k]);
|
||||||
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
|
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
|
||||||
const hasKey = Boolean(
|
const hasKey = Boolean(
|
||||||
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
|
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
|
||||||
);
|
);
|
||||||
if (missing.length > 0 || (!hasToken && !hasKey)) {
|
if (missing.length > 0 || (!hasToken && !hasKey)) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
danger(
|
danger(
|
||||||
`Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`,
|
`Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,28 +2,28 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { isVerbose, isYes, logVerbose, setVerbose, setYes } from "./globals.js";
|
import { isVerbose, isYes, logVerbose, setVerbose, setYes } from "./globals.js";
|
||||||
|
|
||||||
describe("globals", () => {
|
describe("globals", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
setYes(false);
|
setYes(false);
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles verbose flag and logs when enabled", () => {
|
it("toggles verbose flag and logs when enabled", () => {
|
||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
logVerbose("hidden");
|
logVerbose("hidden");
|
||||||
expect(logSpy).not.toHaveBeenCalled();
|
expect(logSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
setVerbose(true);
|
setVerbose(true);
|
||||||
logVerbose("shown");
|
logVerbose("shown");
|
||||||
expect(isVerbose()).toBe(true);
|
expect(isVerbose()).toBe(true);
|
||||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown"));
|
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores yes flag", () => {
|
it("stores yes flag", () => {
|
||||||
setYes(true);
|
setYes(true);
|
||||||
expect(isYes()).toBe(true);
|
expect(isYes()).toBe(true);
|
||||||
setYes(false);
|
setYes(false);
|
||||||
expect(isYes()).toBe(false);
|
expect(isYes()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,23 +4,23 @@ let globalVerbose = false;
|
|||||||
let globalYes = false;
|
let globalYes = false;
|
||||||
|
|
||||||
export function setVerbose(v: boolean) {
|
export function setVerbose(v: boolean) {
|
||||||
globalVerbose = v;
|
globalVerbose = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isVerbose() {
|
export function isVerbose() {
|
||||||
return globalVerbose;
|
return globalVerbose;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logVerbose(message: string) {
|
export function logVerbose(message: string) {
|
||||||
if (globalVerbose) console.log(chalk.gray(message));
|
if (globalVerbose) console.log(chalk.gray(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setYes(v: boolean) {
|
export function setYes(v: boolean) {
|
||||||
globalYes = v;
|
globalYes = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isYes() {
|
export function isYes() {
|
||||||
return globalYes;
|
return globalYes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const success = chalk.green;
|
export const success = chalk.green;
|
||||||
|
|||||||
@ -6,134 +6,134 @@ import * as providerWeb from "./provider-web.js";
|
|||||||
import { defaultRuntime } from "./runtime.js";
|
import { defaultRuntime } from "./runtime.js";
|
||||||
|
|
||||||
vi.mock("twilio", () => {
|
vi.mock("twilio", () => {
|
||||||
const { factory } = createMockTwilio();
|
const { factory } = createMockTwilio();
|
||||||
return { default: factory };
|
return { default: factory };
|
||||||
});
|
});
|
||||||
|
|
||||||
import * as index from "./index.js";
|
import * as index from "./index.js";
|
||||||
import * as provider from "./provider-web.js";
|
import * as provider from "./provider-web.js";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
index.program.exitOverride();
|
index.program.exitOverride();
|
||||||
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
||||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
|
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
|
||||||
process.env.TWILIO_AUTH_TOKEN = "token";
|
process.env.TWILIO_AUTH_TOKEN = "token";
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CLI commands", () => {
|
describe("CLI commands", () => {
|
||||||
it("exposes login command", () => {
|
it("exposes login command", () => {
|
||||||
const names = index.program.commands.map((c) => c.name());
|
const names = index.program.commands.map((c) => c.name());
|
||||||
expect(names).toContain("login");
|
expect(names).toContain("login");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("send command routes to web provider", async () => {
|
it("send command routes to web provider", async () => {
|
||||||
const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue();
|
const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue();
|
||||||
await index.program.parseAsync(
|
await index.program.parseAsync(
|
||||||
[
|
[
|
||||||
"send",
|
"send",
|
||||||
"--to",
|
"--to",
|
||||||
"+1555",
|
"+1555",
|
||||||
"--message",
|
"--message",
|
||||||
"hi",
|
"hi",
|
||||||
"--provider",
|
"--provider",
|
||||||
"web",
|
"web",
|
||||||
"--wait",
|
"--wait",
|
||||||
"0",
|
"0",
|
||||||
],
|
],
|
||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
);
|
);
|
||||||
expect(sendWeb).toHaveBeenCalled();
|
expect(sendWeb).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("send command uses twilio path when provider=twilio", async () => {
|
it("send command uses twilio path when provider=twilio", async () => {
|
||||||
const twilio = (await import("twilio")).default;
|
const twilio = (await import("twilio")).default;
|
||||||
twilio._client.messages.create.mockResolvedValue({ sid: "SM1" });
|
twilio._client.messages.create.mockResolvedValue({ sid: "SM1" });
|
||||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||||
await index.program.parseAsync(
|
await index.program.parseAsync(
|
||||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0"],
|
["send", "--to", "+1555", "--message", "hi", "--wait", "0"],
|
||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
);
|
);
|
||||||
expect(twilio._client.messages.create).toHaveBeenCalled();
|
expect(twilio._client.messages.create).toHaveBeenCalled();
|
||||||
expect(wait).not.toHaveBeenCalled();
|
expect(wait).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("send command supports dry-run and skips sending", async () => {
|
it("send command supports dry-run and skips sending", async () => {
|
||||||
const twilio = (await import("twilio")).default;
|
const twilio = (await import("twilio")).default;
|
||||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||||
await index.program.parseAsync(
|
await index.program.parseAsync(
|
||||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--dry-run"],
|
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--dry-run"],
|
||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
);
|
);
|
||||||
expect(twilio._client.messages.create).not.toHaveBeenCalled();
|
expect(twilio._client.messages.create).not.toHaveBeenCalled();
|
||||||
expect(wait).not.toHaveBeenCalled();
|
expect(wait).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("send command outputs JSON when requested", async () => {
|
it("send command outputs JSON when requested", async () => {
|
||||||
const twilio = (await import("twilio")).default;
|
const twilio = (await import("twilio")).default;
|
||||||
twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" });
|
twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" });
|
||||||
const logSpy = vi.spyOn(defaultRuntime, "log");
|
const logSpy = vi.spyOn(defaultRuntime, "log");
|
||||||
await index.program.parseAsync(
|
await index.program.parseAsync(
|
||||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--json"],
|
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--json"],
|
||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
);
|
);
|
||||||
expect(logSpy).toHaveBeenCalledWith(
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('"sid": "SMJSON"'),
|
expect.stringContaining('"sid": "SMJSON"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("login command calls web login", async () => {
|
it("login command calls web login", async () => {
|
||||||
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
|
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
|
||||||
await index.program.parseAsync(["login"], { from: "user" });
|
await index.program.parseAsync(["login"], { from: "user" });
|
||||||
expect(spy).toHaveBeenCalled();
|
expect(spy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("status command prints JSON", async () => {
|
it("status command prints JSON", async () => {
|
||||||
const twilio = (await import("twilio")).default;
|
const twilio = (await import("twilio")).default;
|
||||||
twilio._client.messages.list
|
twilio._client.messages.list
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
sid: "1",
|
sid: "1",
|
||||||
status: "delivered",
|
status: "delivered",
|
||||||
direction: "inbound",
|
direction: "inbound",
|
||||||
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
||||||
from: "a",
|
from: "a",
|
||||||
to: "b",
|
to: "b",
|
||||||
body: "hi",
|
body: "hi",
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
sid: "2",
|
sid: "2",
|
||||||
status: "sent",
|
status: "sent",
|
||||||
direction: "outbound-api",
|
direction: "outbound-api",
|
||||||
dateCreated: new Date("2024-01-02T00:00:00Z"),
|
dateCreated: new Date("2024-01-02T00:00:00Z"),
|
||||||
from: "b",
|
from: "b",
|
||||||
to: "a",
|
to: "a",
|
||||||
body: "yo",
|
body: "yo",
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const runtime = {
|
const runtime = {
|
||||||
...defaultRuntime,
|
...defaultRuntime,
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: ((code: number) => {
|
exit: ((code: number) => {
|
||||||
throw new Error(`exit ${code}`);
|
throw new Error(`exit ${code}`);
|
||||||
}) as (code: number) => never,
|
}) as (code: number) => never,
|
||||||
};
|
};
|
||||||
await statusCommand(
|
await statusCommand(
|
||||||
{ limit: "1", lookback: "10", json: true },
|
{ limit: "1", lookback: "10", json: true },
|
||||||
createDefaultDeps(),
|
createDefaultDeps(),
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(runtime.log).toHaveBeenCalled();
|
expect(runtime.log).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2,28 +2,28 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { assertProvider, normalizeE164, toWhatsappJid } from "./index.js";
|
import { assertProvider, normalizeE164, toWhatsappJid } from "./index.js";
|
||||||
|
|
||||||
describe("normalizeE164", () => {
|
describe("normalizeE164", () => {
|
||||||
it("strips whatsapp prefix and whitespace", () => {
|
it("strips whatsapp prefix and whitespace", () => {
|
||||||
expect(normalizeE164("whatsapp:+1 555 123 4567")).toBe("+15551234567");
|
expect(normalizeE164("whatsapp:+1 555 123 4567")).toBe("+15551234567");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds plus when missing", () => {
|
it("adds plus when missing", () => {
|
||||||
expect(normalizeE164("1555123")).toBe("+1555123");
|
expect(normalizeE164("1555123")).toBe("+1555123");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("toWhatsappJid", () => {
|
describe("toWhatsappJid", () => {
|
||||||
it("converts E164 to jid", () => {
|
it("converts E164 to jid", () => {
|
||||||
expect(toWhatsappJid("+1 555 123 4567")).toBe("15551234567@s.whatsapp.net");
|
expect(toWhatsappJid("+1 555 123 4567")).toBe("15551234567@s.whatsapp.net");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("assertProvider", () => {
|
describe("assertProvider", () => {
|
||||||
it("accepts valid providers", () => {
|
it("accepts valid providers", () => {
|
||||||
expect(() => assertProvider("twilio")).not.toThrow();
|
expect(() => assertProvider("twilio")).not.toThrow();
|
||||||
expect(() => assertProvider("web")).not.toThrow();
|
expect(() => assertProvider("web")).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on invalid provider", () => {
|
it("throws on invalid provider", () => {
|
||||||
expect(() => assertProvider("invalid" as string)).toThrow();
|
expect(() => assertProvider("invalid" as string)).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
138
src/index.ts
138
src/index.ts
@ -4,8 +4,8 @@ import { fileURLToPath } from "node:url";
|
|||||||
|
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import {
|
import {
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
getReplyFromConfig,
|
getReplyFromConfig,
|
||||||
} from "./auto-reply/reply.js";
|
} from "./auto-reply/reply.js";
|
||||||
import { applyTemplate } from "./auto-reply/templating.js";
|
import { applyTemplate } from "./auto-reply/templating.js";
|
||||||
import { createDefaultDeps, monitorTwilio } from "./cli/deps.js";
|
import { createDefaultDeps, monitorTwilio } from "./cli/deps.js";
|
||||||
@ -13,42 +13,42 @@ import { promptYesNo } from "./cli/prompt.js";
|
|||||||
import { waitForever } from "./cli/wait.js";
|
import { waitForever } from "./cli/wait.js";
|
||||||
import { loadConfig } from "./config/config.js";
|
import { loadConfig } from "./config/config.js";
|
||||||
import {
|
import {
|
||||||
deriveSessionKey,
|
deriveSessionKey,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "./config/sessions.js";
|
} from "./config/sessions.js";
|
||||||
import { readEnv } from "./env.js";
|
import { readEnv } from "./env.js";
|
||||||
import { ensureBinary } from "./infra/binaries.js";
|
import { ensureBinary } from "./infra/binaries.js";
|
||||||
import {
|
import {
|
||||||
describePortOwner,
|
describePortOwner,
|
||||||
ensurePortAvailable,
|
ensurePortAvailable,
|
||||||
handlePortError,
|
handlePortError,
|
||||||
PortInUseError,
|
PortInUseError,
|
||||||
} from "./infra/ports.js";
|
} from "./infra/ports.js";
|
||||||
import {
|
import {
|
||||||
ensureFunnel,
|
ensureFunnel,
|
||||||
ensureGoInstalled,
|
ensureGoInstalled,
|
||||||
ensureTailscaledInstalled,
|
ensureTailscaledInstalled,
|
||||||
getTailnetHostname,
|
getTailnetHostname,
|
||||||
} from "./infra/tailscale.js";
|
} from "./infra/tailscale.js";
|
||||||
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
||||||
import { monitorWebProvider } from "./provider-web.js";
|
import { monitorWebProvider } from "./provider-web.js";
|
||||||
import { createClient } from "./twilio/client.js";
|
import { createClient } from "./twilio/client.js";
|
||||||
import {
|
import {
|
||||||
formatMessageLine,
|
formatMessageLine,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
sortByDateDesc,
|
sortByDateDesc,
|
||||||
uniqueBySid,
|
uniqueBySid,
|
||||||
} from "./twilio/messages.js";
|
} from "./twilio/messages.js";
|
||||||
import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
|
import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
|
||||||
import { findWhatsappSenderSid } from "./twilio/senders.js";
|
import { findWhatsappSenderSid } from "./twilio/senders.js";
|
||||||
import { sendTypingIndicator } from "./twilio/typing.js";
|
import { sendTypingIndicator } from "./twilio/typing.js";
|
||||||
import {
|
import {
|
||||||
findIncomingNumberSid as findIncomingNumberSidImpl,
|
findIncomingNumberSid as findIncomingNumberSidImpl,
|
||||||
findMessagingServiceSid as findMessagingServiceSidImpl,
|
findMessagingServiceSid as findMessagingServiceSidImpl,
|
||||||
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
|
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
|
||||||
updateWebhook as updateWebhookImpl,
|
updateWebhook as updateWebhookImpl,
|
||||||
} from "./twilio/update-webhook.js";
|
} from "./twilio/update-webhook.js";
|
||||||
import { formatTwilioError, logTwilioSendError } from "./twilio/utils.js";
|
import { formatTwilioError, logTwilioSendError } from "./twilio/utils.js";
|
||||||
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
|
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
|
||||||
@ -66,56 +66,56 @@ const setMessagingServiceWebhook = setMessagingServiceWebhookImpl;
|
|||||||
const updateWebhook = updateWebhookImpl;
|
const updateWebhook = updateWebhookImpl;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
assertProvider,
|
assertProvider,
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
applyTemplate,
|
applyTemplate,
|
||||||
createClient,
|
createClient,
|
||||||
deriveSessionKey,
|
deriveSessionKey,
|
||||||
describePortOwner,
|
describePortOwner,
|
||||||
ensureBinary,
|
ensureBinary,
|
||||||
ensureFunnel,
|
ensureFunnel,
|
||||||
ensureGoInstalled,
|
ensureGoInstalled,
|
||||||
ensurePortAvailable,
|
ensurePortAvailable,
|
||||||
ensureTailscaledInstalled,
|
ensureTailscaledInstalled,
|
||||||
findIncomingNumberSidImpl as findIncomingNumberSid,
|
findIncomingNumberSidImpl as findIncomingNumberSid,
|
||||||
findMessagingServiceSidImpl as findMessagingServiceSid,
|
findMessagingServiceSidImpl as findMessagingServiceSid,
|
||||||
findWhatsappSenderSid,
|
findWhatsappSenderSid,
|
||||||
formatMessageLine,
|
formatMessageLine,
|
||||||
formatTwilioError,
|
formatTwilioError,
|
||||||
getReplyFromConfig,
|
getReplyFromConfig,
|
||||||
getTailnetHostname,
|
getTailnetHostname,
|
||||||
handlePortError,
|
handlePortError,
|
||||||
logTwilioSendError,
|
logTwilioSendError,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
monitorTwilio,
|
monitorTwilio,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
normalizeE164,
|
normalizeE164,
|
||||||
PortInUseError,
|
PortInUseError,
|
||||||
promptYesNo,
|
promptYesNo,
|
||||||
createDefaultDeps,
|
createDefaultDeps,
|
||||||
readEnv,
|
readEnv,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
runCommandWithTimeout,
|
runCommandWithTimeout,
|
||||||
runExec,
|
runExec,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendTypingIndicator,
|
sendTypingIndicator,
|
||||||
setMessagingServiceWebhook,
|
setMessagingServiceWebhook,
|
||||||
sortByDateDesc,
|
sortByDateDesc,
|
||||||
startWebhook,
|
startWebhook,
|
||||||
updateWebhook,
|
updateWebhook,
|
||||||
uniqueBySid,
|
uniqueBySid,
|
||||||
waitForFinalStatus,
|
waitForFinalStatus,
|
||||||
waitForever,
|
waitForever,
|
||||||
toWhatsappJid,
|
toWhatsappJid,
|
||||||
program,
|
program,
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMain =
|
const isMain =
|
||||||
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||||
|
|
||||||
if (isMain) {
|
if (isMain) {
|
||||||
program.parseAsync(process.argv);
|
program.parseAsync(process.argv);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,34 +5,34 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { ensureBinary } from "./binaries.js";
|
import { ensureBinary } from "./binaries.js";
|
||||||
|
|
||||||
describe("ensureBinary", () => {
|
describe("ensureBinary", () => {
|
||||||
it("passes through when binary exists", async () => {
|
it("passes through when binary exists", async () => {
|
||||||
const exec: typeof runExec = vi.fn().mockResolvedValue({
|
const exec: typeof runExec = vi.fn().mockResolvedValue({
|
||||||
stdout: "",
|
stdout: "",
|
||||||
stderr: "",
|
stderr: "",
|
||||||
});
|
});
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(),
|
exit: vi.fn(),
|
||||||
};
|
};
|
||||||
await ensureBinary("node", exec, runtime);
|
await ensureBinary("node", exec, runtime);
|
||||||
expect(exec).toHaveBeenCalledWith("which", ["node"]);
|
expect(exec).toHaveBeenCalledWith("which", ["node"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs and exits when missing", async () => {
|
it("logs and exits when missing", async () => {
|
||||||
const exec: typeof runExec = vi
|
const exec: typeof runExec = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValue(new Error("missing"));
|
.mockRejectedValue(new Error("missing"));
|
||||||
const error = vi.fn();
|
const error = vi.fn();
|
||||||
const exit = vi.fn(() => {
|
const exit = vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
|
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
|
||||||
).rejects.toThrow("exit");
|
).rejects.toThrow("exit");
|
||||||
expect(error).toHaveBeenCalledWith(
|
expect(error).toHaveBeenCalledWith(
|
||||||
"Missing required binary: ghost. Please install it.",
|
"Missing required binary: ghost. Please install it.",
|
||||||
);
|
);
|
||||||
expect(exit).toHaveBeenCalledWith(1);
|
expect(exit).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,13 +2,13 @@ import { runExec } from "../process/exec.js";
|
|||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
export async function ensureBinary(
|
export async function ensureBinary(
|
||||||
name: string,
|
name: string,
|
||||||
exec: typeof runExec = runExec,
|
exec: typeof runExec = runExec,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Abort early if a required CLI tool is missing.
|
// Abort early if a required CLI tool is missing.
|
||||||
await exec("which", [name]).catch(() => {
|
await exec("which", [name]).catch(() => {
|
||||||
runtime.error(`Missing required binary: ${name}. Please install it.`);
|
runtime.error(`Missing required binary: ${name}. Please install it.`);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,35 +2,35 @@ import net from "node:net";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ensurePortAvailable,
|
ensurePortAvailable,
|
||||||
handlePortError,
|
handlePortError,
|
||||||
PortInUseError,
|
PortInUseError,
|
||||||
} from "./ports.js";
|
} from "./ports.js";
|
||||||
|
|
||||||
describe("ports helpers", () => {
|
describe("ports helpers", () => {
|
||||||
it("ensurePortAvailable rejects when port busy", async () => {
|
it("ensurePortAvailable rejects when port busy", async () => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
await new Promise((resolve) => server.listen(0, resolve));
|
await new Promise((resolve) => server.listen(0, resolve));
|
||||||
const port = (server.address() as net.AddressInfo).port;
|
const port = (server.address() as net.AddressInfo).port;
|
||||||
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(
|
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(
|
||||||
PortInUseError,
|
PortInUseError,
|
||||||
);
|
);
|
||||||
server.close();
|
server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handlePortError exits nicely on EADDRINUSE", async () => {
|
it("handlePortError exits nicely on EADDRINUSE", async () => {
|
||||||
const runtime = {
|
const runtime = {
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
exit: vi.fn() as unknown as (code: number) => never,
|
exit: vi.fn() as unknown as (code: number) => never,
|
||||||
};
|
};
|
||||||
await handlePortError(
|
await handlePortError(
|
||||||
{ code: "EADDRINUSE" },
|
{ code: "EADDRINUSE" },
|
||||||
1234,
|
1234,
|
||||||
"context",
|
"context",
|
||||||
runtime,
|
runtime,
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
expect(runtime.error).toHaveBeenCalled();
|
expect(runtime.error).toHaveBeenCalled();
|
||||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,103 +5,103 @@ import { runExec } from "../process/exec.js";
|
|||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
class PortInUseError extends Error {
|
class PortInUseError extends Error {
|
||||||
port: number;
|
port: number;
|
||||||
details?: string;
|
details?: string;
|
||||||
|
|
||||||
constructor(port: number, details?: string) {
|
constructor(port: number, details?: string) {
|
||||||
super(`Port ${port} is already in use.`);
|
super(`Port ${port} is already in use.`);
|
||||||
this.name = "PortInUseError";
|
this.name = "PortInUseError";
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.details = details;
|
this.details = details;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isErrno(err: unknown): err is NodeJS.ErrnoException {
|
function isErrno(err: unknown): err is NodeJS.ErrnoException {
|
||||||
return Boolean(err && typeof err === "object" && "code" in err);
|
return Boolean(err && typeof err === "object" && "code" in err);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function describePortOwner(
|
export async function describePortOwner(
|
||||||
port: number,
|
port: number,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
// Best-effort process info for a listening port (macOS/Linux).
|
// Best-effort process info for a listening port (macOS/Linux).
|
||||||
try {
|
try {
|
||||||
const { stdout } = await runExec("lsof", [
|
const { stdout } = await runExec("lsof", [
|
||||||
"-i",
|
"-i",
|
||||||
`tcp:${port}`,
|
`tcp:${port}`,
|
||||||
"-sTCP:LISTEN",
|
"-sTCP:LISTEN",
|
||||||
"-nP",
|
"-nP",
|
||||||
]);
|
]);
|
||||||
const trimmed = stdout.trim();
|
const trimmed = stdout.trim();
|
||||||
if (trimmed) return trimmed;
|
if (trimmed) return trimmed;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(`lsof unavailable: ${String(err)}`);
|
logVerbose(`lsof unavailable: ${String(err)}`);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensurePortAvailable(port: number): Promise<void> {
|
export async function ensurePortAvailable(port: number): Promise<void> {
|
||||||
// Detect EADDRINUSE early with a friendly message.
|
// Detect EADDRINUSE early with a friendly message.
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const tester = net
|
const tester = net
|
||||||
.createServer()
|
.createServer()
|
||||||
.once("error", (err) => reject(err))
|
.once("error", (err) => reject(err))
|
||||||
.once("listening", () => {
|
.once("listening", () => {
|
||||||
tester.close(() => resolve());
|
tester.close(() => resolve());
|
||||||
})
|
})
|
||||||
.listen(port);
|
.listen(port);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isErrno(err) && err.code === "EADDRINUSE") {
|
if (isErrno(err) && err.code === "EADDRINUSE") {
|
||||||
const details = await describePortOwner(port);
|
const details = await describePortOwner(port);
|
||||||
throw new PortInUseError(port, details);
|
throw new PortInUseError(port, details);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePortError(
|
export async function handlePortError(
|
||||||
err: unknown,
|
err: unknown,
|
||||||
port: number,
|
port: number,
|
||||||
context: string,
|
context: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<never> {
|
): Promise<never> {
|
||||||
// Uniform messaging for EADDRINUSE with optional owner details.
|
// Uniform messaging for EADDRINUSE with optional owner details.
|
||||||
if (
|
if (
|
||||||
err instanceof PortInUseError ||
|
err instanceof PortInUseError ||
|
||||||
(isErrno(err) && err.code === "EADDRINUSE")
|
(isErrno(err) && err.code === "EADDRINUSE")
|
||||||
) {
|
) {
|
||||||
const details =
|
const details =
|
||||||
err instanceof PortInUseError
|
err instanceof PortInUseError
|
||||||
? err.details
|
? err.details
|
||||||
: await describePortOwner(port);
|
: await describePortOwner(port);
|
||||||
runtime.error(danger(`${context} failed: port ${port} is already in use.`));
|
runtime.error(danger(`${context} failed: port ${port} is already in use.`));
|
||||||
if (details) {
|
if (details) {
|
||||||
runtime.error(info("Port listener details:"));
|
runtime.error(info("Port listener details:"));
|
||||||
runtime.error(details);
|
runtime.error(details);
|
||||||
if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) {
|
if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
warn(
|
warn(
|
||||||
"It looks like another warelay instance is already running. Stop it or pick a different port.",
|
"It looks like another warelay instance is already running. Stop it or pick a different port.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"Resolve by stopping the process using the port or passing --port <free-port>.",
|
"Resolve by stopping the process using the port or passing --port <free-port>.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
runtime.error(danger(`${context} failed: ${String(err)}`));
|
runtime.error(danger(`${context} failed: ${String(err)}`));
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
const stdout = (err as { stdout?: string })?.stdout;
|
const stdout = (err as { stdout?: string })?.stdout;
|
||||||
const stderr = (err as { stderr?: string })?.stderr;
|
const stderr = (err as { stderr?: string })?.stderr;
|
||||||
if (stdout?.trim()) logDebug(`stdout: ${stdout.trim()}`);
|
if (stdout?.trim()) logDebug(`stdout: ${stdout.trim()}`);
|
||||||
if (stderr?.trim()) logDebug(`stderr: ${stderr.trim()}`);
|
if (stderr?.trim()) logDebug(`stderr: ${stderr.trim()}`);
|
||||||
}
|
}
|
||||||
return runtime.exit(1);
|
return runtime.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { PortInUseError };
|
export { PortInUseError };
|
||||||
|
|||||||
@ -3,26 +3,26 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { retryAsync } from "./retry.js";
|
import { retryAsync } from "./retry.js";
|
||||||
|
|
||||||
describe("retryAsync", () => {
|
describe("retryAsync", () => {
|
||||||
it("returns on first success", async () => {
|
it("returns on first success", async () => {
|
||||||
const fn = vi.fn().mockResolvedValue("ok");
|
const fn = vi.fn().mockResolvedValue("ok");
|
||||||
const result = await retryAsync(fn, 3, 10);
|
const result = await retryAsync(fn, 3, 10);
|
||||||
expect(result).toBe("ok");
|
expect(result).toBe("ok");
|
||||||
expect(fn).toHaveBeenCalledTimes(1);
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retries then succeeds", async () => {
|
it("retries then succeeds", async () => {
|
||||||
const fn = vi
|
const fn = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValueOnce(new Error("fail1"))
|
.mockRejectedValueOnce(new Error("fail1"))
|
||||||
.mockResolvedValueOnce("ok");
|
.mockResolvedValueOnce("ok");
|
||||||
const result = await retryAsync(fn, 3, 1);
|
const result = await retryAsync(fn, 3, 1);
|
||||||
expect(result).toBe("ok");
|
expect(result).toBe("ok");
|
||||||
expect(fn).toHaveBeenCalledTimes(2);
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("propagates after exhausting retries", async () => {
|
it("propagates after exhausting retries", async () => {
|
||||||
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
||||||
await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom");
|
await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom");
|
||||||
expect(fn).toHaveBeenCalledTimes(2);
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
export async function retryAsync<T>(
|
export async function retryAsync<T>(
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
attempts = 3,
|
attempts = 3,
|
||||||
initialDelayMs = 300,
|
initialDelayMs = 300,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
let lastErr: unknown;
|
let lastErr: unknown;
|
||||||
for (let i = 0; i < attempts; i += 1) {
|
for (let i = 0; i < attempts; i += 1) {
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastErr = err;
|
lastErr = err;
|
||||||
if (i === attempts - 1) break;
|
if (i === attempts - 1) break;
|
||||||
const delay = initialDelayMs * 2 ** i;
|
const delay = initialDelayMs * 2 ** i;
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastErr;
|
throw lastErr;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,61 +1,61 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ensureGoInstalled,
|
ensureGoInstalled,
|
||||||
ensureTailscaledInstalled,
|
ensureTailscaledInstalled,
|
||||||
getTailnetHostname,
|
getTailnetHostname,
|
||||||
} from "./tailscale.js";
|
} from "./tailscale.js";
|
||||||
|
|
||||||
describe("tailscale helpers", () => {
|
describe("tailscale helpers", () => {
|
||||||
it("parses DNS name from tailscale status", async () => {
|
it("parses DNS name from tailscale status", async () => {
|
||||||
const exec = vi.fn().mockResolvedValue({
|
const exec = vi.fn().mockResolvedValue({
|
||||||
stdout: JSON.stringify({
|
stdout: JSON.stringify({
|
||||||
Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] },
|
Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const host = await getTailnetHostname(exec);
|
const host = await getTailnetHostname(exec);
|
||||||
expect(host).toBe("host.tailnet.ts.net");
|
expect(host).toBe("host.tailnet.ts.net");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to IP when DNS missing", async () => {
|
it("falls back to IP when DNS missing", async () => {
|
||||||
const exec = vi.fn().mockResolvedValue({
|
const exec = vi.fn().mockResolvedValue({
|
||||||
stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }),
|
stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }),
|
||||||
});
|
});
|
||||||
const host = await getTailnetHostname(exec);
|
const host = await getTailnetHostname(exec);
|
||||||
expect(host).toBe("100.2.2.2");
|
expect(host).toBe("100.2.2.2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ensureGoInstalled installs when missing and user agrees", async () => {
|
it("ensureGoInstalled installs when missing and user agrees", async () => {
|
||||||
const exec = vi
|
const exec = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValueOnce(new Error("no go"))
|
.mockRejectedValueOnce(new Error("no go"))
|
||||||
.mockResolvedValue({}); // brew install go
|
.mockResolvedValue({}); // brew install go
|
||||||
const prompt = vi.fn().mockResolvedValue(true);
|
const prompt = vi.fn().mockResolvedValue(true);
|
||||||
const runtime = {
|
const runtime = {
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
exit: ((code: number) => {
|
exit: ((code: number) => {
|
||||||
throw new Error(`exit ${code}`);
|
throw new Error(`exit ${code}`);
|
||||||
}) as (code: number) => never,
|
}) as (code: number) => never,
|
||||||
};
|
};
|
||||||
await ensureGoInstalled(exec as never, prompt, runtime);
|
await ensureGoInstalled(exec as never, prompt, runtime);
|
||||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]);
|
expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ensureTailscaledInstalled installs when missing and user agrees", async () => {
|
it("ensureTailscaledInstalled installs when missing and user agrees", async () => {
|
||||||
const exec = vi
|
const exec = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValueOnce(new Error("missing"))
|
.mockRejectedValueOnce(new Error("missing"))
|
||||||
.mockResolvedValue({});
|
.mockResolvedValue({});
|
||||||
const prompt = vi.fn().mockResolvedValue(true);
|
const prompt = vi.fn().mockResolvedValue(true);
|
||||||
const runtime = {
|
const runtime = {
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
exit: ((code: number) => {
|
exit: ((code: number) => {
|
||||||
throw new Error(`exit ${code}`);
|
throw new Error(`exit ${code}`);
|
||||||
}) as (code: number) => never,
|
}) as (code: number) => never,
|
||||||
};
|
};
|
||||||
await ensureTailscaledInstalled(exec as never, prompt, runtime);
|
await ensureTailscaledInstalled(exec as never, prompt, runtime);
|
||||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
|
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,158 +6,158 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
|||||||
import { ensureBinary } from "./binaries.js";
|
import { ensureBinary } from "./binaries.js";
|
||||||
|
|
||||||
export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
||||||
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
|
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
|
||||||
const { stdout } = await exec("tailscale", ["status", "--json"]);
|
const { stdout } = await exec("tailscale", ["status", "--json"]);
|
||||||
const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {};
|
const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {};
|
||||||
const self =
|
const self =
|
||||||
typeof parsed.Self === "object" && parsed.Self !== null
|
typeof parsed.Self === "object" && parsed.Self !== null
|
||||||
? (parsed.Self as Record<string, unknown>)
|
? (parsed.Self as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined;
|
||||||
const dns =
|
const dns =
|
||||||
typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined;
|
typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined;
|
||||||
const ips = Array.isArray(self?.TailscaleIPs)
|
const ips = Array.isArray(self?.TailscaleIPs)
|
||||||
? (self.TailscaleIPs as string[])
|
? (self.TailscaleIPs as string[])
|
||||||
: [];
|
: [];
|
||||||
if (dns && dns.length > 0) return dns.replace(/\.$/, "");
|
if (dns && dns.length > 0) return dns.replace(/\.$/, "");
|
||||||
if (ips.length > 0) return ips[0];
|
if (ips.length > 0) return ips[0];
|
||||||
throw new Error("Could not determine Tailscale DNS or IP");
|
throw new Error("Could not determine Tailscale DNS or IP");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureGoInstalled(
|
export async function ensureGoInstalled(
|
||||||
exec: typeof runExec = runExec,
|
exec: typeof runExec = runExec,
|
||||||
prompt: typeof promptYesNo = promptYesNo,
|
prompt: typeof promptYesNo = promptYesNo,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
// Ensure Go toolchain is present; offer Homebrew install if missing.
|
// Ensure Go toolchain is present; offer Homebrew install if missing.
|
||||||
const hasGo = await exec("go", ["version"]).then(
|
const hasGo = await exec("go", ["version"]).then(
|
||||||
() => true,
|
() => true,
|
||||||
() => false,
|
() => false,
|
||||||
);
|
);
|
||||||
if (hasGo) return;
|
if (hasGo) return;
|
||||||
const install = await prompt(
|
const install = await prompt(
|
||||||
"Go is not installed. Install via Homebrew (brew install go)?",
|
"Go is not installed. Install via Homebrew (brew install go)?",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
if (!install) {
|
if (!install) {
|
||||||
runtime.error("Go is required to build tailscaled from source. Aborting.");
|
runtime.error("Go is required to build tailscaled from source. Aborting.");
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
logVerbose("Installing Go via Homebrew…");
|
logVerbose("Installing Go via Homebrew…");
|
||||||
await exec("brew", ["install", "go"]);
|
await exec("brew", ["install", "go"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureTailscaledInstalled(
|
export async function ensureTailscaledInstalled(
|
||||||
exec: typeof runExec = runExec,
|
exec: typeof runExec = runExec,
|
||||||
prompt: typeof promptYesNo = promptYesNo,
|
prompt: typeof promptYesNo = promptYesNo,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
// Ensure tailscaled binary exists; install via Homebrew tailscale if missing.
|
// Ensure tailscaled binary exists; install via Homebrew tailscale if missing.
|
||||||
const hasTailscaled = await exec("tailscaled", ["--version"]).then(
|
const hasTailscaled = await exec("tailscaled", ["--version"]).then(
|
||||||
() => true,
|
() => true,
|
||||||
() => false,
|
() => false,
|
||||||
);
|
);
|
||||||
if (hasTailscaled) return;
|
if (hasTailscaled) return;
|
||||||
|
|
||||||
const install = await prompt(
|
const install = await prompt(
|
||||||
"tailscaled not found. Install via Homebrew (tailscale package)?",
|
"tailscaled not found. Install via Homebrew (tailscale package)?",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
if (!install) {
|
if (!install) {
|
||||||
runtime.error("tailscaled is required for user-space funnel. Aborting.");
|
runtime.error("tailscaled is required for user-space funnel. Aborting.");
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
logVerbose("Installing tailscaled via Homebrew…");
|
logVerbose("Installing tailscaled via Homebrew…");
|
||||||
await exec("brew", ["install", "tailscale"]);
|
await exec("brew", ["install", "tailscale"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureFunnel(
|
export async function ensureFunnel(
|
||||||
port: number,
|
port: number,
|
||||||
exec: typeof runExec = runExec,
|
exec: typeof runExec = runExec,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
prompt: typeof promptYesNo = promptYesNo,
|
prompt: typeof promptYesNo = promptYesNo,
|
||||||
) {
|
) {
|
||||||
// Ensure Funnel is enabled and publish the webhook port.
|
// Ensure Funnel is enabled and publish the webhook port.
|
||||||
try {
|
try {
|
||||||
const statusOut = (
|
const statusOut = (
|
||||||
await exec("tailscale", ["funnel", "status", "--json"])
|
await exec("tailscale", ["funnel", "status", "--json"])
|
||||||
).stdout.trim();
|
).stdout.trim();
|
||||||
const parsed = statusOut
|
const parsed = statusOut
|
||||||
? (JSON.parse(statusOut) as Record<string, unknown>)
|
? (JSON.parse(statusOut) as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
if (!parsed || Object.keys(parsed).length === 0) {
|
if (!parsed || Object.keys(parsed).length === 0) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
danger("Tailscale Funnel is not enabled on this tailnet/device."),
|
danger("Tailscale Funnel is not enabled on this tailnet/device."),
|
||||||
);
|
);
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS",
|
"macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const proceed = await prompt(
|
const proceed = await prompt(
|
||||||
"Attempt local setup with user-space tailscaled?",
|
"Attempt local setup with user-space tailscaled?",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
if (!proceed) runtime.exit(1);
|
if (!proceed) runtime.exit(1);
|
||||||
await ensureBinary("brew", exec, runtime);
|
await ensureBinary("brew", exec, runtime);
|
||||||
await ensureGoInstalled(exec, prompt, runtime);
|
await ensureGoInstalled(exec, prompt, runtime);
|
||||||
await ensureTailscaledInstalled(exec, prompt, runtime);
|
await ensureTailscaledInstalled(exec, prompt, runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
logVerbose(`Enabling funnel on port ${port}…`);
|
logVerbose(`Enabling funnel on port ${port}…`);
|
||||||
const { stdout } = await exec(
|
const { stdout } = await exec(
|
||||||
"tailscale",
|
"tailscale",
|
||||||
["funnel", "--yes", "--bg", `${port}`],
|
["funnel", "--yes", "--bg", `${port}`],
|
||||||
{
|
{
|
||||||
maxBuffer: 200_000,
|
maxBuffer: 200_000,
|
||||||
timeoutMs: 15_000,
|
timeoutMs: 15_000,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (stdout.trim()) console.log(stdout.trim());
|
if (stdout.trim()) console.log(stdout.trim());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errOutput = err as { stdout?: unknown; stderr?: unknown };
|
const errOutput = err as { stdout?: unknown; stderr?: unknown };
|
||||||
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
|
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
|
||||||
const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : "";
|
const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : "";
|
||||||
if (stdout.includes("Funnel is not enabled")) {
|
if (stdout.includes("Funnel is not enabled")) {
|
||||||
console.error(danger("Funnel is not enabled on this tailnet/device."));
|
console.error(danger("Funnel is not enabled on this tailnet/device."));
|
||||||
const linkMatch = stdout.match(/https?:\/\/\S+/);
|
const linkMatch = stdout.match(/https?:\/\/\S+/);
|
||||||
if (linkMatch) {
|
if (linkMatch) {
|
||||||
console.error(info(`Enable it here: ${linkMatch[0]}`));
|
console.error(info(`Enable it here: ${linkMatch[0]}`));
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
info(
|
info(
|
||||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
stderr.includes("client version") ||
|
stderr.includes("client version") ||
|
||||||
stdout.includes("client version")
|
stdout.includes("client version")
|
||||||
) {
|
) {
|
||||||
console.error(
|
console.error(
|
||||||
warn(
|
warn(
|
||||||
"Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.",
|
"Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
runtime.error(
|
runtime.error(
|
||||||
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
|
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
|
||||||
);
|
);
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`",
|
"Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`));
|
if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`));
|
||||||
if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`));
|
if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`));
|
||||||
runtime.error(err as Error);
|
runtime.error(err as Error);
|
||||||
}
|
}
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,72 +11,72 @@ import { resetLogger, setLoggerOverride } from "./logging.js";
|
|||||||
import type { RuntimeEnv } from "./runtime.js";
|
import type { RuntimeEnv } from "./runtime.js";
|
||||||
|
|
||||||
describe("logger helpers", () => {
|
describe("logger helpers", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
resetLogger();
|
resetLogger();
|
||||||
setLoggerOverride(null);
|
setLoggerOverride(null);
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats messages through runtime log/error", () => {
|
it("formats messages through runtime log/error", () => {
|
||||||
const log = vi.fn();
|
const log = vi.fn();
|
||||||
const error = vi.fn();
|
const error = vi.fn();
|
||||||
const runtime: RuntimeEnv = { log, error, exit: vi.fn() };
|
const runtime: RuntimeEnv = { log, error, exit: vi.fn() };
|
||||||
|
|
||||||
logInfo("info", runtime);
|
logInfo("info", runtime);
|
||||||
logWarn("warn", runtime);
|
logWarn("warn", runtime);
|
||||||
logSuccess("ok", runtime);
|
logSuccess("ok", runtime);
|
||||||
logError("bad", runtime);
|
logError("bad", runtime);
|
||||||
|
|
||||||
expect(log).toHaveBeenCalledTimes(3);
|
expect(log).toHaveBeenCalledTimes(3);
|
||||||
expect(error).toHaveBeenCalledTimes(1);
|
expect(error).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only logs debug when verbose is enabled", () => {
|
it("only logs debug when verbose is enabled", () => {
|
||||||
const logVerbose = vi.spyOn(console, "log");
|
const logVerbose = vi.spyOn(console, "log");
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
logDebug("quiet");
|
logDebug("quiet");
|
||||||
expect(logVerbose).not.toHaveBeenCalled();
|
expect(logVerbose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
setVerbose(true);
|
setVerbose(true);
|
||||||
logVerbose.mockClear();
|
logVerbose.mockClear();
|
||||||
logDebug("loud");
|
logDebug("loud");
|
||||||
expect(logVerbose).toHaveBeenCalled();
|
expect(logVerbose).toHaveBeenCalled();
|
||||||
logVerbose.mockRestore();
|
logVerbose.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes to configured log file at configured level", () => {
|
it("writes to configured log file at configured level", () => {
|
||||||
const logPath = pathForTest();
|
const logPath = pathForTest();
|
||||||
cleanup(logPath);
|
cleanup(logPath);
|
||||||
setLoggerOverride({ level: "debug", file: logPath });
|
setLoggerOverride({ level: "debug", file: logPath });
|
||||||
logInfo("hello");
|
logInfo("hello");
|
||||||
logDebug("debug-only");
|
logDebug("debug-only");
|
||||||
const content = fs.readFileSync(logPath, "utf-8");
|
const content = fs.readFileSync(logPath, "utf-8");
|
||||||
expect(content).toContain("hello");
|
expect(content).toContain("hello");
|
||||||
expect(content).toContain("debug-only");
|
expect(content).toContain("debug-only");
|
||||||
cleanup(logPath);
|
cleanup(logPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters messages below configured level", () => {
|
it("filters messages below configured level", () => {
|
||||||
const logPath = pathForTest();
|
const logPath = pathForTest();
|
||||||
cleanup(logPath);
|
cleanup(logPath);
|
||||||
setLoggerOverride({ level: "warn", file: logPath });
|
setLoggerOverride({ level: "warn", file: logPath });
|
||||||
logInfo("info-only");
|
logInfo("info-only");
|
||||||
logWarn("warn-only");
|
logWarn("warn-only");
|
||||||
const content = fs.readFileSync(logPath, "utf-8");
|
const content = fs.readFileSync(logPath, "utf-8");
|
||||||
expect(content).not.toContain("info-only");
|
expect(content).not.toContain("info-only");
|
||||||
expect(content).toContain("warn-only");
|
expect(content).toContain("warn-only");
|
||||||
cleanup(logPath);
|
cleanup(logPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function pathForTest() {
|
function pathForTest() {
|
||||||
return path.join(os.tmpdir(), `warelay-log-${crypto.randomUUID()}.log`);
|
return path.join(os.tmpdir(), `warelay-log-${crypto.randomUUID()}.log`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanup(file: string) {
|
function cleanup(file: string) {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(file, { force: true });
|
fs.rmSync(file, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,42 +1,42 @@
|
|||||||
import {
|
import {
|
||||||
danger,
|
danger,
|
||||||
info,
|
info,
|
||||||
isVerbose,
|
isVerbose,
|
||||||
logVerbose,
|
logVerbose,
|
||||||
success,
|
success,
|
||||||
warn,
|
warn,
|
||||||
} from "./globals.js";
|
} from "./globals.js";
|
||||||
import { getLogger } from "./logging.js";
|
import { getLogger } from "./logging.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||||
|
|
||||||
export function logInfo(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
export function logInfo(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
||||||
runtime.log(info(message));
|
runtime.log(info(message));
|
||||||
getLogger().info(message);
|
getLogger().info(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logWarn(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
export function logWarn(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
||||||
runtime.log(warn(message));
|
runtime.log(warn(message));
|
||||||
getLogger().warn(message);
|
getLogger().warn(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logSuccess(
|
export function logSuccess(
|
||||||
message: string,
|
message: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
runtime.log(success(message));
|
runtime.log(success(message));
|
||||||
getLogger().info(message);
|
getLogger().info(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logError(
|
export function logError(
|
||||||
message: string,
|
message: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
runtime.error(danger(message));
|
runtime.error(danger(message));
|
||||||
getLogger().error(message);
|
getLogger().error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logDebug(message: string) {
|
export function logDebug(message: string) {
|
||||||
// Always emit to file logger (level-filtered); console only when verbose.
|
// Always emit to file logger (level-filtered); console only when verbose.
|
||||||
getLogger().debug(message);
|
getLogger().debug(message);
|
||||||
if (isVerbose()) logVerbose(message);
|
if (isVerbose()) logVerbose(message);
|
||||||
}
|
}
|
||||||
|
|||||||
104
src/logging.ts
104
src/logging.ts
@ -10,23 +10,23 @@ const DEFAULT_LOG_DIR = path.join(os.tmpdir(), "warelay");
|
|||||||
export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "warelay.log");
|
export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "warelay.log");
|
||||||
|
|
||||||
const ALLOWED_LEVELS: readonly LevelWithSilent[] = [
|
const ALLOWED_LEVELS: readonly LevelWithSilent[] = [
|
||||||
"silent",
|
"silent",
|
||||||
"fatal",
|
"fatal",
|
||||||
"error",
|
"error",
|
||||||
"warn",
|
"warn",
|
||||||
"info",
|
"info",
|
||||||
"debug",
|
"debug",
|
||||||
"trace",
|
"trace",
|
||||||
];
|
];
|
||||||
|
|
||||||
export type LoggerSettings = {
|
export type LoggerSettings = {
|
||||||
level?: LevelWithSilent;
|
level?: LevelWithSilent;
|
||||||
file?: string;
|
file?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolvedSettings = {
|
type ResolvedSettings = {
|
||||||
level: LevelWithSilent;
|
level: LevelWithSilent;
|
||||||
file: string;
|
file: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let cachedLogger: Logger | null = null;
|
let cachedLogger: Logger | null = null;
|
||||||
@ -34,68 +34,68 @@ let cachedSettings: ResolvedSettings | null = null;
|
|||||||
let overrideSettings: LoggerSettings | null = null;
|
let overrideSettings: LoggerSettings | null = null;
|
||||||
|
|
||||||
function normalizeLevel(level?: string): LevelWithSilent {
|
function normalizeLevel(level?: string): LevelWithSilent {
|
||||||
if (isVerbose()) return "debug";
|
if (isVerbose()) return "debug";
|
||||||
const candidate = level ?? "info";
|
const candidate = level ?? "info";
|
||||||
return ALLOWED_LEVELS.includes(candidate as LevelWithSilent)
|
return ALLOWED_LEVELS.includes(candidate as LevelWithSilent)
|
||||||
? (candidate as LevelWithSilent)
|
? (candidate as LevelWithSilent)
|
||||||
: "info";
|
: "info";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSettings(): ResolvedSettings {
|
function resolveSettings(): ResolvedSettings {
|
||||||
const cfg: WarelayConfig["logging"] | undefined =
|
const cfg: WarelayConfig["logging"] | undefined =
|
||||||
overrideSettings ?? loadConfig().logging;
|
overrideSettings ?? loadConfig().logging;
|
||||||
const level = normalizeLevel(cfg?.level);
|
const level = normalizeLevel(cfg?.level);
|
||||||
const file = cfg?.file ?? DEFAULT_LOG_FILE;
|
const file = cfg?.file ?? DEFAULT_LOG_FILE;
|
||||||
return { level, file };
|
return { level, file };
|
||||||
}
|
}
|
||||||
|
|
||||||
function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) {
|
function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) {
|
||||||
if (!a) return true;
|
if (!a) return true;
|
||||||
return a.level !== b.level || a.file !== b.file;
|
return a.level !== b.level || a.file !== b.file;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLogger(settings: ResolvedSettings): Logger {
|
function buildLogger(settings: ResolvedSettings): Logger {
|
||||||
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
|
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
|
||||||
const destination = pino.destination({
|
const destination = pino.destination({
|
||||||
dest: settings.file,
|
dest: settings.file,
|
||||||
mkdir: true,
|
mkdir: true,
|
||||||
sync: true, // deterministic for tests; log volume is modest.
|
sync: true, // deterministic for tests; log volume is modest.
|
||||||
});
|
});
|
||||||
return pino(
|
return pino(
|
||||||
{
|
{
|
||||||
level: settings.level,
|
level: settings.level,
|
||||||
base: undefined,
|
base: undefined,
|
||||||
timestamp: pino.stdTimeFunctions.isoTime,
|
timestamp: pino.stdTimeFunctions.isoTime,
|
||||||
},
|
},
|
||||||
destination,
|
destination,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLogger(): Logger {
|
export function getLogger(): Logger {
|
||||||
const settings = resolveSettings();
|
const settings = resolveSettings();
|
||||||
if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
|
if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
|
||||||
cachedLogger = buildLogger(settings);
|
cachedLogger = buildLogger(settings);
|
||||||
cachedSettings = settings;
|
cachedSettings = settings;
|
||||||
}
|
}
|
||||||
return cachedLogger;
|
return cachedLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChildLogger(
|
export function getChildLogger(
|
||||||
bindings?: Bindings,
|
bindings?: Bindings,
|
||||||
opts?: { level?: LevelWithSilent },
|
opts?: { level?: LevelWithSilent },
|
||||||
): Logger {
|
): Logger {
|
||||||
return getLogger().child(bindings ?? {}, opts);
|
return getLogger().child(bindings ?? {}, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test helpers
|
// Test helpers
|
||||||
export function setLoggerOverride(settings: LoggerSettings | null) {
|
export function setLoggerOverride(settings: LoggerSettings | null) {
|
||||||
overrideSettings = settings;
|
overrideSettings = settings;
|
||||||
cachedLogger = null;
|
cachedLogger = null;
|
||||||
cachedSettings = null;
|
cachedSettings = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetLogger() {
|
export function resetLogger() {
|
||||||
cachedLogger = null;
|
cachedLogger = null;
|
||||||
cachedSettings = null;
|
cachedSettings = null;
|
||||||
overrideSettings = null;
|
overrideSettings = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,26 +6,26 @@ export const MAX_DOCUMENT_BYTES = 100 * 1024 * 1024; // 100MB
|
|||||||
export type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
export type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
||||||
|
|
||||||
export function mediaKindFromMime(mime?: string | null): MediaKind {
|
export function mediaKindFromMime(mime?: string | null): MediaKind {
|
||||||
if (!mime) return "unknown";
|
if (!mime) return "unknown";
|
||||||
if (mime.startsWith("image/")) return "image";
|
if (mime.startsWith("image/")) return "image";
|
||||||
if (mime.startsWith("audio/")) return "audio";
|
if (mime.startsWith("audio/")) return "audio";
|
||||||
if (mime.startsWith("video/")) return "video";
|
if (mime.startsWith("video/")) return "video";
|
||||||
if (mime === "application/pdf") return "document";
|
if (mime === "application/pdf") return "document";
|
||||||
if (mime.startsWith("application/")) return "document";
|
if (mime.startsWith("application/")) return "document";
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function maxBytesForKind(kind: MediaKind): number {
|
export function maxBytesForKind(kind: MediaKind): number {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "image":
|
case "image":
|
||||||
return MAX_IMAGE_BYTES;
|
return MAX_IMAGE_BYTES;
|
||||||
case "audio":
|
case "audio":
|
||||||
return MAX_AUDIO_BYTES;
|
return MAX_AUDIO_BYTES;
|
||||||
case "video":
|
case "video":
|
||||||
return MAX_VIDEO_BYTES;
|
return MAX_VIDEO_BYTES;
|
||||||
case "document":
|
case "document":
|
||||||
return MAX_DOCUMENT_BYTES;
|
return MAX_DOCUMENT_BYTES;
|
||||||
default:
|
default:
|
||||||
return MAX_DOCUMENT_BYTES;
|
return MAX_DOCUMENT_BYTES;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,11 +12,11 @@ const logInfo = vi.fn();
|
|||||||
vi.mock("./store.js", () => ({ saveMediaSource }));
|
vi.mock("./store.js", () => ({ saveMediaSource }));
|
||||||
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
|
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
|
||||||
vi.mock("../infra/ports.js", async () => {
|
vi.mock("../infra/ports.js", async () => {
|
||||||
const actual =
|
const actual =
|
||||||
await vi.importActual<typeof import("../infra/ports.js")>(
|
await vi.importActual<typeof import("../infra/ports.js")>(
|
||||||
"../infra/ports.js",
|
"../infra/ports.js",
|
||||||
);
|
);
|
||||||
return { ensurePortAvailable, PortInUseError: actual.PortInUseError };
|
return { ensurePortAvailable, PortInUseError: actual.PortInUseError };
|
||||||
});
|
});
|
||||||
vi.mock("./server.js", () => ({ startMediaServer }));
|
vi.mock("./server.js", () => ({ startMediaServer }));
|
||||||
vi.mock("../logger.js", () => ({ logInfo }));
|
vi.mock("../logger.js", () => ({ logInfo }));
|
||||||
@ -25,69 +25,69 @@ const { ensureMediaHosted } = await import("./host.js");
|
|||||||
const { PortInUseError } = await import("../infra/ports.js");
|
const { PortInUseError } = await import("../infra/ports.js");
|
||||||
|
|
||||||
describe("ensureMediaHosted", () => {
|
describe("ensureMediaHosted", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws and cleans up when server not allowed to start", async () => {
|
it("throws and cleans up when server not allowed to start", async () => {
|
||||||
saveMediaSource.mockResolvedValue({
|
saveMediaSource.mockResolvedValue({
|
||||||
id: "id1",
|
id: "id1",
|
||||||
path: "/tmp/file1",
|
path: "/tmp/file1",
|
||||||
size: 5,
|
size: 5,
|
||||||
});
|
});
|
||||||
getTailnetHostname.mockResolvedValue("tailnet-host");
|
getTailnetHostname.mockResolvedValue("tailnet-host");
|
||||||
ensurePortAvailable.mockResolvedValue(undefined);
|
ensurePortAvailable.mockResolvedValue(undefined);
|
||||||
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
ensureMediaHosted("/tmp/file1", { startServer: false }),
|
ensureMediaHosted("/tmp/file1", { startServer: false }),
|
||||||
).rejects.toThrow("requires the webhook/Funnel server");
|
).rejects.toThrow("requires the webhook/Funnel server");
|
||||||
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
|
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
|
||||||
rmSpy.mockRestore();
|
rmSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts media server when allowed", async () => {
|
it("starts media server when allowed", async () => {
|
||||||
saveMediaSource.mockResolvedValue({
|
saveMediaSource.mockResolvedValue({
|
||||||
id: "id2",
|
id: "id2",
|
||||||
path: "/tmp/file2",
|
path: "/tmp/file2",
|
||||||
size: 9,
|
size: 9,
|
||||||
});
|
});
|
||||||
getTailnetHostname.mockResolvedValue("tail.net");
|
getTailnetHostname.mockResolvedValue("tail.net");
|
||||||
ensurePortAvailable.mockResolvedValue(undefined);
|
ensurePortAvailable.mockResolvedValue(undefined);
|
||||||
const fakeServer = { unref: vi.fn() } as unknown as Server;
|
const fakeServer = { unref: vi.fn() } as unknown as Server;
|
||||||
startMediaServer.mockResolvedValue(fakeServer);
|
startMediaServer.mockResolvedValue(fakeServer);
|
||||||
|
|
||||||
const result = await ensureMediaHosted("/tmp/file2", {
|
const result = await ensureMediaHosted("/tmp/file2", {
|
||||||
startServer: true,
|
startServer: true,
|
||||||
port: 1234,
|
port: 1234,
|
||||||
});
|
});
|
||||||
expect(startMediaServer).toHaveBeenCalledWith(
|
expect(startMediaServer).toHaveBeenCalledWith(
|
||||||
1234,
|
1234,
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
expect(logInfo).toHaveBeenCalled();
|
expect(logInfo).toHaveBeenCalled();
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
url: "https://tail.net/media/id2",
|
url: "https://tail.net/media/id2",
|
||||||
id: "id2",
|
id: "id2",
|
||||||
size: 9,
|
size: 9,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips server start when port already in use", async () => {
|
it("skips server start when port already in use", async () => {
|
||||||
saveMediaSource.mockResolvedValue({
|
saveMediaSource.mockResolvedValue({
|
||||||
id: "id3",
|
id: "id3",
|
||||||
path: "/tmp/file3",
|
path: "/tmp/file3",
|
||||||
size: 7,
|
size: 7,
|
||||||
});
|
});
|
||||||
getTailnetHostname.mockResolvedValue("tail.net");
|
getTailnetHostname.mockResolvedValue("tail.net");
|
||||||
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
|
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
|
||||||
|
|
||||||
const result = await ensureMediaHosted("/tmp/file3", {
|
const result = await ensureMediaHosted("/tmp/file3", {
|
||||||
startServer: false,
|
startServer: false,
|
||||||
port: 3000,
|
port: 3000,
|
||||||
});
|
});
|
||||||
expect(startMediaServer).not.toHaveBeenCalled();
|
expect(startMediaServer).not.toHaveBeenCalled();
|
||||||
expect(result.url).toBe("https://tail.net/media/id3");
|
expect(result.url).toBe("https://tail.net/media/id3");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,54 +12,54 @@ const TTL_MS = 2 * 60 * 1000;
|
|||||||
let mediaServer: import("http").Server | null = null;
|
let mediaServer: import("http").Server | null = null;
|
||||||
|
|
||||||
export type HostedMedia = {
|
export type HostedMedia = {
|
||||||
url: string;
|
url: string;
|
||||||
id: string;
|
id: string;
|
||||||
size: number;
|
size: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function ensureMediaHosted(
|
export async function ensureMediaHosted(
|
||||||
source: string,
|
source: string,
|
||||||
opts: {
|
opts: {
|
||||||
port?: number;
|
port?: number;
|
||||||
startServer?: boolean;
|
startServer?: boolean;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<HostedMedia> {
|
): Promise<HostedMedia> {
|
||||||
const port = opts.port ?? DEFAULT_PORT;
|
const port = opts.port ?? DEFAULT_PORT;
|
||||||
const runtime = opts.runtime ?? defaultRuntime;
|
const runtime = opts.runtime ?? defaultRuntime;
|
||||||
|
|
||||||
const saved = await saveMediaSource(source);
|
const saved = await saveMediaSource(source);
|
||||||
const hostname = await getTailnetHostname();
|
const hostname = await getTailnetHostname();
|
||||||
|
|
||||||
// Decide whether we must start a media server.
|
// Decide whether we must start a media server.
|
||||||
const needsServerStart = await isPortFree(port);
|
const needsServerStart = await isPortFree(port);
|
||||||
if (needsServerStart && !opts.startServer) {
|
if (needsServerStart && !opts.startServer) {
|
||||||
await fs.rm(saved.path).catch(() => {});
|
await fs.rm(saved.path).catch(() => {});
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.",
|
"Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (needsServerStart && opts.startServer) {
|
if (needsServerStart && opts.startServer) {
|
||||||
if (!mediaServer) {
|
if (!mediaServer) {
|
||||||
mediaServer = await startMediaServer(port, TTL_MS, runtime);
|
mediaServer = await startMediaServer(port, TTL_MS, runtime);
|
||||||
logInfo(
|
logInfo(
|
||||||
`📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`,
|
`📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
mediaServer.unref?.();
|
mediaServer.unref?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `https://${hostname}/media/${saved.id}`;
|
const url = `https://${hostname}/media/${saved.id}`;
|
||||||
return { url, id: saved.id, size: saved.size };
|
return { url, id: saved.id, size: saved.size };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isPortFree(port: number) {
|
async function isPortFree(port: number) {
|
||||||
try {
|
try {
|
||||||
await ensurePortAvailable(port);
|
await ensurePortAvailable(port);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof PortInUseError) return false;
|
if (err instanceof PortInUseError) return false;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,53 +1,101 @@
|
|||||||
// Shared helpers for parsing MEDIA tokens from command/stdout text.
|
// Shared helpers for parsing MEDIA tokens from command/stdout text.
|
||||||
|
|
||||||
// Allow optional wrapping backticks and punctuation after the token; capture the core token.
|
// Allow optional wrapping backticks and punctuation after the token; capture the core token.
|
||||||
export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\s`]+)`?/i;
|
export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi;
|
||||||
|
|
||||||
export function normalizeMediaSource(src: string) {
|
export function normalizeMediaSource(src: string) {
|
||||||
return src.startsWith("file://") ? src.replace("file://", "") : src;
|
return src.startsWith("file://") ? src.replace("file://", "") : src;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanCandidate(raw: string) {
|
function cleanCandidate(raw: string) {
|
||||||
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
|
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidMedia(candidate: string) {
|
function isValidMedia(candidate: string) {
|
||||||
if (!candidate) return false;
|
if (!candidate) return false;
|
||||||
if (candidate.length > 1024) return false;
|
if (candidate.length > 1024) return false;
|
||||||
if (/\s/.test(candidate)) return false;
|
if (/\s/.test(candidate)) return false;
|
||||||
return (
|
return (
|
||||||
/^https?:\/\//i.test(candidate) ||
|
/^https?:\/\//i.test(candidate) ||
|
||||||
candidate.startsWith("/") ||
|
candidate.startsWith("/") ||
|
||||||
candidate.startsWith("./")
|
candidate.startsWith("./")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitMediaFromOutput(raw: string): {
|
export function splitMediaFromOutput(raw: string): {
|
||||||
text: string;
|
text: string;
|
||||||
mediaUrl?: string;
|
mediaUrls?: string[];
|
||||||
|
mediaUrl?: string; // legacy first item for backward compatibility
|
||||||
} {
|
} {
|
||||||
const trimmedRaw = raw.trim();
|
const trimmedRaw = raw.trim();
|
||||||
const match = MEDIA_TOKEN_RE.exec(trimmedRaw);
|
if (!trimmedRaw) return { text: "" };
|
||||||
if (!match?.[1]) return { text: trimmedRaw };
|
|
||||||
|
|
||||||
const candidate = normalizeMediaSource(cleanCandidate(match[1]));
|
const media: string[] = [];
|
||||||
const mediaUrl = isValidMedia(candidate) ? candidate : undefined;
|
let foundMediaToken = false;
|
||||||
|
|
||||||
const cleanedText = mediaUrl
|
// Collect tokens line by line so we can strip them cleanly.
|
||||||
? trimmedRaw
|
const lines = trimmedRaw.split("\n");
|
||||||
.replace(match[0], "")
|
const keptLines: string[] = [];
|
||||||
.replace(/[ \t]+\n/g, "\n")
|
|
||||||
.replace(/[ \t]{2,}/g, " ")
|
|
||||||
.replace(/\n{2,}/g, "\n")
|
|
||||||
.trim()
|
|
||||||
: trimmedRaw
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => !MEDIA_TOKEN_RE.test(line))
|
|
||||||
.join("\n")
|
|
||||||
.replace(/[ \t]+\n/g, "\n")
|
|
||||||
.replace(/[ \t]{2,}/g, " ")
|
|
||||||
.replace(/\n{2,}/g, "\n")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
return mediaUrl ? { text: cleanedText, mediaUrl } : { text: cleanedText };
|
for (const line of lines) {
|
||||||
|
const matches = Array.from(line.matchAll(MEDIA_TOKEN_RE));
|
||||||
|
if (matches.length === 0) {
|
||||||
|
keptLines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foundMediaToken = true;
|
||||||
|
const pieces: string[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
let hasValidMedia = false;
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const start = match.index ?? 0;
|
||||||
|
pieces.push(line.slice(cursor, start));
|
||||||
|
|
||||||
|
const payload = match[1];
|
||||||
|
const parts = payload.split(/\s+/).filter(Boolean);
|
||||||
|
const invalidParts: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
const candidate = normalizeMediaSource(cleanCandidate(part));
|
||||||
|
if (isValidMedia(candidate)) {
|
||||||
|
media.push(candidate);
|
||||||
|
hasValidMedia = true;
|
||||||
|
} else {
|
||||||
|
invalidParts.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasValidMedia && invalidParts.length > 0) {
|
||||||
|
pieces.push(invalidParts.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = start + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
pieces.push(line.slice(cursor));
|
||||||
|
|
||||||
|
const cleanedLine = pieces
|
||||||
|
.join("")
|
||||||
|
.replace(/[ \t]{2,}/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// If the line becomes empty, drop it.
|
||||||
|
if (cleanedLine) {
|
||||||
|
keptLines.push(cleanedLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedText = keptLines
|
||||||
|
.join("\n")
|
||||||
|
.replace(/[ \t]+\n/g, "\n")
|
||||||
|
.replace(/[ \t]{2,}/g, " ")
|
||||||
|
.replace(/\n{2,}/g, "\n")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (media.length === 0) {
|
||||||
|
return { text: foundMediaToken ? cleanedText : trimmedRaw };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: cleanedText, mediaUrls: media, mediaUrl: media[0] };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,45 +8,45 @@ const MEDIA_DIR = path.join(process.cwd(), "tmp-media-test");
|
|||||||
const cleanOldMedia = vi.fn().mockResolvedValue(undefined);
|
const cleanOldMedia = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
vi.mock("./store.js", () => ({
|
vi.mock("./store.js", () => ({
|
||||||
getMediaDir: () => MEDIA_DIR,
|
getMediaDir: () => MEDIA_DIR,
|
||||||
cleanOldMedia,
|
cleanOldMedia,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { startMediaServer } = await import("./server.js");
|
const { startMediaServer } = await import("./server.js");
|
||||||
|
|
||||||
describe("media server", () => {
|
describe("media server", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serves media and cleans up after send", async () => {
|
it("serves media and cleans up after send", async () => {
|
||||||
const file = path.join(MEDIA_DIR, "file1");
|
const file = path.join(MEDIA_DIR, "file1");
|
||||||
await fs.writeFile(file, "hello");
|
await fs.writeFile(file, "hello");
|
||||||
const server = await startMediaServer(0, 5_000);
|
const server = await startMediaServer(0, 5_000);
|
||||||
const port = (server.address() as AddressInfo).port;
|
const port = (server.address() as AddressInfo).port;
|
||||||
const res = await fetch(`http://localhost:${port}/media/file1`);
|
const res = await fetch(`http://localhost:${port}/media/file1`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.text()).toBe("hello");
|
expect(await res.text()).toBe("hello");
|
||||||
await new Promise((r) => setTimeout(r, 600));
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
await expect(fs.stat(file)).rejects.toThrow();
|
await expect(fs.stat(file)).rejects.toThrow();
|
||||||
await new Promise((r) => server.close(r));
|
await new Promise((r) => server.close(r));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("expires old media", async () => {
|
it("expires old media", async () => {
|
||||||
const file = path.join(MEDIA_DIR, "old");
|
const file = path.join(MEDIA_DIR, "old");
|
||||||
await fs.writeFile(file, "stale");
|
await fs.writeFile(file, "stale");
|
||||||
const past = Date.now() - 10_000;
|
const past = Date.now() - 10_000;
|
||||||
await fs.utimes(file, past / 1000, past / 1000);
|
await fs.utimes(file, past / 1000, past / 1000);
|
||||||
const server = await startMediaServer(0, 1_000);
|
const server = await startMediaServer(0, 1_000);
|
||||||
const port = (server.address() as AddressInfo).port;
|
const port = (server.address() as AddressInfo).port;
|
||||||
const res = await fetch(`http://localhost:${port}/media/old`);
|
const res = await fetch(`http://localhost:${port}/media/old`);
|
||||||
expect(res.status).toBe(410);
|
expect(res.status).toBe(410);
|
||||||
await expect(fs.stat(file)).rejects.toThrow();
|
await expect(fs.stat(file)).rejects.toThrow();
|
||||||
await new Promise((r) => server.close(r));
|
await new Promise((r) => server.close(r));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,53 +9,53 @@ import { cleanOldMedia, getMediaDir } from "./store.js";
|
|||||||
const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
export function attachMediaRoutes(
|
export function attachMediaRoutes(
|
||||||
app: Express,
|
app: Express,
|
||||||
ttlMs = DEFAULT_TTL_MS,
|
ttlMs = DEFAULT_TTL_MS,
|
||||||
_runtime: RuntimeEnv = defaultRuntime,
|
_runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
const mediaDir = getMediaDir();
|
const mediaDir = getMediaDir();
|
||||||
|
|
||||||
app.get("/media/:id", async (req, res) => {
|
app.get("/media/:id", async (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const file = path.join(mediaDir, id);
|
const file = path.join(mediaDir, id);
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(file);
|
const stat = await fs.stat(file);
|
||||||
if (Date.now() - stat.mtimeMs > ttlMs) {
|
if (Date.now() - stat.mtimeMs > ttlMs) {
|
||||||
await fs.rm(file).catch(() => {});
|
await fs.rm(file).catch(() => {});
|
||||||
res.status(410).send("expired");
|
res.status(410).send("expired");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.sendFile(file);
|
res.sendFile(file);
|
||||||
// best-effort single-use cleanup after response ends
|
// best-effort single-use cleanup after response ends
|
||||||
res.on("finish", () => {
|
res.on("finish", () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fs.rm(file).catch(() => {});
|
fs.rm(file).catch(() => {});
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
res.status(404).send("not found");
|
res.status(404).send("not found");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// periodic cleanup
|
// periodic cleanup
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
void cleanOldMedia(ttlMs);
|
void cleanOldMedia(ttlMs);
|
||||||
}, ttlMs).unref();
|
}, ttlMs).unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startMediaServer(
|
export async function startMediaServer(
|
||||||
port: number,
|
port: number,
|
||||||
ttlMs = DEFAULT_TTL_MS,
|
ttlMs = DEFAULT_TTL_MS,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<Server> {
|
): Promise<Server> {
|
||||||
const app = express();
|
const app = express();
|
||||||
attachMediaRoutes(app, ttlMs, runtime);
|
attachMediaRoutes(app, ttlMs, runtime);
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const server = app.listen(port);
|
const server = app.listen(port);
|
||||||
server.once("listening", () => resolve(server));
|
server.once("listening", () => resolve(server));
|
||||||
server.once("error", (err) => {
|
server.once("error", (err) => {
|
||||||
runtime.error(danger(`Media server failed: ${String(err)}`));
|
runtime.error(danger(`Media server failed: ${String(err)}`));
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,54 +7,54 @@ const realOs = await vi.importActual<typeof import("node:os")>("node:os");
|
|||||||
const HOME = path.join(realOs.tmpdir(), "warelay-home-test");
|
const HOME = path.join(realOs.tmpdir(), "warelay-home-test");
|
||||||
|
|
||||||
vi.mock("node:os", () => ({
|
vi.mock("node:os", () => ({
|
||||||
default: { homedir: () => HOME },
|
default: { homedir: () => HOME },
|
||||||
homedir: () => HOME,
|
homedir: () => HOME,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const store = await import("./store.js");
|
const store = await import("./store.js");
|
||||||
|
|
||||||
describe("media store", () => {
|
describe("media store", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await fs.rm(HOME, { recursive: true, force: true });
|
await fs.rm(HOME, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await fs.rm(HOME, { recursive: true, force: true });
|
await fs.rm(HOME, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates and returns media directory", async () => {
|
it("creates and returns media directory", async () => {
|
||||||
const dir = await store.ensureMediaDir();
|
const dir = await store.ensureMediaDir();
|
||||||
expect(dir).toContain("warelay-home-test");
|
expect(dir).toContain("warelay-home-test");
|
||||||
const stat = await fs.stat(dir);
|
const stat = await fs.stat(dir);
|
||||||
expect(stat.isDirectory()).toBe(true);
|
expect(stat.isDirectory()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves buffers and enforces size limit", async () => {
|
it("saves buffers and enforces size limit", async () => {
|
||||||
const buf = Buffer.from("hello");
|
const buf = Buffer.from("hello");
|
||||||
const saved = await store.saveMediaBuffer(buf, "text/plain");
|
const saved = await store.saveMediaBuffer(buf, "text/plain");
|
||||||
const savedStat = await fs.stat(saved.path);
|
const savedStat = await fs.stat(saved.path);
|
||||||
expect(savedStat.size).toBe(buf.length);
|
expect(savedStat.size).toBe(buf.length);
|
||||||
expect(saved.contentType).toBe("text/plain");
|
expect(saved.contentType).toBe("text/plain");
|
||||||
|
|
||||||
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
|
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
|
||||||
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
|
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
|
||||||
"Media exceeds 5MB limit",
|
"Media exceeds 5MB limit",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("copies local files and cleans old media", async () => {
|
it("copies local files and cleans old media", async () => {
|
||||||
const srcFile = path.join(HOME, "tmp-src.txt");
|
const srcFile = path.join(HOME, "tmp-src.txt");
|
||||||
await fs.mkdir(HOME, { recursive: true });
|
await fs.mkdir(HOME, { recursive: true });
|
||||||
await fs.writeFile(srcFile, "local file");
|
await fs.writeFile(srcFile, "local file");
|
||||||
const saved = await store.saveMediaSource(srcFile);
|
const saved = await store.saveMediaSource(srcFile);
|
||||||
expect(saved.size).toBe(10);
|
expect(saved.size).toBe(10);
|
||||||
const savedStat = await fs.stat(saved.path);
|
const savedStat = await fs.stat(saved.path);
|
||||||
expect(savedStat.isFile()).toBe(true);
|
expect(savedStat.isFile()).toBe(true);
|
||||||
|
|
||||||
// make the file look old and ensure cleanOldMedia removes it
|
// make the file look old and ensure cleanOldMedia removes it
|
||||||
const past = Date.now() - 10_000;
|
const past = Date.now() - 10_000;
|
||||||
await fs.utimes(saved.path, past / 1000, past / 1000);
|
await fs.utimes(saved.path, past / 1000, past / 1000);
|
||||||
await store.cleanOldMedia(1);
|
await store.cleanOldMedia(1);
|
||||||
await expect(fs.stat(saved.path)).rejects.toThrow();
|
await expect(fs.stat(saved.path)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,108 +11,108 @@ const MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
|||||||
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
|
|
||||||
export function getMediaDir() {
|
export function getMediaDir() {
|
||||||
return MEDIA_DIR;
|
return MEDIA_DIR;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureMediaDir() {
|
export async function ensureMediaDir() {
|
||||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||||
return MEDIA_DIR;
|
return MEDIA_DIR;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
|
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
|
||||||
await ensureMediaDir();
|
await ensureMediaDir();
|
||||||
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
|
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
entries.map(async (file) => {
|
entries.map(async (file) => {
|
||||||
const full = path.join(MEDIA_DIR, file);
|
const full = path.join(MEDIA_DIR, file);
|
||||||
const stat = await fs.stat(full).catch(() => null);
|
const stat = await fs.stat(full).catch(() => null);
|
||||||
if (!stat) return;
|
if (!stat) return;
|
||||||
if (now - stat.mtimeMs > ttlMs) {
|
if (now - stat.mtimeMs > ttlMs) {
|
||||||
await fs.rm(full).catch(() => {});
|
await fs.rm(full).catch(() => {});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeUrl(src: string) {
|
function looksLikeUrl(src: string) {
|
||||||
return /^https?:\/\//i.test(src);
|
return /^https?:\/\//i.test(src);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadToFile(
|
async function downloadToFile(
|
||||||
url: string,
|
url: string,
|
||||||
dest: string,
|
dest: string,
|
||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const req = request(url, { headers }, (res) => {
|
const req = request(url, { headers }, (res) => {
|
||||||
if (!res.statusCode || res.statusCode >= 400) {
|
if (!res.statusCode || res.statusCode >= 400) {
|
||||||
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let total = 0;
|
let total = 0;
|
||||||
const out = createWriteStream(dest);
|
const out = createWriteStream(dest);
|
||||||
res.on("data", (chunk) => {
|
res.on("data", (chunk) => {
|
||||||
total += chunk.length;
|
total += chunk.length;
|
||||||
if (total > MAX_BYTES) {
|
if (total > MAX_BYTES) {
|
||||||
req.destroy(new Error("Media exceeds 5MB limit"));
|
req.destroy(new Error("Media exceeds 5MB limit"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
pipeline(res, out)
|
pipeline(res, out)
|
||||||
.then(() => resolve())
|
.then(() => resolve())
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SavedMedia = {
|
export type SavedMedia = {
|
||||||
id: string;
|
id: string;
|
||||||
path: string;
|
path: string;
|
||||||
size: number;
|
size: number;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function saveMediaSource(
|
export async function saveMediaSource(
|
||||||
source: string,
|
source: string,
|
||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
subdir = "",
|
subdir = "",
|
||||||
): Promise<SavedMedia> {
|
): Promise<SavedMedia> {
|
||||||
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
|
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
await cleanOldMedia();
|
await cleanOldMedia();
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const dest = path.join(dir, id);
|
const dest = path.join(dir, id);
|
||||||
if (looksLikeUrl(source)) {
|
if (looksLikeUrl(source)) {
|
||||||
await downloadToFile(source, dest, headers);
|
await downloadToFile(source, dest, headers);
|
||||||
const stat = await fs.stat(dest);
|
const stat = await fs.stat(dest);
|
||||||
return { id, path: dest, size: stat.size };
|
return { id, path: dest, size: stat.size };
|
||||||
}
|
}
|
||||||
// local path
|
// local path
|
||||||
const stat = await fs.stat(source);
|
const stat = await fs.stat(source);
|
||||||
if (!stat.isFile()) {
|
if (!stat.isFile()) {
|
||||||
throw new Error("Media path is not a file");
|
throw new Error("Media path is not a file");
|
||||||
}
|
}
|
||||||
if (stat.size > MAX_BYTES) {
|
if (stat.size > MAX_BYTES) {
|
||||||
throw new Error("Media exceeds 5MB limit");
|
throw new Error("Media exceeds 5MB limit");
|
||||||
}
|
}
|
||||||
await fs.copyFile(source, dest);
|
await fs.copyFile(source, dest);
|
||||||
return { id, path: dest, size: stat.size };
|
return { id, path: dest, size: stat.size };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveMediaBuffer(
|
export async function saveMediaBuffer(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
contentType?: string,
|
contentType?: string,
|
||||||
subdir = "inbound",
|
subdir = "inbound",
|
||||||
): Promise<SavedMedia> {
|
): Promise<SavedMedia> {
|
||||||
if (buffer.byteLength > MAX_BYTES) {
|
if (buffer.byteLength > MAX_BYTES) {
|
||||||
throw new Error("Media exceeds 5MB limit");
|
throw new Error("Media exceeds 5MB limit");
|
||||||
}
|
}
|
||||||
const dir = path.join(MEDIA_DIR, subdir);
|
const dir = path.join(MEDIA_DIR, subdir);
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const dest = path.join(dir, id);
|
const dest = path.join(dir, id);
|
||||||
await fs.writeFile(dest, buffer);
|
await fs.writeFile(dest, buffer);
|
||||||
return { id, path: dest, size: buffer.byteLength, contentType };
|
return { id, path: dest, size: buffer.byteLength, contentType };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,53 +3,53 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { enqueueCommand, getQueueSize } from "./command-queue.js";
|
import { enqueueCommand, getQueueSize } from "./command-queue.js";
|
||||||
|
|
||||||
describe("command queue", () => {
|
describe("command queue", () => {
|
||||||
it("runs tasks one at a time in order", async () => {
|
it("runs tasks one at a time in order", async () => {
|
||||||
let active = 0;
|
let active = 0;
|
||||||
let maxActive = 0;
|
let maxActive = 0;
|
||||||
const calls: number[] = [];
|
const calls: number[] = [];
|
||||||
|
|
||||||
const makeTask = (id: number) => async () => {
|
const makeTask = (id: number) => async () => {
|
||||||
active += 1;
|
active += 1;
|
||||||
maxActive = Math.max(maxActive, active);
|
maxActive = Math.max(maxActive, active);
|
||||||
calls.push(id);
|
calls.push(id);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||||
active -= 1;
|
active -= 1;
|
||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
|
|
||||||
const results = await Promise.all([
|
const results = await Promise.all([
|
||||||
enqueueCommand(makeTask(1)),
|
enqueueCommand(makeTask(1)),
|
||||||
enqueueCommand(makeTask(2)),
|
enqueueCommand(makeTask(2)),
|
||||||
enqueueCommand(makeTask(3)),
|
enqueueCommand(makeTask(3)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(results).toEqual([1, 2, 3]);
|
expect(results).toEqual([1, 2, 3]);
|
||||||
expect(calls).toEqual([1, 2, 3]);
|
expect(calls).toEqual([1, 2, 3]);
|
||||||
expect(maxActive).toBe(1);
|
expect(maxActive).toBe(1);
|
||||||
expect(getQueueSize()).toBe(0);
|
expect(getQueueSize()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes onWait callback when a task waits past the threshold", async () => {
|
it("invokes onWait callback when a task waits past the threshold", async () => {
|
||||||
let waited: number | null = null;
|
let waited: number | null = null;
|
||||||
let queuedAhead: number | null = null;
|
let queuedAhead: number | null = null;
|
||||||
|
|
||||||
// First task holds the queue long enough to trigger wait notice.
|
// First task holds the queue long enough to trigger wait notice.
|
||||||
const first = enqueueCommand(async () => {
|
const first = enqueueCommand(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
});
|
});
|
||||||
|
|
||||||
const second = enqueueCommand(async () => {}, {
|
const second = enqueueCommand(async () => {}, {
|
||||||
warnAfterMs: 5,
|
warnAfterMs: 5,
|
||||||
onWait: (ms, ahead) => {
|
onWait: (ms, ahead) => {
|
||||||
waited = ms;
|
waited = ms;
|
||||||
queuedAhead = ahead;
|
queuedAhead = ahead;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all([first, second]);
|
await Promise.all([first, second]);
|
||||||
|
|
||||||
expect(waited).not.toBeNull();
|
expect(waited).not.toBeNull();
|
||||||
expect(waited as number).toBeGreaterThanOrEqual(5);
|
expect(waited as number).toBeGreaterThanOrEqual(5);
|
||||||
expect(queuedAhead).toBe(0);
|
expect(queuedAhead).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,57 +2,57 @@
|
|||||||
// Ensures only one command runs at a time across webhook, poller, and web inbox flows.
|
// Ensures only one command runs at a time across webhook, poller, and web inbox flows.
|
||||||
|
|
||||||
type QueueEntry = {
|
type QueueEntry = {
|
||||||
task: () => Promise<unknown>;
|
task: () => Promise<unknown>;
|
||||||
resolve: (value: unknown) => void;
|
resolve: (value: unknown) => void;
|
||||||
reject: (reason?: unknown) => void;
|
reject: (reason?: unknown) => void;
|
||||||
enqueuedAt: number;
|
enqueuedAt: number;
|
||||||
warnAfterMs: number;
|
warnAfterMs: number;
|
||||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const queue: QueueEntry[] = [];
|
const queue: QueueEntry[] = [];
|
||||||
let draining = false;
|
let draining = false;
|
||||||
|
|
||||||
async function drainQueue() {
|
async function drainQueue() {
|
||||||
if (draining) return;
|
if (draining) return;
|
||||||
draining = true;
|
draining = true;
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const entry = queue.shift() as QueueEntry;
|
const entry = queue.shift() as QueueEntry;
|
||||||
const waitedMs = Date.now() - entry.enqueuedAt;
|
const waitedMs = Date.now() - entry.enqueuedAt;
|
||||||
if (waitedMs >= entry.warnAfterMs) {
|
if (waitedMs >= entry.warnAfterMs) {
|
||||||
entry.onWait?.(waitedMs, queue.length);
|
entry.onWait?.(waitedMs, queue.length);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await entry.task();
|
const result = await entry.task();
|
||||||
entry.resolve(result);
|
entry.resolve(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
entry.reject(err);
|
entry.reject(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
draining = false;
|
draining = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enqueueCommand<T>(
|
export function enqueueCommand<T>(
|
||||||
task: () => Promise<T>,
|
task: () => Promise<T>,
|
||||||
opts?: {
|
opts?: {
|
||||||
warnAfterMs?: number;
|
warnAfterMs?: number;
|
||||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||||
},
|
},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
queue.push({
|
queue.push({
|
||||||
task: () => task(),
|
task: () => task(),
|
||||||
resolve: (value) => resolve(value as T),
|
resolve: (value) => resolve(value as T),
|
||||||
reject,
|
reject,
|
||||||
enqueuedAt: Date.now(),
|
enqueuedAt: Date.now(),
|
||||||
warnAfterMs,
|
warnAfterMs,
|
||||||
onWait: opts?.onWait,
|
onWait: opts?.onWait,
|
||||||
});
|
});
|
||||||
void drainQueue();
|
void drainQueue();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getQueueSize() {
|
export function getQueueSize() {
|
||||||
return queue.length + (draining ? 1 : 0);
|
return queue.length + (draining ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,86 +8,86 @@ const execFileAsync = promisify(execFile);
|
|||||||
|
|
||||||
// Simple promise-wrapped execFile with optional verbosity logging.
|
// Simple promise-wrapped execFile with optional verbosity logging.
|
||||||
export async function runExec(
|
export async function runExec(
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
|
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
const options =
|
const options =
|
||||||
typeof opts === "number"
|
typeof opts === "number"
|
||||||
? { timeout: opts, encoding: "utf8" as const }
|
? { timeout: opts, encoding: "utf8" as const }
|
||||||
: {
|
: {
|
||||||
timeout: opts.timeoutMs,
|
timeout: opts.timeoutMs,
|
||||||
maxBuffer: opts.maxBuffer,
|
maxBuffer: opts.maxBuffer,
|
||||||
encoding: "utf8" as const,
|
encoding: "utf8" as const,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr } = await execFileAsync(command, args, options);
|
const { stdout, stderr } = await execFileAsync(command, args, options);
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
if (stdout.trim()) logDebug(stdout.trim());
|
if (stdout.trim()) logDebug(stdout.trim());
|
||||||
if (stderr.trim()) logError(stderr.trim());
|
if (stderr.trim()) logError(stderr.trim());
|
||||||
}
|
}
|
||||||
return { stdout, stderr };
|
return { stdout, stderr };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
logError(danger(`Command failed: ${command} ${args.join(" ")}`));
|
logError(danger(`Command failed: ${command} ${args.join(" ")}`));
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SpawnResult = {
|
export type SpawnResult = {
|
||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
code: number | null;
|
code: number | null;
|
||||||
signal: NodeJS.Signals | null;
|
signal: NodeJS.Signals | null;
|
||||||
killed: boolean;
|
killed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommandOptions = {
|
export type CommandOptions = {
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function runCommandWithTimeout(
|
export async function runCommandWithTimeout(
|
||||||
argv: string[],
|
argv: string[],
|
||||||
optionsOrTimeout: number | CommandOptions,
|
optionsOrTimeout: number | CommandOptions,
|
||||||
): Promise<SpawnResult> {
|
): Promise<SpawnResult> {
|
||||||
const options: CommandOptions =
|
const options: CommandOptions =
|
||||||
typeof optionsOrTimeout === "number"
|
typeof optionsOrTimeout === "number"
|
||||||
? { timeoutMs: optionsOrTimeout }
|
? { timeoutMs: optionsOrTimeout }
|
||||||
: optionsOrTimeout;
|
: optionsOrTimeout;
|
||||||
const { timeoutMs, cwd } = options;
|
const { timeoutMs, cwd } = options;
|
||||||
|
|
||||||
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
|
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const child = spawn(argv[0], argv.slice(1), {
|
const child = spawn(argv[0], argv.slice(1), {
|
||||||
stdio: ["inherit", "pipe", "pipe"],
|
stdio: ["inherit", "pipe", "pipe"],
|
||||||
cwd,
|
cwd,
|
||||||
});
|
});
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
child.kill("SIGKILL");
|
child.kill("SIGKILL");
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
child.stdout?.on("data", (d) => {
|
child.stdout?.on("data", (d) => {
|
||||||
stdout += d.toString();
|
stdout += d.toString();
|
||||||
});
|
});
|
||||||
child.stderr?.on("data", (d) => {
|
child.stderr?.on("data", (d) => {
|
||||||
stderr += d.toString();
|
stderr += d.toString();
|
||||||
});
|
});
|
||||||
child.on("error", (err) => {
|
child.on("error", (err) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
child.on("close", (code, signal) => {
|
child.on("close", (code, signal) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
resolve({ stdout, stderr, code, signal, killed: child.killed });
|
resolve({ stdout, stderr, code, signal, killed: child.killed });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1612
src/provider-web.ts
1612
src/provider-web.ts
File diff suppressed because it is too large
Load Diff
@ -1,16 +1,16 @@
|
|||||||
export { createClient } from "../../twilio/client.js";
|
export { createClient } from "../../twilio/client.js";
|
||||||
export {
|
export {
|
||||||
formatMessageLine,
|
formatMessageLine,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
} from "../../twilio/messages.js";
|
} from "../../twilio/messages.js";
|
||||||
export { monitorTwilio } from "../../twilio/monitor.js";
|
export { monitorTwilio } from "../../twilio/monitor.js";
|
||||||
export { sendMessage, waitForFinalStatus } from "../../twilio/send.js";
|
export { sendMessage, waitForFinalStatus } from "../../twilio/send.js";
|
||||||
export { findWhatsappSenderSid } from "../../twilio/senders.js";
|
export { findWhatsappSenderSid } from "../../twilio/senders.js";
|
||||||
export { sendTypingIndicator } from "../../twilio/typing.js";
|
export { sendTypingIndicator } from "../../twilio/typing.js";
|
||||||
export {
|
export {
|
||||||
findIncomingNumberSid,
|
findIncomingNumberSid,
|
||||||
findMessagingServiceSid,
|
findMessagingServiceSid,
|
||||||
setMessagingServiceWebhook,
|
setMessagingServiceWebhook,
|
||||||
updateWebhook,
|
updateWebhook,
|
||||||
} from "../../twilio/update-webhook.js";
|
} from "../../twilio/update-webhook.js";
|
||||||
export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.js";
|
export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.js";
|
||||||
|
|||||||
@ -4,16 +4,16 @@ import * as impl from "../../provider-web.js";
|
|||||||
import * as entry from "./index.js";
|
import * as entry from "./index.js";
|
||||||
|
|
||||||
describe("providers/web entrypoint", () => {
|
describe("providers/web entrypoint", () => {
|
||||||
it("re-exports web provider helpers", () => {
|
it("re-exports web provider helpers", () => {
|
||||||
expect(entry.createWaSocket).toBe(impl.createWaSocket);
|
expect(entry.createWaSocket).toBe(impl.createWaSocket);
|
||||||
expect(entry.loginWeb).toBe(impl.loginWeb);
|
expect(entry.loginWeb).toBe(impl.loginWeb);
|
||||||
expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
|
expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
|
||||||
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
|
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
|
||||||
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
|
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
|
||||||
expect(entry.pickProvider).toBe(impl.pickProvider);
|
expect(entry.pickProvider).toBe(impl.pickProvider);
|
||||||
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
|
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
|
||||||
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
|
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
|
||||||
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
|
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
|
||||||
expect(entry.webAuthExists).toBe(impl.webAuthExists);
|
expect(entry.webAuthExists).toBe(impl.webAuthExists);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
/* istanbul ignore file */
|
/* istanbul ignore file */
|
||||||
export {
|
export {
|
||||||
createWaSocket,
|
createWaSocket,
|
||||||
loginWeb,
|
loginWeb,
|
||||||
logWebSelfId,
|
logWebSelfId,
|
||||||
monitorWebInbox,
|
monitorWebInbox,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
pickProvider,
|
pickProvider,
|
||||||
sendMessageWeb,
|
sendMessageWeb,
|
||||||
WA_WEB_AUTH_DIR,
|
WA_WEB_AUTH_DIR,
|
||||||
waitForWaConnection,
|
waitForWaConnection,
|
||||||
webAuthExists,
|
webAuthExists,
|
||||||
} from "../../provider-web.js";
|
} from "../../provider-web.js";
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
export type RuntimeEnv = {
|
export type RuntimeEnv = {
|
||||||
log: typeof console.log;
|
log: typeof console.log;
|
||||||
error: typeof console.error;
|
error: typeof console.error;
|
||||||
exit: (code: number) => never;
|
exit: (code: number) => never;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultRuntime: RuntimeEnv = {
|
export const defaultRuntime: RuntimeEnv = {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
error: console.error,
|
error: console.error,
|
||||||
exit: (code) => {
|
exit: (code) => {
|
||||||
process.exit(code);
|
process.exit(code);
|
||||||
throw new Error("unreachable"); // satisfies tests when mocked
|
throw new Error("unreachable"); // satisfies tests when mocked
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,13 +2,13 @@ import Twilio from "twilio";
|
|||||||
import type { EnvConfig } from "../env.js";
|
import type { EnvConfig } from "../env.js";
|
||||||
|
|
||||||
export function createClient(env: EnvConfig) {
|
export function createClient(env: EnvConfig) {
|
||||||
// Twilio client using either auth token or API key/secret.
|
// Twilio client using either auth token or API key/secret.
|
||||||
if ("authToken" in env.auth) {
|
if ("authToken" in env.auth) {
|
||||||
return Twilio(env.accountSid, env.auth.authToken, {
|
return Twilio(env.accountSid, env.auth.authToken, {
|
||||||
accountSid: env.accountSid,
|
accountSid: env.accountSid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Twilio(env.auth.apiKey, env.auth.apiSecret, {
|
return Twilio(env.auth.apiKey, env.auth.apiSecret, {
|
||||||
accountSid: env.accountSid,
|
accountSid: env.accountSid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,97 +3,97 @@ import { withWhatsAppPrefix } from "../utils.js";
|
|||||||
import { createClient } from "./client.js";
|
import { createClient } from "./client.js";
|
||||||
|
|
||||||
export type ListedMessage = {
|
export type ListedMessage = {
|
||||||
sid: string;
|
sid: string;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
direction: string | null;
|
direction: string | null;
|
||||||
dateCreated: Date | undefined;
|
dateCreated: Date | undefined;
|
||||||
from?: string | null;
|
from?: string | null;
|
||||||
to?: string | null;
|
to?: string | null;
|
||||||
body?: string | null;
|
body?: string | null;
|
||||||
errorCode: number | null;
|
errorCode: number | null;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove duplicates by SID while preserving order.
|
// Remove duplicates by SID while preserving order.
|
||||||
export function uniqueBySid(messages: ListedMessage[]): ListedMessage[] {
|
export function uniqueBySid(messages: ListedMessage[]): ListedMessage[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const deduped: ListedMessage[] = [];
|
const deduped: ListedMessage[] = [];
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
if (seen.has(m.sid)) continue;
|
if (seen.has(m.sid)) continue;
|
||||||
seen.add(m.sid);
|
seen.add(m.sid);
|
||||||
deduped.push(m);
|
deduped.push(m);
|
||||||
}
|
}
|
||||||
return deduped;
|
return deduped;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort messages newest -> oldest by dateCreated.
|
// Sort messages newest -> oldest by dateCreated.
|
||||||
export function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] {
|
export function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] {
|
||||||
return [...messages].sort((a, b) => {
|
return [...messages].sort((a, b) => {
|
||||||
const da = a.dateCreated?.getTime() ?? 0;
|
const da = a.dateCreated?.getTime() ?? 0;
|
||||||
const db = b.dateCreated?.getTime() ?? 0;
|
const db = b.dateCreated?.getTime() ?? 0;
|
||||||
return db - da;
|
return db - da;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge inbound/outbound messages (recent first) for status commands and tests.
|
// Merge inbound/outbound messages (recent first) for status commands and tests.
|
||||||
export async function listRecentMessages(
|
export async function listRecentMessages(
|
||||||
lookbackMinutes: number,
|
lookbackMinutes: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
clientOverride?: ReturnType<typeof createClient>,
|
clientOverride?: ReturnType<typeof createClient>,
|
||||||
): Promise<ListedMessage[]> {
|
): Promise<ListedMessage[]> {
|
||||||
const env = readEnv();
|
const env = readEnv();
|
||||||
const client = clientOverride ?? createClient(env);
|
const client = clientOverride ?? createClient(env);
|
||||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||||
const since = new Date(Date.now() - lookbackMinutes * 60_000);
|
const since = new Date(Date.now() - lookbackMinutes * 60_000);
|
||||||
|
|
||||||
// Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit.
|
// Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit.
|
||||||
const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100);
|
const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100);
|
||||||
const inbound = await client.messages.list({
|
const inbound = await client.messages.list({
|
||||||
to: from,
|
to: from,
|
||||||
dateSentAfter: since,
|
dateSentAfter: since,
|
||||||
limit: fetchLimit,
|
limit: fetchLimit,
|
||||||
});
|
});
|
||||||
const outbound = await client.messages.list({
|
const outbound = await client.messages.list({
|
||||||
from,
|
from,
|
||||||
dateSentAfter: since,
|
dateSentAfter: since,
|
||||||
limit: fetchLimit,
|
limit: fetchLimit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const inboundArr = Array.isArray(inbound) ? inbound : [];
|
const inboundArr = Array.isArray(inbound) ? inbound : [];
|
||||||
const outboundArr = Array.isArray(outbound) ? outbound : [];
|
const outboundArr = Array.isArray(outbound) ? outbound : [];
|
||||||
const combined = uniqueBySid(
|
const combined = uniqueBySid(
|
||||||
[...inboundArr, ...outboundArr].map((m) => ({
|
[...inboundArr, ...outboundArr].map((m) => ({
|
||||||
sid: m.sid,
|
sid: m.sid,
|
||||||
status: m.status ?? null,
|
status: m.status ?? null,
|
||||||
direction: m.direction ?? null,
|
direction: m.direction ?? null,
|
||||||
dateCreated: m.dateCreated,
|
dateCreated: m.dateCreated,
|
||||||
from: m.from,
|
from: m.from,
|
||||||
to: m.to,
|
to: m.to,
|
||||||
body: m.body,
|
body: m.body,
|
||||||
errorCode: m.errorCode ?? null,
|
errorCode: m.errorCode ?? null,
|
||||||
errorMessage: m.errorMessage ?? null,
|
errorMessage: m.errorMessage ?? null,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
return sortByDateDesc(combined).slice(0, limit);
|
return sortByDateDesc(combined).slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Human-friendly single-line formatter for recent messages.
|
// Human-friendly single-line formatter for recent messages.
|
||||||
export function formatMessageLine(m: ListedMessage): string {
|
export function formatMessageLine(m: ListedMessage): string {
|
||||||
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
|
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
|
||||||
const dir =
|
const dir =
|
||||||
m.direction === "inbound"
|
m.direction === "inbound"
|
||||||
? "⬅️ "
|
? "⬅️ "
|
||||||
: m.direction === "outbound-api" || m.direction === "outbound-reply"
|
: m.direction === "outbound-api" || m.direction === "outbound-reply"
|
||||||
? "➡️ "
|
? "➡️ "
|
||||||
: "↔️ ";
|
: "↔️ ";
|
||||||
const status = m.status ?? "unknown";
|
const status = m.status ?? "unknown";
|
||||||
const err =
|
const err =
|
||||||
m.errorCode != null
|
m.errorCode != null
|
||||||
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
|
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
|
||||||
: "";
|
: "";
|
||||||
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
|
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
|
||||||
const bodyPreview =
|
const bodyPreview =
|
||||||
body.length > 140 ? `${body.slice(0, 137)}…` : body || "<empty>";
|
body.length > 140 ? `${body.slice(0, 137)}…` : body || "<empty>";
|
||||||
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
|
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,43 +3,43 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { monitorTwilio } from "./monitor.js";
|
import { monitorTwilio } from "./monitor.js";
|
||||||
|
|
||||||
describe("monitorTwilio", () => {
|
describe("monitorTwilio", () => {
|
||||||
it("processes inbound messages once with injected deps", async () => {
|
it("processes inbound messages once with injected deps", async () => {
|
||||||
const listRecentMessages = vi.fn().mockResolvedValue([
|
const listRecentMessages = vi.fn().mockResolvedValue([
|
||||||
{
|
{
|
||||||
sid: "m1",
|
sid: "m1",
|
||||||
direction: "inbound",
|
direction: "inbound",
|
||||||
dateCreated: new Date(),
|
dateCreated: new Date(),
|
||||||
from: "+1",
|
from: "+1",
|
||||||
to: "+2",
|
to: "+2",
|
||||||
body: "hi",
|
body: "hi",
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
status: null,
|
status: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined);
|
const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined);
|
||||||
const readEnv = vi.fn(() => ({
|
const readEnv = vi.fn(() => ({
|
||||||
accountSid: "AC",
|
accountSid: "AC",
|
||||||
whatsappFrom: "whatsapp:+1",
|
whatsappFrom: "whatsapp:+1",
|
||||||
auth: { accountSid: "AC", authToken: "t" },
|
auth: { accountSid: "AC", authToken: "t" },
|
||||||
}));
|
}));
|
||||||
const createClient = vi.fn(
|
const createClient = vi.fn(
|
||||||
() => ({ messages: { create: vi.fn() } }) as never,
|
() => ({ messages: { create: vi.fn() } }) as never,
|
||||||
);
|
);
|
||||||
const sleep = vi.fn().mockResolvedValue(undefined);
|
const sleep = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
await monitorTwilio(0, 0, {
|
await monitorTwilio(0, 0, {
|
||||||
deps: {
|
deps: {
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
readEnv,
|
readEnv,
|
||||||
createClient,
|
createClient,
|
||||||
sleep,
|
sleep,
|
||||||
},
|
},
|
||||||
maxIterations: 1,
|
maxIterations: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(listRecentMessages).toHaveBeenCalledTimes(1);
|
expect(listRecentMessages).toHaveBeenCalledTimes(1);
|
||||||
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
|
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,122 +8,122 @@ import { sleep, withWhatsAppPrefix } from "../utils.js";
|
|||||||
import { createClient } from "./client.js";
|
import { createClient } from "./client.js";
|
||||||
|
|
||||||
type MonitorDeps = {
|
type MonitorDeps = {
|
||||||
autoReplyIfConfigured: typeof autoReplyIfConfigured;
|
autoReplyIfConfigured: typeof autoReplyIfConfigured;
|
||||||
listRecentMessages: (
|
listRecentMessages: (
|
||||||
lookbackMinutes: number,
|
lookbackMinutes: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
clientOverride?: ReturnType<typeof createClient>,
|
clientOverride?: ReturnType<typeof createClient>,
|
||||||
) => Promise<ListedMessage[]>;
|
) => Promise<ListedMessage[]>;
|
||||||
readEnv: typeof readEnv;
|
readEnv: typeof readEnv;
|
||||||
createClient: typeof createClient;
|
createClient: typeof createClient;
|
||||||
sleep: typeof sleep;
|
sleep: typeof sleep;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
||||||
|
|
||||||
export type ListedMessage = {
|
export type ListedMessage = {
|
||||||
sid: string;
|
sid: string;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
direction: string | null;
|
direction: string | null;
|
||||||
dateCreated: Date | undefined;
|
dateCreated: Date | undefined;
|
||||||
from?: string | null;
|
from?: string | null;
|
||||||
to?: string | null;
|
to?: string | null;
|
||||||
body?: string | null;
|
body?: string | null;
|
||||||
errorCode: number | null;
|
errorCode: number | null;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MonitorOptions = {
|
type MonitorOptions = {
|
||||||
client?: ReturnType<typeof createClient>;
|
client?: ReturnType<typeof createClient>;
|
||||||
maxIterations?: number;
|
maxIterations?: number;
|
||||||
deps?: MonitorDeps;
|
deps?: MonitorDeps;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultDeps: MonitorDeps = {
|
const defaultDeps: MonitorDeps = {
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
listRecentMessages: () => Promise.resolve([]),
|
listRecentMessages: () => Promise.resolve([]),
|
||||||
readEnv,
|
readEnv,
|
||||||
createClient,
|
createClient,
|
||||||
sleep,
|
sleep,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Poll Twilio for inbound messages and auto-reply when configured.
|
// Poll Twilio for inbound messages and auto-reply when configured.
|
||||||
export async function monitorTwilio(
|
export async function monitorTwilio(
|
||||||
pollSeconds: number,
|
pollSeconds: number,
|
||||||
lookbackMinutes: number,
|
lookbackMinutes: number,
|
||||||
opts?: MonitorOptions,
|
opts?: MonitorOptions,
|
||||||
) {
|
) {
|
||||||
const deps = opts?.deps ?? defaultDeps;
|
const deps = opts?.deps ?? defaultDeps;
|
||||||
const runtime = opts?.runtime ?? defaultRuntime;
|
const runtime = opts?.runtime ?? defaultRuntime;
|
||||||
const maxIterations = opts?.maxIterations ?? Infinity;
|
const maxIterations = opts?.maxIterations ?? Infinity;
|
||||||
let backoffMs = 1_000;
|
let backoffMs = 1_000;
|
||||||
|
|
||||||
const env = deps.readEnv(runtime);
|
const env = deps.readEnv(runtime);
|
||||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||||
const client = opts?.client ?? deps.createClient(env);
|
const client = opts?.client ?? deps.createClient(env);
|
||||||
logInfo(
|
logInfo(
|
||||||
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
|
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
|
|
||||||
let lastSeenSid: string | undefined;
|
let lastSeenSid: string | undefined;
|
||||||
let iterations = 0;
|
let iterations = 0;
|
||||||
while (iterations < maxIterations) {
|
while (iterations < maxIterations) {
|
||||||
let messages: ListedMessage[] = [];
|
let messages: ListedMessage[] = [];
|
||||||
try {
|
try {
|
||||||
messages =
|
messages =
|
||||||
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
|
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
|
||||||
backoffMs = 1_000; // reset after success
|
backoffMs = 1_000; // reset after success
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logWarn(
|
logWarn(
|
||||||
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
|
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
await deps.sleep(backoffMs);
|
await deps.sleep(backoffMs);
|
||||||
backoffMs = Math.min(backoffMs * 2, 10_000);
|
backoffMs = Math.min(backoffMs * 2, 10_000);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const inboundOnly = messages.filter((m) => m.direction === "inbound");
|
const inboundOnly = messages.filter((m) => m.direction === "inbound");
|
||||||
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
|
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
|
||||||
const newestFirst = [...inboundOnly].sort(
|
const newestFirst = [...inboundOnly].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
|
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
|
||||||
);
|
);
|
||||||
await handleMessages(messages, client, lastSeenSid, deps, runtime);
|
await handleMessages(messages, client, lastSeenSid, deps, runtime);
|
||||||
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
|
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
|
||||||
iterations += 1;
|
iterations += 1;
|
||||||
if (iterations >= maxIterations) break;
|
if (iterations >= maxIterations) break;
|
||||||
await deps.sleep(
|
await deps.sleep(
|
||||||
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
|
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMessages(
|
async function handleMessages(
|
||||||
messages: ListedMessage[],
|
messages: ListedMessage[],
|
||||||
client: ReturnType<typeof createClient>,
|
client: ReturnType<typeof createClient>,
|
||||||
lastSeenSid: string | undefined,
|
lastSeenSid: string | undefined,
|
||||||
deps: MonitorDeps,
|
deps: MonitorDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
if (!m.sid) continue;
|
if (!m.sid) continue;
|
||||||
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
|
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
|
||||||
logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
|
logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
|
||||||
if (m.direction !== "inbound") continue;
|
if (m.direction !== "inbound") continue;
|
||||||
if (!m.from || !m.to) continue;
|
if (!m.from || !m.to) continue;
|
||||||
try {
|
try {
|
||||||
await deps.autoReplyIfConfigured(
|
await deps.autoReplyIfConfigured(
|
||||||
client as unknown as import("./types.js").TwilioRequester & {
|
client as unknown as import("./types.js").TwilioRequester & {
|
||||||
messages: { create: (opts: unknown) => Promise<unknown> };
|
messages: { create: (opts: unknown) => Promise<unknown> };
|
||||||
},
|
},
|
||||||
m as unknown as MessageInstance,
|
m as unknown as MessageInstance,
|
||||||
undefined,
|
undefined,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(danger(`Auto-reply failed: ${String(err)}`));
|
runtime.error(danger(`Auto-reply failed: ${String(err)}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,30 +3,30 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { waitForFinalStatus } from "./send.js";
|
import { waitForFinalStatus } from "./send.js";
|
||||||
|
|
||||||
describe("twilio send helpers", () => {
|
describe("twilio send helpers", () => {
|
||||||
it("waitForFinalStatus resolves on delivered", async () => {
|
it("waitForFinalStatus resolves on delivered", async () => {
|
||||||
const fetch = vi
|
const fetch = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce({ status: "queued" })
|
.mockResolvedValueOnce({ status: "queued" })
|
||||||
.mockResolvedValueOnce({ status: "delivered" });
|
.mockResolvedValueOnce({ status: "delivered" });
|
||||||
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||||
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
|
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
|
||||||
expect(fetch).toHaveBeenCalledTimes(2);
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("waitForFinalStatus exits on failure", async () => {
|
it("waitForFinalStatus exits on failure", async () => {
|
||||||
const fetch = vi
|
const fetch = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ status: "failed", errorMessage: "boom" });
|
.mockResolvedValue({ status: "failed", errorMessage: "boom" });
|
||||||
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||||
const runtime = {
|
const runtime = {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
error: () => {},
|
error: () => {},
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
} as never;
|
} as never;
|
||||||
await expect(
|
await expect(
|
||||||
waitForFinalStatus(client, "SM1", 1, 0.01, runtime),
|
waitForFinalStatus(client, "SM1", 1, 0.01, runtime),
|
||||||
).rejects.toBeInstanceOf(Error);
|
).rejects.toBeInstanceOf(Error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,60 +10,60 @@ const failureTerminalStatuses = new Set(["failed", "undelivered", "canceled"]);
|
|||||||
|
|
||||||
// Send outbound WhatsApp message; exit non-zero on API failure.
|
// Send outbound WhatsApp message; exit non-zero on API failure.
|
||||||
export async function sendMessage(
|
export async function sendMessage(
|
||||||
to: string,
|
to: string,
|
||||||
body: string,
|
body: string,
|
||||||
opts?: { mediaUrl?: string },
|
opts?: { mediaUrl?: string },
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
const env = readEnv(runtime);
|
const env = readEnv(runtime);
|
||||||
const client = createClient(env);
|
const client = createClient(env);
|
||||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||||
const toNumber = withWhatsAppPrefix(to);
|
const toNumber = withWhatsAppPrefix(to);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = await client.messages.create({
|
const message = await client.messages.create({
|
||||||
from,
|
from,
|
||||||
to: toNumber,
|
to: toNumber,
|
||||||
body,
|
body,
|
||||||
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
|
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
logInfo(
|
logInfo(
|
||||||
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
|
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
return { client, sid: message.sid };
|
return { client, sid: message.sid };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logTwilioSendError(err, toNumber, runtime);
|
logTwilioSendError(err, toNumber, runtime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll message status until delivered/failed or timeout.
|
// Poll message status until delivered/failed or timeout.
|
||||||
export async function waitForFinalStatus(
|
export async function waitForFinalStatus(
|
||||||
client: ReturnType<typeof createClient>,
|
client: ReturnType<typeof createClient>,
|
||||||
sid: string,
|
sid: string,
|
||||||
timeoutSeconds: number,
|
timeoutSeconds: number,
|
||||||
pollSeconds: number,
|
pollSeconds: number,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const m = await client.messages(sid).fetch();
|
const m = await client.messages(sid).fetch();
|
||||||
const status = m.status ?? "unknown";
|
const status = m.status ?? "unknown";
|
||||||
if (successTerminalStatuses.has(status)) {
|
if (successTerminalStatuses.has(status)) {
|
||||||
logInfo(`✅ Delivered (status: ${status})`, runtime);
|
logInfo(`✅ Delivered (status: ${status})`, runtime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (failureTerminalStatuses.has(status)) {
|
if (failureTerminalStatuses.has(status)) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
|
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
|
||||||
);
|
);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
await sleep(pollSeconds * 1000);
|
await sleep(pollSeconds * 1000);
|
||||||
}
|
}
|
||||||
logInfo(
|
logInfo(
|
||||||
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,50 +4,50 @@ import { withWhatsAppPrefix } from "../utils.js";
|
|||||||
import type { TwilioSenderListClient } from "./types.js";
|
import type { TwilioSenderListClient } from "./types.js";
|
||||||
|
|
||||||
export async function findWhatsappSenderSid(
|
export async function findWhatsappSenderSid(
|
||||||
client: TwilioSenderListClient,
|
client: TwilioSenderListClient,
|
||||||
from: string,
|
from: string,
|
||||||
explicitSenderSid?: string,
|
explicitSenderSid?: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
// Use explicit sender SID if provided, otherwise list and match by sender_id.
|
// Use explicit sender SID if provided, otherwise list and match by sender_id.
|
||||||
if (explicitSenderSid) {
|
if (explicitSenderSid) {
|
||||||
logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`);
|
logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`);
|
||||||
return explicitSenderSid;
|
return explicitSenderSid;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Prefer official SDK list helper to avoid request-shape mismatches.
|
// Prefer official SDK list helper to avoid request-shape mismatches.
|
||||||
// Twilio helper types are broad; we narrow to expected shape.
|
// Twilio helper types are broad; we narrow to expected shape.
|
||||||
const senderClient = client as unknown as TwilioSenderListClient;
|
const senderClient = client as unknown as TwilioSenderListClient;
|
||||||
const senders = await senderClient.messaging.v2.channelsSenders.list({
|
const senders = await senderClient.messaging.v2.channelsSenders.list({
|
||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
});
|
});
|
||||||
if (!senders) {
|
if (!senders) {
|
||||||
throw new Error('List senders response missing "senders" array');
|
throw new Error('List senders response missing "senders" array');
|
||||||
}
|
}
|
||||||
const match = senders.find(
|
const match = senders.find(
|
||||||
(s) =>
|
(s) =>
|
||||||
(typeof s.senderId === "string" &&
|
(typeof s.senderId === "string" &&
|
||||||
s.senderId === withWhatsAppPrefix(from)) ||
|
s.senderId === withWhatsAppPrefix(from)) ||
|
||||||
(typeof s.sender_id === "string" &&
|
(typeof s.sender_id === "string" &&
|
||||||
s.sender_id === withWhatsAppPrefix(from)),
|
s.sender_id === withWhatsAppPrefix(from)),
|
||||||
);
|
);
|
||||||
if (!match || typeof match.sid !== "string") {
|
if (!match || typeof match.sid !== "string") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`,
|
`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return match.sid;
|
return match.sid;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(danger("Unable to list WhatsApp senders via Twilio API."));
|
runtime.error(danger("Unable to list WhatsApp senders via Twilio API."));
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
runtime.error(err as Error);
|
runtime.error(err as Error);
|
||||||
}
|
}
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).",
|
"Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,79 +1,79 @@
|
|||||||
export type TwilioRequestOptions = {
|
export type TwilioRequestOptions = {
|
||||||
method: "get" | "post";
|
method: "get" | "post";
|
||||||
uri: string;
|
uri: string;
|
||||||
params?: Record<string, string | number>;
|
params?: Record<string, string | number>;
|
||||||
form?: Record<string, string>;
|
form?: Record<string, string>;
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TwilioSender = { sid: string; sender_id: string };
|
export type TwilioSender = { sid: string; sender_id: string };
|
||||||
|
|
||||||
export type TwilioRequestResponse = {
|
export type TwilioRequestResponse = {
|
||||||
data?: {
|
data?: {
|
||||||
senders?: TwilioSender[];
|
senders?: TwilioSender[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IncomingNumber = {
|
export type IncomingNumber = {
|
||||||
sid: string;
|
sid: string;
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
smsUrl?: string;
|
smsUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TwilioChannelsSender = {
|
export type TwilioChannelsSender = {
|
||||||
sid?: string;
|
sid?: string;
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
sender_id?: string;
|
sender_id?: string;
|
||||||
webhook?: {
|
webhook?: {
|
||||||
callback_url?: string;
|
callback_url?: string;
|
||||||
callback_method?: string;
|
callback_method?: string;
|
||||||
fallback_url?: string;
|
fallback_url?: string;
|
||||||
fallback_method?: string;
|
fallback_method?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelSenderUpdater = {
|
export type ChannelSenderUpdater = {
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
update: (params: Record<string, string>) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IncomingPhoneNumberUpdater = {
|
export type IncomingPhoneNumberUpdater = {
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
update: (params: Record<string, string>) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IncomingPhoneNumbersClient = {
|
export type IncomingPhoneNumbersClient = {
|
||||||
list: (params: {
|
list: (params: {
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}) => Promise<IncomingNumber[]>;
|
}) => Promise<IncomingNumber[]>;
|
||||||
get: (sid: string) => IncomingPhoneNumberUpdater;
|
get: (sid: string) => IncomingPhoneNumberUpdater;
|
||||||
} & ((sid: string) => IncomingPhoneNumberUpdater);
|
} & ((sid: string) => IncomingPhoneNumberUpdater);
|
||||||
|
|
||||||
export type TwilioSenderListClient = {
|
export type TwilioSenderListClient = {
|
||||||
messaging: {
|
messaging: {
|
||||||
v2: {
|
v2: {
|
||||||
channelsSenders: {
|
channelsSenders: {
|
||||||
list: (params: {
|
list: (params: {
|
||||||
channel: string;
|
channel: string;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}) => Promise<TwilioChannelsSender[]>;
|
}) => Promise<TwilioChannelsSender[]>;
|
||||||
(
|
(
|
||||||
sid: string,
|
sid: string,
|
||||||
): ChannelSenderUpdater & {
|
): ChannelSenderUpdater & {
|
||||||
fetch: () => Promise<TwilioChannelsSender>;
|
fetch: () => Promise<TwilioChannelsSender>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
v1: {
|
v1: {
|
||||||
services: (sid: string) => {
|
services: (sid: string) => {
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
update: (params: Record<string, string>) => Promise<unknown>;
|
||||||
fetch: () => Promise<{ inboundRequestUrl?: string }>;
|
fetch: () => Promise<{ inboundRequestUrl?: string }>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
incomingPhoneNumbers: IncomingPhoneNumbersClient;
|
incomingPhoneNumbers: IncomingPhoneNumbersClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TwilioRequester = {
|
export type TwilioRequester = {
|
||||||
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
|
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,42 +2,42 @@ import { isVerbose, logVerbose, warn } from "../globals.js";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
type TwilioRequestOptions = {
|
type TwilioRequestOptions = {
|
||||||
method: "get" | "post";
|
method: "get" | "post";
|
||||||
uri: string;
|
uri: string;
|
||||||
params?: Record<string, string | number>;
|
params?: Record<string, string | number>;
|
||||||
form?: Record<string, string>;
|
form?: Record<string, string>;
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TwilioRequester = {
|
type TwilioRequester = {
|
||||||
request: (options: TwilioRequestOptions) => Promise<unknown>;
|
request: (options: TwilioRequestOptions) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function sendTypingIndicator(
|
export async function sendTypingIndicator(
|
||||||
client: TwilioRequester,
|
client: TwilioRequester,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
messageSid?: string,
|
messageSid?: string,
|
||||||
) {
|
) {
|
||||||
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
|
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
|
||||||
if (!messageSid) {
|
if (!messageSid) {
|
||||||
logVerbose("Skipping typing indicator: missing MessageSid");
|
logVerbose("Skipping typing indicator: missing MessageSid");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await client.request({
|
await client.request({
|
||||||
method: "post",
|
method: "post",
|
||||||
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
|
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
|
||||||
form: {
|
form: {
|
||||||
messageId: messageSid,
|
messageId: messageSid,
|
||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
|
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
runtime.error(warn("Typing indicator failed (continuing without it)"));
|
runtime.error(warn("Typing indicator failed (continuing without it)"));
|
||||||
runtime.error(err as Error);
|
runtime.error(err as Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,61 +1,61 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
findIncomingNumberSid,
|
findIncomingNumberSid,
|
||||||
findMessagingServiceSid,
|
findMessagingServiceSid,
|
||||||
setMessagingServiceWebhook,
|
setMessagingServiceWebhook,
|
||||||
} from "./update-webhook.js";
|
} from "./update-webhook.js";
|
||||||
|
|
||||||
const envBackup = { ...process.env } as Record<string, string | undefined>;
|
const envBackup = { ...process.env } as Record<string, string | undefined>;
|
||||||
|
|
||||||
describe("update-webhook helpers", () => {
|
describe("update-webhook helpers", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.TWILIO_ACCOUNT_SID = "AC";
|
process.env.TWILIO_ACCOUNT_SID = "AC";
|
||||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
|
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
|
||||||
process.env.TWILIO_AUTH_TOKEN = "dummy-token";
|
process.env.TWILIO_AUTH_TOKEN = "dummy-token";
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Object.entries(envBackup).forEach(([k, v]) => {
|
Object.entries(envBackup).forEach(([k, v]) => {
|
||||||
if (v === undefined) delete process.env[k];
|
if (v === undefined) delete process.env[k];
|
||||||
else process.env[k] = v;
|
else process.env[k] = v;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("findIncomingNumberSid returns first match", async () => {
|
it("findIncomingNumberSid returns first match", async () => {
|
||||||
const client = {
|
const client = {
|
||||||
incomingPhoneNumbers: {
|
incomingPhoneNumbers: {
|
||||||
list: async () => [{ sid: "PN1", phoneNumber: "+1555" }],
|
list: async () => [{ sid: "PN1", phoneNumber: "+1555" }],
|
||||||
},
|
},
|
||||||
} as never;
|
} as never;
|
||||||
const sid = await findIncomingNumberSid(client);
|
const sid = await findIncomingNumberSid(client);
|
||||||
expect(sid).toBe("PN1");
|
expect(sid).toBe("PN1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("findMessagingServiceSid reads messagingServiceSid", async () => {
|
it("findMessagingServiceSid reads messagingServiceSid", async () => {
|
||||||
const client = {
|
const client = {
|
||||||
incomingPhoneNumbers: {
|
incomingPhoneNumbers: {
|
||||||
list: async () => [{ messagingServiceSid: "MG1" }],
|
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||||
},
|
},
|
||||||
} as never;
|
} as never;
|
||||||
const sid = await findMessagingServiceSid(client);
|
const sid = await findMessagingServiceSid(client);
|
||||||
expect(sid).toBe("MG1");
|
expect(sid).toBe("MG1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("setMessagingServiceWebhook updates via service helper", async () => {
|
it("setMessagingServiceWebhook updates via service helper", async () => {
|
||||||
const update = async (_: unknown) => {};
|
const update = async (_: unknown) => {};
|
||||||
const fetch = async () => ({ inboundRequestUrl: "https://cb" });
|
const fetch = async () => ({ inboundRequestUrl: "https://cb" });
|
||||||
const client = {
|
const client = {
|
||||||
messaging: {
|
messaging: {
|
||||||
v1: {
|
v1: {
|
||||||
services: () => ({ update, fetch }),
|
services: () => ({ update, fetch }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
incomingPhoneNumbers: {
|
incomingPhoneNumbers: {
|
||||||
list: async () => [{ messagingServiceSid: "MG1" }],
|
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||||
},
|
},
|
||||||
} as never;
|
} as never;
|
||||||
const ok = await setMessagingServiceWebhook(client, "https://cb", "POST");
|
const ok = await setMessagingServiceWebhook(client, "https://cb", "POST");
|
||||||
expect(ok).toBe(true);
|
expect(ok).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,193 +6,193 @@ import type { createClient } from "./client.js";
|
|||||||
import type { TwilioRequester, TwilioSenderListClient } from "./types.js";
|
import type { TwilioRequester, TwilioSenderListClient } from "./types.js";
|
||||||
|
|
||||||
export async function findIncomingNumberSid(
|
export async function findIncomingNumberSid(
|
||||||
client: TwilioSenderListClient,
|
client: TwilioSenderListClient,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
// Look up incoming phone number SID matching the configured WhatsApp number.
|
// Look up incoming phone number SID matching the configured WhatsApp number.
|
||||||
try {
|
try {
|
||||||
const env = readEnv();
|
const env = readEnv();
|
||||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||||
const list = await client.incomingPhoneNumbers.list({
|
const list = await client.incomingPhoneNumbers.list({
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
return list?.[0]?.sid ?? null;
|
return list?.[0]?.sid ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findMessagingServiceSid(
|
export async function findMessagingServiceSid(
|
||||||
client: TwilioSenderListClient,
|
client: TwilioSenderListClient,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
|
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
|
||||||
type IncomingNumberWithService = { messagingServiceSid?: string };
|
type IncomingNumberWithService = { messagingServiceSid?: string };
|
||||||
try {
|
try {
|
||||||
const env = readEnv();
|
const env = readEnv();
|
||||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||||
const list = await client.incomingPhoneNumbers.list({
|
const list = await client.incomingPhoneNumbers.list({
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
const msid =
|
const msid =
|
||||||
(list?.[0] as IncomingNumberWithService | undefined)
|
(list?.[0] as IncomingNumberWithService | undefined)
|
||||||
?.messagingServiceSid ?? null;
|
?.messagingServiceSid ?? null;
|
||||||
return msid;
|
return msid;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setMessagingServiceWebhook(
|
export async function setMessagingServiceWebhook(
|
||||||
client: TwilioSenderListClient,
|
client: TwilioSenderListClient,
|
||||||
url: string,
|
url: string,
|
||||||
method: "POST" | "GET",
|
method: "POST" | "GET",
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const msid = await findMessagingServiceSid(client);
|
const msid = await findMessagingServiceSid(client);
|
||||||
if (!msid) return false;
|
if (!msid) return false;
|
||||||
try {
|
try {
|
||||||
await client.messaging.v1.services(msid).update({
|
await client.messaging.v1.services(msid).update({
|
||||||
InboundRequestUrl: url,
|
InboundRequestUrl: url,
|
||||||
InboundRequestMethod: method,
|
InboundRequestMethod: method,
|
||||||
});
|
});
|
||||||
const fetched = await client.messaging.v1.services(msid).fetch();
|
const fetched = await client.messaging.v1.services(msid).fetch();
|
||||||
const stored = fetched?.inboundRequestUrl;
|
const stored = fetched?.inboundRequestUrl;
|
||||||
logInfo(
|
logInfo(
|
||||||
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
|
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update sender webhook URL with layered fallbacks (channels, form, helper, phone).
|
// Update sender webhook URL with layered fallbacks (channels, form, helper, phone).
|
||||||
export async function updateWebhook(
|
export async function updateWebhook(
|
||||||
client: ReturnType<typeof createClient>,
|
client: ReturnType<typeof createClient>,
|
||||||
senderSid: string,
|
senderSid: string,
|
||||||
url: string,
|
url: string,
|
||||||
method: "POST" | "GET" = "POST",
|
method: "POST" | "GET" = "POST",
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
// Point Twilio sender webhook at the provided URL.
|
// Point Twilio sender webhook at the provided URL.
|
||||||
const requester = client as unknown as TwilioRequester;
|
const requester = client as unknown as TwilioRequester;
|
||||||
const clientTyped = client as unknown as TwilioSenderListClient;
|
const clientTyped = client as unknown as TwilioSenderListClient;
|
||||||
|
|
||||||
// 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA
|
// 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA
|
||||||
try {
|
try {
|
||||||
await requester.request({
|
await requester.request({
|
||||||
method: "post",
|
method: "post",
|
||||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||||
body: {
|
body: {
|
||||||
webhook: {
|
webhook: {
|
||||||
callback_url: url,
|
callback_url: url,
|
||||||
callback_method: method,
|
callback_method: method,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
});
|
});
|
||||||
const fetched = await clientTyped.messaging.v2
|
const fetched = await clientTyped.messaging.v2
|
||||||
.channelsSenders(senderSid)
|
.channelsSenders(senderSid)
|
||||||
.fetch();
|
.fetch();
|
||||||
const storedUrl =
|
const storedUrl =
|
||||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||||
if (storedUrl) {
|
if (storedUrl) {
|
||||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
"Sender updated but webhook callback_url missing; will try fallbacks",
|
"Sender updated but webhook callback_url missing; will try fallbacks",
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
`channelsSenders request update failed, will try client helpers: ${String(err)}`,
|
`channelsSenders request update failed, will try client helpers: ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1b) Form-encoded fallback for older Twilio stacks
|
// 1b) Form-encoded fallback for older Twilio stacks
|
||||||
try {
|
try {
|
||||||
await requester.request({
|
await requester.request({
|
||||||
method: "post",
|
method: "post",
|
||||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||||
form: {
|
form: {
|
||||||
"Webhook.CallbackUrl": url,
|
"Webhook.CallbackUrl": url,
|
||||||
"Webhook.CallbackMethod": method,
|
"Webhook.CallbackMethod": method,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const fetched = await clientTyped.messaging.v2
|
const fetched = await clientTyped.messaging.v2
|
||||||
.channelsSenders(senderSid)
|
.channelsSenders(senderSid)
|
||||||
.fetch();
|
.fetch();
|
||||||
const storedUrl =
|
const storedUrl =
|
||||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||||
if (storedUrl) {
|
if (storedUrl) {
|
||||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
"Form update succeeded but callback_url missing; will try helper fallback",
|
"Form update succeeded but callback_url missing; will try helper fallback",
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
`Form channelsSenders update failed, will try helper fallback: ${String(err)}`,
|
`Form channelsSenders update failed, will try helper fallback: ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) SDK helper fallback (if supported by this client)
|
// 2) SDK helper fallback (if supported by this client)
|
||||||
try {
|
try {
|
||||||
if (clientTyped.messaging?.v2?.channelsSenders) {
|
if (clientTyped.messaging?.v2?.channelsSenders) {
|
||||||
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
|
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
|
||||||
callbackUrl: url,
|
callbackUrl: url,
|
||||||
callbackMethod: method,
|
callbackMethod: method,
|
||||||
});
|
});
|
||||||
const fetched = await clientTyped.messaging.v2
|
const fetched = await clientTyped.messaging.v2
|
||||||
.channelsSenders(senderSid)
|
.channelsSenders(senderSid)
|
||||||
.fetch();
|
.fetch();
|
||||||
const storedUrl =
|
const storedUrl =
|
||||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||||
logInfo(
|
logInfo(
|
||||||
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
|
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
`channelsSenders helper update failed, will try phone number fallback: ${String(err)}`,
|
`channelsSenders helper update failed, will try phone number fallback: ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Incoming phone number fallback (works for many WA senders)
|
// 3) Incoming phone number fallback (works for many WA senders)
|
||||||
try {
|
try {
|
||||||
const phoneSid = await findIncomingNumberSid(clientTyped);
|
const phoneSid = await findIncomingNumberSid(clientTyped);
|
||||||
if (phoneSid) {
|
if (phoneSid) {
|
||||||
await clientTyped.incomingPhoneNumbers(phoneSid).update({
|
await clientTyped.incomingPhoneNumbers(phoneSid).update({
|
||||||
smsUrl: url,
|
smsUrl: url,
|
||||||
smsMethod: method,
|
smsMethod: method,
|
||||||
});
|
});
|
||||||
logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime);
|
logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
`Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`,
|
`Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.error(
|
runtime.error(
|
||||||
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
|
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,36 +2,36 @@ import { danger, info } from "../globals.js";
|
|||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
type TwilioApiError = {
|
type TwilioApiError = {
|
||||||
code?: number | string;
|
code?: number | string;
|
||||||
status?: number | string;
|
status?: number | string;
|
||||||
message?: string;
|
message?: string;
|
||||||
moreInfo?: string;
|
moreInfo?: string;
|
||||||
response?: { body?: unknown };
|
response?: { body?: unknown };
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatTwilioError(err: unknown): string {
|
export function formatTwilioError(err: unknown): string {
|
||||||
// Normalize Twilio error objects into a single readable string.
|
// Normalize Twilio error objects into a single readable string.
|
||||||
const e = err as TwilioApiError;
|
const e = err as TwilioApiError;
|
||||||
const pieces = [];
|
const pieces = [];
|
||||||
if (e.code != null) pieces.push(`code ${e.code}`);
|
if (e.code != null) pieces.push(`code ${e.code}`);
|
||||||
if (e.status != null) pieces.push(`status ${e.status}`);
|
if (e.status != null) pieces.push(`status ${e.status}`);
|
||||||
if (e.message) pieces.push(e.message);
|
if (e.message) pieces.push(e.message);
|
||||||
if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`);
|
if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`);
|
||||||
return pieces.length ? pieces.join(" | ") : String(err);
|
return pieces.length ? pieces.join(" | ") : String(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logTwilioSendError(
|
export function logTwilioSendError(
|
||||||
err: unknown,
|
err: unknown,
|
||||||
destination?: string,
|
destination?: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
// Friendly error logger for send failures, including response body when present.
|
// Friendly error logger for send failures, including response body when present.
|
||||||
const prefix = destination ? `to ${destination}: ` : "";
|
const prefix = destination ? `to ${destination}: ` : "";
|
||||||
runtime.error(
|
runtime.error(
|
||||||
danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`),
|
danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`),
|
||||||
);
|
);
|
||||||
const body = (err as TwilioApiError)?.response?.body;
|
const body = (err as TwilioApiError)?.response?.body;
|
||||||
if (body) {
|
if (body) {
|
||||||
runtime.error(info("Response body:"), JSON.stringify(body, null, 2));
|
runtime.error(info("Response body:"), JSON.stringify(body, null, 2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,143 +16,143 @@ import { logTwilioSendError } from "./utils.js";
|
|||||||
|
|
||||||
/** Start the inbound webhook HTTP server and wire optional auto-replies. */
|
/** Start the inbound webhook HTTP server and wire optional auto-replies. */
|
||||||
export async function startWebhook(
|
export async function startWebhook(
|
||||||
port: number,
|
port: number,
|
||||||
path = "/webhook/whatsapp",
|
path = "/webhook/whatsapp",
|
||||||
autoReply: string | undefined,
|
autoReply: string | undefined,
|
||||||
verbose: boolean,
|
verbose: boolean,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<Server> {
|
): Promise<Server> {
|
||||||
const normalizedPath = normalizePath(path);
|
const normalizedPath = normalizePath(path);
|
||||||
const env = readEnv(runtime);
|
const env = readEnv(runtime);
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
attachMediaRoutes(app, undefined, runtime);
|
attachMediaRoutes(app, undefined, runtime);
|
||||||
// Twilio sends application/x-www-form-urlencoded payloads.
|
// Twilio sends application/x-www-form-urlencoded payloads.
|
||||||
app.use(bodyParser.urlencoded({ extended: false }));
|
app.use(bodyParser.urlencoded({ extended: false }));
|
||||||
app.use((req, _res, next) => {
|
app.use((req, _res, next) => {
|
||||||
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
|
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(normalizedPath, async (req: Request, res: Response) => {
|
app.post(normalizedPath, async (req: Request, res: Response) => {
|
||||||
const { From, To, Body, MessageSid } = req.body ?? {};
|
const { From, To, Body, MessageSid } = req.body ?? {};
|
||||||
runtime.log(`
|
runtime.log(`
|
||||||
[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`);
|
[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`);
|
||||||
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
|
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
|
||||||
|
|
||||||
const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
|
const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
|
||||||
let mediaPath: string | undefined;
|
let mediaPath: string | undefined;
|
||||||
let mediaUrlInbound: string | undefined;
|
let mediaUrlInbound: string | undefined;
|
||||||
let mediaType: string | undefined;
|
let mediaType: string | undefined;
|
||||||
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
|
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
|
||||||
mediaUrlInbound = req.body.MediaUrl0 as string;
|
mediaUrlInbound = req.body.MediaUrl0 as string;
|
||||||
mediaType =
|
mediaType =
|
||||||
typeof req.body?.MediaContentType0 === "string"
|
typeof req.body?.MediaContentType0 === "string"
|
||||||
? (req.body.MediaContentType0 as string)
|
? (req.body.MediaContentType0 as string)
|
||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
const creds = buildTwilioBasicAuth(env);
|
const creds = buildTwilioBasicAuth(env);
|
||||||
const saved = await saveMediaSource(
|
const saved = await saveMediaSource(
|
||||||
mediaUrlInbound,
|
mediaUrlInbound,
|
||||||
{
|
{
|
||||||
Authorization: `Basic ${creds}`,
|
Authorization: `Basic ${creds}`,
|
||||||
},
|
},
|
||||||
"inbound",
|
"inbound",
|
||||||
);
|
);
|
||||||
mediaPath = saved.path;
|
mediaPath = saved.path;
|
||||||
if (!mediaType && saved.contentType) mediaType = saved.contentType;
|
if (!mediaType && saved.contentType) mediaType = saved.contentType;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
danger(`Failed to download inbound media: ${String(err)}`),
|
danger(`Failed to download inbound media: ${String(err)}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = createClient(env);
|
const client = createClient(env);
|
||||||
let replyResult: ReplyPayload | undefined =
|
let replyResult: ReplyPayload | undefined =
|
||||||
autoReply !== undefined ? { text: autoReply } : undefined;
|
autoReply !== undefined ? { text: autoReply } : undefined;
|
||||||
if (!replyResult) {
|
if (!replyResult) {
|
||||||
replyResult = await getReplyFromConfig(
|
replyResult = await getReplyFromConfig(
|
||||||
{
|
{
|
||||||
Body,
|
Body,
|
||||||
From,
|
From,
|
||||||
To,
|
To,
|
||||||
MessageSid,
|
MessageSid,
|
||||||
MediaPath: mediaPath,
|
MediaPath: mediaPath,
|
||||||
MediaUrl: mediaUrlInbound,
|
MediaUrl: mediaUrlInbound,
|
||||||
MediaType: mediaType,
|
MediaType: mediaType,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
|
if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
|
||||||
try {
|
try {
|
||||||
let mediaUrl = replyResult.mediaUrl;
|
let mediaUrl = replyResult.mediaUrl;
|
||||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
||||||
const hosted = await ensureMediaHosted(mediaUrl);
|
const hosted = await ensureMediaHosted(mediaUrl);
|
||||||
mediaUrl = hosted.url;
|
mediaUrl = hosted.url;
|
||||||
}
|
}
|
||||||
await client.messages.create({
|
await client.messages.create({
|
||||||
from: To,
|
from: To,
|
||||||
to: From,
|
to: From,
|
||||||
body: replyResult.text ?? "",
|
body: replyResult.text ?? "",
|
||||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
||||||
});
|
});
|
||||||
if (verbose)
|
if (verbose)
|
||||||
runtime.log(
|
runtime.log(
|
||||||
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
|
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logTwilioSendError(err, From ?? undefined, runtime);
|
logTwilioSendError(err, From ?? undefined, runtime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respond 200 OK to Twilio.
|
// Respond 200 OK to Twilio.
|
||||||
res.type("text/xml").send("<Response></Response>");
|
res.type("text/xml").send("<Response></Response>");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use((_req, res) => {
|
app.use((_req, res) => {
|
||||||
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
|
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
|
||||||
res.status(404).send("warelay webhook: not found");
|
res.status(404).send("warelay webhook: not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start server and resolve once listening; reject on bind error.
|
// Start server and resolve once listening; reject on bind error.
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const server = app.listen(port);
|
const server = app.listen(port);
|
||||||
|
|
||||||
const onListening = () => {
|
const onListening = () => {
|
||||||
cleanup();
|
cleanup();
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
|
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
|
||||||
);
|
);
|
||||||
resolve(server);
|
resolve(server);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onError = (err: NodeJS.ErrnoException) => {
|
const onError = (err: NodeJS.ErrnoException) => {
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(err);
|
reject(err);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
server.off("listening", onListening);
|
server.off("listening", onListening);
|
||||||
server.off("error", onError);
|
server.off("error", onError);
|
||||||
};
|
};
|
||||||
|
|
||||||
server.once("listening", onListening);
|
server.once("listening", onListening);
|
||||||
server.once("error", onError);
|
server.once("error", onError);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTwilioBasicAuth(env: EnvConfig) {
|
function buildTwilioBasicAuth(env: EnvConfig) {
|
||||||
if ("authToken" in env.auth) {
|
if ("authToken" in env.auth) {
|
||||||
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
|
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
|
||||||
"base64",
|
"base64",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
|
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
|
||||||
"base64",
|
"base64",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,67 +3,67 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
assertProvider,
|
assertProvider,
|
||||||
ensureDir,
|
ensureDir,
|
||||||
normalizeE164,
|
normalizeE164,
|
||||||
normalizePath,
|
normalizePath,
|
||||||
sleep,
|
sleep,
|
||||||
toWhatsappJid,
|
toWhatsappJid,
|
||||||
withWhatsAppPrefix,
|
withWhatsAppPrefix,
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
|
|
||||||
describe("normalizePath", () => {
|
describe("normalizePath", () => {
|
||||||
it("adds leading slash when missing", () => {
|
it("adds leading slash when missing", () => {
|
||||||
expect(normalizePath("foo")).toBe("/foo");
|
expect(normalizePath("foo")).toBe("/foo");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps existing slash", () => {
|
it("keeps existing slash", () => {
|
||||||
expect(normalizePath("/bar")).toBe("/bar");
|
expect(normalizePath("/bar")).toBe("/bar");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("withWhatsAppPrefix", () => {
|
describe("withWhatsAppPrefix", () => {
|
||||||
it("adds whatsapp prefix", () => {
|
it("adds whatsapp prefix", () => {
|
||||||
expect(withWhatsAppPrefix("+1555")).toBe("whatsapp:+1555");
|
expect(withWhatsAppPrefix("+1555")).toBe("whatsapp:+1555");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves prefixed intact", () => {
|
it("leaves prefixed intact", () => {
|
||||||
expect(withWhatsAppPrefix("whatsapp:+1555")).toBe("whatsapp:+1555");
|
expect(withWhatsAppPrefix("whatsapp:+1555")).toBe("whatsapp:+1555");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ensureDir", () => {
|
describe("ensureDir", () => {
|
||||||
it("creates nested directory", async () => {
|
it("creates nested directory", async () => {
|
||||||
const tmp = await fs.promises.mkdtemp(
|
const tmp = await fs.promises.mkdtemp(
|
||||||
path.join(os.tmpdir(), "warelay-test-"),
|
path.join(os.tmpdir(), "warelay-test-"),
|
||||||
);
|
);
|
||||||
const target = path.join(tmp, "nested", "dir");
|
const target = path.join(tmp, "nested", "dir");
|
||||||
await ensureDir(target);
|
await ensureDir(target);
|
||||||
expect(fs.existsSync(target)).toBe(true);
|
expect(fs.existsSync(target)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sleep", () => {
|
describe("sleep", () => {
|
||||||
it("resolves after delay using fake timers", async () => {
|
it("resolves after delay using fake timers", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const promise = sleep(1000);
|
const promise = sleep(1000);
|
||||||
vi.advanceTimersByTime(1000);
|
vi.advanceTimersByTime(1000);
|
||||||
await expect(promise).resolves.toBeUndefined();
|
await expect(promise).resolves.toBeUndefined();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("assertProvider", () => {
|
describe("assertProvider", () => {
|
||||||
it("throws for invalid provider", () => {
|
it("throws for invalid provider", () => {
|
||||||
expect(() => assertProvider("bad" as string)).toThrow();
|
expect(() => assertProvider("bad" as string)).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("normalizeE164 & toWhatsappJid", () => {
|
describe("normalizeE164 & toWhatsappJid", () => {
|
||||||
it("strips formatting and prefixes", () => {
|
it("strips formatting and prefixes", () => {
|
||||||
expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567");
|
expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567");
|
||||||
expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe(
|
expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe(
|
||||||
"5551234567@s.whatsapp.net",
|
"5551234567@s.whatsapp.net",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
40
src/utils.ts
40
src/utils.ts
@ -2,49 +2,49 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
||||||
export async function ensureDir(dir: string) {
|
export async function ensureDir(dir: string) {
|
||||||
await fs.promises.mkdir(dir, { recursive: true });
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Provider = "twilio" | "web";
|
export type Provider = "twilio" | "web";
|
||||||
|
|
||||||
export function assertProvider(input: string): asserts input is Provider {
|
export function assertProvider(input: string): asserts input is Provider {
|
||||||
if (input !== "twilio" && input !== "web") {
|
if (input !== "twilio" && input !== "web") {
|
||||||
throw new Error("Provider must be 'twilio' or 'web'");
|
throw new Error("Provider must be 'twilio' or 'web'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePath(p: string): string {
|
export function normalizePath(p: string): string {
|
||||||
if (!p.startsWith("/")) return `/${p}`;
|
if (!p.startsWith("/")) return `/${p}`;
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withWhatsAppPrefix(number: string): string {
|
export function withWhatsAppPrefix(number: string): string {
|
||||||
return number.startsWith("whatsapp:") ? number : `whatsapp:${number}`;
|
return number.startsWith("whatsapp:") ? number : `whatsapp:${number}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeE164(number: string): string {
|
export function normalizeE164(number: string): string {
|
||||||
const withoutPrefix = number.replace(/^whatsapp:/, "").trim();
|
const withoutPrefix = number.replace(/^whatsapp:/, "").trim();
|
||||||
const digits = withoutPrefix.replace(/[^\d+]/g, "");
|
const digits = withoutPrefix.replace(/[^\d+]/g, "");
|
||||||
if (digits.startsWith("+")) return `+${digits.slice(1)}`;
|
if (digits.startsWith("+")) return `+${digits.slice(1)}`;
|
||||||
return `+${digits}`;
|
return `+${digits}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toWhatsappJid(number: string): string {
|
export function toWhatsappJid(number: string): string {
|
||||||
const e164 = normalizeE164(number);
|
const e164 = normalizeE164(number);
|
||||||
const digits = e164.replace(/\D/g, "");
|
const digits = e164.replace(/\D/g, "");
|
||||||
return `${digits}@s.whatsapp.net`;
|
return `${digits}@s.whatsapp.net`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jidToE164(jid: string): string | null {
|
export function jidToE164(jid: string): string | null {
|
||||||
// Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234.
|
// Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234.
|
||||||
const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/);
|
const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const digits = match[1];
|
const digits = match[1];
|
||||||
return `+${digits}`;
|
return `+${digits}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sleep(ms: number) {
|
export function sleep(ms: number) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONFIG_DIR = `${os.homedir()}/.warelay`;
|
export const CONFIG_DIR = `${os.homedir()}/.warelay`;
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import * as impl from "../twilio/webhook.js";
|
|||||||
import * as entry from "./server.js";
|
import * as entry from "./server.js";
|
||||||
|
|
||||||
describe("webhook server wrapper", () => {
|
describe("webhook server wrapper", () => {
|
||||||
it("re-exports startWebhook", () => {
|
it("re-exports startWebhook", () => {
|
||||||
expect(entry.startWebhook).toBe(impl.startWebhook);
|
expect(entry.startWebhook).toBe(impl.startWebhook);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,12 +4,12 @@ import * as impl from "../twilio/update-webhook.js";
|
|||||||
import * as entry from "./update.js";
|
import * as entry from "./update.js";
|
||||||
|
|
||||||
describe("webhook update wrappers", () => {
|
describe("webhook update wrappers", () => {
|
||||||
it("mirror the Twilio implementations", () => {
|
it("mirror the Twilio implementations", () => {
|
||||||
expect(entry.updateWebhook).toBe(impl.updateWebhook);
|
expect(entry.updateWebhook).toBe(impl.updateWebhook);
|
||||||
expect(entry.findIncomingNumberSid).toBe(impl.findIncomingNumberSid);
|
expect(entry.findIncomingNumberSid).toBe(impl.findIncomingNumberSid);
|
||||||
expect(entry.findMessagingServiceSid).toBe(impl.findMessagingServiceSid);
|
expect(entry.findMessagingServiceSid).toBe(impl.findMessagingServiceSid);
|
||||||
expect(entry.setMessagingServiceWebhook).toBe(
|
expect(entry.setMessagingServiceWebhook).toBe(
|
||||||
impl.setMessagingServiceWebhook,
|
impl.setMessagingServiceWebhook,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/* istanbul ignore file */
|
/* istanbul ignore file */
|
||||||
export {
|
export {
|
||||||
findIncomingNumberSid,
|
findIncomingNumberSid,
|
||||||
findMessagingServiceSid,
|
findMessagingServiceSid,
|
||||||
setMessagingServiceWebhook,
|
setMessagingServiceWebhook,
|
||||||
updateWebhook,
|
updateWebhook,
|
||||||
} from "../twilio/update-webhook.js";
|
} from "../twilio/update-webhook.js";
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user