Merge branch 'main' into fix/lid-format-and-allowfrom-wildcard
This commit is contained in:
commit
668b30f4a3
@ -3,7 +3,10 @@
|
|||||||
## 1.1.1 — Unreleased
|
## 1.1.1 — Unreleased
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Placeholder for upcoming patch fixes.
|
- Web relay now supports configurable command heartbeats (`inbound.reply.heartbeatMinutes`, default 30m) that ping Claude with a `HEARTBEAT_OK` sentinel; outbound messages are skipped when the token is returned, and normal/verbose logs record each heartbeat tick.
|
||||||
|
- New `warelay heartbeat` CLI triggers a one-off heartbeat (web provider, auto-detects logged-in session; optional `--to` override). Relay gains `--heartbeat-now` to fire an immediate heartbeat on startup.
|
||||||
|
- Added `warelay relay:tmux:heartbeat` helper to start relay in tmux and emit a startup heartbeat automatically.
|
||||||
|
- Heartbeat session handling now supports `inbound.reply.session.heartbeatIdleMinutes` and does not refresh `updatedAt` on skipped heartbeats, so sessions still expire on idle.
|
||||||
|
|
||||||
## 1.1.0 — 2025-11-26
|
## 1.1.0 — 2025-11-26
|
||||||
|
|
||||||
|
|||||||
11
README.md
11
README.md
@ -43,6 +43,8 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on
|
|||||||
| `warelay send` | Send a WhatsApp message (Twilio or Web) | `--to <e164>` `--message <text>` `--wait <sec>` `--poll <sec>` `--provider twilio\|web` `--json` `--dry-run` `--verbose` |
|
| `warelay send` | Send a WhatsApp message (Twilio or Web) | `--to <e164>` `--message <text>` `--wait <sec>` `--poll <sec>` `--provider twilio\|web` `--json` `--dry-run` `--verbose` |
|
||||||
| `warelay relay` | Auto-reply loop (poll Twilio or listen on Web) | `--provider <auto\|twilio\|web>` `--interval <sec>` `--lookback <min>` `--verbose` |
|
| `warelay relay` | Auto-reply loop (poll Twilio or listen on Web) | `--provider <auto\|twilio\|web>` `--interval <sec>` `--lookback <min>` `--verbose` |
|
||||||
| `warelay status` | Show recent sent/received messages | `--limit <n>` `--lookback <min>` `--json` `--verbose` |
|
| `warelay status` | Show recent sent/received messages | `--limit <n>` `--lookback <min>` `--json` `--verbose` |
|
||||||
|
| `warelay heartbeat` | Trigger one heartbeat poll (web) | `--provider <auto\|web>` `--to <e164?>` `--verbose` |
|
||||||
|
| `warelay relay:tmux:heartbeat` | Start relay in tmux and fire a heartbeat on start (web) | _no flags_ |
|
||||||
| `warelay webhook` | Run inbound webhook (`ingress=tailscale` updates Twilio; `none` is local-only) | `--ingress tailscale\|none` `--port <port>` `--path <path>` `--reply <text>` `--verbose` `--yes` `--dry-run` |
|
| `warelay webhook` | Run inbound webhook (`ingress=tailscale` updates Twilio; `none` is local-only) | `--ingress tailscale\|none` `--port <port>` `--path <path>` `--reply <text>` `--verbose` `--yes` `--dry-run` |
|
||||||
| `warelay login` | Link personal WhatsApp Web via QR | `--verbose` |
|
| `warelay login` | Link personal WhatsApp Web via QR | `--verbose` |
|
||||||
|
|
||||||
@ -111,12 +113,19 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a
|
|||||||
bodyPrefix: "You are a concise WhatsApp assistant.\n\n",
|
bodyPrefix: "You are a concise WhatsApp assistant.\n\n",
|
||||||
command: ["claude", "--dangerously-skip-permissions", "{{BodyStripped}}"],
|
command: ["claude", "--dangerously-skip-permissions", "{{BodyStripped}}"],
|
||||||
claudeOutputFormat: "text",
|
claudeOutputFormat: "text",
|
||||||
session: { scope: "per-sender", resetTriggers: ["/new"], idleMinutes: 60 }
|
session: { scope: "per-sender", resetTriggers: ["/new"], idleMinutes: 60 },
|
||||||
|
heartbeatMinutes: 30 // optional; pings Claude every 30m and only sends if it omits HEARTBEAT_OK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Heartbeat pings (command mode)
|
||||||
|
- When `heartbeatMinutes` is set (default 30 for `mode: "command"`), the relay periodically runs your command/Claude session with a heartbeat prompt.
|
||||||
|
- If Claude replies exactly `HEARTBEAT_OK`, the message is suppressed; otherwise the reply (or media) is forwarded. Suppressions are still logged so you know the heartbeat ran.
|
||||||
|
- Override session freshness for heartbeats with `session.heartbeatIdleMinutes` (defaults to `session.idleMinutes`). Heartbeat skips do **not** bump `updatedAt`, so sessions still expire normally.
|
||||||
|
- Trigger one manually with `warelay heartbeat` (web provider only, `--verbose` prints session info). Use `--heartbeat-now` to fire once at relay start.
|
||||||
|
|
||||||
### Logging (optional)
|
### Logging (optional)
|
||||||
- File logs are written to `/tmp/warelay/warelay.log` by default. Levels: `silent | fatal | error | warn | info | debug | trace` (CLI `--verbose` forces `debug`). Web-provider inbound/outbound entries include message bodies and auto-reply text for easier auditing.
|
- File logs are written to `/tmp/warelay/warelay.log` by default. Levels: `silent | fatal | error | warn | info | debug | trace` (CLI `--verbose` forces `debug`). Web-provider inbound/outbound entries include message bodies and auto-reply text for easier auditing.
|
||||||
- Override in `~/.warelay/warelay.json`:
|
- Override in `~/.warelay/warelay.json`:
|
||||||
|
|||||||
38
docs/heartbeat.md
Normal file
38
docs/heartbeat.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Heartbeat polling plan (2025-11-26)
|
||||||
|
|
||||||
|
Goal: add a simple heartbeat poll for command-based auto-replies (Claude-driven) that only notifies users when something matters, using the `HEARTBEAT_OK` sentinel.
|
||||||
|
|
||||||
|
## Prompt contract
|
||||||
|
- Extend the Claude system/identity text to explain: “If this is a heartbeat poll and nothing needs attention, reply exactly `HEARTBEAT_OK` and nothing else. For any alert, do **not** include `HEARTBEAT_OK`; just return the alert text.”
|
||||||
|
- Keep existing WhatsApp length guidance; forbid burying the sentinel inside alerts.
|
||||||
|
|
||||||
|
## Config & defaults
|
||||||
|
- New config key: `inbound.reply.heartbeatMinutes` (number of minutes; `0` or undefined disables).
|
||||||
|
- Default: 30 minutes when a command-mode reply is configured.
|
||||||
|
- New optional idle override for heartbeats: `inbound.reply.session.heartbeatIdleMinutes` (defaults to `idleMinutes`). Heartbeat skips do **not** update the session `updatedAt` so idle expiry still works.
|
||||||
|
|
||||||
|
## Poller behavior
|
||||||
|
- When relay runs with command-mode auto-reply, start a timer with the resolved heartbeat interval.
|
||||||
|
- Each tick invokes the configured command with a short heartbeat body (e.g., “(heartbeat) summarize any important changes since last turn”) while reusing the active session args so Claude context stays warm.
|
||||||
|
- Abort timer on SIGINT/abort of the relay.
|
||||||
|
|
||||||
|
## Sentinel handling
|
||||||
|
- Trim output. If the trimmed text equals `HEARTBEAT_OK` (case-sensitive) -> skip outbound message.
|
||||||
|
- Otherwise, send the text/media as normal, stripping the sentinel if it somehow appears.
|
||||||
|
- Treat empty output as `HEARTBEAT_OK` to avoid spurious pings.
|
||||||
|
|
||||||
|
## Logging requirements
|
||||||
|
- Normal mode: single info line per tick, e.g., `heartbeat: ok (skipped)` or `heartbeat: alert sent (32ms)`.
|
||||||
|
- `--verbose`: log start/end, command argv, duration, and whether it was skipped/sent/error; include session ID and connection/run IDs via `getChildLogger` for correlation.
|
||||||
|
- On command failure: warn-level one-liner in normal mode; verbose log includes stdout/stderr snippets.
|
||||||
|
|
||||||
|
## Failure/backoff
|
||||||
|
- If a heartbeat command errors, log it and retry on the next scheduled tick (no exponential backoff unless command repeatedly fails; keep it simple for now).
|
||||||
|
|
||||||
|
## Tests to add
|
||||||
|
- Unit: sentinel detection (`HEARTBEAT_OK`, empty output, mixed text), skip vs send decision, default interval resolver (30m, override, disable).
|
||||||
|
- Unit/integration: verbose logger emits start/end lines; normal logger emits a single line.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- Add a short README snippet under configuration showing `heartbeatMinutes` and the sentinel rule.
|
||||||
|
- Expose a CLI trigger: `warelay heartbeat` (web provider, defaults to first `allowFrom`; optional `--to` override). Relay supports `--heartbeat-now` to fire once at startup.
|
||||||
@ -4,7 +4,7 @@ import { z } from "zod";
|
|||||||
// Preferred binary name for Claude CLI invocations.
|
// Preferred binary name for Claude CLI invocations.
|
||||||
export const CLAUDE_BIN = "claude";
|
export const CLAUDE_BIN = "claude";
|
||||||
export const CLAUDE_IDENTITY_PREFIX =
|
export const CLAUDE_IDENTITY_PREFIX =
|
||||||
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present.";
|
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present. If a prompt is a heartbeat poll and nothing needs attention, reply with exactly HEARTBEAT_OK and nothing else; for any alert, do not include HEARTBEAT_OK.";
|
||||||
|
|
||||||
function extractClaudeText(payload: unknown): string | undefined {
|
function extractClaudeText(payload: unknown): string | undefined {
|
||||||
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
logoutWeb,
|
logoutWeb,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
pickProvider,
|
pickProvider,
|
||||||
|
runWebHeartbeatOnce,
|
||||||
type WebMonitorTuning,
|
type WebMonitorTuning,
|
||||||
} from "../provider-web.js";
|
} from "../provider-web.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@ -174,6 +175,62 @@ Examples:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("heartbeat")
|
||||||
|
.description("Trigger a heartbeat poll once (web provider)")
|
||||||
|
.option("--provider <provider>", "auto | web", "auto")
|
||||||
|
.option("--to <number>", "Override target E.164; defaults to allowFrom[0]")
|
||||||
|
.option("--verbose", "Verbose logging", false)
|
||||||
|
.addHelpText(
|
||||||
|
"after",
|
||||||
|
`
|
||||||
|
Examples:
|
||||||
|
warelay heartbeat # uses web session + first allowFrom contact
|
||||||
|
warelay heartbeat --verbose # prints detailed heartbeat logs
|
||||||
|
warelay heartbeat --to +1555123 # override destination`,
|
||||||
|
)
|
||||||
|
.action(async (opts) => {
|
||||||
|
setVerbose(Boolean(opts.verbose));
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const to =
|
||||||
|
opts.to ??
|
||||||
|
(Array.isArray(cfg.inbound?.allowFrom) &&
|
||||||
|
cfg.inbound?.allowFrom?.length > 0
|
||||||
|
? cfg.inbound.allowFrom[0]
|
||||||
|
: null);
|
||||||
|
if (!to) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
danger(
|
||||||
|
"No destination found. Set inbound.allowFrom in ~/.warelay/warelay.json or pass --to <E.164>.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
const providerPref = String(opts.provider ?? "auto");
|
||||||
|
if (!["auto", "web"].includes(providerPref)) {
|
||||||
|
defaultRuntime.error("--provider must be auto or web");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
const provider = await pickProvider(providerPref as "auto" | "web");
|
||||||
|
if (provider !== "web") {
|
||||||
|
defaultRuntime.error(
|
||||||
|
danger(
|
||||||
|
"Heartbeat is only supported for the web provider. Link with `warelay login --verbose`.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await runWebHeartbeatOnce({
|
||||||
|
to,
|
||||||
|
verbose: Boolean(opts.verbose),
|
||||||
|
runtime: defaultRuntime,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("relay")
|
.command("relay")
|
||||||
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
||||||
@ -197,6 +254,11 @@ Examples:
|
|||||||
"Initial reconnect backoff for web relay (ms)",
|
"Initial reconnect backoff for web relay (ms)",
|
||||||
)
|
)
|
||||||
.option("--web-retry-max <ms>", "Max reconnect backoff for web relay (ms)")
|
.option("--web-retry-max <ms>", "Max reconnect backoff for web relay (ms)")
|
||||||
|
.option(
|
||||||
|
"--heartbeat-now",
|
||||||
|
"Run a heartbeat immediately when relay starts (web provider)",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option("--verbose", "Verbose logging", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
@ -234,6 +296,7 @@ Examples:
|
|||||||
opts.webRetryMax !== undefined
|
opts.webRetryMax !== undefined
|
||||||
? Number.parseInt(String(opts.webRetryMax), 10)
|
? Number.parseInt(String(opts.webRetryMax), 10)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const heartbeatNow = Boolean(opts.heartbeatNow);
|
||||||
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
|
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
|
||||||
defaultRuntime.error("Interval must be a positive integer");
|
defaultRuntime.error("Interval must be a positive integer");
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
@ -281,6 +344,7 @@ Examples:
|
|||||||
|
|
||||||
const webTuning: WebMonitorTuning = {};
|
const webTuning: WebMonitorTuning = {};
|
||||||
if (webHeartbeat !== undefined) webTuning.heartbeatSeconds = webHeartbeat;
|
if (webHeartbeat !== undefined) webTuning.heartbeatSeconds = webHeartbeat;
|
||||||
|
if (heartbeatNow) webTuning.replyHeartbeatNow = true;
|
||||||
const reconnect: WebMonitorTuning["reconnect"] = {};
|
const reconnect: WebMonitorTuning["reconnect"] = {};
|
||||||
if (webRetries !== undefined) reconnect.maxAttempts = webRetries;
|
if (webRetries !== undefined) reconnect.maxAttempts = webRetries;
|
||||||
if (webRetryInitial !== undefined) reconnect.initialMs = webRetryInitial;
|
if (webRetryInitial !== undefined) reconnect.initialMs = webRetryInitial;
|
||||||
@ -451,5 +515,31 @@ Examples:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("relay:tmux:heartbeat")
|
||||||
|
.description(
|
||||||
|
"Run relay --verbose with an immediate heartbeat inside tmux (session warelay-relay), then attach",
|
||||||
|
)
|
||||||
|
.action(async () => {
|
||||||
|
try {
|
||||||
|
const session = await spawnRelayTmux(
|
||||||
|
"pnpm warelay relay --verbose --heartbeat-now",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
defaultRuntime.log(
|
||||||
|
info(
|
||||||
|
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose --heartbeat-now")`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
danger(
|
||||||
|
`Failed to start relay tmux session with heartbeat: ${String(err)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export type SessionConfig = {
|
|||||||
scope?: SessionScope;
|
scope?: SessionScope;
|
||||||
resetTriggers?: string[];
|
resetTriggers?: string[];
|
||||||
idleMinutes?: number;
|
idleMinutes?: number;
|
||||||
|
heartbeatIdleMinutes?: number;
|
||||||
store?: string;
|
store?: string;
|
||||||
sessionArgNew?: string[];
|
sessionArgNew?: string[];
|
||||||
sessionArgResume?: string[];
|
sessionArgResume?: string[];
|
||||||
@ -20,6 +21,7 @@ export type SessionConfig = {
|
|||||||
sendSystemOnce?: boolean;
|
sendSystemOnce?: boolean;
|
||||||
sessionIntro?: string;
|
sessionIntro?: string;
|
||||||
typingIntervalSeconds?: number;
|
typingIntervalSeconds?: number;
|
||||||
|
heartbeatMinutes?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoggingConfig = {
|
export type LoggingConfig = {
|
||||||
@ -88,6 +90,7 @@ const ReplySchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
resetTriggers: z.array(z.string()).optional(),
|
resetTriggers: z.array(z.string()).optional(),
|
||||||
idleMinutes: z.number().int().positive().optional(),
|
idleMinutes: z.number().int().positive().optional(),
|
||||||
|
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
||||||
store: z.string().optional(),
|
store: z.string().optional(),
|
||||||
sessionArgNew: z.array(z.string()).optional(),
|
sessionArgNew: z.array(z.string()).optional(),
|
||||||
sessionArgResume: z.array(z.string()).optional(),
|
sessionArgResume: z.array(z.string()).optional(),
|
||||||
@ -97,6 +100,7 @@ const ReplySchema = z
|
|||||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
heartbeatMinutes: z.number().int().nonnegative().optional(),
|
||||||
claudeOutputFormat: z
|
claudeOutputFormat: z
|
||||||
.union([
|
.union([
|
||||||
z.literal("text"),
|
z.literal("text"),
|
||||||
|
|||||||
@ -2,7 +2,10 @@
|
|||||||
// module keeps responsibilities small and testable without changing the public API.
|
// module keeps responsibilities small and testable without changing the public API.
|
||||||
export {
|
export {
|
||||||
DEFAULT_WEB_MEDIA_BYTES,
|
DEFAULT_WEB_MEDIA_BYTES,
|
||||||
|
HEARTBEAT_PROMPT,
|
||||||
|
HEARTBEAT_TOKEN,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
|
runWebHeartbeatOnce,
|
||||||
type WebMonitorTuning,
|
type WebMonitorTuning,
|
||||||
} from "./web/auto-reply.js";
|
} from "./web/auto-reply.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -1,15 +1,185 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { WarelayConfig } from "../config/config.js";
|
||||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||||
import { monitorWebProvider } from "./auto-reply.js";
|
import {
|
||||||
|
HEARTBEAT_TOKEN,
|
||||||
|
monitorWebProvider,
|
||||||
|
resolveReplyHeartbeatMinutes,
|
||||||
|
runWebHeartbeatOnce,
|
||||||
|
stripHeartbeatToken,
|
||||||
|
} from "./auto-reply.js";
|
||||||
|
import type { sendMessageWeb } from "./outbound.js";
|
||||||
import {
|
import {
|
||||||
resetBaileysMocks,
|
resetBaileysMocks,
|
||||||
resetLoadConfigMock,
|
resetLoadConfigMock,
|
||||||
setLoadConfigMock,
|
setLoadConfigMock,
|
||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
|
import { resolveStorePath } from "../config/sessions.js";
|
||||||
|
|
||||||
|
describe("heartbeat helpers", () => {
|
||||||
|
it("strips heartbeat token and skips when only token", () => {
|
||||||
|
expect(stripHeartbeatToken(undefined)).toEqual({
|
||||||
|
shouldSkip: true,
|
||||||
|
text: "",
|
||||||
|
});
|
||||||
|
expect(stripHeartbeatToken(" ")).toEqual({
|
||||||
|
shouldSkip: true,
|
||||||
|
text: "",
|
||||||
|
});
|
||||||
|
expect(stripHeartbeatToken(HEARTBEAT_TOKEN)).toEqual({
|
||||||
|
shouldSkip: true,
|
||||||
|
text: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps content and removes token when mixed", () => {
|
||||||
|
expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`)).toEqual({
|
||||||
|
shouldSkip: false,
|
||||||
|
text: "ALERT",
|
||||||
|
});
|
||||||
|
expect(stripHeartbeatToken(`hello`)).toEqual({
|
||||||
|
shouldSkip: false,
|
||||||
|
text: "hello",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves heartbeat minutes with default and overrides", () => {
|
||||||
|
const cfgBase: WarelayConfig = {
|
||||||
|
inbound: {
|
||||||
|
reply: { mode: "command" as const },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30);
|
||||||
|
expect(
|
||||||
|
resolveReplyHeartbeatMinutes({
|
||||||
|
inbound: { reply: { mode: "command", heartbeatMinutes: 5 } },
|
||||||
|
}),
|
||||||
|
).toBe(5);
|
||||||
|
expect(
|
||||||
|
resolveReplyHeartbeatMinutes({
|
||||||
|
inbound: { reply: { mode: "command", heartbeatMinutes: 0 } },
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7);
|
||||||
|
expect(
|
||||||
|
resolveReplyHeartbeatMinutes({
|
||||||
|
inbound: { reply: { mode: "text" } },
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runWebHeartbeatOnce", () => {
|
||||||
|
it("skips when heartbeat token returned", async () => {
|
||||||
|
const sender: typeof sendMessageWeb = vi.fn();
|
||||||
|
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
||||||
|
setLoadConfigMock({
|
||||||
|
inbound: { allowFrom: ["+1555"], reply: { mode: "command" } },
|
||||||
|
});
|
||||||
|
await runWebHeartbeatOnce({
|
||||||
|
to: "+1555",
|
||||||
|
verbose: false,
|
||||||
|
sender,
|
||||||
|
replyResolver: resolver,
|
||||||
|
});
|
||||||
|
expect(resolver).toHaveBeenCalled();
|
||||||
|
expect(sender).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends when alert text present", async () => {
|
||||||
|
const sender: typeof sendMessageWeb = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
||||||
|
const resolver = vi.fn(async () => ({ text: "ALERT" }));
|
||||||
|
setLoadConfigMock({
|
||||||
|
inbound: { allowFrom: ["+1555"], reply: { mode: "command" } },
|
||||||
|
});
|
||||||
|
await runWebHeartbeatOnce({
|
||||||
|
to: "+1555",
|
||||||
|
verbose: false,
|
||||||
|
sender,
|
||||||
|
replyResolver: resolver,
|
||||||
|
});
|
||||||
|
expect(sender).toHaveBeenCalledWith("+1555", "ALERT", { verbose: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to most recent session when no to is provided", async () => {
|
||||||
|
const sender: typeof sendMessageWeb = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
||||||
|
const resolver = vi.fn(async () => ({ text: "ALERT" }));
|
||||||
|
// Seed session store
|
||||||
|
const now = Date.now();
|
||||||
|
const store = {
|
||||||
|
"+1222": { sessionId: "s1", updatedAt: now - 1000 },
|
||||||
|
"+1333": { sessionId: "s2", updatedAt: now },
|
||||||
|
};
|
||||||
|
const storePath = resolveStorePath();
|
||||||
|
await fs.mkdir(resolveStorePath().replace("sessions.json", ""), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
await fs.writeFile(storePath, JSON.stringify(store));
|
||||||
|
setLoadConfigMock({
|
||||||
|
inbound: {
|
||||||
|
allowFrom: ["+1999"],
|
||||||
|
reply: { mode: "command", session: {} },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await runWebHeartbeatOnce({
|
||||||
|
to: "+1999",
|
||||||
|
verbose: false,
|
||||||
|
sender,
|
||||||
|
replyResolver: resolver,
|
||||||
|
});
|
||||||
|
expect(sender).toHaveBeenCalledWith("+1333", "ALERT", { verbose: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not refresh updatedAt when heartbeat is skipped", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "warelay-heartbeat-"),
|
||||||
|
);
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
const now = Date.now();
|
||||||
|
const originalUpdated = now - 30 * 60 * 1000;
|
||||||
|
const store = {
|
||||||
|
"+1555": { sessionId: "sess1", updatedAt: originalUpdated },
|
||||||
|
};
|
||||||
|
await fs.writeFile(storePath, JSON.stringify(store));
|
||||||
|
|
||||||
|
const sender: typeof sendMessageWeb = vi.fn();
|
||||||
|
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
||||||
|
setLoadConfigMock({
|
||||||
|
inbound: {
|
||||||
|
allowFrom: ["+1555"],
|
||||||
|
reply: {
|
||||||
|
mode: "command",
|
||||||
|
session: {
|
||||||
|
store: storePath,
|
||||||
|
idleMinutes: 60,
|
||||||
|
heartbeatIdleMinutes: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await runWebHeartbeatOnce({
|
||||||
|
to: "+1555",
|
||||||
|
verbose: false,
|
||||||
|
sender,
|
||||||
|
replyResolver: resolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||||
|
expect(after["+1555"].updatedAt).toBe(originalUpdated);
|
||||||
|
expect(sender).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("web auto-reply", () => {
|
describe("web auto-reply", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@ -1,12 +1,22 @@
|
|||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import { waitForever } from "../cli/wait.js";
|
import { waitForever } from "../cli/wait.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_IDLE_MINUTES,
|
||||||
|
deriveSessionKey,
|
||||||
|
loadSessionStore,
|
||||||
|
resolveStorePath,
|
||||||
|
saveSessionStore,
|
||||||
|
} from "../config/sessions.js";
|
||||||
import { danger, isVerbose, logVerbose, success } from "../globals.js";
|
import { danger, isVerbose, logVerbose, success } from "../globals.js";
|
||||||
import { logInfo } from "../logger.js";
|
import { logInfo } from "../logger.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
import { normalizeE164 } from "../utils.js";
|
||||||
import { monitorWebInbox } from "./inbound.js";
|
import { monitorWebInbox } from "./inbound.js";
|
||||||
import { loadWebMedia } from "./media.js";
|
import { loadWebMedia } from "./media.js";
|
||||||
|
import { sendMessageWeb } from "./outbound.js";
|
||||||
import {
|
import {
|
||||||
computeBackoff,
|
computeBackoff,
|
||||||
newConnectionId,
|
newConnectionId,
|
||||||
@ -18,16 +28,317 @@ import {
|
|||||||
import { getWebAuthAgeMs } from "./session.js";
|
import { getWebAuthAgeMs } from "./session.js";
|
||||||
|
|
||||||
const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;
|
const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;
|
||||||
|
type WebInboundMsg = Parameters<
|
||||||
|
typeof monitorWebInbox
|
||||||
|
>[0]["onMessage"] extends (msg: infer M) => unknown
|
||||||
|
? M
|
||||||
|
: never;
|
||||||
|
|
||||||
export type WebMonitorTuning = {
|
export type WebMonitorTuning = {
|
||||||
reconnect?: Partial<ReconnectPolicy>;
|
reconnect?: Partial<ReconnectPolicy>;
|
||||||
heartbeatSeconds?: number;
|
heartbeatSeconds?: number;
|
||||||
|
replyHeartbeatMinutes?: number;
|
||||||
|
replyHeartbeatNow?: boolean;
|
||||||
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (ms: number) =>
|
const formatDuration = (ms: number) =>
|
||||||
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
||||||
|
|
||||||
|
const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30;
|
||||||
|
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
|
||||||
|
export const HEARTBEAT_PROMPT =
|
||||||
|
"HEARTBEAT ping — if nothing important happened, reply exactly HEARTBEAT_OK. Otherwise return a concise alert.";
|
||||||
|
|
||||||
|
export function resolveReplyHeartbeatMinutes(
|
||||||
|
cfg: ReturnType<typeof loadConfig>,
|
||||||
|
overrideMinutes?: number,
|
||||||
|
) {
|
||||||
|
const raw = overrideMinutes ?? cfg.inbound?.reply?.heartbeatMinutes;
|
||||||
|
if (raw === 0) return null;
|
||||||
|
if (typeof raw === "number" && raw > 0) return raw;
|
||||||
|
return cfg.inbound?.reply?.mode === "command"
|
||||||
|
? DEFAULT_REPLY_HEARTBEAT_MINUTES
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripHeartbeatToken(raw?: string) {
|
||||||
|
if (!raw) return { shouldSkip: true, text: "" };
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return { shouldSkip: true, text: "" };
|
||||||
|
if (trimmed === HEARTBEAT_TOKEN) return { shouldSkip: true, text: "" };
|
||||||
|
const withoutToken = trimmed.replaceAll(HEARTBEAT_TOKEN, "").trim();
|
||||||
|
return {
|
||||||
|
shouldSkip: withoutToken.length === 0,
|
||||||
|
text: withoutToken || trimmed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runWebHeartbeatOnce(opts: {
|
||||||
|
to: string;
|
||||||
|
verbose?: boolean;
|
||||||
|
replyResolver?: typeof getReplyFromConfig;
|
||||||
|
runtime?: RuntimeEnv;
|
||||||
|
sender?: typeof sendMessageWeb;
|
||||||
|
}) {
|
||||||
|
const { to, verbose = false } = opts;
|
||||||
|
const _runtime = opts.runtime ?? defaultRuntime;
|
||||||
|
const replyResolver = opts.replyResolver ?? getReplyFromConfig;
|
||||||
|
const sender = opts.sender ?? sendMessageWeb;
|
||||||
|
const runId = newConnectionId();
|
||||||
|
const heartbeatLogger = getChildLogger({
|
||||||
|
module: "web-heartbeat",
|
||||||
|
runId,
|
||||||
|
to,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const sessionSnapshot = getSessionSnapshot(cfg, to, true);
|
||||||
|
if (verbose) {
|
||||||
|
heartbeatLogger.info(
|
||||||
|
{
|
||||||
|
to,
|
||||||
|
sessionKey: sessionSnapshot.key,
|
||||||
|
sessionId: sessionSnapshot.entry?.sessionId ?? null,
|
||||||
|
sessionFresh: sessionSnapshot.fresh,
|
||||||
|
idleMinutes: sessionSnapshot.idleMinutes,
|
||||||
|
},
|
||||||
|
"heartbeat session snapshot",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const replyResult = await replyResolver(
|
||||||
|
{
|
||||||
|
Body: HEARTBEAT_PROMPT,
|
||||||
|
From: to,
|
||||||
|
To: to,
|
||||||
|
MessageSid: sessionSnapshot.entry?.sessionId,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!replyResult ||
|
||||||
|
(!replyResult.text &&
|
||||||
|
!replyResult.mediaUrl &&
|
||||||
|
!replyResult.mediaUrls?.length)
|
||||||
|
) {
|
||||||
|
heartbeatLogger.info(
|
||||||
|
{
|
||||||
|
to,
|
||||||
|
reason: "empty-reply",
|
||||||
|
sessionId: sessionSnapshot.entry?.sessionId ?? null,
|
||||||
|
},
|
||||||
|
"heartbeat skipped",
|
||||||
|
);
|
||||||
|
if (verbose) console.log(success("heartbeat: ok (empty reply)"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMedia =
|
||||||
|
(replyResult.mediaUrl ?? replyResult.mediaUrls?.length ?? 0) > 0;
|
||||||
|
const stripped = stripHeartbeatToken(replyResult.text);
|
||||||
|
if (stripped.shouldSkip && !hasMedia) {
|
||||||
|
// Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works.
|
||||||
|
const sessionCfg = cfg.inbound?.reply?.session;
|
||||||
|
const storePath = resolveStorePath(sessionCfg?.store);
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
if (sessionSnapshot.entry && store[sessionSnapshot.key]) {
|
||||||
|
store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt;
|
||||||
|
await saveSessionStore(storePath, store);
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeatLogger.info(
|
||||||
|
{ to, reason: "heartbeat-token", rawLength: replyResult.text?.length },
|
||||||
|
"heartbeat skipped",
|
||||||
|
);
|
||||||
|
console.log(success("heartbeat: ok (HEARTBEAT_OK)"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMedia) {
|
||||||
|
heartbeatLogger.warn(
|
||||||
|
{ to },
|
||||||
|
"heartbeat reply contained media; sending text only",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalText = stripped.text || replyResult.text || "";
|
||||||
|
const sendResult = await sender(to, finalText, { verbose });
|
||||||
|
heartbeatLogger.info(
|
||||||
|
{ to, messageId: sendResult.messageId, chars: finalText.length },
|
||||||
|
"heartbeat sent",
|
||||||
|
);
|
||||||
|
console.log(success(`heartbeat: alert sent to ${to}`));
|
||||||
|
} catch (err) {
|
||||||
|
heartbeatLogger.warn({ to, error: String(err) }, "heartbeat failed");
|
||||||
|
console.log(danger(`heartbeat: failed - ${String(err)}`));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFallbackRecipient(cfg: ReturnType<typeof loadConfig>) {
|
||||||
|
const sessionCfg = cfg.inbound?.reply?.session;
|
||||||
|
const storePath = resolveStorePath(sessionCfg?.store);
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const candidates = Object.entries(store).filter(([key]) => key !== "global");
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return (
|
||||||
|
(Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom[0]) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mostRecent = candidates.sort(
|
||||||
|
(a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0),
|
||||||
|
)[0];
|
||||||
|
return mostRecent ? normalizeE164(mostRecent[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionSnapshot(
|
||||||
|
cfg: ReturnType<typeof loadConfig>,
|
||||||
|
from: string,
|
||||||
|
isHeartbeat = false,
|
||||||
|
) {
|
||||||
|
const sessionCfg = cfg.inbound?.reply?.session;
|
||||||
|
const scope = sessionCfg?.scope ?? "per-sender";
|
||||||
|
const key = deriveSessionKey(scope, { From: from, To: "", Body: "" });
|
||||||
|
const store = loadSessionStore(resolveStorePath(sessionCfg?.store));
|
||||||
|
const entry = store[key];
|
||||||
|
const idleMinutes = Math.max(
|
||||||
|
(isHeartbeat
|
||||||
|
? (sessionCfg?.heartbeatIdleMinutes ?? sessionCfg?.idleMinutes)
|
||||||
|
: sessionCfg?.idleMinutes) ?? DEFAULT_IDLE_MINUTES,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const fresh = !!(
|
||||||
|
entry && Date.now() - entry.updatedAt <= idleMinutes * 60_000
|
||||||
|
);
|
||||||
|
return { key, entry, fresh, idleMinutes };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deliverWebReply(params: {
|
||||||
|
replyResult: ReplyPayload;
|
||||||
|
msg: WebInboundMsg;
|
||||||
|
maxMediaBytes: number;
|
||||||
|
replyLogger: ReturnType<typeof getChildLogger>;
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
connectionId?: string;
|
||||||
|
skipLog?: boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
replyResult,
|
||||||
|
msg,
|
||||||
|
maxMediaBytes,
|
||||||
|
replyLogger,
|
||||||
|
runtime,
|
||||||
|
connectionId,
|
||||||
|
skipLog,
|
||||||
|
} = params;
|
||||||
|
const replyStarted = Date.now();
|
||||||
|
const mediaList = replyResult.mediaUrls?.length
|
||||||
|
? replyResult.mediaUrls
|
||||||
|
: replyResult.mediaUrl
|
||||||
|
? [replyResult.mediaUrl]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (mediaList.length === 0 && replyResult.text) {
|
||||||
|
await msg.reply(replyResult.text || "");
|
||||||
|
if (!skipLog) {
|
||||||
|
logInfo(
|
||||||
|
`✅ Sent web reply to ${msg.from} (${(Date.now() - replyStarted).toFixed(0)}ms)`,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
replyLogger.info(
|
||||||
|
{
|
||||||
|
correlationId: msg.id ?? newConnectionId(),
|
||||||
|
connectionId: connectionId ?? null,
|
||||||
|
to: msg.from,
|
||||||
|
from: msg.to,
|
||||||
|
text: replyResult.text,
|
||||||
|
mediaUrl: null,
|
||||||
|
mediaSizeBytes: null,
|
||||||
|
mediaKind: null,
|
||||||
|
durationMs: Date.now() - replyStarted,
|
||||||
|
},
|
||||||
|
"auto-reply sent (text)",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanText = replyResult.text ?? undefined;
|
||||||
|
for (const [index, mediaUrl] of mediaList.entries()) {
|
||||||
|
try {
|
||||||
|
const media = await loadWebMedia(mediaUrl, maxMediaBytes);
|
||||||
|
if (isVerbose()) {
|
||||||
|
logVerbose(
|
||||||
|
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
|
||||||
|
);
|
||||||
|
logVerbose(
|
||||||
|
`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const caption = index === 0 ? cleanText || undefined : undefined;
|
||||||
|
if (media.kind === "image") {
|
||||||
|
await msg.sendMedia({
|
||||||
|
image: media.buffer,
|
||||||
|
caption,
|
||||||
|
mimetype: media.contentType,
|
||||||
|
});
|
||||||
|
} else if (media.kind === "audio") {
|
||||||
|
await msg.sendMedia({
|
||||||
|
audio: media.buffer,
|
||||||
|
ptt: true,
|
||||||
|
mimetype: media.contentType,
|
||||||
|
caption,
|
||||||
|
});
|
||||||
|
} else if (media.kind === "video") {
|
||||||
|
await msg.sendMedia({
|
||||||
|
video: media.buffer,
|
||||||
|
caption,
|
||||||
|
mimetype: media.contentType,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const fileName = mediaUrl.split("/").pop() ?? "file";
|
||||||
|
const mimetype = media.contentType ?? "application/octet-stream";
|
||||||
|
await msg.sendMedia({
|
||||||
|
document: media.buffer,
|
||||||
|
fileName,
|
||||||
|
caption,
|
||||||
|
mimetype,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logInfo(
|
||||||
|
`✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
replyLogger.info(
|
||||||
|
{
|
||||||
|
correlationId: msg.id ?? newConnectionId(),
|
||||||
|
connectionId: connectionId ?? null,
|
||||||
|
to: msg.from,
|
||||||
|
from: msg.to,
|
||||||
|
text: index === 0 ? (cleanText ?? null) : null,
|
||||||
|
mediaUrl,
|
||||||
|
mediaSizeBytes: media.buffer.length,
|
||||||
|
mediaKind: media.kind,
|
||||||
|
durationMs: Date.now() - replyStarted,
|
||||||
|
},
|
||||||
|
"auto-reply sent (media)",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
danger(`Failed sending web media to ${msg.from}: ${String(err)}`),
|
||||||
|
);
|
||||||
|
if (index === 0 && cleanText) {
|
||||||
|
console.log(`⚠️ Media skipped; sent text-only to ${msg.from}`);
|
||||||
|
await msg.reply(cleanText || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function monitorWebProvider(
|
export async function monitorWebProvider(
|
||||||
verbose: boolean,
|
verbose: boolean,
|
||||||
listenerFactory: typeof monitorWebInbox | undefined = monitorWebInbox,
|
listenerFactory: typeof monitorWebInbox | undefined = monitorWebInbox,
|
||||||
@ -51,6 +362,10 @@ export async function monitorWebProvider(
|
|||||||
cfg,
|
cfg,
|
||||||
tuning.heartbeatSeconds,
|
tuning.heartbeatSeconds,
|
||||||
);
|
);
|
||||||
|
const replyHeartbeatMinutes = resolveReplyHeartbeatMinutes(
|
||||||
|
cfg,
|
||||||
|
tuning.replyHeartbeatMinutes,
|
||||||
|
);
|
||||||
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
||||||
const sleep =
|
const sleep =
|
||||||
tuning.sleep ??
|
tuning.sleep ??
|
||||||
@ -79,8 +394,10 @@ export async function monitorWebProvider(
|
|||||||
const connectionId = newConnectionId();
|
const connectionId = newConnectionId();
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let heartbeat: NodeJS.Timeout | null = null;
|
let heartbeat: NodeJS.Timeout | null = null;
|
||||||
|
let replyHeartbeatTimer: NodeJS.Timeout | null = null;
|
||||||
let lastMessageAt: number | null = null;
|
let lastMessageAt: number | null = null;
|
||||||
let handledMessages = 0;
|
let handledMessages = 0;
|
||||||
|
let lastInboundMsg: WebInboundMsg | null = null;
|
||||||
|
|
||||||
const listener = await (listenerFactory ?? monitorWebInbox)({
|
const listener = await (listenerFactory ?? monitorWebInbox)({
|
||||||
verbose,
|
verbose,
|
||||||
@ -106,7 +423,8 @@ export async function monitorWebProvider(
|
|||||||
|
|
||||||
console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`);
|
console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`);
|
||||||
|
|
||||||
const replyStarted = Date.now();
|
lastInboundMsg = msg;
|
||||||
|
|
||||||
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
||||||
{
|
{
|
||||||
Body: msg.body,
|
Body: msg.body,
|
||||||
@ -133,122 +451,27 @@ export async function monitorWebProvider(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const mediaList = replyResult.mediaUrls?.length
|
await deliverWebReply({
|
||||||
? replyResult.mediaUrls
|
replyResult,
|
||||||
: replyResult.mediaUrl
|
msg,
|
||||||
? [replyResult.mediaUrl]
|
maxMediaBytes,
|
||||||
: [];
|
replyLogger,
|
||||||
|
runtime,
|
||||||
if (mediaList.length > 0) {
|
connectionId,
|
||||||
logVerbose(
|
});
|
||||||
`Web auto-reply media detected: ${mediaList.filter(Boolean).join(", ")}`,
|
|
||||||
);
|
|
||||||
for (const [index, mediaUrl] of mediaList.entries()) {
|
|
||||||
try {
|
|
||||||
const media = await loadWebMedia(mediaUrl, maxMediaBytes);
|
|
||||||
if (isVerbose()) {
|
|
||||||
logVerbose(
|
|
||||||
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
|
|
||||||
);
|
|
||||||
logVerbose(
|
|
||||||
`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const caption =
|
|
||||||
index === 0 ? replyResult.text || undefined : undefined;
|
|
||||||
if (media.kind === "image") {
|
|
||||||
await msg.sendMedia({
|
|
||||||
image: media.buffer,
|
|
||||||
caption,
|
|
||||||
mimetype: media.contentType,
|
|
||||||
});
|
|
||||||
} else if (media.kind === "audio") {
|
|
||||||
await msg.sendMedia({
|
|
||||||
audio: media.buffer,
|
|
||||||
ptt: true,
|
|
||||||
mimetype: media.contentType,
|
|
||||||
caption,
|
|
||||||
});
|
|
||||||
} else if (media.kind === "video") {
|
|
||||||
await msg.sendMedia({
|
|
||||||
video: media.buffer,
|
|
||||||
caption,
|
|
||||||
mimetype: media.contentType,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const fileName = mediaUrl.split("/").pop() ?? "file";
|
|
||||||
const mimetype =
|
|
||||||
media.contentType ?? "application/octet-stream";
|
|
||||||
await msg.sendMedia({
|
|
||||||
document: media.buffer,
|
|
||||||
fileName,
|
|
||||||
caption,
|
|
||||||
mimetype,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
logInfo(
|
|
||||||
`✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
replyLogger.info(
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
correlationId,
|
|
||||||
to: msg.from,
|
|
||||||
from: msg.to,
|
|
||||||
text: index === 0 ? (replyResult.text ?? null) : null,
|
|
||||||
mediaUrl,
|
|
||||||
mediaSizeBytes: media.buffer.length,
|
|
||||||
mediaKind: media.kind,
|
|
||||||
durationMs: Date.now() - replyStarted,
|
|
||||||
},
|
|
||||||
"auto-reply sent (media)",
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
danger(
|
|
||||||
`Failed sending web media to ${msg.from}: ${String(err)}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (index === 0 && replyResult.text) {
|
|
||||||
console.log(
|
|
||||||
`⚠️ Media skipped; sent text-only to ${msg.from}`,
|
|
||||||
);
|
|
||||||
await msg.reply(replyResult.text || "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (replyResult.text) {
|
|
||||||
await msg.reply(replyResult.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
const durationMs = Date.now() - replyStarted;
|
|
||||||
const hasMedia = mediaList.length > 0;
|
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
console.log(
|
console.log(
|
||||||
success(
|
success(
|
||||||
`↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${hasMedia ? ", media" : ""}, ${formatDuration(durationMs)})`,
|
`↩️ Auto-replied to ${msg.from} (web${replyResult.mediaUrl || replyResult.mediaUrls?.length ? ", media" : ""})`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log(
|
||||||
success(
|
success(
|
||||||
`↩️ ${replyResult.text ?? "<media>"}${hasMedia ? " (media)" : ""}`,
|
`↩️ ${replyResult.text ?? "<media>"}${replyResult.mediaUrl || replyResult.mediaUrls?.length ? " (media)" : ""}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
replyLogger.info(
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
correlationId,
|
|
||||||
to: msg.from,
|
|
||||||
from: msg.to,
|
|
||||||
text: replyResult.text ?? null,
|
|
||||||
mediaUrl: mediaList[0] ?? null,
|
|
||||||
durationMs,
|
|
||||||
},
|
|
||||||
"auto-reply sent",
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
danger(
|
danger(
|
||||||
@ -261,6 +484,7 @@ export async function monitorWebProvider(
|
|||||||
|
|
||||||
const closeListener = async () => {
|
const closeListener = async () => {
|
||||||
if (heartbeat) clearInterval(heartbeat);
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
if (replyHeartbeatTimer) clearInterval(replyHeartbeatTimer);
|
||||||
try {
|
try {
|
||||||
await listener.close();
|
await listener.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -285,6 +509,164 @@ export async function monitorWebProvider(
|
|||||||
}, heartbeatSeconds * 1000);
|
}, heartbeatSeconds * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runReplyHeartbeat = async () => {
|
||||||
|
if (!replyHeartbeatMinutes) return;
|
||||||
|
const tickStart = Date.now();
|
||||||
|
if (!lastInboundMsg) {
|
||||||
|
const fallbackTo = getFallbackRecipient(cfg);
|
||||||
|
if (!fallbackTo) {
|
||||||
|
heartbeatLogger.info(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
reason: "no-recent-inbound",
|
||||||
|
durationMs: Date.now() - tickStart,
|
||||||
|
},
|
||||||
|
"reply heartbeat skipped",
|
||||||
|
);
|
||||||
|
console.log(success("heartbeat: skipped (no recent inbound)"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isVerbose()) {
|
||||||
|
heartbeatLogger.info(
|
||||||
|
{ connectionId, to: fallbackTo, reason: "fallback-session" },
|
||||||
|
"reply heartbeat start",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await runWebHeartbeatOnce({
|
||||||
|
to: fallbackTo,
|
||||||
|
verbose,
|
||||||
|
replyResolver,
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
heartbeatLogger.info(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
to: fallbackTo,
|
||||||
|
...getSessionSnapshot(cfg, fallbackTo),
|
||||||
|
durationMs: Date.now() - tickStart,
|
||||||
|
},
|
||||||
|
"reply heartbeat sent (fallback session)",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isVerbose()) {
|
||||||
|
const snapshot = getSessionSnapshot(cfg, lastInboundMsg.from);
|
||||||
|
heartbeatLogger.info(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
to: lastInboundMsg.from,
|
||||||
|
intervalMinutes: replyHeartbeatMinutes,
|
||||||
|
sessionKey: snapshot.key,
|
||||||
|
sessionId: snapshot.entry?.sessionId ?? null,
|
||||||
|
sessionFresh: snapshot.fresh,
|
||||||
|
},
|
||||||
|
"reply heartbeat start",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
||||||
|
{
|
||||||
|
Body: HEARTBEAT_PROMPT,
|
||||||
|
From: lastInboundMsg.from,
|
||||||
|
To: lastInboundMsg.to,
|
||||||
|
MessageSid: undefined,
|
||||||
|
MediaPath: undefined,
|
||||||
|
MediaUrl: undefined,
|
||||||
|
MediaType: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onReplyStart: lastInboundMsg.sendComposing,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!replyResult ||
|
||||||
|
(!replyResult.text &&
|
||||||
|
!replyResult.mediaUrl &&
|
||||||
|
!replyResult.mediaUrls?.length)
|
||||||
|
) {
|
||||||
|
heartbeatLogger.info(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
durationMs: Date.now() - tickStart,
|
||||||
|
reason: "empty-reply",
|
||||||
|
},
|
||||||
|
"reply heartbeat skipped",
|
||||||
|
);
|
||||||
|
console.log(success("heartbeat: ok (empty reply)"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripped = stripHeartbeatToken(replyResult.text);
|
||||||
|
const hasMedia =
|
||||||
|
(replyResult.mediaUrl ?? replyResult.mediaUrls?.length ?? 0) > 0;
|
||||||
|
if (stripped.shouldSkip && !hasMedia) {
|
||||||
|
heartbeatLogger.info(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
durationMs: Date.now() - tickStart,
|
||||||
|
reason: "heartbeat-token",
|
||||||
|
rawLength: replyResult.text?.length ?? 0,
|
||||||
|
},
|
||||||
|
"reply heartbeat skipped",
|
||||||
|
);
|
||||||
|
console.log(success("heartbeat: ok (HEARTBEAT_OK)"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedReply: ReplyPayload = {
|
||||||
|
...replyResult,
|
||||||
|
text: stripped.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
await deliverWebReply({
|
||||||
|
replyResult: cleanedReply,
|
||||||
|
msg: lastInboundMsg,
|
||||||
|
maxMediaBytes,
|
||||||
|
replyLogger,
|
||||||
|
runtime,
|
||||||
|
connectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const durationMs = Date.now() - tickStart;
|
||||||
|
const summary = `heartbeat: alert sent (${formatDuration(durationMs)})`;
|
||||||
|
console.log(summary);
|
||||||
|
heartbeatLogger.info(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
durationMs,
|
||||||
|
hasMedia,
|
||||||
|
chars: stripped.text?.length ?? 0,
|
||||||
|
},
|
||||||
|
"reply heartbeat sent",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const durationMs = Date.now() - tickStart;
|
||||||
|
heartbeatLogger.warn(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
error: String(err),
|
||||||
|
durationMs,
|
||||||
|
},
|
||||||
|
"reply heartbeat failed",
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
danger(`heartbeat: failed (${formatDuration(durationMs)})`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (replyHeartbeatMinutes && !replyHeartbeatTimer) {
|
||||||
|
const intervalMs = replyHeartbeatMinutes * 60_000;
|
||||||
|
replyHeartbeatTimer = setInterval(() => {
|
||||||
|
void runReplyHeartbeat();
|
||||||
|
}, intervalMs);
|
||||||
|
if (tuning.replyHeartbeatNow) {
|
||||||
|
void runReplyHeartbeat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logInfo(
|
logInfo(
|
||||||
"📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
|
"📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
|
||||||
runtime,
|
runtime,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user