Heartbeat: harden targeting and support lid mapping
This commit is contained in:
@@ -8,6 +8,8 @@
|
|||||||
- Added `warelay relay:heartbeat` (no tmux) and `warelay relay:heartbeat:tmux` helpers to start relay with an immediate startup heartbeat.
|
- Added `warelay relay:heartbeat` (no tmux) and `warelay relay:heartbeat:tmux` helpers to start relay with an immediate startup heartbeat.
|
||||||
- Relay now prints the active file log path and level on startup so you can tail logs without attaching.
|
- Relay now prints the active file log path and level on startup so you can tail logs without attaching.
|
||||||
- Heartbeat session handling now supports `inbound.reply.session.heartbeatIdleMinutes` and does not refresh `updatedAt` on skipped heartbeats, so sessions still expire on idle.
|
- Heartbeat session handling now supports `inbound.reply.session.heartbeatIdleMinutes` and does not refresh `updatedAt` on skipped heartbeats, so sessions still expire on idle.
|
||||||
|
- Web inbound now resolves WhatsApp Linked IDs (`@lid`) using Baileys’ reverse mapping files, so new-format senders are no longer dropped.
|
||||||
|
- `allowFrom: ["*"]` is honored for auto-replies; manual heartbeats require `--to` or `--all` when multiple sessions exist or the allowlist is wildcard-only.
|
||||||
|
|
||||||
## 1.1.0 — 2025-11-26
|
## 1.1.0 — 2025-11-26
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ 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 heartbeat` | Trigger one heartbeat poll (web) | `--provider <auto\|web>` `--to <e164?>` `--all` `--verbose` |
|
||||||
| `warelay relay:heartbeat` | Run relay with an immediate heartbeat (no tmux) | `--provider <auto\|web>` `--verbose` |
|
| `warelay relay:heartbeat` | Run relay with an immediate heartbeat (no tmux) | `--provider <auto\|web>` `--verbose` |
|
||||||
| `warelay relay:heartbeat:tmux` | Start relay in tmux and fire a heartbeat on start (web) | _no flags_ |
|
| `warelay relay:heartbeat:tmux` | 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` |
|
||||||
@@ -126,6 +126,7 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a
|
|||||||
- 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.
|
- 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.
|
- 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 `warelay relay:heartbeat` for a full relay run with an immediate heartbeat, or `--heartbeat-now` on `relay`/`relay:heartbeat:tmux`.
|
- Trigger one manually with `warelay heartbeat` (web provider only, `--verbose` prints session info). Use `warelay relay:heartbeat` for a full relay run with an immediate heartbeat, or `--heartbeat-now` on `relay`/`relay:heartbeat:tmux`.
|
||||||
|
- When multiple active sessions exist, `warelay heartbeat` requires `--to <E.164>` or `--all`; if `allowFrom` is just `"*"`, you must choose a target with one of those flags.
|
||||||
|
|
||||||
### 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.
|
||||||
@@ -149,7 +150,7 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a
|
|||||||
### Auto-reply parameter table (compact)
|
### Auto-reply parameter table (compact)
|
||||||
| Key | Type & default | Notes |
|
| Key | Type & default | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`). |
|
| `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`); `"*"` allows any sender. |
|
||||||
| `inbound.reply.mode` | `"text"` \| `"command"` (default: —) | Reply style. |
|
| `inbound.reply.mode` | `"text"` \| `"command"` (default: —) | Reply style. |
|
||||||
| `inbound.reply.text` | `string` (default: —) | Used when `mode=text`; templating supported. |
|
| `inbound.reply.text` | `string` (default: —) | Used when `mode=text`; templating supported. |
|
||||||
| `inbound.reply.command` | `string[]` (default: —) | Argv for `mode=command`; each element templated. Stdout (trimmed) is sent. |
|
| `inbound.reply.command` | `string[]` (default: —) | Argv for `mode=command`; each element templated. Stdout (trimmed) is sent. |
|
||||||
|
|||||||
@@ -40,3 +40,4 @@ Goal: add a simple heartbeat poll for command-based auto-replies (Claude-driven)
|
|||||||
- `warelay relay:heartbeat` to run the relay loop with an immediate heartbeat (no tmux)
|
- `warelay relay:heartbeat` to run the relay loop with an immediate heartbeat (no tmux)
|
||||||
- `warelay relay:heartbeat:tmux` to run the same in tmux (detached, attachable)
|
- `warelay relay:heartbeat:tmux` to run the same in tmux (detached, attachable)
|
||||||
- Relay supports `--heartbeat-now` to fire once at startup (including the tmux helper).
|
- Relay supports `--heartbeat-now` to fire once at startup (including the tmux helper).
|
||||||
|
- When multiple sessions are active or `allowFrom` is only `"*"`, require `--to <E.164>` or `--all` for manual heartbeats to avoid ambiguous targets.
|
||||||
|
|||||||
@@ -181,6 +181,11 @@ Examples:
|
|||||||
.description("Trigger a heartbeat poll once (web provider, no tmux)")
|
.description("Trigger a heartbeat poll once (web provider, no tmux)")
|
||||||
.option("--provider <provider>", "auto | web", "auto")
|
.option("--provider <provider>", "auto | web", "auto")
|
||||||
.option("--to <number>", "Override target E.164; defaults to allowFrom[0]")
|
.option("--to <number>", "Override target E.164; defaults to allowFrom[0]")
|
||||||
|
.option(
|
||||||
|
"--all",
|
||||||
|
"Send heartbeat to all active sessions (or allowFrom entries when none)",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option("--verbose", "Verbose logging", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
@@ -188,21 +193,35 @@ Examples:
|
|||||||
Examples:
|
Examples:
|
||||||
warelay heartbeat # uses web session + first allowFrom contact
|
warelay heartbeat # uses web session + first allowFrom contact
|
||||||
warelay heartbeat --verbose # prints detailed heartbeat logs
|
warelay heartbeat --verbose # prints detailed heartbeat logs
|
||||||
warelay heartbeat --to +1555123 # override destination`,
|
warelay heartbeat --to +1555123 # override destination
|
||||||
|
warelay heartbeat --all # send to every active session recipient`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const to =
|
const allowAll = Boolean(opts.all);
|
||||||
opts.to ??
|
const resolution = resolveHeartbeatRecipients(cfg, {
|
||||||
(Array.isArray(cfg.inbound?.allowFrom) &&
|
to: opts.to,
|
||||||
cfg.inbound?.allowFrom?.length > 0
|
all: allowAll,
|
||||||
? cfg.inbound.allowFrom[0]
|
});
|
||||||
: null);
|
if (
|
||||||
if (!to) {
|
!opts.to &&
|
||||||
|
!allowAll &&
|
||||||
|
resolution.source === "session-ambiguous" &&
|
||||||
|
resolution.recipients.length > 1
|
||||||
|
) {
|
||||||
defaultRuntime.error(
|
defaultRuntime.error(
|
||||||
danger(
|
danger(
|
||||||
"No destination found. Set inbound.allowFrom in ~/.warelay/warelay.json or pass --to <E.164>.",
|
`Multiple active sessions found (${resolution.recipients.join(", ")}). Pass --to <E.164> or --all to send to all.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
const recipients = resolution.recipients;
|
||||||
|
if (!recipients || recipients.length === 0) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
danger(
|
||||||
|
"No destination found. Add inbound.allowFrom numbers or pass --to <E.164>.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
@@ -222,11 +241,13 @@ Examples:
|
|||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await runWebHeartbeatOnce({
|
for (const to of recipients) {
|
||||||
to,
|
await runWebHeartbeatOnce({
|
||||||
verbose: Boolean(opts.verbose),
|
to,
|
||||||
runtime: defaultRuntime,
|
verbose: Boolean(opts.verbose),
|
||||||
});
|
runtime: defaultRuntime,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export {
|
|||||||
HEARTBEAT_PROMPT,
|
HEARTBEAT_PROMPT,
|
||||||
HEARTBEAT_TOKEN,
|
HEARTBEAT_TOKEN,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
|
resolveHeartbeatRecipients,
|
||||||
runWebHeartbeatOnce,
|
runWebHeartbeatOnce,
|
||||||
type WebMonitorTuning,
|
type WebMonitorTuning,
|
||||||
} from "./web/auto-reply.js";
|
} from "./web/auto-reply.js";
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import path from "node:path";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
assertProvider,
|
assertProvider,
|
||||||
|
CONFIG_DIR,
|
||||||
ensureDir,
|
ensureDir,
|
||||||
|
jidToE164,
|
||||||
normalizeE164,
|
normalizeE164,
|
||||||
normalizePath,
|
normalizePath,
|
||||||
sleep,
|
sleep,
|
||||||
@@ -67,3 +69,19 @@ describe("normalizeE164 & toWhatsappJid", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("jidToE164", () => {
|
||||||
|
it("maps @lid using reverse mapping file", () => {
|
||||||
|
const mappingPath = `${CONFIG_DIR}/credentials/lid-mapping-123_reverse.json`;
|
||||||
|
const original = fs.readFileSync;
|
||||||
|
const spy = vi
|
||||||
|
.spyOn(fs, "readFileSync")
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: forwarding to native signature
|
||||||
|
.mockImplementation((path: any, encoding?: any) => {
|
||||||
|
if (path === mappingPath) return `"5551234"`;
|
||||||
|
return original(path, encoding);
|
||||||
|
});
|
||||||
|
expect(jidToE164("123@lid")).toBe("+5551234");
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
import { isVerbose, logVerbose } from "./globals.js";
|
||||||
|
|
||||||
export async function ensureDir(dir: string) {
|
export async function ensureDir(dir: string) {
|
||||||
await fs.promises.mkdir(dir, { recursive: true });
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
@@ -53,6 +54,11 @@ export function jidToE164(jid: string): string | null {
|
|||||||
const phone = JSON.parse(data);
|
const phone = JSON.parse(data);
|
||||||
if (phone) return `+${phone}`;
|
if (phone) return `+${phone}`;
|
||||||
} catch {
|
} catch {
|
||||||
|
if (isVerbose()) {
|
||||||
|
logVerbose(
|
||||||
|
`LID mapping not found for ${lid}; skipping inbound message`,
|
||||||
|
);
|
||||||
|
}
|
||||||
// Mapping not found, fall through
|
// Mapping not found, fall through
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import type { WarelayConfig } from "../config/config.js";
|
|||||||
import { resolveStorePath } from "../config/sessions.js";
|
import { resolveStorePath } from "../config/sessions.js";
|
||||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||||
import {
|
import {
|
||||||
|
HEARTBEAT_PROMPT,
|
||||||
HEARTBEAT_TOKEN,
|
HEARTBEAT_TOKEN,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
|
resolveHeartbeatRecipients,
|
||||||
resolveReplyHeartbeatMinutes,
|
resolveReplyHeartbeatMinutes,
|
||||||
runWebHeartbeatOnce,
|
runWebHeartbeatOnce,
|
||||||
stripHeartbeatToken,
|
stripHeartbeatToken,
|
||||||
@@ -75,6 +77,80 @@ describe("heartbeat helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveHeartbeatRecipients", () => {
|
||||||
|
const makeStore = async (entries: Record<string, { updatedAt: number }>) => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "warelay-heartbeat-"));
|
||||||
|
const storePath = path.join(dir, "sessions.json");
|
||||||
|
await fs.writeFile(storePath, JSON.stringify(entries));
|
||||||
|
return {
|
||||||
|
storePath,
|
||||||
|
cleanup: async () => fs.rm(dir, { recursive: true, force: true }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it("returns the sole session recipient", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const store = await makeStore({ "+1000": { updatedAt: now } });
|
||||||
|
const cfg: WarelayConfig = {
|
||||||
|
inbound: {
|
||||||
|
allowFrom: ["+1999"],
|
||||||
|
reply: { mode: "command", session: { store: store.storePath } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = resolveHeartbeatRecipients(cfg);
|
||||||
|
expect(result.source).toBe("session-single");
|
||||||
|
expect(result.recipients).toEqual(["+1000"]);
|
||||||
|
await store.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces ambiguity when multiple sessions exist", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const store = await makeStore({
|
||||||
|
"+1000": { updatedAt: now },
|
||||||
|
"+2000": { updatedAt: now - 10 },
|
||||||
|
});
|
||||||
|
const cfg: WarelayConfig = {
|
||||||
|
inbound: {
|
||||||
|
allowFrom: ["+1999"],
|
||||||
|
reply: { mode: "command", session: { store: store.storePath } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = resolveHeartbeatRecipients(cfg);
|
||||||
|
expect(result.source).toBe("session-ambiguous");
|
||||||
|
expect(result.recipients).toEqual(["+1000", "+2000"]);
|
||||||
|
await store.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters wildcard allowFrom when no sessions exist", async () => {
|
||||||
|
const store = await makeStore({});
|
||||||
|
const cfg: WarelayConfig = {
|
||||||
|
inbound: {
|
||||||
|
allowFrom: ["*"],
|
||||||
|
reply: { mode: "command", session: { store: store.storePath } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = resolveHeartbeatRecipients(cfg);
|
||||||
|
expect(result.recipients).toHaveLength(0);
|
||||||
|
expect(result.source).toBe("allowFrom");
|
||||||
|
await store.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges sessions and allowFrom when --all is set", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const store = await makeStore({ "+1000": { updatedAt: now } });
|
||||||
|
const cfg: WarelayConfig = {
|
||||||
|
inbound: {
|
||||||
|
allowFrom: ["+1999"],
|
||||||
|
reply: { mode: "command", session: { store: store.storePath } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = resolveHeartbeatRecipients(cfg, { all: true });
|
||||||
|
expect(result.source).toBe("all");
|
||||||
|
expect(result.recipients.sort()).toEqual(["+1000", "+1999"].sort());
|
||||||
|
await store.cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("runWebHeartbeatOnce", () => {
|
describe("runWebHeartbeatOnce", () => {
|
||||||
it("skips when heartbeat token returned", async () => {
|
it("skips when heartbeat token returned", async () => {
|
||||||
const sender: typeof sendMessageWeb = vi.fn();
|
const sender: typeof sendMessageWeb = vi.fn();
|
||||||
@@ -179,6 +255,56 @@ describe("runWebHeartbeatOnce", () => {
|
|||||||
expect(after["+1555"].updatedAt).toBe(originalUpdated);
|
expect(after["+1555"].updatedAt).toBe(originalUpdated);
|
||||||
expect(sender).not.toHaveBeenCalled();
|
expect(sender).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("heartbeat reuses existing session id when last inbound is present", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "warelay-heartbeat-session-"),
|
||||||
|
);
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
const sessionId = "sess-keep";
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify({
|
||||||
|
"+4367": { sessionId, updatedAt: Date.now(), systemSent: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
setLoadConfigMock(() => ({
|
||||||
|
inbound: {
|
||||||
|
allowFrom: ["+4367"],
|
||||||
|
reply: {
|
||||||
|
mode: "command",
|
||||||
|
heartbeatMinutes: 0.001,
|
||||||
|
session: { store: storePath, idleMinutes: 60 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const replyResolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN });
|
||||||
|
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never;
|
||||||
|
const cfg: WarelayConfig = {
|
||||||
|
inbound: {
|
||||||
|
allowFrom: ["+4367"],
|
||||||
|
reply: {
|
||||||
|
mode: "command",
|
||||||
|
session: { store: storePath, idleMinutes: 60 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await runWebHeartbeatOnce({
|
||||||
|
cfg,
|
||||||
|
to: "+4367",
|
||||||
|
verbose: false,
|
||||||
|
replyResolver,
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
const heartbeatCall = replyResolver.mock.calls.find(
|
||||||
|
(call) => call[0]?.Body === HEARTBEAT_PROMPT,
|
||||||
|
);
|
||||||
|
expect(heartbeatCall?.[0]?.MessageSid).toBe(sessionId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("web auto-reply", () => {
|
describe("web auto-reply", () => {
|
||||||
|
|||||||
@@ -75,13 +75,14 @@ export function stripHeartbeatToken(raw?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runWebHeartbeatOnce(opts: {
|
export async function runWebHeartbeatOnce(opts: {
|
||||||
|
cfg?: ReturnType<typeof loadConfig>;
|
||||||
to: string;
|
to: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
replyResolver?: typeof getReplyFromConfig;
|
replyResolver?: typeof getReplyFromConfig;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
sender?: typeof sendMessageWeb;
|
sender?: typeof sendMessageWeb;
|
||||||
}) {
|
}) {
|
||||||
const { to, verbose = false } = opts;
|
const { cfg: cfgOverride, to, verbose = false } = opts;
|
||||||
const _runtime = opts.runtime ?? defaultRuntime;
|
const _runtime = opts.runtime ?? defaultRuntime;
|
||||||
const replyResolver = opts.replyResolver ?? getReplyFromConfig;
|
const replyResolver = opts.replyResolver ?? getReplyFromConfig;
|
||||||
const sender = opts.sender ?? sendMessageWeb;
|
const sender = opts.sender ?? sendMessageWeb;
|
||||||
@@ -92,7 +93,7 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
to,
|
to,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = cfgOverride ?? loadConfig();
|
||||||
const sessionSnapshot = getSessionSnapshot(cfg, to, true);
|
const sessionSnapshot = getSessionSnapshot(cfg, to, true);
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
heartbeatLogger.info(
|
heartbeatLogger.info(
|
||||||
@@ -184,10 +185,12 @@ function getFallbackRecipient(cfg: ReturnType<typeof loadConfig>) {
|
|||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
const candidates = Object.entries(store).filter(([key]) => key !== "global");
|
const candidates = Object.entries(store).filter(([key]) => key !== "global");
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return (
|
const allowFrom =
|
||||||
(Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom[0]) ||
|
Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom.length > 0
|
||||||
null
|
? cfg.inbound.allowFrom.filter((v) => v !== "*")
|
||||||
);
|
: [];
|
||||||
|
if (allowFrom.length === 0) return null;
|
||||||
|
return allowFrom[0] ? normalizeE164(allowFrom[0]) : null;
|
||||||
}
|
}
|
||||||
const mostRecent = candidates.sort(
|
const mostRecent = candidates.sort(
|
||||||
(a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0),
|
(a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0),
|
||||||
@@ -195,6 +198,54 @@ function getFallbackRecipient(cfg: ReturnType<typeof loadConfig>) {
|
|||||||
return mostRecent ? normalizeE164(mostRecent[0]) : null;
|
return mostRecent ? normalizeE164(mostRecent[0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
|
||||||
|
const sessionCfg = cfg.inbound?.reply?.session;
|
||||||
|
const scope = sessionCfg?.scope ?? "per-sender";
|
||||||
|
if (scope === "global") return [];
|
||||||
|
const storePath = resolveStorePath(sessionCfg?.store);
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
return Object.entries(store)
|
||||||
|
.filter(([key]) => key !== "global" && key !== "unknown")
|
||||||
|
.map(([key, entry]) => ({
|
||||||
|
to: normalizeE164(key),
|
||||||
|
updatedAt: entry?.updatedAt ?? 0,
|
||||||
|
}))
|
||||||
|
.filter(({ to }) => Boolean(to))
|
||||||
|
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHeartbeatRecipients(
|
||||||
|
cfg: ReturnType<typeof loadConfig>,
|
||||||
|
opts: { to?: string; all?: boolean } = {},
|
||||||
|
) {
|
||||||
|
if (opts.to) return { recipients: [normalizeE164(opts.to)], source: "flag" };
|
||||||
|
|
||||||
|
const sessionRecipients = getSessionRecipients(cfg);
|
||||||
|
const allowFrom =
|
||||||
|
Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom.length > 0
|
||||||
|
? cfg.inbound.allowFrom.filter((v) => v !== "*").map(normalizeE164)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const unique = (list: string[]) => [...new Set(list.filter(Boolean))];
|
||||||
|
|
||||||
|
if (opts.all) {
|
||||||
|
const all = unique([...sessionRecipients.map((s) => s.to), ...allowFrom]);
|
||||||
|
return { recipients: all, source: "all" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionRecipients.length === 1) {
|
||||||
|
return { recipients: [sessionRecipients[0].to], source: "session-single" };
|
||||||
|
}
|
||||||
|
if (sessionRecipients.length > 1) {
|
||||||
|
return {
|
||||||
|
recipients: sessionRecipients.map((s) => s.to),
|
||||||
|
source: "session-ambiguous" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { recipients: allowFrom, source: "allowFrom" as const };
|
||||||
|
}
|
||||||
|
|
||||||
function getSessionSnapshot(
|
function getSessionSnapshot(
|
||||||
cfg: ReturnType<typeof loadConfig>,
|
cfg: ReturnType<typeof loadConfig>,
|
||||||
from: string,
|
from: string,
|
||||||
@@ -551,8 +602,8 @@ export async function monitorWebProvider(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const snapshot = getSessionSnapshot(cfg, lastInboundMsg.from);
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
const snapshot = getSessionSnapshot(cfg, lastInboundMsg.from);
|
|
||||||
heartbeatLogger.info(
|
heartbeatLogger.info(
|
||||||
{
|
{
|
||||||
connectionId,
|
connectionId,
|
||||||
@@ -570,7 +621,7 @@ export async function monitorWebProvider(
|
|||||||
Body: HEARTBEAT_PROMPT,
|
Body: HEARTBEAT_PROMPT,
|
||||||
From: lastInboundMsg.from,
|
From: lastInboundMsg.from,
|
||||||
To: lastInboundMsg.to,
|
To: lastInboundMsg.to,
|
||||||
MessageSid: undefined,
|
MessageSid: snapshot.entry?.sessionId,
|
||||||
MediaPath: undefined,
|
MediaPath: undefined,
|
||||||
MediaUrl: undefined,
|
MediaUrl: undefined,
|
||||||
MediaType: undefined,
|
MediaType: undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user