Add web provider inbound monitor with auto-replies
This commit is contained in:
24
README.md
24
README.md
@@ -22,12 +22,13 @@ You can also use a personal WhatsApp Web session (QR login) via `--provider web`
|
|||||||
## Providers (choose per command)
|
## Providers (choose per command)
|
||||||
|
|
||||||
- **Twilio (default)** — full feature set: send, wait/poll delivery, status, inbound polling/webhook, auto-replies. Requires `.env` Twilio creds and a WhatsApp-enabled number (`TWILIO_WHATSAPP_FROM`).
|
- **Twilio (default)** — full feature set: send, wait/poll delivery, status, inbound polling/webhook, auto-replies. Requires `.env` Twilio creds and a WhatsApp-enabled number (`TWILIO_WHATSAPP_FROM`).
|
||||||
- **Web (`--provider web`)** — uses your personal WhatsApp Web session via QR. Currently **send-only** (no inbound/auto-reply/status yet) and returns immediately without delivery polling. Setup: `pnpm warelay web:login` then send with `--provider web`. Session data lives in `~/.warelay/waweb/`; if logged out, rerun `web:login`. Use at your own risk (personal-account automation can be rate-limited or logged out by WhatsApp).
|
- **Web (`--provider web`)** — personal WhatsApp Web session via QR. Supports outbound sends (`send --provider web`) and inbound auto-replies when you run `pnpm warelay web:monitor`. No delivery-status polling for web sends. Setup: `pnpm warelay web:login` then either send with `--provider web` or keep `web:monitor` running. Session data lives in `~/.warelay/waweb/`; if logged out, rerun `web:login`. Use at your own risk (personal-account automation can be rate-limited or logged out by WhatsApp).
|
||||||
|
|
||||||
## Common Commands
|
## Common Commands
|
||||||
|
|
||||||
- Send: `pnpm warelay send --to +12345550000 --message "Hello" --wait 20 --poll 2`
|
- Send: `pnpm warelay send --to +12345550000 --message "Hello" --wait 20 --poll 2`
|
||||||
- Send via personal WhatsApp Web: first `pnpm warelay web:login` (scan QR), then `pnpm warelay send --provider web --to +12345550000 --message "Hi"`
|
- Send via personal WhatsApp Web: first `pnpm warelay web:login` (scan QR), then `pnpm warelay send --provider web --to +12345550000 --message "Hi"`
|
||||||
|
- Web auto-replies (personal WA): `pnpm warelay web:login` once, then run `pnpm warelay web:monitor` to listen and auto-reply using your `~/.warelay/warelay.json` config
|
||||||
- Poll (lightweight): `pnpm warelay poll --interval 5 --lookback 10 --verbose`
|
- Poll (lightweight): `pnpm warelay poll --interval 5 --lookback 10 --verbose`
|
||||||
- Webhook only: `pnpm warelay webhook --port 42873 --path /webhook/whatsapp --verbose`
|
- Webhook only: `pnpm warelay webhook --port 42873 --path /webhook/whatsapp --verbose`
|
||||||
- Webhook + Funnel + Twilio update: `pnpm warelay up --port 42873 --path /webhook/whatsapp --verbose`
|
- Webhook + Funnel + Twilio update: `pnpm warelay up --port 42873 --path /webhook/whatsapp --verbose`
|
||||||
@@ -46,9 +47,19 @@ You can also use a personal WhatsApp Web session (QR login) via `--provider web`
|
|||||||
command: [
|
command: [
|
||||||
"claude",
|
"claude",
|
||||||
"-p",
|
"-p",
|
||||||
|
"--output-format",
|
||||||
|
"json",
|
||||||
"--dangerously-skip-permissions",
|
"--dangerously-skip-permissions",
|
||||||
"{{Body}}"
|
"{{Body}}"
|
||||||
]
|
],
|
||||||
|
session: {
|
||||||
|
scope: "per-sender",
|
||||||
|
resetTriggers: ["/new"],
|
||||||
|
idleMinutes: 60,
|
||||||
|
sessionArgNew: ["--session-id", "{{SessionId}}"],
|
||||||
|
sessionArgResume: ["--resume", "{{SessionId}}"],
|
||||||
|
sessionArgBeforeBody: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,7 +75,8 @@ You can also use a personal WhatsApp Web session (QR login) via `--provider web`
|
|||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Templates support `{{Body}}`, `{{From}}`, `{{To}}`, `{{MessageSid}}`.
|
- Templates support `{{Body}}`, `{{BodyStripped}}`, `{{From}}`, `{{To}}`, `{{MessageSid}}`, plus `{{SessionId}}`/`{{IsNewSession}}` when session reuse is enabled.
|
||||||
|
- `/new` (or any `resetTriggers` value) resets the session. `/new ask…` resets and sends `ask…` as the prompt (via `BodyStripped`).
|
||||||
- When an auto-reply starts (text or command), warelay sends a WhatsApp typing indicator tied to the inbound `MessageSid`.
|
- When an auto-reply starts (text or command), warelay sends a WhatsApp typing indicator tied to the inbound `MessageSid`.
|
||||||
|
|
||||||
## Troubleshooting Delivery
|
## Troubleshooting Delivery
|
||||||
@@ -83,6 +95,12 @@ Notes:
|
|||||||
| `inbound.reply.command` | `string[]` | — | Argv to run for command mode; templated per element. Stdout (trimmed) is sent. |
|
| `inbound.reply.command` | `string[]` | — | Argv to run for command mode; templated per element. Stdout (trimmed) is sent. |
|
||||||
| `inbound.reply.template` | `string` | — | Optional string inserted as second argv element (prompt prefix). |
|
| `inbound.reply.template` | `string` | — | Optional string inserted as second argv element (prompt prefix). |
|
||||||
| `inbound.reply.bodyPrefix` | `string` | — | Prepends to `Body` before templating (ideal for system instructions). |
|
| `inbound.reply.bodyPrefix` | `string` | — | Prepends to `Body` before templating (ideal for system instructions). |
|
||||||
|
| `inbound.reply.session.scope` | `"per-sender" \| "global"` | `per-sender` | Session key: one per sender or single global chat. |
|
||||||
|
| `inbound.reply.session.resetTriggers` | `string[]` | `["/new"]` | Any entry acts as both exact reset token and prefix (`/new hi`). |
|
||||||
|
| `inbound.reply.session.idleMinutes` | `number` | `60` | Expire and recreate session after this idle time. |
|
||||||
|
| `inbound.reply.session.sessionArgNew` | `string[]` | `["--session-id","{{SessionId}}"]` | Args inserted for a new session run. |
|
||||||
|
| `inbound.reply.session.sessionArgResume` | `string[]` | `["--resume","{{SessionId}}"]` | Args inserted when resuming an existing session. |
|
||||||
|
| `inbound.reply.session.sessionArgBeforeBody` | `boolean` | `true` | Place session args before the final body argument. |
|
||||||
| `inbound.reply.timeoutSeconds` | `number` | 600 | Command timeout. |
|
| `inbound.reply.timeoutSeconds` | `number` | 600 | Command timeout. |
|
||||||
|
|
||||||
## Dev Notes
|
## Dev Notes
|
||||||
|
|||||||
301
src/index.ts
301
src/index.ts
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { execFile, spawn } from "node:child_process";
|
import { execFile, spawn } from "node:child_process";
|
||||||
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
@@ -8,6 +9,7 @@ import process, { stdin as input, stdout as output } from "node:process";
|
|||||||
import readline from "node:readline/promises";
|
import readline from "node:readline/promises";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
import bodyParser from "body-parser";
|
import bodyParser from "body-parser";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
@@ -16,21 +18,22 @@ import express, { type Request, type Response } from "express";
|
|||||||
import JSON5 from "json5";
|
import JSON5 from "json5";
|
||||||
import Twilio from "twilio";
|
import Twilio from "twilio";
|
||||||
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 {
|
import {
|
||||||
danger,
|
danger,
|
||||||
info,
|
info,
|
||||||
isYes,
|
|
||||||
isVerbose,
|
isVerbose,
|
||||||
|
isYes,
|
||||||
logVerbose,
|
logVerbose,
|
||||||
setVerbose,
|
setVerbose,
|
||||||
setYes,
|
setYes,
|
||||||
success,
|
success,
|
||||||
warn,
|
warn,
|
||||||
} from "./globals.js";
|
} from "./globals.js";
|
||||||
import { loginWeb, sendMessageWeb } from "./provider-web.js";
|
import { loginWeb, monitorWebInbox, sendMessageWeb } from "./provider-web.js";
|
||||||
import {
|
import {
|
||||||
Provider,
|
|
||||||
assertProvider,
|
assertProvider,
|
||||||
|
CONFIG_DIR,
|
||||||
normalizeE164,
|
normalizeE164,
|
||||||
normalizePath,
|
normalizePath,
|
||||||
sleep,
|
sleep,
|
||||||
@@ -379,10 +382,23 @@ type WarelayConfig = {
|
|||||||
template?: string; // prepend template string when building command/prompt
|
template?: string; // prepend template string when building command/prompt
|
||||||
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
||||||
bodyPrefix?: string; // optional string prepended to Body before templating
|
bodyPrefix?: string; // optional string prepended to Body before templating
|
||||||
|
session?: SessionConfig;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SessionScope = "per-sender" | "global";
|
||||||
|
|
||||||
|
type SessionConfig = {
|
||||||
|
scope?: SessionScope;
|
||||||
|
resetTriggers?: string[];
|
||||||
|
idleMinutes?: number;
|
||||||
|
store?: string;
|
||||||
|
sessionArgNew?: string[];
|
||||||
|
sessionArgResume?: string[];
|
||||||
|
sessionArgBeforeBody?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function loadConfig(): WarelayConfig {
|
function loadConfig(): WarelayConfig {
|
||||||
// Read ~/.warelay/warelay.json (JSON5) if present.
|
// Read ~/.warelay/warelay.json (JSON5) if present.
|
||||||
try {
|
try {
|
||||||
@@ -408,7 +424,7 @@ type GetReplyOptions = {
|
|||||||
onReplyStart?: () => Promise<void> | void;
|
onReplyStart?: () => Promise<void> | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyTemplate(str: string, ctx: MsgContext) {
|
function applyTemplate(str: string, ctx: TemplateContext) {
|
||||||
// Simple {{Placeholder}} interpolation using inbound message context.
|
// Simple {{Placeholder}} interpolation using inbound message context.
|
||||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||||
const value = (ctx as Record<string, unknown>)[key];
|
const value = (ctx as Record<string, unknown>)[key];
|
||||||
@@ -416,12 +432,56 @@ function applyTemplate(str: string, ctx: MsgContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TemplateContext = MsgContext & {
|
||||||
|
BodyStripped?: string;
|
||||||
|
SessionId?: string;
|
||||||
|
IsNewSession?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionEntry = { sessionId: string; updatedAt: number };
|
||||||
|
|
||||||
|
const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
|
||||||
|
const DEFAULT_RESET_TRIGGER = "/new";
|
||||||
|
const DEFAULT_IDLE_MINUTES = 60;
|
||||||
|
|
||||||
|
function resolveStorePath(store?: string) {
|
||||||
|
if (!store) return SESSION_STORE_DEFAULT;
|
||||||
|
if (store.startsWith("~")) return path.resolve(store.replace("~", os.homedir()));
|
||||||
|
return path.resolve(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSessionStore(storePath: string): Record<string, SessionEntry> {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(storePath, "utf-8");
|
||||||
|
const parsed = JSON5.parse(raw);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
return parsed as Record<string, SessionEntry>;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore missing/invalid store; we'll recreate it
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSessionStore(storePath: string, store: Record<string, SessionEntry>) {
|
||||||
|
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||||
|
await fs.promises.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
||||||
|
if (scope === "global") return "global";
|
||||||
|
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
||||||
|
return from || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
async function getReplyFromConfig(
|
async function getReplyFromConfig(
|
||||||
ctx: MsgContext,
|
ctx: MsgContext,
|
||||||
opts?: GetReplyOptions,
|
opts?: GetReplyOptions,
|
||||||
|
configOverride?: WarelayConfig,
|
||||||
|
commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
// Choose reply from config: static text or external command stdout.
|
// Choose reply from config: static text or external command stdout.
|
||||||
const cfg = loadConfig();
|
const cfg = configOverride ?? loadConfig();
|
||||||
const reply = cfg.inbound?.reply;
|
const reply = cfg.inbound?.reply;
|
||||||
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
|
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
|
||||||
const timeoutMs = timeoutSeconds * 1000;
|
const timeoutMs = timeoutSeconds * 1000;
|
||||||
@@ -432,14 +492,73 @@ async function getReplyFromConfig(
|
|||||||
await opts?.onReplyStart?.();
|
await opts?.onReplyStart?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Optional session handling (conversation reuse + /new resets)
|
||||||
|
const sessionCfg = reply?.session;
|
||||||
|
const resetTriggers =
|
||||||
|
sessionCfg?.resetTriggers?.length
|
||||||
|
? sessionCfg.resetTriggers
|
||||||
|
: [DEFAULT_RESET_TRIGGER];
|
||||||
|
const idleMinutes = Math.max(sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1);
|
||||||
|
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||||
|
const storePath = resolveStorePath(sessionCfg?.store);
|
||||||
|
|
||||||
|
let sessionId: string | undefined;
|
||||||
|
let isNewSession = false;
|
||||||
|
let bodyStripped: string | undefined;
|
||||||
|
|
||||||
|
if (sessionCfg) {
|
||||||
|
const trimmedBody = (ctx.Body ?? "").trim();
|
||||||
|
for (const trigger of resetTriggers) {
|
||||||
|
if (!trigger) continue;
|
||||||
|
if (trimmedBody === trigger) {
|
||||||
|
isNewSession = true;
|
||||||
|
bodyStripped = "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const triggerPrefix = `${trigger} `;
|
||||||
|
if (trimmedBody.startsWith(triggerPrefix)) {
|
||||||
|
isNewSession = true;
|
||||||
|
bodyStripped = trimmedBody.slice(trigger.length).trimStart();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionKey = deriveSessionKey(sessionScope, ctx);
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const entry = store[sessionKey];
|
||||||
|
const idleMs = idleMinutes * 60_000;
|
||||||
|
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||||
|
|
||||||
|
if (!isNewSession && freshEntry) {
|
||||||
|
sessionId = entry.sessionId;
|
||||||
|
} else {
|
||||||
|
sessionId = crypto.randomUUID();
|
||||||
|
isNewSession = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
store[sessionKey] = { sessionId, updatedAt: Date.now() };
|
||||||
|
await saveSessionStore(storePath, store);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCtx: TemplateContext = {
|
||||||
|
...ctx,
|
||||||
|
BodyStripped: bodyStripped ?? ctx.Body,
|
||||||
|
SessionId: sessionId,
|
||||||
|
IsNewSession: isNewSession ? "true" : "false",
|
||||||
|
};
|
||||||
|
|
||||||
// Optional prefix injected before Body for templating/command prompts.
|
// Optional prefix injected before Body for templating/command prompts.
|
||||||
const bodyPrefix = reply?.bodyPrefix
|
const bodyPrefix = reply?.bodyPrefix
|
||||||
? applyTemplate(reply.bodyPrefix, ctx)
|
? applyTemplate(reply.bodyPrefix, sessionCtx)
|
||||||
: "";
|
: "";
|
||||||
const templatingCtx: MsgContext =
|
const prefixedBody = bodyPrefix
|
||||||
bodyPrefix && (ctx.Body ?? "").length >= 0
|
? `${bodyPrefix}${sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""}`
|
||||||
? { ...ctx, Body: `${bodyPrefix}${ctx.Body ?? ""}` }
|
: sessionCtx.BodyStripped ?? sessionCtx.Body;
|
||||||
: ctx;
|
const templatingCtx: TemplateContext = {
|
||||||
|
...sessionCtx,
|
||||||
|
Body: prefixedBody,
|
||||||
|
BodyStripped: prefixedBody,
|
||||||
|
};
|
||||||
|
|
||||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||||
const allowFrom = cfg.inbound?.allowFrom;
|
const allowFrom = cfg.inbound?.allowFrom;
|
||||||
@@ -465,20 +584,36 @@ async function getReplyFromConfig(
|
|||||||
|
|
||||||
if (reply.mode === "command" && reply.command?.length) {
|
if (reply.mode === "command" && reply.command?.length) {
|
||||||
await onReplyStart();
|
await onReplyStart();
|
||||||
const argv = reply.command.map((part) =>
|
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
||||||
applyTemplate(part, templatingCtx),
|
|
||||||
);
|
|
||||||
const templatePrefix = reply.template
|
const templatePrefix = reply.template
|
||||||
? applyTemplate(reply.template, templatingCtx)
|
? applyTemplate(reply.template, templatingCtx)
|
||||||
: "";
|
: "";
|
||||||
const finalArgv = templatePrefix
|
if (templatePrefix && argv.length > 0) {
|
||||||
? [argv[0], templatePrefix, ...argv.slice(1)]
|
argv = [argv[0], templatePrefix, ...argv.slice(1)];
|
||||||
: argv;
|
}
|
||||||
|
|
||||||
|
// Inject session args if configured (use resume for existing, session-id for new)
|
||||||
|
if (reply.session) {
|
||||||
|
const sessionArgList = (isNewSession
|
||||||
|
? reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"]
|
||||||
|
: reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"]
|
||||||
|
).map((part) => applyTemplate(part, templatingCtx));
|
||||||
|
if (sessionArgList.length) {
|
||||||
|
const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true;
|
||||||
|
const insertAt = insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length;
|
||||||
|
argv = [
|
||||||
|
...argv.slice(0, insertAt),
|
||||||
|
...sessionArgList,
|
||||||
|
...argv.slice(insertAt),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const finalArgv = argv;
|
||||||
logVerbose(`Running command auto-reply: ${finalArgv.join(" ")}`);
|
logVerbose(`Running command auto-reply: ${finalArgv.join(" ")}`);
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr, code, signal, killed } =
|
const { stdout, stderr, code, signal, killed } =
|
||||||
await runCommandWithTimeout(finalArgv, timeoutMs);
|
await commandRunner(finalArgv, timeoutMs);
|
||||||
const trimmed = stdout.trim();
|
const trimmed = stdout.trim();
|
||||||
if (stderr?.trim()) {
|
if (stderr?.trim()) {
|
||||||
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
|
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
|
||||||
@@ -528,6 +663,7 @@ async function getReplyFromConfig(
|
|||||||
async function autoReplyIfConfigured(
|
async function autoReplyIfConfigured(
|
||||||
client: ReturnType<typeof createClient>,
|
client: ReturnType<typeof createClient>,
|
||||||
message: MessageInstance,
|
message: MessageInstance,
|
||||||
|
configOverride?: WarelayConfig,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Fire a config-driven reply (text or command) for the inbound message, if configured.
|
// Fire a config-driven reply (text or command) for the inbound message, if configured.
|
||||||
const ctx: MsgContext = {
|
const ctx: MsgContext = {
|
||||||
@@ -537,9 +673,13 @@ async function autoReplyIfConfigured(
|
|||||||
MessageSid: message.sid,
|
MessageSid: message.sid,
|
||||||
};
|
};
|
||||||
|
|
||||||
const replyText = await getReplyFromConfig(ctx, {
|
const replyText = await getReplyFromConfig(
|
||||||
onReplyStart: () => sendTypingIndicator(client, message.sid),
|
ctx,
|
||||||
});
|
{
|
||||||
|
onReplyStart: () => sendTypingIndicator(client, message.sid),
|
||||||
|
},
|
||||||
|
configOverride,
|
||||||
|
);
|
||||||
if (!replyText) return;
|
if (!replyText) return;
|
||||||
|
|
||||||
const replyFrom = message.to;
|
const replyFrom = message.to;
|
||||||
@@ -1290,6 +1430,56 @@ async function monitor(intervalSeconds: number, lookbackMinutes: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function monitorWebProvider(verbose: boolean) {
|
||||||
|
// Listen for inbound personal WhatsApp Web messages and auto-reply if configured.
|
||||||
|
const listener = await monitorWebInbox({
|
||||||
|
verbose,
|
||||||
|
onMessage: async (msg) => {
|
||||||
|
const ts = msg.timestamp
|
||||||
|
? new Date(msg.timestamp).toISOString()
|
||||||
|
: new Date().toISOString();
|
||||||
|
console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`);
|
||||||
|
|
||||||
|
const replyText = await getReplyFromConfig(
|
||||||
|
{
|
||||||
|
Body: msg.body,
|
||||||
|
From: msg.from,
|
||||||
|
To: msg.to,
|
||||||
|
MessageSid: msg.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onReplyStart: msg.sendComposing,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!replyText) return;
|
||||||
|
try {
|
||||||
|
await msg.reply(replyText);
|
||||||
|
if (isVerbose()) {
|
||||||
|
console.log(success(`↩️ Auto-replied to ${msg.from} (web)`));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
danger(`Failed sending web auto-reply to ${msg.from}: ${String(err)}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
info(
|
||||||
|
"📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
void listener.close().finally(() => {
|
||||||
|
console.log("\n👋 Web monitor stopped");
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForever();
|
||||||
|
}
|
||||||
|
|
||||||
type ListedMessage = {
|
type ListedMessage = {
|
||||||
sid: string;
|
sid: string;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
@@ -1343,9 +1533,10 @@ function formatMessageLine(m: ListedMessage): string {
|
|||||||
async function listRecentMessages(
|
async function listRecentMessages(
|
||||||
lookbackMinutes: number,
|
lookbackMinutes: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
|
clientOverride?: ReturnType<typeof createClient>,
|
||||||
): Promise<ListedMessage[]> {
|
): Promise<ListedMessage[]> {
|
||||||
const env = readEnv();
|
const env = readEnv();
|
||||||
const client = 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);
|
||||||
|
|
||||||
@@ -1390,7 +1581,12 @@ program
|
|||||||
.option("--verbose", "Verbose connection logs", false)
|
.option("--verbose", "Verbose connection logs", false)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
await loginWeb(Boolean(opts.verbose));
|
try {
|
||||||
|
await loginWeb(Boolean(opts.verbose));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(danger(`Web login failed: ${String(err)}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -1475,6 +1671,23 @@ Examples:
|
|||||||
await monitor(intervalSeconds, lookbackMinutes);
|
await monitor(intervalSeconds, lookbackMinutes);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("web:monitor")
|
||||||
|
.description("Listen for inbound messages via personal WhatsApp Web and auto-reply")
|
||||||
|
.option("--verbose", "Verbose logging", false)
|
||||||
|
.addHelpText(
|
||||||
|
"after",
|
||||||
|
`
|
||||||
|
Examples:
|
||||||
|
warelay web:monitor # start auto-replies on your linked web session
|
||||||
|
warelay web:monitor --verbose # show low-level Baileys logs
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.action(async (opts) => {
|
||||||
|
setVerbose(Boolean(opts.verbose));
|
||||||
|
await monitorWebProvider(Boolean(opts.verbose));
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("status")
|
.command("status")
|
||||||
.description("Show recent WhatsApp messages (sent and received)")
|
.description("Show recent WhatsApp messages (sent and received)")
|
||||||
@@ -1669,7 +1882,49 @@ program
|
|||||||
await waitForever();
|
await waitForever();
|
||||||
});
|
});
|
||||||
|
|
||||||
export { normalizeE164, toWhatsappJid, assertProvider };
|
export {
|
||||||
|
assertProvider,
|
||||||
|
autoReplyIfConfigured,
|
||||||
|
applyTemplate,
|
||||||
|
createClient,
|
||||||
|
deriveSessionKey,
|
||||||
|
describePortOwner,
|
||||||
|
ensureBinary,
|
||||||
|
ensureFunnel,
|
||||||
|
ensureGoInstalled,
|
||||||
|
ensurePortAvailable,
|
||||||
|
ensureTailscaledInstalled,
|
||||||
|
findIncomingNumberSid,
|
||||||
|
findMessagingServiceSid,
|
||||||
|
findWhatsappSenderSid,
|
||||||
|
formatMessageLine,
|
||||||
|
getReplyFromConfig,
|
||||||
|
getTailnetHostname,
|
||||||
|
handlePortError,
|
||||||
|
listRecentMessages,
|
||||||
|
loadConfig,
|
||||||
|
loadSessionStore,
|
||||||
|
monitor,
|
||||||
|
monitorWebProvider,
|
||||||
|
normalizeE164,
|
||||||
|
PortInUseError,
|
||||||
|
promptYesNo,
|
||||||
|
readEnv,
|
||||||
|
resolveStorePath,
|
||||||
|
runCommandWithTimeout,
|
||||||
|
runExec,
|
||||||
|
saveSessionStore,
|
||||||
|
sendMessage,
|
||||||
|
sendTypingIndicator,
|
||||||
|
setMessagingServiceWebhook,
|
||||||
|
sortByDateDesc,
|
||||||
|
startWebhook,
|
||||||
|
updateWebhook,
|
||||||
|
uniqueBySid,
|
||||||
|
waitForFinalStatus,
|
||||||
|
waitForever,
|
||||||
|
toWhatsappJid,
|
||||||
|
};
|
||||||
|
|
||||||
const isMain =
|
const isMain =
|
||||||
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "node:path";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
fetchLatestBaileysVersion,
|
fetchLatestBaileysVersion,
|
||||||
@@ -7,10 +8,11 @@ import {
|
|||||||
makeWASocket,
|
makeWASocket,
|
||||||
useMultiFileAuthState,
|
useMultiFileAuthState,
|
||||||
} from "baileys";
|
} from "baileys";
|
||||||
|
import type { proto } from "baileys";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import qrcode from "qrcode-terminal";
|
import qrcode from "qrcode-terminal";
|
||||||
import { danger, info, logVerbose, success } from "./globals.js";
|
import { danger, info, logVerbose, success } from "./globals.js";
|
||||||
import { ensureDir, toWhatsappJid } from "./utils.js";
|
import { ensureDir, jidToE164, toWhatsappJid } from "./utils.js";
|
||||||
|
|
||||||
const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "waweb");
|
const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "waweb");
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ export async function createWaSocket(printQr: boolean, verbose: boolean) {
|
|||||||
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
||||||
},
|
},
|
||||||
version,
|
version,
|
||||||
|
logger,
|
||||||
printQRInTerminal: false,
|
printQRInTerminal: false,
|
||||||
browser: ["Warelay", "CLI", "1.0.0"],
|
browser: ["Warelay", "CLI", "1.0.0"],
|
||||||
syncFullHistory: false,
|
syncFullHistory: false,
|
||||||
@@ -32,25 +35,27 @@ export async function createWaSocket(printQr: boolean, verbose: boolean) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
sock.ev.on("creds.update", saveCreds);
|
sock.ev.on("creds.update", saveCreds);
|
||||||
sock.ev.on("connection.update", (update: Partial<import("baileys").ConnectionState>) => {
|
sock.ev.on(
|
||||||
const { connection, lastDisconnect, qr } = update;
|
"connection.update",
|
||||||
if (qr && printQr) {
|
(update: Partial<import("baileys").ConnectionState>) => {
|
||||||
console.log("Scan this QR in WhatsApp (Linked Devices):");
|
const { connection, lastDisconnect, qr } = update;
|
||||||
qrcode.generate(qr, { small: true });
|
if (qr && printQr) {
|
||||||
}
|
console.log("Scan this QR in WhatsApp (Linked Devices):");
|
||||||
if (connection === "close") {
|
qrcode.generate(qr, { small: true });
|
||||||
const code = (lastDisconnect?.error as { output?: { statusCode?: number } })
|
|
||||||
?.output?.statusCode;
|
|
||||||
if (code === DisconnectReason.loggedOut) {
|
|
||||||
console.error(
|
|
||||||
danger("WhatsApp session logged out. Run: warelay web:login"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
if (connection === "close") {
|
||||||
if (connection === "open" && verbose) {
|
const status = getStatusCode(lastDisconnect?.error);
|
||||||
console.log(success("WhatsApp Web connected."));
|
if (status === DisconnectReason.loggedOut) {
|
||||||
}
|
console.error(
|
||||||
});
|
danger("WhatsApp session logged out. Run: warelay web:login"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (connection === "open" && verbose) {
|
||||||
|
console.log(success("WhatsApp Web connected."));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return sock;
|
return sock;
|
||||||
}
|
}
|
||||||
@@ -104,12 +109,44 @@ export async function sendMessageWeb(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginWeb(verbose: boolean) {
|
export async function loginWeb(
|
||||||
|
verbose: boolean,
|
||||||
|
waitForConnection: typeof waitForWaConnection = waitForWaConnection,
|
||||||
|
) {
|
||||||
const sock = await createWaSocket(true, verbose);
|
const sock = await createWaSocket(true, verbose);
|
||||||
console.log(info("Waiting for WhatsApp connection..."));
|
console.log(info("Waiting for WhatsApp connection..."));
|
||||||
try {
|
try {
|
||||||
await waitForWaConnection(sock);
|
await waitForConnection(sock);
|
||||||
console.log(success("✅ Linked! Credentials saved for future sends."));
|
console.log(success("✅ Linked! Credentials saved for future sends."));
|
||||||
|
} catch (err) {
|
||||||
|
const code =
|
||||||
|
(err as { error?: { output?: { statusCode?: number } } })?.error?.output
|
||||||
|
?.statusCode ??
|
||||||
|
(err as { output?: { statusCode?: number } })?.output?.statusCode;
|
||||||
|
if (code === 515) {
|
||||||
|
console.log(
|
||||||
|
info(
|
||||||
|
"WhatsApp asked for a restart after pairing (code 515); creds are saved. You can now send with provider=web.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code === DisconnectReason.loggedOut) {
|
||||||
|
await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true });
|
||||||
|
console.error(
|
||||||
|
danger(
|
||||||
|
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun warelay web:login and scan the QR again.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
throw new Error("Session logged out; cache cleared. Re-run web:login.");
|
||||||
|
}
|
||||||
|
const formatted = formatError(err);
|
||||||
|
console.error(
|
||||||
|
danger(
|
||||||
|
`WhatsApp Web connection ended before fully opening. ${formatted}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
throw new Error(formatted);
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
@@ -122,3 +159,111 @@ export async function loginWeb(verbose: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { WA_WEB_AUTH_DIR };
|
export { WA_WEB_AUTH_DIR };
|
||||||
|
|
||||||
|
export type WebInboundMessage = {
|
||||||
|
id?: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
body: string;
|
||||||
|
pushName?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
sendComposing: () => Promise<void>;
|
||||||
|
reply: (text: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function monitorWebInbox(options: {
|
||||||
|
verbose: boolean;
|
||||||
|
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const sock = await createWaSocket(false, options.verbose);
|
||||||
|
await waitForWaConnection(sock);
|
||||||
|
const selfJid = sock.user?.id;
|
||||||
|
const selfE164 = selfJid ? jidToE164(selfJid) : null;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
sock.ev.on("messages.upsert", async (upsert) => {
|
||||||
|
if (upsert.type !== "notify") return;
|
||||||
|
for (const msg of upsert.messages) {
|
||||||
|
const id = msg.key?.id ?? undefined;
|
||||||
|
// De-dupe on message id; Baileys can emit retries.
|
||||||
|
if (id && seen.has(id)) continue;
|
||||||
|
if (id) seen.add(id);
|
||||||
|
if (msg.key?.fromMe) continue;
|
||||||
|
const remoteJid = msg.key?.remoteJid;
|
||||||
|
if (!remoteJid) continue;
|
||||||
|
// Ignore status/broadcast traffic; we only care about direct chats.
|
||||||
|
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast"))
|
||||||
|
continue;
|
||||||
|
const from = jidToE164(remoteJid);
|
||||||
|
if (!from) continue;
|
||||||
|
const body = extractText(msg.message);
|
||||||
|
if (!body) continue;
|
||||||
|
const chatJid = remoteJid;
|
||||||
|
const sendComposing = async () => {
|
||||||
|
try {
|
||||||
|
await sock.sendPresenceUpdate("composing", chatJid);
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`Presence update failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const reply = async (text: string) => {
|
||||||
|
await sock.sendMessage(chatJid, { text });
|
||||||
|
};
|
||||||
|
const timestamp = msg.messageTimestamp
|
||||||
|
? Number(msg.messageTimestamp) * 1000
|
||||||
|
: undefined;
|
||||||
|
try {
|
||||||
|
await options.onMessage({
|
||||||
|
id,
|
||||||
|
from,
|
||||||
|
to: selfE164 ?? "me",
|
||||||
|
body,
|
||||||
|
pushName: msg.pushName ?? undefined,
|
||||||
|
timestamp,
|
||||||
|
sendComposing,
|
||||||
|
reply,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(danger(`Failed handling inbound web message: ${String(err)}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: async () => {
|
||||||
|
try {
|
||||||
|
sock.ws?.close();
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`Socket close failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractText(message: proto.IMessage | undefined): string | undefined {
|
||||||
|
if (!message) return undefined;
|
||||||
|
if (typeof message.conversation === "string" && message.conversation.trim()) {
|
||||||
|
return message.conversation.trim();
|
||||||
|
}
|
||||||
|
const extended = message.extendedTextMessage?.text;
|
||||||
|
if (extended?.trim()) return extended.trim();
|
||||||
|
const caption = message.imageMessage?.caption ?? message.videoMessage?.caption;
|
||||||
|
if (caption?.trim()) return caption.trim();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusCode(err: unknown) {
|
||||||
|
return (
|
||||||
|
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
|
||||||
|
(err as { status?: number })?.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatError(err: unknown): string {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
if (typeof err === "string") return err;
|
||||||
|
const status = getStatusCode(err);
|
||||||
|
const code = (err as { code?: unknown })?.code;
|
||||||
|
if (status || code) return `status=${status ?? "unknown"} code=${code ?? "unknown"}`;
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ export function toWhatsappJid(number: string): string {
|
|||||||
return `${digits}@s.whatsapp.net`;
|
return `${digits}@s.whatsapp.net`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function jidToE164(jid: string): string | null {
|
||||||
|
// Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234.
|
||||||
|
const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const digits = match[1];
|
||||||
|
return `+${digits}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function sleep(ms: number) {
|
export function sleep(ms: number) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user