chore: sync source updates
This commit is contained in:
@@ -2,12 +2,7 @@ import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||||
import { CLAUDE_BIN, CLAUDE_IDENTITY_PREFIX, parseClaudeJson } from "./claude.js";
|
||||
import {
|
||||
applyTemplate,
|
||||
type MsgContext,
|
||||
type TemplateContext,
|
||||
} from "./templating.js";
|
||||
import { loadConfig, type WarelayConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
DEFAULT_RESET_TRIGGER,
|
||||
@@ -16,16 +11,25 @@ import {
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { loadConfig, type WarelayConfig } from "../config/config.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 { 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 = {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
@@ -51,20 +55,20 @@ function summarizeClaudeMetadata(payload: unknown): string | undefined {
|
||||
|
||||
const usage = obj.usage;
|
||||
if (usage && typeof usage === "object") {
|
||||
const serverToolUse = (
|
||||
usage as { server_tool_use?: Record<string, unknown> }
|
||||
).server_tool_use;
|
||||
if (serverToolUse && typeof serverToolUse === "object") {
|
||||
const toolCalls = Object.values(serverToolUse).reduce<number>(
|
||||
(sum, val) => {
|
||||
if (typeof val === "number") return sum + val;
|
||||
return sum;
|
||||
},
|
||||
0,
|
||||
);
|
||||
if (toolCalls > 0) parts.push(`tool_calls=${toolCalls}`);
|
||||
}
|
||||
const serverToolUse = (
|
||||
usage as { server_tool_use?: Record<string, unknown> }
|
||||
).server_tool_use;
|
||||
if (serverToolUse && typeof serverToolUse === "object") {
|
||||
const toolCalls = Object.values(serverToolUse).reduce<number>(
|
||||
(sum, val) => {
|
||||
if (typeof val === "number") return sum + val;
|
||||
return sum;
|
||||
},
|
||||
0,
|
||||
);
|
||||
if (toolCalls > 0) parts.push(`tool_calls=${toolCalls}`);
|
||||
}
|
||||
}
|
||||
|
||||
const modelUsage = obj.modelUsage;
|
||||
if (modelUsage && typeof modelUsage === "object") {
|
||||
@@ -168,8 +172,7 @@ export async function getReplyFromConfig(
|
||||
const prefixedBody = bodyPrefix
|
||||
? `${bodyPrefix}${sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""}`
|
||||
: (sessionCtx.BodyStripped ?? sessionCtx.Body);
|
||||
const mediaNote =
|
||||
ctx.MediaPath && ctx.MediaPath.length
|
||||
const mediaNote = ctx.MediaPath?.length
|
||||
? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]`
|
||||
: undefined;
|
||||
// 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) {
|
||||
await onReplyStart();
|
||||
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) {
|
||||
@@ -303,7 +309,10 @@ const mediaNote =
|
||||
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
|
||||
}
|
||||
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.
|
||||
parsed = parseClaudeJson(trimmed);
|
||||
if (parsed?.parsed && isVerbose()) {
|
||||
@@ -333,7 +342,9 @@ const mediaNote =
|
||||
logVerbose("No MEDIA token extracted from final text");
|
||||
}
|
||||
if (!trimmed && !mediaFromCommand) {
|
||||
const meta = parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined;
|
||||
const meta = parsed
|
||||
? summarizeClaudeMetadata(parsed.parsed)
|
||||
: undefined;
|
||||
trimmed = `(command produced no output${meta ? `; ${meta}` : ""})`;
|
||||
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)`,
|
||||
);
|
||||
} else {
|
||||
logError(`Command auto-reply failed after ${elapsed}ms: ${String(err)}`);
|
||||
logError(
|
||||
`Command auto-reply failed after ${elapsed}ms: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -431,7 +444,9 @@ export async function autoReplyIfConfigured(
|
||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyResult.text.length}`,
|
||||
);
|
||||
} else {
|
||||
logVerbose(`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`);
|
||||
logVerbose(
|
||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -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 { ensurePortAvailable, handlePortError } from "../infra/ports.js";
|
||||
import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js";
|
||||
import { waitForever } from "./wait.js";
|
||||
import { readEnv } from "../env.js";
|
||||
import { monitorTwilio as monitorTwilioImpl } from "../twilio/monitor.js";
|
||||
import { sendMessage, waitForFinalStatus } from "../twilio/send.js";
|
||||
import { sendMessageWeb, monitorWebProvider, logWebSelfId } from "../providers/web/index.js";
|
||||
import { assertProvider, sleep } from "../utils.js";
|
||||
import { ensureMediaHosted } from "../media/host.js";
|
||||
import {
|
||||
logWebSelfId,
|
||||
monitorWebProvider,
|
||||
sendMessageWeb,
|
||||
} from "../providers/web/index.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { createClient } from "../twilio/client.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 { assertProvider, sleep } from "../utils.js";
|
||||
import { startWebhook } from "../webhook/server.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { info } from "../globals.js";
|
||||
import { autoReplyIfConfigured } from "../auto-reply/reply.js";
|
||||
import { ensureMediaHosted } from "../media/host.js";
|
||||
import { updateWebhook } from "../webhook/update.js";
|
||||
import { waitForever } from "./wait.js";
|
||||
|
||||
export type CliDeps = {
|
||||
sendMessage: typeof sendMessage;
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
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 { statusCommand } from "../commands/status.js";
|
||||
import { upCommand } from "../commands/up.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 { 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";
|
||||
|
||||
export function buildProgram() {
|
||||
@@ -50,20 +53,31 @@ export function buildProgram() {
|
||||
});
|
||||
|
||||
program
|
||||
.command("send")
|
||||
.description("Send a WhatsApp message")
|
||||
.requiredOption(
|
||||
"-t, --to <number>",
|
||||
"Recipient number in E.164 (e.g. +15551234567)",
|
||||
)
|
||||
.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("--serve-media", "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)
|
||||
.command("send")
|
||||
.description("Send a WhatsApp message")
|
||||
.requiredOption(
|
||||
"-t, --to <number>",
|
||||
"Recipient number in E.164 (e.g. +15551234567)",
|
||||
)
|
||||
.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(
|
||||
"--serve-media",
|
||||
"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(
|
||||
"after",
|
||||
`
|
||||
@@ -201,7 +215,9 @@ With Tailscale:
|
||||
try {
|
||||
const server = await webhookCommand(opts, deps, defaultRuntime);
|
||||
if (!server) {
|
||||
defaultRuntime.log(info("Webhook dry-run complete; no server started."));
|
||||
defaultRuntime.log(
|
||||
info("Webhook dry-run complete; no server started."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.on("SIGINT", () => {
|
||||
@@ -226,7 +242,11 @@ With Tailscale:
|
||||
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
|
||||
.option("--verbose", "Verbose logging during setup/webhook", 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
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
@@ -258,27 +278,36 @@ With Tailscale:
|
||||
)
|
||||
.action(async () => {
|
||||
try {
|
||||
const session = await spawnRelayTmux("pnpm warelay relay --verbose", true);
|
||||
const session = await spawnRelayTmux(
|
||||
"pnpm warelay relay --verbose",
|
||||
true,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`,
|
||||
),
|
||||
);
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.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 () => {
|
||||
try {
|
||||
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
|
||||
defaultRuntime.log(info("Attached to warelay-relay session."));
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import readline from "node:readline/promises";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import readline from "node:readline/promises";
|
||||
|
||||
import { isVerbose, isYes } from "../globals.js";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { info } from "../globals.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 { Provider } from "../utils.js";
|
||||
|
||||
export async function sendCommand(
|
||||
opts: {
|
||||
@@ -40,14 +40,10 @@ export async function sendCommand(
|
||||
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
||||
}
|
||||
const res = await deps
|
||||
.sendMessageWeb(
|
||||
opts.to,
|
||||
opts.message,
|
||||
{
|
||||
verbose: false,
|
||||
mediaUrl: opts.media,
|
||||
},
|
||||
)
|
||||
.sendMessageWeb(opts.to, opts.message, {
|
||||
verbose: false,
|
||||
mediaUrl: opts.media,
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error(`❌ Web send failed: ${String(err)}`);
|
||||
throw err;
|
||||
@@ -76,7 +72,7 @@ export async function sendCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
let mediaUrl: string | undefined = undefined;
|
||||
let mediaUrl: string | undefined;
|
||||
if (opts.media) {
|
||||
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
|
||||
serveMedia: Boolean(opts.serveMedia),
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { waitForever as defaultWaitForever } from "../cli/wait.js";
|
||||
import { retryAsync } from "../infra/retry.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
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,
|
||||
runtime: RuntimeEnv,
|
||||
waiter: typeof defaultWaitForever = defaultWaitForever,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { retryAsync } from "../infra/retry.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export async function webhookCommand(
|
||||
opts: {
|
||||
@@ -19,7 +19,9 @@ export async function webhookCommand(
|
||||
}
|
||||
await deps.ensurePortAvailable(port);
|
||||
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;
|
||||
}
|
||||
const server = await retryAsync(
|
||||
|
||||
@@ -3,9 +3,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import JSON5 from "json5";
|
||||
|
||||
import { CONFIG_DIR, normalizeE164 } from "../utils.js";
|
||||
import type { MsgContext } from "../auto-reply/templating.js";
|
||||
import { CONFIG_DIR, normalizeE164 } from "../utils.js";
|
||||
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
|
||||
@@ -22,7 +21,9 @@ export function resolveStorePath(store?: string) {
|
||||
return path.resolve(store);
|
||||
}
|
||||
|
||||
export function loadSessionStore(storePath: string): Record<string, SessionEntry> {
|
||||
export function loadSessionStore(
|
||||
storePath: string,
|
||||
): Record<string, SessionEntry> {
|
||||
try {
|
||||
const raw = fs.readFileSync(storePath, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
|
||||
92
src/index.ts
92
src/index.ts
@@ -3,66 +3,56 @@ import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
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 {
|
||||
updateWebhook as updateWebhookImpl,
|
||||
findIncomingNumberSid as findIncomingNumberSidImpl,
|
||||
findMessagingServiceSid as findMessagingServiceSidImpl,
|
||||
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
|
||||
} from "./twilio/update-webhook.js";
|
||||
import { listRecentMessages, formatMessageLine, uniqueBySid, sortByDateDesc } from "./twilio/messages.js";
|
||||
import { CLAUDE_BIN } from "./auto-reply/claude.js";
|
||||
import { applyTemplate, type MsgContext, type TemplateContext } from "./auto-reply/templating.js";
|
||||
autoReplyIfConfigured,
|
||||
getReplyFromConfig,
|
||||
} from "./auto-reply/reply.js";
|
||||
import { applyTemplate } from "./auto-reply/templating.js";
|
||||
import { createDefaultDeps, monitorTwilio } from "./cli/deps.js";
|
||||
import { promptYesNo } from "./cli/prompt.js";
|
||||
import { waitForever } from "./cli/wait.js";
|
||||
import { loadConfig } from "./config/config.js";
|
||||
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,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
SESSION_STORE_DEFAULT,
|
||||
} 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 { ensureFunnel, ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./infra/tailscale.js";
|
||||
import { promptYesNo } from "./cli/prompt.js";
|
||||
import { waitForever } from "./cli/wait.js";
|
||||
import { findWhatsappSenderSid } from "./twilio/senders.js";
|
||||
import { createDefaultDeps, logTwilioFrom, logWebSelfId, monitorTwilio } from "./cli/deps.js";
|
||||
import {
|
||||
describePortOwner,
|
||||
ensurePortAvailable,
|
||||
handlePortError,
|
||||
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 { 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 });
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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 { logDebug } from "../logger.js";
|
||||
import { runExec } from "../process/exec.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
|
||||
class PortInUseError extends Error {
|
||||
port: number;
|
||||
@@ -21,7 +20,9 @@ function isErrno(err: unknown): err is NodeJS.ErrnoException {
|
||||
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).
|
||||
try {
|
||||
const { stdout } = await runExec("lsof", [
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import chalk from "chalk";
|
||||
|
||||
import { danger, info, isVerbose, logVerbose, warn } from "../globals.js";
|
||||
import { promptYesNo } from "../cli/prompt.js";
|
||||
import { danger, info, isVerbose, logVerbose, warn } from "../globals.js";
|
||||
import { runExec } from "../process/exec.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { ensureBinary } from "./binaries.js";
|
||||
|
||||
@@ -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";
|
||||
|
||||
export function logInfo(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
||||
@@ -16,7 +23,10 @@ export function logSuccess(
|
||||
runtime.log(success(message));
|
||||
}
|
||||
|
||||
export function logError(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
||||
export function logError(
|
||||
message: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
runtime.error(danger(message));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { once } from "node:events";
|
||||
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 { 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 { saveMediaSource } from "./store.js";
|
||||
|
||||
const DEFAULT_PORT = 42873;
|
||||
const TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
@@ -15,7 +15,11 @@ function isValidMedia(candidate: string) {
|
||||
if (!candidate) return false;
|
||||
if (candidate.length > 1024) 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): {
|
||||
@@ -29,22 +33,21 @@ export function splitMediaFromOutput(raw: string): {
|
||||
const candidate = normalizeMediaSource(cleanCandidate(match[1]));
|
||||
const mediaUrl = isValidMedia(candidate) ? candidate : undefined;
|
||||
|
||||
const cleanedText =
|
||||
mediaUrl
|
||||
? trimmedRaw
|
||||
.replace(match[0], "")
|
||||
.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();
|
||||
const cleanedText = mediaUrl
|
||||
? trimmedRaw
|
||||
.replace(match[0], "")
|
||||
.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 };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { Server } from "node:http";
|
||||
import path from "node:path";
|
||||
import express, { type Express } from "express";
|
||||
import type { Server } from "http";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { cleanOldMedia, getMediaDir } from "./store.js";
|
||||
@@ -11,7 +11,7 @@ const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
||||
export function attachMediaRoutes(
|
||||
app: Express,
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
_runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const mediaDir = getMediaDir();
|
||||
|
||||
@@ -41,7 +41,6 @@ export function attachMediaRoutes(
|
||||
setInterval(() => {
|
||||
void cleanOldMedia(ttlMs);
|
||||
}, ttlMs).unref();
|
||||
|
||||
}
|
||||
|
||||
export async function startMediaServer(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 fs from "node:fs/promises";
|
||||
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 MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
||||
@@ -58,7 +58,9 @@ async function downloadToFile(
|
||||
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.end();
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
export { sendTypingIndicator } from "../../twilio/typing.js";
|
||||
export { createClient } from "../../twilio/client.js";
|
||||
export {
|
||||
formatMessageLine,
|
||||
listRecentMessages,
|
||||
} from "../../twilio/messages.js";
|
||||
export { monitorTwilio } from "../../twilio/monitor.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 {
|
||||
updateWebhook,
|
||||
findIncomingNumberSid,
|
||||
findMessagingServiceSid,
|
||||
setMessagingServiceWebhook,
|
||||
updateWebhook,
|
||||
} from "../../twilio/update-webhook.js";
|
||||
export { findWhatsappSenderSid } from "../../twilio/senders.js";
|
||||
export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.js";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export {
|
||||
createWaSocket,
|
||||
waitForWaConnection,
|
||||
sendMessageWeb,
|
||||
loginWeb,
|
||||
logWebSelfId,
|
||||
monitorWebInbox,
|
||||
monitorWebProvider,
|
||||
webAuthExists,
|
||||
logWebSelfId,
|
||||
pickProvider,
|
||||
sendMessageWeb,
|
||||
WA_WEB_AUTH_DIR,
|
||||
waitForWaConnection,
|
||||
webAuthExists,
|
||||
} from "../../provider-web.js";
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { withWhatsAppPrefix } from "../utils.js";
|
||||
import { readEnv } from "../env.js";
|
||||
import { withWhatsAppPrefix } from "../utils.js";
|
||||
import { createClient } from "./client.js";
|
||||
|
||||
export type ListedMessage = {
|
||||
sid: string;
|
||||
status: string | null;
|
||||
direction: string | null;
|
||||
dateCreated: Date | undefined;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
body?: string | null;
|
||||
errorCode: number | null;
|
||||
errorMessage: string | null;
|
||||
sid: string;
|
||||
status: string | null;
|
||||
direction: string | null;
|
||||
dateCreated: Date | undefined;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
body?: string | null;
|
||||
errorCode: number | null;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
// 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.
|
||||
export async function listRecentMessages(
|
||||
lookbackMinutes: number,
|
||||
limit: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
lookbackMinutes: number,
|
||||
limit: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
): Promise<ListedMessage[]> {
|
||||
const env = readEnv();
|
||||
const client = clientOverride ?? createClient(env);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const since = new Date(Date.now() - lookbackMinutes * 60_000);
|
||||
const env = readEnv();
|
||||
const client = clientOverride ?? createClient(env);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const since = new Date(Date.now() - lookbackMinutes * 60_000);
|
||||
|
||||
// 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 inbound = await client.messages.list({ to: from, dateSentAfter: since, limit: fetchLimit });
|
||||
const outbound = await client.messages.list({ from, dateSentAfter: since, limit: fetchLimit });
|
||||
// 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 inbound = await client.messages.list({
|
||||
to: from,
|
||||
dateSentAfter: since,
|
||||
limit: fetchLimit,
|
||||
});
|
||||
const outbound = await client.messages.list({
|
||||
from,
|
||||
dateSentAfter: since,
|
||||
limit: fetchLimit,
|
||||
});
|
||||
|
||||
const inboundArr = Array.isArray(inbound) ? inbound : [];
|
||||
const outboundArr = Array.isArray(outbound) ? outbound : [];
|
||||
const combined = uniqueBySid(
|
||||
[...inboundArr, ...outboundArr].map((m) => ({
|
||||
sid: m.sid,
|
||||
status: m.status ?? null,
|
||||
direction: m.direction ?? null,
|
||||
dateCreated: m.dateCreated,
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
body: m.body,
|
||||
errorCode: m.errorCode ?? null,
|
||||
errorMessage: m.errorMessage ?? null,
|
||||
})),
|
||||
);
|
||||
const inboundArr = Array.isArray(inbound) ? inbound : [];
|
||||
const outboundArr = Array.isArray(outbound) ? outbound : [];
|
||||
const combined = uniqueBySid(
|
||||
[...inboundArr, ...outboundArr].map((m) => ({
|
||||
sid: m.sid,
|
||||
status: m.status ?? null,
|
||||
direction: m.direction ?? null,
|
||||
dateCreated: m.dateCreated,
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
body: m.body,
|
||||
errorCode: m.errorCode ?? 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.
|
||||
export function formatMessageLine(m: ListedMessage): string {
|
||||
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
|
||||
const dir =
|
||||
m.direction === "inbound"
|
||||
? "⬅️ "
|
||||
: m.direction === "outbound-api" || m.direction === "outbound-reply"
|
||||
? "➡️ "
|
||||
: "↔️ ";
|
||||
const status = m.status ?? "unknown";
|
||||
const err =
|
||||
m.errorCode != null
|
||||
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
|
||||
: "";
|
||||
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
|
||||
const bodyPreview = body.length > 140 ? `${body.slice(0, 137)}…` : body || "<empty>";
|
||||
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
|
||||
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
|
||||
const dir =
|
||||
m.direction === "inbound"
|
||||
? "⬅️ "
|
||||
: m.direction === "outbound-api" || m.direction === "outbound-reply"
|
||||
? "➡️ "
|
||||
: "↔️ ";
|
||||
const status = m.status ?? "unknown";
|
||||
const err =
|
||||
m.errorCode != null
|
||||
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
|
||||
: "";
|
||||
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
|
||||
const bodyPreview =
|
||||
body.length > 140 ? `${body.slice(0, 137)}…` : body || "<empty>";
|
||||
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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 { createClient } from "./client.js";
|
||||
import { readEnv } from "../env.js";
|
||||
import { danger } from "../globals.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 = {
|
||||
autoReplyIfConfigured: typeof autoReplyIfConfigured;
|
||||
@@ -95,7 +94,9 @@ export async function monitorTwilio(
|
||||
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
|
||||
iterations += 1;
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { success } from "../globals.js";
|
||||
import { readEnv } from "../env.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { withWhatsAppPrefix, sleep } from "../utils.js";
|
||||
import { readEnv } from "../env.js";
|
||||
import { sleep, withWhatsAppPrefix } from "../utils.js";
|
||||
import { createClient } from "./client.js";
|
||||
import { logTwilioSendError } from "./utils.js";
|
||||
|
||||
@@ -29,7 +28,10 @@ export async function sendMessage(
|
||||
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 };
|
||||
} catch (err) {
|
||||
logTwilioSendError(err, toNumber, runtime);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { warn, isVerbose, logVerbose } from "../globals.js";
|
||||
import { isVerbose, logVerbose, warn } from "../globals.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
type TwilioRequestOptions = {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { isVerbose, success, warn } from "../globals.js";
|
||||
import { logError, logInfo } from "../logger.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 { createClient } from "./client.js";
|
||||
import type { TwilioSenderListClient, TwilioRequester } from "./types.js";
|
||||
import type { createClient } from "./client.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.
|
||||
try {
|
||||
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).
|
||||
type IncomingNumberWithService = { messagingServiceSid?: string };
|
||||
try {
|
||||
@@ -65,7 +68,6 @@ export async function setMessagingServiceWebhook(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update sender webhook URL with layered fallbacks (channels, form, helper, phone).
|
||||
export async function updateWebhook(
|
||||
client: ReturnType<typeof createClient>,
|
||||
|
||||
@@ -1,142 +1,158 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import type { Server } from "node:http";
|
||||
import bodyParser from "body-parser";
|
||||
import chalk from "chalk";
|
||||
import type { Server } from "http";
|
||||
|
||||
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 express, { type Request, type Response } from "express";
|
||||
import { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.js";
|
||||
import { sendTypingIndicator } from "./typing.js";
|
||||
import { logTwilioSendError } from "./utils.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { type EnvConfig, readEnv } from "../env.js";
|
||||
import { danger, success } from "../globals.js";
|
||||
import { ensureMediaHosted } from "../media/host.js";
|
||||
import { attachMediaRoutes } from "../media/server.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. */
|
||||
export async function startWebhook(
|
||||
port: number,
|
||||
path = "/webhook/whatsapp",
|
||||
autoReply: string | undefined,
|
||||
verbose: boolean,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
port: number,
|
||||
path = "/webhook/whatsapp",
|
||||
autoReply: string | undefined,
|
||||
verbose: boolean,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<Server> {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const env = readEnv(runtime);
|
||||
const app = express();
|
||||
const normalizedPath = normalizePath(path);
|
||||
const env = readEnv(runtime);
|
||||
const app = express();
|
||||
|
||||
attachMediaRoutes(app, undefined, runtime);
|
||||
// Twilio sends application/x-www-form-urlencoded payloads.
|
||||
app.use(bodyParser.urlencoded({ extended: false }));
|
||||
app.use((req, _res, next) => {
|
||||
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
|
||||
next();
|
||||
});
|
||||
attachMediaRoutes(app, undefined, runtime);
|
||||
// Twilio sends application/x-www-form-urlencoded payloads.
|
||||
app.use(bodyParser.urlencoded({ extended: false }));
|
||||
app.use((req, _res, next) => {
|
||||
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
|
||||
next();
|
||||
});
|
||||
|
||||
app.post(normalizedPath, async (req: Request, res: Response) => {
|
||||
const { From, To, Body, MessageSid } = req.body ?? {};
|
||||
runtime.log(`
|
||||
app.post(normalizedPath, async (req: Request, res: Response) => {
|
||||
const { From, To, Body, MessageSid } = req.body ?? {};
|
||||
runtime.log(`
|
||||
[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);
|
||||
let mediaPath: string | undefined;
|
||||
let mediaUrlInbound: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
|
||||
mediaUrlInbound = req.body.MediaUrl0 as string;
|
||||
mediaType = typeof req.body?.MediaContentType0 === "string"
|
||||
? (req.body.MediaContentType0 as string)
|
||||
: undefined;
|
||||
try {
|
||||
const creds = buildTwilioBasicAuth(env);
|
||||
const saved = await saveMediaSource(mediaUrlInbound, {
|
||||
Authorization: `Basic ${creds}`,
|
||||
}, "inbound");
|
||||
mediaPath = saved.path;
|
||||
if (!mediaType && saved.contentType) mediaType = saved.contentType;
|
||||
} catch (err) {
|
||||
runtime.error(danger(`Failed to download inbound media: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
|
||||
let mediaPath: string | undefined;
|
||||
let mediaUrlInbound: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
|
||||
mediaUrlInbound = req.body.MediaUrl0 as string;
|
||||
mediaType =
|
||||
typeof req.body?.MediaContentType0 === "string"
|
||||
? (req.body.MediaContentType0 as string)
|
||||
: undefined;
|
||||
try {
|
||||
const creds = buildTwilioBasicAuth(env);
|
||||
const saved = await saveMediaSource(
|
||||
mediaUrlInbound,
|
||||
{
|
||||
Authorization: `Basic ${creds}`,
|
||||
},
|
||||
"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);
|
||||
let replyResult: ReplyPayload | undefined =
|
||||
autoReply !== undefined ? { text: autoReply } : undefined;
|
||||
if (!replyResult) {
|
||||
replyResult = await getReplyFromConfig(
|
||||
{ Body, From, To, MessageSid, MediaPath: mediaPath, MediaUrl: mediaUrlInbound, MediaType: mediaType },
|
||||
{
|
||||
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
||||
},
|
||||
);
|
||||
}
|
||||
const client = createClient(env);
|
||||
let replyResult: ReplyPayload | undefined =
|
||||
autoReply !== undefined ? { text: autoReply } : undefined;
|
||||
if (!replyResult) {
|
||||
replyResult = await getReplyFromConfig(
|
||||
{
|
||||
Body,
|
||||
From,
|
||||
To,
|
||||
MessageSid,
|
||||
MediaPath: mediaPath,
|
||||
MediaUrl: mediaUrlInbound,
|
||||
MediaType: mediaType,
|
||||
},
|
||||
{
|
||||
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
|
||||
try {
|
||||
let mediaUrl = replyResult.mediaUrl;
|
||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
||||
const hosted = await ensureMediaHosted(mediaUrl);
|
||||
mediaUrl = hosted.url;
|
||||
}
|
||||
await client.messages.create({
|
||||
from: To,
|
||||
to: From,
|
||||
body: replyResult.text ?? "",
|
||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
||||
});
|
||||
if (verbose)
|
||||
runtime.log(
|
||||
success(
|
||||
`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
logTwilioSendError(err, From ?? undefined, runtime);
|
||||
}
|
||||
}
|
||||
if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
|
||||
try {
|
||||
let mediaUrl = replyResult.mediaUrl;
|
||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
||||
const hosted = await ensureMediaHosted(mediaUrl);
|
||||
mediaUrl = hosted.url;
|
||||
}
|
||||
await client.messages.create({
|
||||
from: To,
|
||||
to: From,
|
||||
body: replyResult.text ?? "",
|
||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
||||
});
|
||||
if (verbose)
|
||||
runtime.log(
|
||||
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
|
||||
);
|
||||
} catch (err) {
|
||||
logTwilioSendError(err, From ?? undefined, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
// Respond 200 OK to Twilio.
|
||||
res.type("text/xml").send("<Response></Response>");
|
||||
});
|
||||
// Respond 200 OK to Twilio.
|
||||
res.type("text/xml").send("<Response></Response>");
|
||||
});
|
||||
|
||||
app.use((_req, res) => {
|
||||
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
|
||||
res.status(404).send("warelay webhook: not found");
|
||||
});
|
||||
app.use((_req, res) => {
|
||||
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
|
||||
res.status(404).send("warelay webhook: not found");
|
||||
});
|
||||
|
||||
// Start server and resolve once listening; reject on bind error.
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = app.listen(port);
|
||||
// Start server and resolve once listening; reject on bind error.
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = app.listen(port);
|
||||
|
||||
const onListening = () => {
|
||||
cleanup();
|
||||
runtime.log(
|
||||
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
|
||||
);
|
||||
resolve(server);
|
||||
};
|
||||
const onListening = () => {
|
||||
cleanup();
|
||||
runtime.log(
|
||||
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
|
||||
);
|
||||
resolve(server);
|
||||
};
|
||||
|
||||
const onError = (err: NodeJS.ErrnoException) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
const onError = (err: NodeJS.ErrnoException) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
server.off("listening", onListening);
|
||||
server.off("error", onError);
|
||||
};
|
||||
const cleanup = () => {
|
||||
server.off("listening", onListening);
|
||||
server.off("error", onError);
|
||||
};
|
||||
|
||||
server.once("listening", onListening);
|
||||
server.once("error", onError);
|
||||
});
|
||||
server.once("listening", onListening);
|
||||
server.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
function buildTwilioBasicAuth(env: EnvConfig) {
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { startWebhook } from "../twilio/webhook.js";
|
||||
|
||||
// Thin wrapper to keep webhook server co-located with other webhook helpers.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export {
|
||||
updateWebhook,
|
||||
setMessagingServiceWebhook,
|
||||
findIncomingNumberSid,
|
||||
findMessagingServiceSid,
|
||||
setMessagingServiceWebhook,
|
||||
updateWebhook,
|
||||
} from "../twilio/update-webhook.js";
|
||||
|
||||
Reference in New Issue
Block a user