chore: sync source updates

main
Peter Steinberger 2025-11-25 12:12:13 +01:00
parent ea745764d7
commit 800c7a1e1f
26 changed files with 462 additions and 376 deletions

View File

@ -2,12 +2,7 @@ import crypto from "node:crypto";
import path from "node:path"; import path from "node:path";
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js"; import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
import { CLAUDE_BIN, CLAUDE_IDENTITY_PREFIX, parseClaudeJson } from "./claude.js"; import { loadConfig, type WarelayConfig } from "../config/config.js";
import {
applyTemplate,
type MsgContext,
type TemplateContext,
} from "./templating.js";
import { import {
DEFAULT_IDLE_MINUTES, DEFAULT_IDLE_MINUTES,
DEFAULT_RESET_TRIGGER, DEFAULT_RESET_TRIGGER,
@ -16,16 +11,25 @@ import {
resolveStorePath, resolveStorePath,
saveSessionStore, saveSessionStore,
} from "../config/sessions.js"; } from "../config/sessions.js";
import { loadConfig, type WarelayConfig } from "../config/config.js";
import { info, isVerbose, logVerbose } from "../globals.js"; import { info, isVerbose, logVerbose } from "../globals.js";
import { enqueueCommand } from "../process/command-queue.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { sendTypingIndicator } from "../twilio/typing.js";
import type { TwilioRequester } from "../twilio/types.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { logError } from "../logger.js"; import { logError } from "../logger.js";
import { ensureMediaHosted } from "../media/host.js"; import { ensureMediaHosted } from "../media/host.js";
import { normalizeMediaSource, splitMediaFromOutput } from "../media/parse.js"; import { splitMediaFromOutput } from "../media/parse.js";
import { enqueueCommand } from "../process/command-queue.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import type { TwilioRequester } from "../twilio/types.js";
import { sendTypingIndicator } from "../twilio/typing.js";
import {
CLAUDE_BIN,
CLAUDE_IDENTITY_PREFIX,
parseClaudeJson,
} from "./claude.js";
import {
applyTemplate,
type MsgContext,
type TemplateContext,
} from "./templating.js";
type GetReplyOptions = { type GetReplyOptions = {
onReplyStart?: () => Promise<void> | void; onReplyStart?: () => Promise<void> | void;
@ -51,20 +55,20 @@ function summarizeClaudeMetadata(payload: unknown): string | undefined {
const usage = obj.usage; const usage = obj.usage;
if (usage && typeof usage === "object") { if (usage && typeof usage === "object") {
const serverToolUse = ( const serverToolUse = (
usage as { server_tool_use?: Record<string, unknown> } usage as { server_tool_use?: Record<string, unknown> }
).server_tool_use; ).server_tool_use;
if (serverToolUse && typeof serverToolUse === "object") { if (serverToolUse && typeof serverToolUse === "object") {
const toolCalls = Object.values(serverToolUse).reduce<number>( const toolCalls = Object.values(serverToolUse).reduce<number>(
(sum, val) => { (sum, val) => {
if (typeof val === "number") return sum + val; if (typeof val === "number") return sum + val;
return sum; return sum;
}, },
0, 0,
); );
if (toolCalls > 0) parts.push(`tool_calls=${toolCalls}`); if (toolCalls > 0) parts.push(`tool_calls=${toolCalls}`);
}
} }
}
const modelUsage = obj.modelUsage; const modelUsage = obj.modelUsage;
if (modelUsage && typeof modelUsage === "object") { if (modelUsage && typeof modelUsage === "object") {
@ -168,8 +172,7 @@ export async function getReplyFromConfig(
const prefixedBody = bodyPrefix const prefixedBody = bodyPrefix
? `${bodyPrefix}${sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""}` ? `${bodyPrefix}${sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""}`
: (sessionCtx.BodyStripped ?? sessionCtx.Body); : (sessionCtx.BodyStripped ?? sessionCtx.Body);
const mediaNote = const mediaNote = ctx.MediaPath?.length
ctx.MediaPath && ctx.MediaPath.length
? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]` ? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]`
: undefined; : undefined;
// For command prompts we prepend the media note so Claude et al. see it; text replies stay clean. // For command prompts we prepend the media note so Claude et al. see it; text replies stay clean.
@ -208,7 +211,10 @@ const mediaNote =
if (reply.mode === "text" && reply.text) { if (reply.mode === "text" && reply.text) {
await onReplyStart(); await onReplyStart();
logVerbose("Using text auto-reply from config"); logVerbose("Using text auto-reply from config");
return { text: applyTemplate(reply.text, templatingCtx), mediaUrl: reply.mediaUrl }; return {
text: applyTemplate(reply.text, templatingCtx),
mediaUrl: reply.mediaUrl,
};
} }
if (reply.mode === "command" && reply.command?.length) { if (reply.mode === "command" && reply.command?.length) {
@ -303,7 +309,10 @@ const mediaNote =
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`); logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
} }
let parsed: ClaudeJsonParseResult | undefined; let parsed: ClaudeJsonParseResult | undefined;
if (trimmed && (reply.claudeOutputFormat === "json" || isClaudeInvocation)) { if (
trimmed &&
(reply.claudeOutputFormat === "json" || isClaudeInvocation)
) {
// Claude JSON mode: extract the human text for both logging and reply while keeping metadata. // Claude JSON mode: extract the human text for both logging and reply while keeping metadata.
parsed = parseClaudeJson(trimmed); parsed = parseClaudeJson(trimmed);
if (parsed?.parsed && isVerbose()) { if (parsed?.parsed && isVerbose()) {
@ -333,7 +342,9 @@ const mediaNote =
logVerbose("No MEDIA token extracted from final text"); logVerbose("No MEDIA token extracted from final text");
} }
if (!trimmed && !mediaFromCommand) { if (!trimmed && !mediaFromCommand) {
const meta = parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined; const meta = parsed
? summarizeClaudeMetadata(parsed.parsed)
: undefined;
trimmed = `(command produced no output${meta ? `; ${meta}` : ""})`; trimmed = `(command produced no output${meta ? `; ${meta}` : ""})`;
logVerbose("No text/media produced; injecting fallback notice to user"); logVerbose("No text/media produced; injecting fallback notice to user");
} }
@ -373,7 +384,9 @@ const mediaNote =
`Command auto-reply timed out after ${elapsed}ms (limit ${timeoutMs}ms)`, `Command auto-reply timed out after ${elapsed}ms (limit ${timeoutMs}ms)`,
); );
} else { } else {
logError(`Command auto-reply failed after ${elapsed}ms: ${String(err)}`); logError(
`Command auto-reply failed after ${elapsed}ms: ${String(err)}`,
);
} }
return undefined; return undefined;
} }
@ -431,7 +444,9 @@ export async function autoReplyIfConfigured(
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyResult.text.length}`, `Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyResult.text.length}`,
); );
} else { } else {
logVerbose(`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`); logVerbose(
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`,
);
} }
try { try {

View File

@ -1,21 +1,25 @@
import { autoReplyIfConfigured } from "../auto-reply/reply.js";
import { readEnv } from "../env.js";
import { info } from "../globals.js";
import { ensureBinary } from "../infra/binaries.js"; import { ensureBinary } from "../infra/binaries.js";
import { ensurePortAvailable, handlePortError } from "../infra/ports.js"; import { ensurePortAvailable, handlePortError } from "../infra/ports.js";
import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js"; import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js";
import { waitForever } from "./wait.js"; import { ensureMediaHosted } from "../media/host.js";
import { readEnv } from "../env.js"; import {
import { monitorTwilio as monitorTwilioImpl } from "../twilio/monitor.js"; logWebSelfId,
import { sendMessage, waitForFinalStatus } from "../twilio/send.js"; monitorWebProvider,
import { sendMessageWeb, monitorWebProvider, logWebSelfId } from "../providers/web/index.js"; sendMessageWeb,
import { assertProvider, sleep } from "../utils.js"; } from "../providers/web/index.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { createClient } from "../twilio/client.js"; import { createClient } from "../twilio/client.js";
import { listRecentMessages } from "../twilio/messages.js"; import { listRecentMessages } from "../twilio/messages.js";
import { updateWebhook } from "../webhook/update.js"; import { monitorTwilio as monitorTwilioImpl } from "../twilio/monitor.js";
import { sendMessage, waitForFinalStatus } from "../twilio/send.js";
import { findWhatsappSenderSid } from "../twilio/senders.js"; import { findWhatsappSenderSid } from "../twilio/senders.js";
import { assertProvider, sleep } from "../utils.js";
import { startWebhook } from "../webhook/server.js"; import { startWebhook } from "../webhook/server.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { updateWebhook } from "../webhook/update.js";
import { info } from "../globals.js"; import { waitForever } from "./wait.js";
import { autoReplyIfConfigured } from "../auto-reply/reply.js";
import { ensureMediaHosted } from "../media/host.js";
export type CliDeps = { export type CliDeps = {
sendMessage: typeof sendMessage; sendMessage: typeof sendMessage;

View File

@ -1,16 +1,19 @@
import { Command } from "commander"; import { Command } from "commander";
import { setVerbose, setYes, danger, info, warn } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { sendCommand } from "../commands/send.js"; import { sendCommand } from "../commands/send.js";
import { statusCommand } from "../commands/status.js"; import { statusCommand } from "../commands/status.js";
import { upCommand } from "../commands/up.js"; import { upCommand } from "../commands/up.js";
import { webhookCommand } from "../commands/webhook.js"; import { webhookCommand } from "../commands/webhook.js";
import { loginWeb, monitorWebProvider } from "../provider-web.js";
import { pickProvider } from "../provider-web.js";
import type { Provider } from "../utils.js";
import { createDefaultDeps, logWebSelfId, logTwilioFrom, monitorTwilio } from "./deps.js";
import { ensureTwilioEnv } from "../env.js"; import { ensureTwilioEnv } from "../env.js";
import { danger, info, setVerbose, setYes, warn } from "../globals.js";
import { loginWeb, monitorWebProvider, pickProvider } from "../provider-web.js";
import { defaultRuntime } from "../runtime.js";
import type { Provider } from "../utils.js";
import {
createDefaultDeps,
logTwilioFrom,
logWebSelfId,
monitorTwilio,
} from "./deps.js";
import { spawnRelayTmux } from "./relay_tmux.js"; import { spawnRelayTmux } from "./relay_tmux.js";
export function buildProgram() { export function buildProgram() {
@ -50,20 +53,31 @@ export function buildProgram() {
}); });
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("--media <path-or-url>", "Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.") .option(
.option("--serve-media", "For Twilio: start a temporary media server if webhook is not running", false) "--media <path-or-url>",
.option("-w, --wait <seconds>", "Wait for delivery status (0 to skip)", "20") "Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
.option("-p, --poll <seconds>", "Polling interval while waiting", "2") )
.option("--provider <provider>", "Provider: twilio | web", "twilio") .option(
.option("--dry-run", "Print payload and skip sending", false) "--serve-media",
.option("--json", "Output result as JSON", false) "For Twilio: start a temporary media server if webhook is not running",
false,
)
.option(
"-w, --wait <seconds>",
"Wait for delivery status (0 to skip)",
"20",
)
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
.option("--provider <provider>", "Provider: twilio | web", "twilio")
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
.addHelpText( .addHelpText(
"after", "after",
` `
@ -201,7 +215,9 @@ With Tailscale:
try { try {
const server = await webhookCommand(opts, deps, defaultRuntime); const server = await webhookCommand(opts, deps, defaultRuntime);
if (!server) { if (!server) {
defaultRuntime.log(info("Webhook dry-run complete; no server started.")); defaultRuntime.log(
info("Webhook dry-run complete; no server started."),
);
return; return;
} }
process.on("SIGINT", () => { process.on("SIGINT", () => {
@ -226,7 +242,11 @@ With Tailscale:
.option("--path <path>", "Webhook path", "/webhook/whatsapp") .option("--path <path>", "Webhook path", "/webhook/whatsapp")
.option("--verbose", "Verbose logging during setup/webhook", false) .option("--verbose", "Verbose logging during setup/webhook", 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 touching network", false) .option(
"--dry-run",
"Print planned actions without touching network",
false,
)
// istanbul ignore next // istanbul ignore next
.action(async (opts) => { .action(async (opts) => {
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));
@ -258,27 +278,36 @@ With Tailscale:
) )
.action(async () => { .action(async () => {
try { try {
const session = await spawnRelayTmux("pnpm warelay relay --verbose", true); const session = await spawnRelayTmux(
"pnpm warelay relay --verbose",
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(danger(`Failed to start relay tmux session: ${String(err)}`)); defaultRuntime.error(
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("Attach to the existing warelay-relay tmux session (no restart)") .description(
"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(danger(`Failed to attach to warelay-relay: ${String(err)}`)); defaultRuntime.error(
danger(`Failed to attach to warelay-relay: ${String(err)}`),
);
defaultRuntime.exit(1); defaultRuntime.exit(1);
} }
}); });

View File

@ -1,5 +1,5 @@
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process"; import { stdin as input, stdout as output } from "node:process";
import readline from "node:readline/promises";
import { isVerbose, isYes } from "../globals.js"; import { isVerbose, isYes } from "../globals.js";

View File

@ -1,7 +1,7 @@
import { info } from "../globals.js";
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import type { Provider } from "../utils.js"; import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import type { Provider } from "../utils.js";
export async function sendCommand( export async function sendCommand(
opts: { opts: {
@ -40,14 +40,10 @@ export async function sendCommand(
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( .sendMessageWeb(opts.to, opts.message, {
opts.to, verbose: false,
opts.message, mediaUrl: opts.media,
{ })
verbose: false,
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;
@ -76,7 +72,7 @@ export async function sendCommand(
return; return;
} }
let mediaUrl: string | undefined = 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),

View File

@ -1,10 +1,16 @@
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import type { RuntimeEnv } from "../runtime.js";
import { waitForever as defaultWaitForever } from "../cli/wait.js"; import { waitForever as defaultWaitForever } from "../cli/wait.js";
import { retryAsync } from "../infra/retry.js"; import { retryAsync } from "../infra/retry.js";
import type { RuntimeEnv } from "../runtime.js";
export async function upCommand( export async function upCommand(
opts: { port: string; path: string; verbose?: boolean; yes?: boolean; dryRun?: boolean }, opts: {
port: string;
path: string;
verbose?: boolean;
yes?: boolean;
dryRun?: boolean;
},
deps: CliDeps, deps: CliDeps,
runtime: RuntimeEnv, runtime: RuntimeEnv,
waiter: typeof defaultWaitForever = defaultWaitForever, waiter: typeof defaultWaitForever = defaultWaitForever,

View File

@ -1,6 +1,6 @@
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import type { RuntimeEnv } from "../runtime.js";
import { retryAsync } from "../infra/retry.js"; import { retryAsync } from "../infra/retry.js";
import type { RuntimeEnv } from "../runtime.js";
export async function webhookCommand( export async function webhookCommand(
opts: { opts: {
@ -19,7 +19,9 @@ export async function webhookCommand(
} }
await deps.ensurePortAvailable(port); await deps.ensurePortAvailable(port);
if (opts.reply === "dry-run") { if (opts.reply === "dry-run") {
runtime.log(`[dry-run] would start webhook on port ${port} path ${opts.path}`); runtime.log(
`[dry-run] would start webhook on port ${port} path ${opts.path}`,
);
return undefined; return undefined;
} }
const server = await retryAsync( const server = await retryAsync(

View File

@ -3,9 +3,8 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import JSON5 from "json5"; import JSON5 from "json5";
import { CONFIG_DIR, normalizeE164 } from "../utils.js";
import type { MsgContext } from "../auto-reply/templating.js"; import type { MsgContext } from "../auto-reply/templating.js";
import { CONFIG_DIR, normalizeE164 } from "../utils.js";
export type SessionScope = "per-sender" | "global"; export type SessionScope = "per-sender" | "global";
@ -22,7 +21,9 @@ export function resolveStorePath(store?: string) {
return path.resolve(store); return path.resolve(store);
} }
export function loadSessionStore(storePath: string): Record<string, SessionEntry> { export function loadSessionStore(
storePath: string,
): 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);

View File

@ -3,66 +3,56 @@ import process from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import dotenv from "dotenv"; import dotenv from "dotenv";
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
import type { TwilioRequester } from "./twilio/types.js";
import { runCommandWithTimeout, runExec } from "./process/exec.js";
import { sendTypingIndicator } from "./twilio/typing.js";
import { autoReplyIfConfigured, getReplyFromConfig } from "./auto-reply/reply.js";
import { readEnv, ensureTwilioEnv, type EnvConfig } from "./env.js";
import { createClient } from "./twilio/client.js";
import { logTwilioSendError, formatTwilioError } from "./twilio/utils.js";
import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
import { import {
updateWebhook as updateWebhookImpl, autoReplyIfConfigured,
findIncomingNumberSid as findIncomingNumberSidImpl, getReplyFromConfig,
findMessagingServiceSid as findMessagingServiceSidImpl, } from "./auto-reply/reply.js";
setMessagingServiceWebhook as setMessagingServiceWebhookImpl, import { applyTemplate } from "./auto-reply/templating.js";
} from "./twilio/update-webhook.js"; import { createDefaultDeps, monitorTwilio } from "./cli/deps.js";
import { listRecentMessages, formatMessageLine, uniqueBySid, sortByDateDesc } from "./twilio/messages.js"; import { promptYesNo } from "./cli/prompt.js";
import { CLAUDE_BIN } from "./auto-reply/claude.js"; import { waitForever } from "./cli/wait.js";
import { applyTemplate, type MsgContext, type TemplateContext } from "./auto-reply/templating.js"; import { loadConfig } from "./config/config.js";
import { import {
CONFIG_PATH,
type WarelayConfig,
type SessionConfig,
type SessionScope,
type ReplyMode,
type ClaudeOutputFormat,
loadConfig,
} from "./config/config.js";
import { sendCommand } from "./commands/send.js";
import { statusCommand } from "./commands/status.js";
import { upCommand } from "./commands/up.js";
import { webhookCommand } from "./commands/webhook.js";
import type { Provider } from "./utils.js";
import {
assertProvider,
CONFIG_DIR,
jidToE164,
normalizeE164,
normalizePath,
sleep,
toWhatsappJid,
withWhatsAppPrefix,
} from "./utils.js";
import {
DEFAULT_IDLE_MINUTES,
DEFAULT_RESET_TRIGGER,
deriveSessionKey, deriveSessionKey,
loadSessionStore, loadSessionStore,
resolveStorePath, resolveStorePath,
saveSessionStore, saveSessionStore,
SESSION_STORE_DEFAULT,
} from "./config/sessions.js"; } from "./config/sessions.js";
import { ensurePortAvailable, describePortOwner, PortInUseError, handlePortError } from "./infra/ports.js"; import { readEnv } from "./env.js";
import { ensureBinary } from "./infra/binaries.js"; import { ensureBinary } from "./infra/binaries.js";
import { ensureFunnel, ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./infra/tailscale.js"; import {
import { promptYesNo } from "./cli/prompt.js"; describePortOwner,
import { waitForever } from "./cli/wait.js"; ensurePortAvailable,
import { findWhatsappSenderSid } from "./twilio/senders.js"; handlePortError,
import { createDefaultDeps, logTwilioFrom, logWebSelfId, monitorTwilio } from "./cli/deps.js"; PortInUseError,
} from "./infra/ports.js";
import {
ensureFunnel,
ensureGoInstalled,
ensureTailscaledInstalled,
getTailnetHostname,
} from "./infra/tailscale.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 {
formatMessageLine,
listRecentMessages,
sortByDateDesc,
uniqueBySid,
} from "./twilio/messages.js";
import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
import { findWhatsappSenderSid } from "./twilio/senders.js";
import { sendTypingIndicator } from "./twilio/typing.js";
import {
findIncomingNumberSid as findIncomingNumberSidImpl,
findMessagingServiceSid as findMessagingServiceSidImpl,
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
updateWebhook as updateWebhookImpl,
} from "./twilio/update-webhook.js";
import { formatTwilioError, logTwilioSendError } from "./twilio/utils.js";
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
import { assertProvider, normalizeE164, toWhatsappJid } from "./utils.js";
dotenv.config({ quiet: true }); dotenv.config({ quiet: true });

View File

@ -1,9 +1,8 @@
import net from "node:net"; import net from "node:net";
import { runExec } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { danger, info, isVerbose, logVerbose, warn } from "../globals.js"; import { danger, info, isVerbose, logVerbose, warn } from "../globals.js";
import { logDebug } from "../logger.js"; import { logDebug } from "../logger.js";
import { runExec } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
class PortInUseError extends Error { class PortInUseError extends Error {
port: number; port: number;
@ -21,7 +20,9 @@ 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(port: number): Promise<string | undefined> { export async function describePortOwner(
port: number,
): 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", [

View File

@ -1,7 +1,6 @@
import chalk from "chalk"; import chalk from "chalk";
import { danger, info, isVerbose, logVerbose, warn } from "../globals.js";
import { promptYesNo } from "../cli/prompt.js"; import { promptYesNo } from "../cli/prompt.js";
import { danger, info, isVerbose, logVerbose, warn } from "../globals.js";
import { runExec } from "../process/exec.js"; import { runExec } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { ensureBinary } from "./binaries.js"; import { ensureBinary } from "./binaries.js";

View File

@ -1,4 +1,11 @@
import { danger, info, success, warn, logVerbose, isVerbose } from "./globals.js"; import {
danger,
info,
isVerbose,
logVerbose,
success,
warn,
} from "./globals.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) {
@ -16,7 +23,10 @@ export function logSuccess(
runtime.log(success(message)); runtime.log(success(message));
} }
export function logError(message: string, runtime: RuntimeEnv = defaultRuntime) { export function logError(
message: string,
runtime: RuntimeEnv = defaultRuntime,
) {
runtime.error(danger(message)); runtime.error(danger(message));
} }

View File

@ -1,13 +1,10 @@
import { once } from "node:events";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path";
import { danger, warn } from "../globals.js";
import { logInfo, logWarn } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { ensurePortAvailable, PortInUseError } from "../infra/ports.js"; import { ensurePortAvailable, PortInUseError } from "../infra/ports.js";
import { getTailnetHostname } from "../infra/tailscale.js"; import { getTailnetHostname } from "../infra/tailscale.js";
import { saveMediaSource } from "./store.js"; import { logInfo } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { startMediaServer } from "./server.js"; import { startMediaServer } from "./server.js";
import { saveMediaSource } from "./store.js";
const DEFAULT_PORT = 42873; const DEFAULT_PORT = 42873;
const TTL_MS = 2 * 60 * 1000; const TTL_MS = 2 * 60 * 1000;

View File

@ -15,7 +15,11 @@ 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 /^https?:\/\//i.test(candidate) || candidate.startsWith("/") || candidate.startsWith("./"); return (
/^https?:\/\//i.test(candidate) ||
candidate.startsWith("/") ||
candidate.startsWith("./")
);
} }
export function splitMediaFromOutput(raw: string): { export function splitMediaFromOutput(raw: string): {
@ -29,22 +33,21 @@ export function splitMediaFromOutput(raw: string): {
const candidate = normalizeMediaSource(cleanCandidate(match[1])); const candidate = normalizeMediaSource(cleanCandidate(match[1]));
const mediaUrl = isValidMedia(candidate) ? candidate : undefined; const mediaUrl = isValidMedia(candidate) ? candidate : undefined;
const cleanedText = const cleanedText = mediaUrl
mediaUrl ? trimmedRaw
? trimmedRaw .replace(match[0], "")
.replace(match[0], "") .replace(/[ \t]+\n/g, "\n")
.replace(/[ \t]+\n/g, "\n") .replace(/[ \t]{2,}/g, " ")
.replace(/[ \t]{2,}/g, " ") .replace(/\n{2,}/g, "\n")
.replace(/\n{2,}/g, "\n") .trim()
.trim() : trimmedRaw
: trimmedRaw .split("\n")
.split("\n") .filter((line) => !MEDIA_TOKEN_RE.test(line))
.filter((line) => !MEDIA_TOKEN_RE.test(line)) .join("\n")
.join("\n") .replace(/[ \t]+\n/g, "\n")
.replace(/[ \t]+\n/g, "\n") .replace(/[ \t]{2,}/g, " ")
.replace(/[ \t]{2,}/g, " ") .replace(/\n{2,}/g, "\n")
.replace(/\n{2,}/g, "\n") .trim();
.trim();
return mediaUrl ? { text: cleanedText, mediaUrl } : { text: cleanedText }; return mediaUrl ? { text: cleanedText, mediaUrl } : { text: cleanedText };
} }

View File

@ -1,7 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import type { Server } from "node:http";
import path from "node:path"; import path from "node:path";
import express, { type Express } from "express"; import express, { type Express } from "express";
import type { Server } from "http";
import { danger } from "../globals.js"; import { danger } from "../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { cleanOldMedia, getMediaDir } from "./store.js"; import { cleanOldMedia, getMediaDir } from "./store.js";
@ -11,7 +11,7 @@ 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();
@ -41,7 +41,6 @@ export function attachMediaRoutes(
setInterval(() => { setInterval(() => {
void cleanOldMedia(ttlMs); void cleanOldMedia(ttlMs);
}, ttlMs).unref(); }, ttlMs).unref();
} }
export async function startMediaServer( export async function startMediaServer(

View File

@ -1,10 +1,10 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs"; import { createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import { request } from "node:https"; import { request } from "node:https";
import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
const MEDIA_DIR = path.join(os.homedir(), ".warelay", "media"); const MEDIA_DIR = path.join(os.homedir(), ".warelay", "media");
const MAX_BYTES = 5 * 1024 * 1024; // 5MB const MAX_BYTES = 5 * 1024 * 1024; // 5MB
@ -58,7 +58,9 @@ async function downloadToFile(
req.destroy(new Error("Media exceeds 5MB limit")); req.destroy(new Error("Media exceeds 5MB limit"));
} }
}); });
pipeline(res, out).then(() => resolve()).catch(reject); pipeline(res, out)
.then(() => resolve())
.catch(reject);
}); });
req.on("error", reject); req.on("error", reject);
req.end(); req.end();

View File

@ -1,13 +1,16 @@
export { sendTypingIndicator } from "../../twilio/typing.js";
export { createClient } from "../../twilio/client.js"; export { createClient } from "../../twilio/client.js";
export {
formatMessageLine,
listRecentMessages,
} 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 { listRecentMessages, formatMessageLine } from "../../twilio/messages.js"; export { findWhatsappSenderSid } from "../../twilio/senders.js";
export { sendTypingIndicator } from "../../twilio/typing.js";
export { export {
updateWebhook,
findIncomingNumberSid, findIncomingNumberSid,
findMessagingServiceSid, findMessagingServiceSid,
setMessagingServiceWebhook, setMessagingServiceWebhook,
updateWebhook,
} from "../../twilio/update-webhook.js"; } from "../../twilio/update-webhook.js";
export { findWhatsappSenderSid } from "../../twilio/senders.js";
export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.js"; export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.js";

View File

@ -1,12 +1,12 @@
export { export {
createWaSocket, createWaSocket,
waitForWaConnection,
sendMessageWeb,
loginWeb, loginWeb,
logWebSelfId,
monitorWebInbox, monitorWebInbox,
monitorWebProvider, monitorWebProvider,
webAuthExists,
logWebSelfId,
pickProvider, pickProvider,
sendMessageWeb,
WA_WEB_AUTH_DIR, WA_WEB_AUTH_DIR,
waitForWaConnection,
webAuthExists,
} from "../../provider-web.js"; } from "../../provider-web.js";

View File

@ -1,17 +1,17 @@
import { withWhatsAppPrefix } from "../utils.js";
import { readEnv } from "../env.js"; import { readEnv } from "../env.js";
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.
@ -37,54 +37,63 @@ export function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] {
// 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({ to: from, dateSentAfter: since, limit: fetchLimit }); const inbound = await client.messages.list({
const outbound = await client.messages.list({ from, dateSentAfter: since, limit: fetchLimit }); to: from,
dateSentAfter: since,
limit: fetchLimit,
});
const outbound = await client.messages.list({
from,
dateSentAfter: since,
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 = body.length > 140 ? `${body.slice(0, 137)}` : body || "<empty>"; const bodyPreview =
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`; body.length > 140 ? `${body.slice(0, 137)}` : body || "<empty>";
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
} }

View File

@ -1,12 +1,11 @@
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js"; import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
import { danger, warn } from "../globals.js";
import { sleep, withWhatsAppPrefix } from "../utils.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { autoReplyIfConfigured } from "../auto-reply/reply.js"; import { autoReplyIfConfigured } from "../auto-reply/reply.js";
import { createClient } from "./client.js";
import { readEnv } from "../env.js"; import { readEnv } from "../env.js";
import { danger } from "../globals.js";
import { logDebug, logInfo, logWarn } from "../logger.js"; import { logDebug, logInfo, logWarn } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { sleep, withWhatsAppPrefix } from "../utils.js";
import { createClient } from "./client.js";
type MonitorDeps = { type MonitorDeps = {
autoReplyIfConfigured: typeof autoReplyIfConfigured; autoReplyIfConfigured: typeof autoReplyIfConfigured;
@ -95,7 +94,9 @@ export async function monitorTwilio(
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(Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000); await deps.sleep(
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
);
} }
} }

View File

@ -1,8 +1,7 @@
import { success } from "../globals.js"; import { readEnv } from "../env.js";
import { logInfo } from "../logger.js"; import { logInfo } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { withWhatsAppPrefix, sleep } from "../utils.js"; import { sleep, withWhatsAppPrefix } from "../utils.js";
import { readEnv } from "../env.js";
import { createClient } from "./client.js"; import { createClient } from "./client.js";
import { logTwilioSendError } from "./utils.js"; import { logTwilioSendError } from "./utils.js";
@ -29,7 +28,10 @@ export async function sendMessage(
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined, mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
}); });
logInfo(`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`, runtime); logInfo(
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
runtime,
);
return { client, sid: message.sid }; return { client, sid: message.sid };
} catch (err) { } catch (err) {
logTwilioSendError(err, toNumber, runtime); logTwilioSendError(err, toNumber, runtime);

View File

@ -1,4 +1,4 @@
import { warn, isVerbose, logVerbose } from "../globals.js"; import { isVerbose, logVerbose, warn } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
type TwilioRequestOptions = { type TwilioRequestOptions = {

View File

@ -1,12 +1,13 @@
import { isVerbose, success, warn } from "../globals.js";
import { logError, logInfo } from "../logger.js";
import { readEnv } from "../env.js"; import { readEnv } from "../env.js";
import { normalizeE164 } from "../utils.js"; import { isVerbose } from "../globals.js";
import { logError, logInfo } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { createClient } from "./client.js"; import type { createClient } from "./client.js";
import type { TwilioSenderListClient, TwilioRequester } from "./types.js"; import type { TwilioRequester, TwilioSenderListClient } from "./types.js";
export async function findIncomingNumberSid(client: TwilioSenderListClient): Promise<string | null> { export async function findIncomingNumberSid(
client: TwilioSenderListClient,
): 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();
@ -21,7 +22,9 @@ export async function findIncomingNumberSid(client: TwilioSenderListClient): Pro
} }
} }
export async function findMessagingServiceSid(client: TwilioSenderListClient): Promise<string | null> { export async function findMessagingServiceSid(
client: TwilioSenderListClient,
): 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 {
@ -65,7 +68,6 @@ export async function setMessagingServiceWebhook(
} }
} }
// 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>,

View File

@ -1,142 +1,158 @@
import express, { type Request, type Response } from "express"; import type { Server } from "node:http";
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import chalk from "chalk"; import chalk from "chalk";
import type { Server } from "http"; import express, { type Request, type Response } from "express";
import { success, logVerbose, danger } from "../globals.js";
import { readEnv, type EnvConfig } from "../env.js";
import { createClient } from "./client.js";
import { normalizePath } from "../utils.js";
import { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.js"; import { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.js";
import { sendTypingIndicator } from "./typing.js"; import { type EnvConfig, readEnv } from "../env.js";
import { logTwilioSendError } from "./utils.js"; import { danger, success } from "../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { ensureMediaHosted } from "../media/host.js";
import { attachMediaRoutes } from "../media/server.js"; import { attachMediaRoutes } from "../media/server.js";
import { saveMediaSource } from "../media/store.js"; import { saveMediaSource } from "../media/store.js";
import { ensureMediaHosted } from "../media/host.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizePath } from "../utils.js";
import { createClient } from "./client.js";
import { sendTypingIndicator } from "./typing.js";
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 = typeof req.body?.MediaContentType0 === "string" mediaType =
? (req.body.MediaContentType0 as string) typeof req.body?.MediaContentType0 === "string"
: undefined; ? (req.body.MediaContentType0 as string)
try { : undefined;
const creds = buildTwilioBasicAuth(env); try {
const saved = await saveMediaSource(mediaUrlInbound, { const creds = buildTwilioBasicAuth(env);
Authorization: `Basic ${creds}`, const saved = await saveMediaSource(
}, "inbound"); mediaUrlInbound,
mediaPath = saved.path; {
if (!mediaType && saved.contentType) mediaType = saved.contentType; Authorization: `Basic ${creds}`,
} catch (err) { },
runtime.error(danger(`Failed to download inbound media: ${String(err)}`)); "inbound",
} );
} mediaPath = saved.path;
if (!mediaType && saved.contentType) mediaType = saved.contentType;
} catch (err) {
runtime.error(
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, From, To, MessageSid, MediaPath: mediaPath, MediaUrl: mediaUrlInbound, MediaType: mediaType }, {
{ Body,
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid), From,
}, To,
); MessageSid,
} MediaPath: mediaPath,
MediaUrl: mediaUrlInbound,
MediaType: mediaType,
},
{
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( success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`, );
), } catch (err) {
); logTwilioSendError(err, From ?? undefined, runtime);
} catch (err) { }
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("base64"); return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
"base64",
);
} }
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString("base64"); return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
"base64",
);
} }

View File

@ -1,4 +1,3 @@
import type { RuntimeEnv } from "../runtime.js";
import { startWebhook } from "../twilio/webhook.js"; import { startWebhook } from "../twilio/webhook.js";
// Thin wrapper to keep webhook server co-located with other webhook helpers. // Thin wrapper to keep webhook server co-located with other webhook helpers.

View File

@ -1,6 +1,6 @@
export { export {
updateWebhook,
setMessagingServiceWebhook,
findIncomingNumberSid, findIncomingNumberSid,
findMessagingServiceSid, findMessagingServiceSid,
setMessagingServiceWebhook,
updateWebhook,
} from "../twilio/update-webhook.js"; } from "../twilio/update-webhook.js";