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)
|
||||
|
||||
- **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
|
||||
|
||||
- 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"`
|
||||
- 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`
|
||||
- Webhook only: `pnpm warelay webhook --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: [
|
||||
"claude",
|
||||
"-p",
|
||||
"--output-format",
|
||||
"json",
|
||||
"--dangerously-skip-permissions",
|
||||
"{{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:
|
||||
- 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`.
|
||||
|
||||
## 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.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.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. |
|
||||
|
||||
## Dev Notes
|
||||
|
||||
301
src/index.ts
301
src/index.ts
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
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 { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import bodyParser from "body-parser";
|
||||
import chalk from "chalk";
|
||||
import { Command } from "commander";
|
||||
@@ -16,21 +18,22 @@ import express, { type Request, type Response } from "express";
|
||||
import JSON5 from "json5";
|
||||
import Twilio from "twilio";
|
||||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||||
|
||||
import {
|
||||
danger,
|
||||
info,
|
||||
isYes,
|
||||
isVerbose,
|
||||
isYes,
|
||||
logVerbose,
|
||||
setVerbose,
|
||||
setYes,
|
||||
success,
|
||||
warn,
|
||||
} from "./globals.js";
|
||||
import { loginWeb, sendMessageWeb } from "./provider-web.js";
|
||||
import { loginWeb, monitorWebInbox, sendMessageWeb } from "./provider-web.js";
|
||||
import {
|
||||
Provider,
|
||||
assertProvider,
|
||||
CONFIG_DIR,
|
||||
normalizeE164,
|
||||
normalizePath,
|
||||
sleep,
|
||||
@@ -379,10 +382,23 @@ type WarelayConfig = {
|
||||
template?: string; // prepend template string when building command/prompt
|
||||
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
||||
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 {
|
||||
// Read ~/.warelay/warelay.json (JSON5) if present.
|
||||
try {
|
||||
@@ -408,7 +424,7 @@ type GetReplyOptions = {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
function applyTemplate(str: string, ctx: MsgContext) {
|
||||
function applyTemplate(str: string, ctx: TemplateContext) {
|
||||
// Simple {{Placeholder}} interpolation using inbound message context.
|
||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, 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(
|
||||
ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
configOverride?: WarelayConfig,
|
||||
commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout,
|
||||
): Promise<string | undefined> {
|
||||
// Choose reply from config: static text or external command stdout.
|
||||
const cfg = loadConfig();
|
||||
const cfg = configOverride ?? loadConfig();
|
||||
const reply = cfg.inbound?.reply;
|
||||
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
@@ -432,14 +492,73 @@ async function getReplyFromConfig(
|
||||
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.
|
||||
const bodyPrefix = reply?.bodyPrefix
|
||||
? applyTemplate(reply.bodyPrefix, ctx)
|
||||
? applyTemplate(reply.bodyPrefix, sessionCtx)
|
||||
: "";
|
||||
const templatingCtx: MsgContext =
|
||||
bodyPrefix && (ctx.Body ?? "").length >= 0
|
||||
? { ...ctx, Body: `${bodyPrefix}${ctx.Body ?? ""}` }
|
||||
: ctx;
|
||||
const prefixedBody = bodyPrefix
|
||||
? `${bodyPrefix}${sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""}`
|
||||
: sessionCtx.BodyStripped ?? sessionCtx.Body;
|
||||
const templatingCtx: TemplateContext = {
|
||||
...sessionCtx,
|
||||
Body: prefixedBody,
|
||||
BodyStripped: prefixedBody,
|
||||
};
|
||||
|
||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||
const allowFrom = cfg.inbound?.allowFrom;
|
||||
@@ -465,20 +584,36 @@ async function getReplyFromConfig(
|
||||
|
||||
if (reply.mode === "command" && reply.command?.length) {
|
||||
await onReplyStart();
|
||||
const argv = reply.command.map((part) =>
|
||||
applyTemplate(part, templatingCtx),
|
||||
);
|
||||
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
||||
const templatePrefix = reply.template
|
||||
? applyTemplate(reply.template, templatingCtx)
|
||||
: "";
|
||||
const finalArgv = templatePrefix
|
||||
? [argv[0], templatePrefix, ...argv.slice(1)]
|
||||
: argv;
|
||||
if (templatePrefix && argv.length > 0) {
|
||||
argv = [argv[0], templatePrefix, ...argv.slice(1)];
|
||||
}
|
||||
|
||||
// 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(" ")}`);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const { stdout, stderr, code, signal, killed } =
|
||||
await runCommandWithTimeout(finalArgv, timeoutMs);
|
||||
await commandRunner(finalArgv, timeoutMs);
|
||||
const trimmed = stdout.trim();
|
||||
if (stderr?.trim()) {
|
||||
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
|
||||
@@ -528,6 +663,7 @@ async function getReplyFromConfig(
|
||||
async function autoReplyIfConfigured(
|
||||
client: ReturnType<typeof createClient>,
|
||||
message: MessageInstance,
|
||||
configOverride?: WarelayConfig,
|
||||
): Promise<void> {
|
||||
// Fire a config-driven reply (text or command) for the inbound message, if configured.
|
||||
const ctx: MsgContext = {
|
||||
@@ -537,9 +673,13 @@ async function autoReplyIfConfigured(
|
||||
MessageSid: message.sid,
|
||||
};
|
||||
|
||||
const replyText = await getReplyFromConfig(ctx, {
|
||||
onReplyStart: () => sendTypingIndicator(client, message.sid),
|
||||
});
|
||||
const replyText = await getReplyFromConfig(
|
||||
ctx,
|
||||
{
|
||||
onReplyStart: () => sendTypingIndicator(client, message.sid),
|
||||
},
|
||||
configOverride,
|
||||
);
|
||||
if (!replyText) return;
|
||||
|
||||
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 = {
|
||||
sid: string;
|
||||
status: string | null;
|
||||
@@ -1343,9 +1533,10 @@ function formatMessageLine(m: ListedMessage): string {
|
||||
async function listRecentMessages(
|
||||
lookbackMinutes: number,
|
||||
limit: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
): Promise<ListedMessage[]> {
|
||||
const env = readEnv();
|
||||
const client = createClient(env);
|
||||
const client = clientOverride ?? createClient(env);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const since = new Date(Date.now() - lookbackMinutes * 60_000);
|
||||
|
||||
@@ -1390,7 +1581,12 @@ program
|
||||
.option("--verbose", "Verbose connection logs", false)
|
||||
.action(async (opts) => {
|
||||
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
|
||||
@@ -1475,6 +1671,23 @@ Examples:
|
||||
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
|
||||
.command("status")
|
||||
.description("Show recent WhatsApp messages (sent and received)")
|
||||
@@ -1669,7 +1882,49 @@ program
|
||||
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 =
|
||||
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 path from "node:path";
|
||||
import {
|
||||
DisconnectReason,
|
||||
fetchLatestBaileysVersion,
|
||||
@@ -7,10 +8,11 @@ import {
|
||||
makeWASocket,
|
||||
useMultiFileAuthState,
|
||||
} from "baileys";
|
||||
import type { proto } from "baileys";
|
||||
import pino from "pino";
|
||||
import qrcode from "qrcode-terminal";
|
||||
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");
|
||||
|
||||
@@ -25,6 +27,7 @@ export async function createWaSocket(printQr: boolean, verbose: boolean) {
|
||||
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
||||
},
|
||||
version,
|
||||
logger,
|
||||
printQRInTerminal: false,
|
||||
browser: ["Warelay", "CLI", "1.0.0"],
|
||||
syncFullHistory: false,
|
||||
@@ -32,25 +35,27 @@ export async function createWaSocket(printQr: boolean, verbose: boolean) {
|
||||
});
|
||||
|
||||
sock.ev.on("creds.update", saveCreds);
|
||||
sock.ev.on("connection.update", (update: Partial<import("baileys").ConnectionState>) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
if (qr && printQr) {
|
||||
console.log("Scan this QR in WhatsApp (Linked Devices):");
|
||||
qrcode.generate(qr, { small: true });
|
||||
}
|
||||
if (connection === "close") {
|
||||
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"),
|
||||
);
|
||||
sock.ev.on(
|
||||
"connection.update",
|
||||
(update: Partial<import("baileys").ConnectionState>) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
if (qr && printQr) {
|
||||
console.log("Scan this QR in WhatsApp (Linked Devices):");
|
||||
qrcode.generate(qr, { small: true });
|
||||
}
|
||||
}
|
||||
if (connection === "open" && verbose) {
|
||||
console.log(success("WhatsApp Web connected."));
|
||||
}
|
||||
});
|
||||
if (connection === "close") {
|
||||
const status = getStatusCode(lastDisconnect?.error);
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
console.log(info("Waiting for WhatsApp connection..."));
|
||||
try {
|
||||
await waitForWaConnection(sock);
|
||||
await waitForConnection(sock);
|
||||
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 {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
@@ -122,3 +159,111 @@ export async function loginWeb(verbose: boolean) {
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
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) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user