feat: unify gateway heartbeat
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
### Breaking
|
### Breaking
|
||||||
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.
|
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.
|
||||||
- Heartbeat config moved to `agent.heartbeat`: set `every: "30m"` (duration string) and optional `model`. `agent.heartbeatMinutes` is removed, and heartbeats are disabled unless `agent.heartbeat.every` is set.
|
- Heartbeat config moved to `agent.heartbeat`: set `every: "30m"` (duration string) and optional `model`. `agent.heartbeatMinutes` is removed, and heartbeats are disabled unless `agent.heartbeat.every` is set.
|
||||||
|
- Heartbeats now run via the gateway runner (main session) and deliver to the last used channel by default. WhatsApp reply-heartbeat behavior is removed; use `agent.heartbeat.target`/`to` (or `target: "none"`) to control delivery.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam.
|
- Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam.
|
||||||
|
|||||||
@@ -130,7 +130,8 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
|
|||||||
timeoutSeconds: 600,
|
timeoutSeconds: 600,
|
||||||
mediaMaxMb: 5,
|
mediaMaxMb: 5,
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
every: "30m"
|
every: "30m",
|
||||||
|
target: "last"
|
||||||
},
|
},
|
||||||
maxConcurrent: 3,
|
maxConcurrent: 3,
|
||||||
bash: {
|
bash: {
|
||||||
@@ -151,6 +152,9 @@ deprecation fallback.
|
|||||||
- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Omit or set
|
- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Omit or set
|
||||||
`0m` to disable.
|
`0m` to disable.
|
||||||
- `model`: optional override model for heartbeat runs (`provider/model`).
|
- `model`: optional override model for heartbeat runs (`provider/model`).
|
||||||
|
- `target`: delivery channel (`last`, `whatsapp`, `telegram`, `none`). Default: `last`.
|
||||||
|
- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram).
|
||||||
|
- `prompt`: override the default heartbeat body (`HEARTBEAT`).
|
||||||
|
|
||||||
`agent.bash` configures background bash defaults:
|
`agent.bash` configures background bash defaults:
|
||||||
- `backgroundMs`: time before auto-background (ms, default 20000)
|
- `backgroundMs`: time before auto-background (ms, default 20000)
|
||||||
|
|||||||
14
docs/cron.md
14
docs/cron.md
@@ -14,7 +14,7 @@ Last updated: 2025-12-13
|
|||||||
## Context
|
## Context
|
||||||
|
|
||||||
Clawdis already has:
|
Clawdis already has:
|
||||||
- A **periodic reply heartbeat** that runs the agent with `HEARTBEAT` and suppresses `HEARTBEAT_OK` (`src/web/auto-reply.ts`).
|
- A **gateway heartbeat runner** that runs the agent with `HEARTBEAT` and suppresses `HEARTBEAT_OK` (`src/infra/heartbeat-runner.ts`).
|
||||||
- A lightweight, in-memory **system event queue** (`enqueueSystemEvent`) that is injected into the next **main session** turn (`drainSystemEvents` in `src/auto-reply/reply.ts`).
|
- A lightweight, in-memory **system event queue** (`enqueueSystemEvent`) that is injected into the next **main session** turn (`drainSystemEvents` in `src/auto-reply/reply.ts`).
|
||||||
- A WebSocket **Gateway** daemon that is intended to be always-on (`docs/gateway.md`).
|
- A WebSocket **Gateway** daemon that is intended to be always-on (`docs/gateway.md`).
|
||||||
|
|
||||||
@@ -197,12 +197,12 @@ This yields:
|
|||||||
We need a way for the Gateway (or the scheduler) to request an immediate heartbeat without duplicating heartbeat logic.
|
We need a way for the Gateway (or the scheduler) to request an immediate heartbeat without duplicating heartbeat logic.
|
||||||
|
|
||||||
Design:
|
Design:
|
||||||
- `monitorWebProvider` owns the real `runReplyHeartbeat()` function (it already has all the local state needed).
|
- `startHeartbeatRunner` owns the real heartbeat execution and installs a wake handler.
|
||||||
- Add a small global hook module:
|
- Wake hook lives in `src/infra/heartbeat-wake.ts`:
|
||||||
- `setReplyHeartbeatWakeHandler(fn | null)` installed by `monitorWebProvider`
|
- `setHeartbeatWakeHandler(fn | null)` installed by the heartbeat runner
|
||||||
- `requestReplyHeartbeatNow({ reason, coalesceMs? })`
|
- `requestHeartbeatNow({ reason, coalesceMs? })`
|
||||||
- If the handler is absent (provider not connected), the request is stored as “pending”; the next time the handler is installed, it runs once.
|
- If the handler is absent, the request is stored as “pending”; the next time the handler is installed, it runs once.
|
||||||
- Coalesce rapid calls and respect the existing “skip when queue busy” behavior (prefer retrying soon vs dropping).
|
- Coalesce rapid calls and respect the “skip when queue busy” behavior (retry soon vs dropping).
|
||||||
|
|
||||||
## Run history log (JSONL)
|
## Run history log (JSONL)
|
||||||
|
|
||||||
|
|||||||
@@ -3,49 +3,53 @@ summary: "Plan for heartbeat polling messages and notification rules"
|
|||||||
read_when:
|
read_when:
|
||||||
- Adjusting heartbeat cadence or messaging
|
- Adjusting heartbeat cadence or messaging
|
||||||
---
|
---
|
||||||
# Heartbeat polling plan (2025-11-26)
|
# Heartbeat (Gateway)
|
||||||
|
|
||||||
Goal: add a simple heartbeat poll for the embedded agent that only notifies users when something matters, using the `HEARTBEAT_OK` sentinel. The heartbeat body we send is `HEARTBEAT` so the model can easily spot it.
|
Heartbeat runs periodic agent turns in the **main session** so the model can
|
||||||
|
surface anything that needs attention without spamming the user.
|
||||||
|
|
||||||
## Prompt contract
|
## Prompt contract
|
||||||
- Extend the agent system prompt 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.” Heartbeat prompt body is `HEARTBEAT`.
|
- Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`).
|
||||||
- Keep existing WhatsApp length guidance; forbid burying the sentinel inside alerts.
|
- If nothing needs attention, the model must reply **exactly** `HEARTBEAT_OK`.
|
||||||
|
- For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text.
|
||||||
|
|
||||||
## Config & defaults
|
## Config
|
||||||
- New config key: `agent.heartbeat` with:
|
|
||||||
- `every`: duration string (`ms`, `s`, `m`, `h`; default unit minutes). `0m` disables.
|
|
||||||
- `model`: optional override model (`provider/model`) for heartbeat runs.
|
|
||||||
- Default: disabled unless `agent.heartbeat.every` is set.
|
|
||||||
- New optional idle override for heartbeats: `session.heartbeatIdleMinutes` (defaults to `idleMinutes`). Heartbeat skips do **not** update the session `updatedAt` so idle expiry still works.
|
|
||||||
|
|
||||||
## Poller behavior
|
```json5
|
||||||
- When gateway 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 Pi context stays warm.
|
agent: {
|
||||||
- Heartbeats never create a new session implicitly: if there’s no stored session for the target (fallback path), the heartbeat is skipped instead of starting a fresh Pi session.
|
heartbeat: {
|
||||||
- Abort timer on SIGINT/abort of the gateway.
|
every: "30m", // duration string: ms|s|m|h (0m disables)
|
||||||
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
target: "last", // last | whatsapp | telegram | none
|
||||||
|
to: "+15551234567", // optional override for whatsapp/telegram
|
||||||
|
prompt: "HEARTBEAT" // optional override
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Sentinel handling
|
### Fields
|
||||||
- Trim output. If the trimmed text equals `HEARTBEAT_OK` (case-sensitive) -> skip outbound message.
|
- `every`: heartbeat interval (duration string; default unit minutes). Omit or set
|
||||||
- Otherwise, send the text/media as normal, stripping the sentinel if it somehow appears.
|
to `0m` to disable.
|
||||||
- Treat empty output as `HEARTBEAT_OK` to avoid spurious pings.
|
- `model`: optional model override for heartbeat runs (`provider/model`).
|
||||||
|
- `target`: where heartbeat output is delivered.
|
||||||
|
- `last` (default): send to the last used external channel.
|
||||||
|
- `whatsapp` / `telegram`: force the channel (optionally set `to`).
|
||||||
|
- `none`: do not deliver externally; output stays in the session (WebChat-visible).
|
||||||
|
- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram).
|
||||||
|
- `prompt`: override the default heartbeat body.
|
||||||
|
|
||||||
## Logging requirements
|
## Behavior
|
||||||
- Normal mode: single info line per tick, e.g., `heartbeat: ok (skipped)` or `heartbeat: alert sent (32ms)`.
|
- Runs in the main session (`session.mainKey`, or `global` when scope is global).
|
||||||
- `--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.
|
- Uses the main lane queue; if requests are in flight, the wake is retried.
|
||||||
- On command failure: warn-level one-liner in normal mode; verbose log includes stdout/stderr snippets.
|
- Empty output or `HEARTBEAT_OK` is treated as “ok” and does **not** keep the
|
||||||
|
session alive (`updatedAt` is restored).
|
||||||
|
- If `target` resolves to no external destination (no last route or `none`), the
|
||||||
|
heartbeat still runs but no outbound message is sent.
|
||||||
|
|
||||||
## Failure/backoff
|
## Wake hook
|
||||||
- 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).
|
- The gateway exposes a heartbeat wake hook so cron/jobs/webhooks can request an
|
||||||
|
immediate run (`requestHeartbeatNow`).
|
||||||
## Tests to add
|
- `wake` endpoints should enqueue system events and optionally trigger a wake; the
|
||||||
- Unit: sentinel detection (`HEARTBEAT_OK`, empty output, mixed text), skip vs send decision, default interval resolver (30m, override, disable).
|
heartbeat runner picks those up on the next tick or immediately.
|
||||||
- Unit/integration: verbose logger emits start/end lines; normal logger emits a single line.
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
- Add a short README snippet under configuration showing `agent.heartbeat` and the sentinel rule.
|
|
||||||
- Expose CLI triggers:
|
|
||||||
- `clawdis heartbeat` (web provider, defaults to first `routing.allowFrom`; optional `--to` override)
|
|
||||||
- `--session-id <uuid>` forces resuming a specific session for that heartbeat
|
|
||||||
- `clawdis gateway --heartbeat-now` to run the gateway loop with an immediate heartbeat
|
|
||||||
- Gateway supports `--heartbeat-now` to fire once at startup.
|
|
||||||
- When multiple sessions are active or `routing.allowFrom` is only `"*"`, require `--to <E.164>` or `--all` for manual heartbeats to avoid ambiguous targets.
|
|
||||||
|
|||||||
@@ -86,10 +86,9 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
|
|||||||
|
|
||||||
## Heartbeats
|
## Heartbeats
|
||||||
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
|
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
|
||||||
- **Reply heartbeat** asks agent on a timer (`agent.heartbeat.every`).
|
- **Agent heartbeat** is global (`agent.heartbeat.*`) and runs in the main session.
|
||||||
- Uses `HEARTBEAT` prompt + `HEARTBEAT_TOKEN` skip behavior.
|
- Uses `HEARTBEAT` prompt + `HEARTBEAT_OK` skip behavior.
|
||||||
- Skips if queue busy or last inbound was a group.
|
- Delivery defaults to the last used channel (or configured target).
|
||||||
- Falls back to last direct recipient if needed.
|
|
||||||
|
|
||||||
## Reconnect behavior
|
## Reconnect behavior
|
||||||
- Backoff policy: `web.reconnect`:
|
- Backoff policy: `web.reconnect`:
|
||||||
@@ -106,6 +105,8 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
|
|||||||
- `agent.mediaMaxMb`
|
- `agent.mediaMaxMb`
|
||||||
- `agent.heartbeat.every`
|
- `agent.heartbeat.every`
|
||||||
- `agent.heartbeat.model` (optional override)
|
- `agent.heartbeat.model` (optional override)
|
||||||
|
- `agent.heartbeat.target`
|
||||||
|
- `agent.heartbeat.to`
|
||||||
- `session.*` (scope, idle, store, mainKey)
|
- `session.*` (scope, idle, store, mainKey)
|
||||||
- `web.heartbeatSeconds`
|
- `web.heartbeatSeconds`
|
||||||
- `web.reconnect.*`
|
- `web.reconnect.*`
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe("resolveConfiguredModelRef", () => {
|
|||||||
expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" });
|
expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to default provider when agent.model omits it", () => {
|
it("falls back to anthropic when agent.model omits provider", () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
agent: { model: "claude-opus-4-5" },
|
agent: { model: "claude-opus-4-5" },
|
||||||
} satisfies ClawdisConfig;
|
} satisfies ClawdisConfig;
|
||||||
@@ -30,10 +30,7 @@ describe("resolveConfiguredModelRef", () => {
|
|||||||
defaultModel: DEFAULT_MODEL,
|
defaultModel: DEFAULT_MODEL,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(resolved).toEqual({
|
expect(resolved).toEqual({ provider: "anthropic", model: "claude-opus-4-5" });
|
||||||
provider: DEFAULT_PROVIDER,
|
|
||||||
model: "claude-opus-4-5",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to defaults when agent.model is missing", () => {
|
it("falls back to defaults when agent.model is missing", () => {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function resolveConfiguredModelRef(params: {
|
|||||||
if (parsed) return parsed;
|
if (parsed) return parsed;
|
||||||
}
|
}
|
||||||
// TODO(steipete): drop this fallback once provider-less agent.model is fully deprecated.
|
// TODO(steipete): drop this fallback once provider-less agent.model is fully deprecated.
|
||||||
return { provider: params.defaultProvider, model: trimmed };
|
return { provider: "anthropic", model: trimmed };
|
||||||
}
|
}
|
||||||
return { provider: params.defaultProvider, model: params.defaultModel };
|
return { provider: params.defaultProvider, model: params.defaultModel };
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/auto-reply/heartbeat.test.ts
Normal file
55
src/auto-reply/heartbeat.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { stripHeartbeatToken } from "./heartbeat.js";
|
||||||
|
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||||
|
|
||||||
|
describe("stripHeartbeatToken", () => {
|
||||||
|
it("skips empty or token-only replies", () => {
|
||||||
|
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("strips repeated OK tails after heartbeat token", () => {
|
||||||
|
expect(stripHeartbeatToken("HEARTBEAT_OK_OK_OK")).toEqual({
|
||||||
|
shouldSkip: true,
|
||||||
|
text: "",
|
||||||
|
});
|
||||||
|
expect(stripHeartbeatToken("HEARTBEAT_OK_OK")).toEqual({
|
||||||
|
shouldSkip: true,
|
||||||
|
text: "",
|
||||||
|
});
|
||||||
|
expect(stripHeartbeatToken("HEARTBEAT_OK _OK")).toEqual({
|
||||||
|
shouldSkip: true,
|
||||||
|
text: "",
|
||||||
|
});
|
||||||
|
expect(stripHeartbeatToken("HEARTBEAT_OK OK")).toEqual({
|
||||||
|
shouldSkip: true,
|
||||||
|
text: "",
|
||||||
|
});
|
||||||
|
expect(stripHeartbeatToken("ALERT HEARTBEAT_OK_OK")).toEqual({
|
||||||
|
shouldSkip: false,
|
||||||
|
text: "ALERT",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
21
src/auto-reply/heartbeat.ts
Normal file
21
src/auto-reply/heartbeat.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||||
|
|
||||||
|
export const HEARTBEAT_PROMPT = "HEARTBEAT";
|
||||||
|
|
||||||
|
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 hadToken = trimmed.includes(HEARTBEAT_TOKEN);
|
||||||
|
let withoutToken = trimmed.replaceAll(HEARTBEAT_TOKEN, "").trim();
|
||||||
|
if (hadToken && withoutToken) {
|
||||||
|
// LLMs sometimes echo malformed HEARTBEAT_OK_OK... tails; strip trailing OK runs to avoid spam.
|
||||||
|
withoutToken = withoutToken.replace(/[\s_]*OK(?:[\s_]*OK)*$/gi, "").trim();
|
||||||
|
}
|
||||||
|
const shouldSkip = withoutToken.length === 0;
|
||||||
|
return {
|
||||||
|
shouldSkip,
|
||||||
|
text: shouldSkip ? "" : withoutToken || trimmed,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -329,6 +329,12 @@ export type ClawdisConfig = {
|
|||||||
every?: string;
|
every?: string;
|
||||||
/** Heartbeat model override (provider/model). */
|
/** Heartbeat model override (provider/model). */
|
||||||
model?: string;
|
model?: string;
|
||||||
|
/** Delivery target (last|whatsapp|telegram|none). */
|
||||||
|
target?: "last" | "whatsapp" | "telegram" | "none";
|
||||||
|
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
|
||||||
|
to?: string;
|
||||||
|
/** Override the heartbeat prompt body (default: "HEARTBEAT"). */
|
||||||
|
prompt?: string;
|
||||||
};
|
};
|
||||||
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
|
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
|
||||||
maxConcurrent?: number;
|
maxConcurrent?: number;
|
||||||
@@ -454,6 +460,16 @@ const HeartbeatSchema = z
|
|||||||
.object({
|
.object({
|
||||||
every: z.string().optional(),
|
every: z.string().optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
|
target: z
|
||||||
|
.union([
|
||||||
|
z.literal("last"),
|
||||||
|
z.literal("whatsapp"),
|
||||||
|
z.literal("telegram"),
|
||||||
|
z.literal("none"),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
to: z.string().optional(),
|
||||||
|
prompt: z.string().optional(),
|
||||||
})
|
})
|
||||||
.superRefine((val, ctx) => {
|
.superRefine((val, ctx) => {
|
||||||
if (!val.every) return;
|
if (!val.every) return;
|
||||||
|
|||||||
@@ -40,14 +40,14 @@ describe("CronService", () => {
|
|||||||
it("runs a one-shot main job and disables it after success", async () => {
|
it("runs a one-shot main job and disables it after success", async () => {
|
||||||
const store = await makeStorePath();
|
const store = await makeStorePath();
|
||||||
const enqueueSystemEvent = vi.fn();
|
const enqueueSystemEvent = vi.fn();
|
||||||
const requestReplyHeartbeatNow = vi.fn();
|
const requestHeartbeatNow = vi.fn();
|
||||||
|
|
||||||
const cron = new CronService({
|
const cron = new CronService({
|
||||||
storePath: store.storePath,
|
storePath: store.storePath,
|
||||||
cronEnabled: true,
|
cronEnabled: true,
|
||||||
log: noopLogger,
|
log: noopLogger,
|
||||||
enqueueSystemEvent,
|
enqueueSystemEvent,
|
||||||
requestReplyHeartbeatNow,
|
requestHeartbeatNow,
|
||||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ describe("CronService", () => {
|
|||||||
const updated = jobs.find((j) => j.id === job.id);
|
const updated = jobs.find((j) => j.id === job.id);
|
||||||
expect(updated?.enabled).toBe(false);
|
expect(updated?.enabled).toBe(false);
|
||||||
expect(enqueueSystemEvent).toHaveBeenCalledWith("hello");
|
expect(enqueueSystemEvent).toHaveBeenCalledWith("hello");
|
||||||
expect(requestReplyHeartbeatNow).toHaveBeenCalled();
|
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||||
|
|
||||||
await cron.list({ includeDisabled: true });
|
await cron.list({ includeDisabled: true });
|
||||||
cron.stop();
|
cron.stop();
|
||||||
@@ -81,7 +81,7 @@ describe("CronService", () => {
|
|||||||
it("runs an isolated job and posts summary to main", async () => {
|
it("runs an isolated job and posts summary to main", async () => {
|
||||||
const store = await makeStorePath();
|
const store = await makeStorePath();
|
||||||
const enqueueSystemEvent = vi.fn();
|
const enqueueSystemEvent = vi.fn();
|
||||||
const requestReplyHeartbeatNow = vi.fn();
|
const requestHeartbeatNow = vi.fn();
|
||||||
const runIsolatedAgentJob = vi.fn(async () => ({
|
const runIsolatedAgentJob = vi.fn(async () => ({
|
||||||
status: "ok" as const,
|
status: "ok" as const,
|
||||||
summary: "done",
|
summary: "done",
|
||||||
@@ -92,7 +92,7 @@ describe("CronService", () => {
|
|||||||
cronEnabled: true,
|
cronEnabled: true,
|
||||||
log: noopLogger,
|
log: noopLogger,
|
||||||
enqueueSystemEvent,
|
enqueueSystemEvent,
|
||||||
requestReplyHeartbeatNow,
|
requestHeartbeatNow,
|
||||||
runIsolatedAgentJob,
|
runIsolatedAgentJob,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ describe("CronService", () => {
|
|||||||
await cron.list({ includeDisabled: true });
|
await cron.list({ includeDisabled: true });
|
||||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||||
expect(enqueueSystemEvent).toHaveBeenCalledWith("Cron: done");
|
expect(enqueueSystemEvent).toHaveBeenCalledWith("Cron: done");
|
||||||
expect(requestReplyHeartbeatNow).toHaveBeenCalled();
|
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||||
cron.stop();
|
cron.stop();
|
||||||
await store.cleanup();
|
await store.cleanup();
|
||||||
});
|
});
|
||||||
@@ -121,7 +121,7 @@ describe("CronService", () => {
|
|||||||
it("posts last output to main even when isolated job errors", async () => {
|
it("posts last output to main even when isolated job errors", async () => {
|
||||||
const store = await makeStorePath();
|
const store = await makeStorePath();
|
||||||
const enqueueSystemEvent = vi.fn();
|
const enqueueSystemEvent = vi.fn();
|
||||||
const requestReplyHeartbeatNow = vi.fn();
|
const requestHeartbeatNow = vi.fn();
|
||||||
const runIsolatedAgentJob = vi.fn(async () => ({
|
const runIsolatedAgentJob = vi.fn(async () => ({
|
||||||
status: "error" as const,
|
status: "error" as const,
|
||||||
summary: "last output",
|
summary: "last output",
|
||||||
@@ -133,7 +133,7 @@ describe("CronService", () => {
|
|||||||
cronEnabled: true,
|
cronEnabled: true,
|
||||||
log: noopLogger,
|
log: noopLogger,
|
||||||
enqueueSystemEvent,
|
enqueueSystemEvent,
|
||||||
requestReplyHeartbeatNow,
|
requestHeartbeatNow,
|
||||||
runIsolatedAgentJob,
|
runIsolatedAgentJob,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ describe("CronService", () => {
|
|||||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
"Cron (error): last output",
|
"Cron (error): last output",
|
||||||
);
|
);
|
||||||
expect(requestReplyHeartbeatNow).toHaveBeenCalled();
|
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||||
cron.stop();
|
cron.stop();
|
||||||
await store.cleanup();
|
await store.cleanup();
|
||||||
});
|
});
|
||||||
@@ -168,7 +168,7 @@ describe("CronService", () => {
|
|||||||
cronEnabled: true,
|
cronEnabled: true,
|
||||||
log: noopLogger,
|
log: noopLogger,
|
||||||
enqueueSystemEvent: vi.fn(),
|
enqueueSystemEvent: vi.fn(),
|
||||||
requestReplyHeartbeatNow: vi.fn(),
|
requestHeartbeatNow: vi.fn(),
|
||||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ describe("CronService", () => {
|
|||||||
it("skips invalid main jobs with agentTurn payloads from disk", async () => {
|
it("skips invalid main jobs with agentTurn payloads from disk", async () => {
|
||||||
const store = await makeStorePath();
|
const store = await makeStorePath();
|
||||||
const enqueueSystemEvent = vi.fn();
|
const enqueueSystemEvent = vi.fn();
|
||||||
const requestReplyHeartbeatNow = vi.fn();
|
const requestHeartbeatNow = vi.fn();
|
||||||
|
|
||||||
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
|
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
|
||||||
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
||||||
@@ -232,7 +232,7 @@ describe("CronService", () => {
|
|||||||
cronEnabled: true,
|
cronEnabled: true,
|
||||||
log: noopLogger,
|
log: noopLogger,
|
||||||
enqueueSystemEvent,
|
enqueueSystemEvent,
|
||||||
requestReplyHeartbeatNow,
|
requestHeartbeatNow,
|
||||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -242,7 +242,7 @@ describe("CronService", () => {
|
|||||||
await vi.runOnlyPendingTimersAsync();
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
|
||||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||||
expect(requestReplyHeartbeatNow).not.toHaveBeenCalled();
|
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const jobs = await cron.list({ includeDisabled: true });
|
const jobs = await cron.list({ includeDisabled: true });
|
||||||
expect(jobs[0]?.state.lastStatus).toBe("skipped");
|
expect(jobs[0]?.state.lastStatus).toBe("skipped");
|
||||||
@@ -255,14 +255,14 @@ describe("CronService", () => {
|
|||||||
it("skips main jobs with empty systemEvent text", async () => {
|
it("skips main jobs with empty systemEvent text", async () => {
|
||||||
const store = await makeStorePath();
|
const store = await makeStorePath();
|
||||||
const enqueueSystemEvent = vi.fn();
|
const enqueueSystemEvent = vi.fn();
|
||||||
const requestReplyHeartbeatNow = vi.fn();
|
const requestHeartbeatNow = vi.fn();
|
||||||
|
|
||||||
const cron = new CronService({
|
const cron = new CronService({
|
||||||
storePath: store.storePath,
|
storePath: store.storePath,
|
||||||
cronEnabled: true,
|
cronEnabled: true,
|
||||||
log: noopLogger,
|
log: noopLogger,
|
||||||
enqueueSystemEvent,
|
enqueueSystemEvent,
|
||||||
requestReplyHeartbeatNow,
|
requestHeartbeatNow,
|
||||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ describe("CronService", () => {
|
|||||||
await vi.runOnlyPendingTimersAsync();
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
|
||||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||||
expect(requestReplyHeartbeatNow).not.toHaveBeenCalled();
|
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const jobs = await cron.list({ includeDisabled: true });
|
const jobs = await cron.list({ includeDisabled: true });
|
||||||
expect(jobs[0]?.state.lastStatus).toBe("skipped");
|
expect(jobs[0]?.state.lastStatus).toBe("skipped");
|
||||||
@@ -294,14 +294,14 @@ describe("CronService", () => {
|
|||||||
it("does not schedule timers when cron is disabled", async () => {
|
it("does not schedule timers when cron is disabled", async () => {
|
||||||
const store = await makeStorePath();
|
const store = await makeStorePath();
|
||||||
const enqueueSystemEvent = vi.fn();
|
const enqueueSystemEvent = vi.fn();
|
||||||
const requestReplyHeartbeatNow = vi.fn();
|
const requestHeartbeatNow = vi.fn();
|
||||||
|
|
||||||
const cron = new CronService({
|
const cron = new CronService({
|
||||||
storePath: store.storePath,
|
storePath: store.storePath,
|
||||||
cronEnabled: false,
|
cronEnabled: false,
|
||||||
log: noopLogger,
|
log: noopLogger,
|
||||||
enqueueSystemEvent,
|
enqueueSystemEvent,
|
||||||
requestReplyHeartbeatNow,
|
requestHeartbeatNow,
|
||||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@ describe("CronService", () => {
|
|||||||
await vi.runOnlyPendingTimersAsync();
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
|
||||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||||
expect(requestReplyHeartbeatNow).not.toHaveBeenCalled();
|
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||||
expect(noopLogger.warn).toHaveBeenCalled();
|
expect(noopLogger.warn).toHaveBeenCalled();
|
||||||
|
|
||||||
cron.stop();
|
cron.stop();
|
||||||
@@ -334,14 +334,14 @@ describe("CronService", () => {
|
|||||||
it("status reports next wake when enabled", async () => {
|
it("status reports next wake when enabled", async () => {
|
||||||
const store = await makeStorePath();
|
const store = await makeStorePath();
|
||||||
const enqueueSystemEvent = vi.fn();
|
const enqueueSystemEvent = vi.fn();
|
||||||
const requestReplyHeartbeatNow = vi.fn();
|
const requestHeartbeatNow = vi.fn();
|
||||||
|
|
||||||
const cron = new CronService({
|
const cron = new CronService({
|
||||||
storePath: store.storePath,
|
storePath: store.storePath,
|
||||||
cronEnabled: true,
|
cronEnabled: true,
|
||||||
log: noopLogger,
|
log: noopLogger,
|
||||||
enqueueSystemEvent,
|
enqueueSystemEvent,
|
||||||
requestReplyHeartbeatNow,
|
requestHeartbeatNow,
|
||||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export type CronServiceDeps = {
|
|||||||
storePath: string;
|
storePath: string;
|
||||||
cronEnabled: boolean;
|
cronEnabled: boolean;
|
||||||
enqueueSystemEvent: (text: string) => void;
|
enqueueSystemEvent: (text: string) => void;
|
||||||
requestReplyHeartbeatNow: (opts?: { reason?: string }) => void;
|
requestHeartbeatNow: (opts?: { reason?: string }) => void;
|
||||||
runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{
|
runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{
|
||||||
status: "ok" | "error" | "skipped";
|
status: "ok" | "error" | "skipped";
|
||||||
summary?: string;
|
summary?: string;
|
||||||
@@ -276,7 +276,7 @@ export class CronService {
|
|||||||
if (!text) return { ok: false };
|
if (!text) return { ok: false };
|
||||||
this.deps.enqueueSystemEvent(text);
|
this.deps.enqueueSystemEvent(text);
|
||||||
if (opts.mode === "now") {
|
if (opts.mode === "now") {
|
||||||
this.deps.requestReplyHeartbeatNow({ reason: "wake" });
|
this.deps.requestHeartbeatNow({ reason: "wake" });
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -479,7 +479,7 @@ export class CronService {
|
|||||||
const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
|
const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
|
||||||
this.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`);
|
this.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`);
|
||||||
if (job.wakeMode === "now") {
|
if (job.wakeMode === "now") {
|
||||||
this.deps.requestReplyHeartbeatNow({ reason: `cron:${job.id}:post` });
|
this.deps.requestHeartbeatNow({ reason: `cron:${job.id}:post` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -503,7 +503,7 @@ export class CronService {
|
|||||||
}
|
}
|
||||||
this.deps.enqueueSystemEvent(text);
|
this.deps.enqueueSystemEvent(text);
|
||||||
if (job.wakeMode === "now") {
|
if (job.wakeMode === "now") {
|
||||||
this.deps.requestReplyHeartbeatNow({ reason: `cron:${job.id}` });
|
this.deps.requestHeartbeatNow({ reason: `cron:${job.id}` });
|
||||||
}
|
}
|
||||||
await finish("ok", undefined, text);
|
await finish("ok", undefined, text);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ import {
|
|||||||
getLastHeartbeatEvent,
|
getLastHeartbeatEvent,
|
||||||
onHeartbeatEvent,
|
onHeartbeatEvent,
|
||||||
} from "../infra/heartbeat-events.js";
|
} from "../infra/heartbeat-events.js";
|
||||||
|
import {
|
||||||
|
setHeartbeatsEnabled,
|
||||||
|
startHeartbeatRunner,
|
||||||
|
} from "../infra/heartbeat-runner.js";
|
||||||
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import {
|
import {
|
||||||
approveNodePairing,
|
approveNodePairing,
|
||||||
@@ -129,13 +134,9 @@ import { monitorTelegramProvider } from "../telegram/monitor.js";
|
|||||||
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
|
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
|
||||||
import { sendMessageTelegram } from "../telegram/send.js";
|
import { sendMessageTelegram } from "../telegram/send.js";
|
||||||
import { normalizeE164, resolveUserPath } from "../utils.js";
|
import { normalizeE164, resolveUserPath } from "../utils.js";
|
||||||
import {
|
import type { WebProviderStatus } from "../web/auto-reply.js";
|
||||||
setHeartbeatsEnabled,
|
|
||||||
type WebProviderStatus,
|
|
||||||
} from "../web/auto-reply.js";
|
|
||||||
import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js";
|
import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js";
|
||||||
import { sendMessageWhatsApp } from "../web/outbound.js";
|
import { sendMessageWhatsApp } from "../web/outbound.js";
|
||||||
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
|
|
||||||
import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js";
|
import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js";
|
||||||
import {
|
import {
|
||||||
assertGatewayAuthConfigured,
|
assertGatewayAuthConfigured,
|
||||||
@@ -1423,7 +1424,7 @@ export async function startGatewayServer(
|
|||||||
}) => {
|
}) => {
|
||||||
enqueueSystemEvent(value.text);
|
enqueueSystemEvent(value.text);
|
||||||
if (value.mode === "now") {
|
if (value.mode === "now") {
|
||||||
requestReplyHeartbeatNow({ reason: "hook:wake" });
|
requestHeartbeatNow({ reason: "hook:wake" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1481,13 +1482,13 @@ export async function startGatewayServer(
|
|||||||
: `Hook ${value.name} (${result.status})`;
|
: `Hook ${value.name} (${result.status})`;
|
||||||
enqueueSystemEvent(`${prefix}: ${summary}`.trim());
|
enqueueSystemEvent(`${prefix}: ${summary}`.trim());
|
||||||
if (value.wakeMode === "now") {
|
if (value.wakeMode === "now") {
|
||||||
requestReplyHeartbeatNow({ reason: `hook:${jobId}` });
|
requestHeartbeatNow({ reason: `hook:${jobId}` });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logHooks.warn(`hook agent failed: ${String(err)}`);
|
logHooks.warn(`hook agent failed: ${String(err)}`);
|
||||||
enqueueSystemEvent(`Hook ${value.name} (error): ${String(err)}`);
|
enqueueSystemEvent(`Hook ${value.name} (error): ${String(err)}`);
|
||||||
if (value.wakeMode === "now") {
|
if (value.wakeMode === "now") {
|
||||||
requestReplyHeartbeatNow({ reason: `hook:${jobId}:error` });
|
requestHeartbeatNow({ reason: `hook:${jobId}:error` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -1758,7 +1759,7 @@ export async function startGatewayServer(
|
|||||||
storePath: cronStorePath,
|
storePath: cronStorePath,
|
||||||
cronEnabled,
|
cronEnabled,
|
||||||
enqueueSystemEvent,
|
enqueueSystemEvent,
|
||||||
requestReplyHeartbeatNow,
|
requestHeartbeatNow,
|
||||||
runIsolatedAgentJob: async ({ job, message }) => {
|
runIsolatedAgentJob: async ({ job, message }) => {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
return await runCronIsolatedAgentTurn({
|
return await runCronIsolatedAgentTurn({
|
||||||
@@ -3360,6 +3361,8 @@ export async function startGatewayServer(
|
|||||||
broadcast("heartbeat", evt, { dropIfSlow: true });
|
broadcast("heartbeat", evt, { dropIfSlow: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const heartbeatRunner = startHeartbeatRunner({ cfg: cfgAtStart });
|
||||||
|
|
||||||
void cron
|
void cron
|
||||||
.start()
|
.start()
|
||||||
.catch((err) => logCron.error(`failed to start: ${String(err)}`));
|
.catch((err) => logCron.error(`failed to start: ${String(err)}`));
|
||||||
@@ -5970,6 +5973,7 @@ export async function startGatewayServer(
|
|||||||
await stopWhatsAppProvider();
|
await stopWhatsAppProvider();
|
||||||
await stopTelegramProvider();
|
await stopTelegramProvider();
|
||||||
cron.stop();
|
cron.stop();
|
||||||
|
heartbeatRunner.stop();
|
||||||
broadcast("shutdown", {
|
broadcast("shutdown", {
|
||||||
reason,
|
reason,
|
||||||
restartExpectedMs,
|
restartExpectedMs,
|
||||||
|
|||||||
116
src/infra/heartbeat-runner.test.ts
Normal file
116
src/infra/heartbeat-runner.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdisConfig } from "../config/config.js";
|
||||||
|
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
|
||||||
|
import {
|
||||||
|
resolveHeartbeatDeliveryTarget,
|
||||||
|
resolveHeartbeatIntervalMs,
|
||||||
|
resolveHeartbeatPrompt,
|
||||||
|
} from "./heartbeat-runner.js";
|
||||||
|
|
||||||
|
describe("resolveHeartbeatIntervalMs", () => {
|
||||||
|
it("returns null when unset or invalid", () => {
|
||||||
|
expect(resolveHeartbeatIntervalMs({})).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveHeartbeatIntervalMs({ agent: { heartbeat: { every: "0m" } } }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveHeartbeatIntervalMs({ agent: { heartbeat: { every: "oops" } } }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses duration strings with minute defaults", () => {
|
||||||
|
expect(
|
||||||
|
resolveHeartbeatIntervalMs({ agent: { heartbeat: { every: "5m" } } }),
|
||||||
|
).toBe(5 * 60_000);
|
||||||
|
expect(
|
||||||
|
resolveHeartbeatIntervalMs({ agent: { heartbeat: { every: "5" } } }),
|
||||||
|
).toBe(5 * 60_000);
|
||||||
|
expect(
|
||||||
|
resolveHeartbeatIntervalMs({ agent: { heartbeat: { every: "2h" } } }),
|
||||||
|
).toBe(2 * 60 * 60_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveHeartbeatPrompt", () => {
|
||||||
|
it("uses the default prompt when unset", () => {
|
||||||
|
expect(resolveHeartbeatPrompt({})).toBe(HEARTBEAT_PROMPT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a trimmed override when configured", () => {
|
||||||
|
const cfg: ClawdisConfig = {
|
||||||
|
agent: { heartbeat: { prompt: " ping " } },
|
||||||
|
};
|
||||||
|
expect(resolveHeartbeatPrompt(cfg)).toBe("ping");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveHeartbeatDeliveryTarget", () => {
|
||||||
|
const baseEntry = {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("respects target none", () => {
|
||||||
|
const cfg: ClawdisConfig = {
|
||||||
|
agent: { heartbeat: { target: "none" } },
|
||||||
|
};
|
||||||
|
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
|
||||||
|
channel: "none",
|
||||||
|
reason: "target-none",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses last route by default", () => {
|
||||||
|
const cfg: ClawdisConfig = {};
|
||||||
|
const entry = {
|
||||||
|
...baseEntry,
|
||||||
|
lastChannel: "whatsapp" as const,
|
||||||
|
lastTo: "+1555",
|
||||||
|
};
|
||||||
|
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
|
||||||
|
channel: "whatsapp",
|
||||||
|
to: "+1555",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips when last route is webchat", () => {
|
||||||
|
const cfg: ClawdisConfig = {};
|
||||||
|
const entry = {
|
||||||
|
...baseEntry,
|
||||||
|
lastChannel: "webchat" as const,
|
||||||
|
lastTo: "web",
|
||||||
|
};
|
||||||
|
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
|
||||||
|
channel: "none",
|
||||||
|
reason: "no-target",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies allowFrom fallback for WhatsApp targets", () => {
|
||||||
|
const cfg: ClawdisConfig = {
|
||||||
|
agent: { heartbeat: { target: "whatsapp", to: "+1999" } },
|
||||||
|
routing: { allowFrom: ["+1555", "+1666"] },
|
||||||
|
};
|
||||||
|
const entry = {
|
||||||
|
...baseEntry,
|
||||||
|
lastChannel: "whatsapp" as const,
|
||||||
|
lastTo: "+1222",
|
||||||
|
};
|
||||||
|
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
|
||||||
|
channel: "whatsapp",
|
||||||
|
to: "+1555",
|
||||||
|
reason: "allowFrom-fallback",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps explicit telegram targets", () => {
|
||||||
|
const cfg: ClawdisConfig = {
|
||||||
|
agent: { heartbeat: { target: "telegram", to: "123" } },
|
||||||
|
};
|
||||||
|
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
|
||||||
|
channel: "telegram",
|
||||||
|
to: "123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
421
src/infra/heartbeat-runner.ts
Normal file
421
src/infra/heartbeat-runner.ts
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import { chunkText } from "../auto-reply/chunk.js";
|
||||||
|
import { HEARTBEAT_PROMPT, stripHeartbeatToken } from "../auto-reply/heartbeat.js";
|
||||||
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
|
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||||
|
import type { ClawdisConfig } from "../config/config.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import {
|
||||||
|
loadSessionStore,
|
||||||
|
resolveStorePath,
|
||||||
|
saveSessionStore,
|
||||||
|
type SessionEntry,
|
||||||
|
} from "../config/sessions.js";
|
||||||
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
|
import { getQueueSize } from "../process/command-queue.js";
|
||||||
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
import { normalizeE164 } from "../utils.js";
|
||||||
|
import { sendMessageTelegram } from "../telegram/send.js";
|
||||||
|
import { sendMessageWhatsApp } from "../web/outbound.js";
|
||||||
|
import { emitHeartbeatEvent } from "./heartbeat-events.js";
|
||||||
|
import {
|
||||||
|
requestHeartbeatNow,
|
||||||
|
setHeartbeatWakeHandler,
|
||||||
|
type HeartbeatRunResult,
|
||||||
|
} from "./heartbeat-wake.js";
|
||||||
|
|
||||||
|
export type HeartbeatTarget = "last" | "whatsapp" | "telegram" | "none";
|
||||||
|
|
||||||
|
export type HeartbeatDeliveryTarget = {
|
||||||
|
channel: "whatsapp" | "telegram" | "none";
|
||||||
|
to?: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HeartbeatDeps = {
|
||||||
|
runtime?: RuntimeEnv;
|
||||||
|
sendWhatsApp?: typeof sendMessageWhatsApp;
|
||||||
|
sendTelegram?: typeof sendMessageTelegram;
|
||||||
|
getQueueSize?: (lane?: string) => number;
|
||||||
|
nowMs?: () => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const log = createSubsystemLogger("gateway/heartbeat");
|
||||||
|
let heartbeatsEnabled = true;
|
||||||
|
|
||||||
|
export function setHeartbeatsEnabled(enabled: boolean) {
|
||||||
|
heartbeatsEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHeartbeatIntervalMs(
|
||||||
|
cfg: ClawdisConfig,
|
||||||
|
overrideEvery?: string,
|
||||||
|
) {
|
||||||
|
const raw = overrideEvery ?? cfg.agent?.heartbeat?.every;
|
||||||
|
if (!raw) return null;
|
||||||
|
const trimmed = String(raw).trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
let ms: number;
|
||||||
|
try {
|
||||||
|
ms = parseDurationMs(trimmed, { defaultUnit: "m" });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (ms <= 0) return null;
|
||||||
|
return ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHeartbeatPrompt(cfg: ClawdisConfig) {
|
||||||
|
const raw = cfg.agent?.heartbeat?.prompt;
|
||||||
|
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
||||||
|
return trimmed || HEARTBEAT_PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHeartbeatSession(cfg: ClawdisConfig) {
|
||||||
|
const sessionCfg = cfg.session;
|
||||||
|
const scope = sessionCfg?.scope ?? "per-sender";
|
||||||
|
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
||||||
|
const sessionKey = scope === "global" ? "global" : mainKey;
|
||||||
|
const storePath = resolveStorePath(sessionCfg?.store);
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const entry = store[sessionKey];
|
||||||
|
return { sessionKey, storePath, store, entry };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHeartbeatSender(params: {
|
||||||
|
allowFrom: Array<string | number>;
|
||||||
|
lastTo?: string;
|
||||||
|
lastChannel?: SessionEntry["lastChannel"];
|
||||||
|
}) {
|
||||||
|
const { allowFrom, lastTo, lastChannel } = params;
|
||||||
|
const candidates = [
|
||||||
|
lastTo?.trim(),
|
||||||
|
lastChannel === "telegram" && lastTo ? `telegram:${lastTo}` : undefined,
|
||||||
|
lastChannel === "whatsapp" && lastTo ? `whatsapp:${lastTo}` : undefined,
|
||||||
|
].filter((val): val is string => Boolean(val && val.trim()));
|
||||||
|
|
||||||
|
const allowList = allowFrom
|
||||||
|
.map((entry) => String(entry))
|
||||||
|
.filter((entry) => entry && entry !== "*");
|
||||||
|
if (allowFrom.includes("*")) {
|
||||||
|
return candidates[0] ?? "heartbeat";
|
||||||
|
}
|
||||||
|
if (candidates.length > 0 && allowList.length > 0) {
|
||||||
|
const matched = candidates.find((candidate) =>
|
||||||
|
allowList.includes(candidate),
|
||||||
|
);
|
||||||
|
if (matched) return matched;
|
||||||
|
}
|
||||||
|
if (candidates.length > 0 && allowList.length === 0) {
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
if (allowList.length > 0) return allowList[0];
|
||||||
|
return candidates[0] ?? "heartbeat";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHeartbeatDeliveryTarget(params: {
|
||||||
|
cfg: ClawdisConfig;
|
||||||
|
entry?: SessionEntry;
|
||||||
|
}): HeartbeatDeliveryTarget {
|
||||||
|
const { cfg, entry } = params;
|
||||||
|
const rawTarget = cfg.agent?.heartbeat?.target;
|
||||||
|
const target: HeartbeatTarget =
|
||||||
|
rawTarget === "whatsapp" ||
|
||||||
|
rawTarget === "telegram" ||
|
||||||
|
rawTarget === "none" ||
|
||||||
|
rawTarget === "last"
|
||||||
|
? rawTarget
|
||||||
|
: "last";
|
||||||
|
if (target === "none") {
|
||||||
|
return { channel: "none", reason: "target-none" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitTo =
|
||||||
|
typeof cfg.agent?.heartbeat?.to === "string" &&
|
||||||
|
cfg.agent.heartbeat.to.trim()
|
||||||
|
? cfg.agent.heartbeat.to.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const lastChannel =
|
||||||
|
entry?.lastChannel && entry.lastChannel !== "webchat"
|
||||||
|
? entry.lastChannel
|
||||||
|
: undefined;
|
||||||
|
const lastTo = typeof entry?.lastTo === "string" ? entry.lastTo.trim() : "";
|
||||||
|
|
||||||
|
const channel: "whatsapp" | "telegram" | undefined =
|
||||||
|
target === "last"
|
||||||
|
? lastChannel
|
||||||
|
: target === "whatsapp" || target === "telegram"
|
||||||
|
? target
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const to =
|
||||||
|
explicitTo ||
|
||||||
|
(channel && lastChannel === channel ? lastTo : undefined) ||
|
||||||
|
(target === "last" ? lastTo : undefined);
|
||||||
|
|
||||||
|
if (!channel || !to) {
|
||||||
|
return { channel: "none", reason: "no-target" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreHeartbeatUpdatedAt(params: {
|
||||||
|
storePath: string;
|
||||||
|
sessionKey: string;
|
||||||
|
updatedAt?: number;
|
||||||
|
}) {
|
||||||
|
const { storePath, sessionKey, updatedAt } = params;
|
||||||
|
if (typeof updatedAt !== "number") return;
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const entry = store[sessionKey];
|
||||||
|
if (!entry) return;
|
||||||
|
if (entry.updatedAt === updatedAt) return;
|
||||||
|
store[sessionKey] = { ...entry, updatedAt };
|
||||||
|
await saveSessionStore(storePath, store);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel !== "whatsapp") {
|
||||||
|
return { channel, to };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawAllow = cfg.routing?.allowFrom ?? [];
|
||||||
|
if (rawAllow.includes("*")) return { channel, to };
|
||||||
|
const allowFrom = rawAllow
|
||||||
|
.map((val) => normalizeE164(val))
|
||||||
|
.filter((val) => val.length > 1);
|
||||||
|
if (allowFrom.length === 0) return { channel, to };
|
||||||
|
|
||||||
|
const normalized = normalizeE164(to);
|
||||||
|
if (allowFrom.includes(normalized)) return { channel, to: normalized };
|
||||||
|
return { channel, to: allowFrom[0], reason: "allowFrom-fallback" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeartbeatReply(
|
||||||
|
payload: ReplyPayload,
|
||||||
|
responsePrefix?: string,
|
||||||
|
) {
|
||||||
|
const stripped = stripHeartbeatToken(payload.text);
|
||||||
|
const hasMedia = Boolean(
|
||||||
|
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
if (stripped.shouldSkip && !hasMedia) {
|
||||||
|
return {
|
||||||
|
shouldSkip: true,
|
||||||
|
text: "",
|
||||||
|
hasMedia,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let finalText = stripped.text;
|
||||||
|
if (responsePrefix && finalText && !finalText.startsWith(responsePrefix)) {
|
||||||
|
finalText = `${responsePrefix} ${finalText}`;
|
||||||
|
}
|
||||||
|
return { shouldSkip: false, text: finalText, hasMedia };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deliverHeartbeatReply(params: {
|
||||||
|
channel: "whatsapp" | "telegram";
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
mediaUrls: string[];
|
||||||
|
deps: Required<Pick<HeartbeatDeps, "sendWhatsApp" | "sendTelegram">>;
|
||||||
|
}) {
|
||||||
|
const { channel, to, text, mediaUrls, deps } = params;
|
||||||
|
if (channel === "whatsapp") {
|
||||||
|
if (mediaUrls.length === 0) {
|
||||||
|
for (const chunk of chunkText(text, 4000)) {
|
||||||
|
await deps.sendWhatsApp(to, chunk, { verbose: false });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let first = true;
|
||||||
|
for (const url of mediaUrls) {
|
||||||
|
const caption = first ? text : "";
|
||||||
|
first = false;
|
||||||
|
await deps.sendWhatsApp(to, caption, { verbose: false, mediaUrl: url });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaUrls.length === 0) {
|
||||||
|
for (const chunk of chunkText(text, 4000)) {
|
||||||
|
await deps.sendTelegram(to, chunk, { verbose: false });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let first = true;
|
||||||
|
for (const url of mediaUrls) {
|
||||||
|
const caption = first ? text : "";
|
||||||
|
first = false;
|
||||||
|
await deps.sendTelegram(to, caption, { verbose: false, mediaUrl: url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runHeartbeatOnce(opts: {
|
||||||
|
cfg?: ClawdisConfig;
|
||||||
|
reason?: string;
|
||||||
|
deps?: HeartbeatDeps;
|
||||||
|
}): Promise<HeartbeatRunResult> {
|
||||||
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
|
if (!heartbeatsEnabled) {
|
||||||
|
return { status: "skipped", reason: "disabled" };
|
||||||
|
}
|
||||||
|
if (!resolveHeartbeatIntervalMs(cfg)) {
|
||||||
|
return { status: "skipped", reason: "disabled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)("main");
|
||||||
|
if (queueSize > 0) {
|
||||||
|
return { status: "skipped", reason: "requests-in-flight" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
|
||||||
|
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg);
|
||||||
|
const previousUpdatedAt = entry?.updatedAt;
|
||||||
|
const allowFrom = cfg.routing?.allowFrom ?? [];
|
||||||
|
const sender = resolveHeartbeatSender({
|
||||||
|
allowFrom,
|
||||||
|
lastTo: entry?.lastTo,
|
||||||
|
lastChannel: entry?.lastChannel,
|
||||||
|
});
|
||||||
|
const prompt = resolveHeartbeatPrompt(cfg);
|
||||||
|
const ctx = {
|
||||||
|
Body: prompt,
|
||||||
|
From: sender,
|
||||||
|
To: sender,
|
||||||
|
Surface: "heartbeat",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const replyResult = await getReplyFromConfig(
|
||||||
|
ctx,
|
||||||
|
{ isHeartbeat: true },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
const replyPayload = Array.isArray(replyResult)
|
||||||
|
? replyResult[0]
|
||||||
|
: replyResult;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!replyPayload ||
|
||||||
|
(!replyPayload.text &&
|
||||||
|
!replyPayload.mediaUrl &&
|
||||||
|
!replyPayload.mediaUrls?.length)
|
||||||
|
) {
|
||||||
|
await restoreHeartbeatUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey,
|
||||||
|
updatedAt: previousUpdatedAt,
|
||||||
|
});
|
||||||
|
emitHeartbeatEvent({
|
||||||
|
status: "ok-empty",
|
||||||
|
reason: opts.reason,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeHeartbeatReply(
|
||||||
|
replyPayload,
|
||||||
|
cfg.messages?.responsePrefix,
|
||||||
|
);
|
||||||
|
if (normalized.shouldSkip && !normalized.hasMedia) {
|
||||||
|
await restoreHeartbeatUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey,
|
||||||
|
updatedAt: previousUpdatedAt,
|
||||||
|
});
|
||||||
|
emitHeartbeatEvent({
|
||||||
|
status: "ok-token",
|
||||||
|
reason: opts.reason,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry });
|
||||||
|
const mediaUrls =
|
||||||
|
replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []);
|
||||||
|
|
||||||
|
if (delivery.channel === "none" || !delivery.to) {
|
||||||
|
emitHeartbeatEvent({
|
||||||
|
status: "skipped",
|
||||||
|
reason: delivery.reason ?? "no-target",
|
||||||
|
preview: normalized.text?.slice(0, 200),
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
hasMedia: mediaUrls.length > 0,
|
||||||
|
});
|
||||||
|
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
const deps = {
|
||||||
|
sendWhatsApp: opts.deps?.sendWhatsApp ?? sendMessageWhatsApp,
|
||||||
|
sendTelegram: opts.deps?.sendTelegram ?? sendMessageTelegram,
|
||||||
|
};
|
||||||
|
await deliverHeartbeatReply({
|
||||||
|
channel: delivery.channel,
|
||||||
|
to: delivery.to,
|
||||||
|
text: normalized.text,
|
||||||
|
mediaUrls,
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
|
||||||
|
emitHeartbeatEvent({
|
||||||
|
status: "sent",
|
||||||
|
to: delivery.to,
|
||||||
|
preview: normalized.text?.slice(0, 200),
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
hasMedia: mediaUrls.length > 0,
|
||||||
|
});
|
||||||
|
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||||
|
} catch (err) {
|
||||||
|
emitHeartbeatEvent({
|
||||||
|
status: "failed",
|
||||||
|
reason: String(err),
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
log.error({ error: String(err) }, "heartbeat failed");
|
||||||
|
return { status: "failed", reason: String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startHeartbeatRunner(opts: {
|
||||||
|
cfg?: ClawdisConfig;
|
||||||
|
runtime?: RuntimeEnv;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
}) {
|
||||||
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
|
const intervalMs = resolveHeartbeatIntervalMs(cfg);
|
||||||
|
if (!intervalMs) {
|
||||||
|
log.info({ enabled: false }, "heartbeat: disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = opts.runtime ?? defaultRuntime;
|
||||||
|
const run = async (params?: { reason?: string }) => {
|
||||||
|
const res = await runHeartbeatOnce({
|
||||||
|
cfg,
|
||||||
|
reason: params?.reason,
|
||||||
|
deps: { runtime },
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
setHeartbeatWakeHandler(async (params) => run({ reason: params.reason }));
|
||||||
|
|
||||||
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
if (intervalMs) {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
||||||
|
}, intervalMs);
|
||||||
|
timer.unref?.();
|
||||||
|
log.info({ intervalMs }, "heartbeat: started");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
setHeartbeatWakeHandler(null);
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
opts.abortSignal?.addEventListener("abort", cleanup, { once: true });
|
||||||
|
|
||||||
|
return { stop: cleanup };
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
export type ReplyHeartbeatWakeResult =
|
export type HeartbeatRunResult =
|
||||||
| { status: "ran"; durationMs: number }
|
| { status: "ran"; durationMs: number }
|
||||||
| { status: "skipped"; reason: string }
|
| { status: "skipped"; reason: string }
|
||||||
| { status: "failed"; reason: string };
|
| { status: "failed"; reason: string };
|
||||||
|
|
||||||
export type ReplyHeartbeatWakeHandler = (opts: {
|
export type HeartbeatWakeHandler = (opts: {
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}) => Promise<ReplyHeartbeatWakeResult>;
|
}) => Promise<HeartbeatRunResult>;
|
||||||
|
|
||||||
let handler: ReplyHeartbeatWakeHandler | null = null;
|
let handler: HeartbeatWakeHandler | null = null;
|
||||||
let pendingReason: string | null = null;
|
let pendingReason: string | null = null;
|
||||||
let scheduled = false;
|
let scheduled = false;
|
||||||
let running = false;
|
let running = false;
|
||||||
@@ -51,27 +51,22 @@ function schedule(coalesceMs: number) {
|
|||||||
timer.unref?.();
|
timer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setReplyHeartbeatWakeHandler(
|
export function setHeartbeatWakeHandler(next: HeartbeatWakeHandler | null) {
|
||||||
next: ReplyHeartbeatWakeHandler | null,
|
|
||||||
) {
|
|
||||||
handler = next;
|
handler = next;
|
||||||
if (handler && pendingReason) {
|
if (handler && pendingReason) {
|
||||||
schedule(DEFAULT_COALESCE_MS);
|
schedule(DEFAULT_COALESCE_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requestReplyHeartbeatNow(opts?: {
|
export function requestHeartbeatNow(opts?: { reason?: string; coalesceMs?: number }) {
|
||||||
reason?: string;
|
|
||||||
coalesceMs?: number;
|
|
||||||
}) {
|
|
||||||
pendingReason = opts?.reason ?? pendingReason ?? "requested";
|
pendingReason = opts?.reason ?? pendingReason ?? "requested";
|
||||||
schedule(opts?.coalesceMs ?? DEFAULT_COALESCE_MS);
|
schedule(opts?.coalesceMs ?? DEFAULT_COALESCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasReplyHeartbeatWakeHandler() {
|
export function hasHeartbeatWakeHandler() {
|
||||||
return handler !== null;
|
return handler !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasPendingReplyHeartbeatWake() {
|
export function hasPendingHeartbeatWake() {
|
||||||
return pendingReason !== null || Boolean(timer) || scheduled;
|
return pendingReason !== null || Boolean(timer) || scheduled;
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,6 @@ export {
|
|||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
resolveHeartbeatRecipients,
|
resolveHeartbeatRecipients,
|
||||||
runWebHeartbeatOnce,
|
runWebHeartbeatOnce,
|
||||||
setHeartbeatsEnabled,
|
|
||||||
type WebMonitorTuning,
|
type WebMonitorTuning,
|
||||||
type WebProviderStatus,
|
type WebProviderStatus,
|
||||||
} from "./web/auto-reply.js";
|
} from "./web/auto-reply.js";
|
||||||
|
|||||||
@@ -15,19 +15,11 @@ import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
|||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import type { ClawdisConfig } from "../config/config.js";
|
import type { ClawdisConfig } from "../config/config.js";
|
||||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||||
import * as commandQueue from "../process/command-queue.js";
|
|
||||||
import {
|
import {
|
||||||
HEARTBEAT_PROMPT,
|
|
||||||
HEARTBEAT_TOKEN,
|
HEARTBEAT_TOKEN,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
resolveHeartbeatRecipients,
|
|
||||||
resolveReplyHeartbeatIntervalMs,
|
|
||||||
runWebHeartbeatOnce,
|
|
||||||
SILENT_REPLY_TOKEN,
|
SILENT_REPLY_TOKEN,
|
||||||
stripHeartbeatToken,
|
|
||||||
} from "./auto-reply.js";
|
} from "./auto-reply.js";
|
||||||
import type { sendMessageWhatsApp } from "./outbound.js";
|
|
||||||
import { requestReplyHeartbeatNow } from "./reply-heartbeat-wake.js";
|
|
||||||
import {
|
import {
|
||||||
resetBaileysMocks,
|
resetBaileysMocks,
|
||||||
resetLoadConfigMock,
|
resetLoadConfigMock,
|
||||||
@@ -107,146 +99,6 @@ const makeSessionStore = async (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
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("strips repeated OK tails after heartbeat token", () => {
|
|
||||||
expect(stripHeartbeatToken("HEARTBEAT_OK_OK_OK")).toEqual({
|
|
||||||
shouldSkip: true,
|
|
||||||
text: "",
|
|
||||||
});
|
|
||||||
expect(stripHeartbeatToken("HEARTBEAT_OK_OK")).toEqual({
|
|
||||||
shouldSkip: true,
|
|
||||||
text: "",
|
|
||||||
});
|
|
||||||
expect(stripHeartbeatToken("HEARTBEAT_OK _OK")).toEqual({
|
|
||||||
shouldSkip: true,
|
|
||||||
text: "",
|
|
||||||
});
|
|
||||||
expect(stripHeartbeatToken("HEARTBEAT_OK OK")).toEqual({
|
|
||||||
shouldSkip: true,
|
|
||||||
text: "",
|
|
||||||
});
|
|
||||||
expect(stripHeartbeatToken("ALERT HEARTBEAT_OK_OK")).toEqual({
|
|
||||||
shouldSkip: false,
|
|
||||||
text: "ALERT",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves reply heartbeat interval from config and overrides", () => {
|
|
||||||
const cfgBase: ClawdisConfig = {};
|
|
||||||
expect(resolveReplyHeartbeatIntervalMs(cfgBase)).toBeNull();
|
|
||||||
expect(
|
|
||||||
resolveReplyHeartbeatIntervalMs({
|
|
||||||
agent: { heartbeat: { every: "5m" } },
|
|
||||||
}),
|
|
||||||
).toBe(5 * 60_000);
|
|
||||||
expect(
|
|
||||||
resolveReplyHeartbeatIntervalMs({
|
|
||||||
agent: { heartbeat: { every: "0m" } },
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
expect(resolveReplyHeartbeatIntervalMs(cfgBase, "7m")).toBe(7 * 60_000);
|
|
||||||
expect(
|
|
||||||
resolveReplyHeartbeatIntervalMs({
|
|
||||||
agent: { heartbeat: { every: "5" } },
|
|
||||||
}),
|
|
||||||
).toBe(5 * 60_000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveHeartbeatRecipients", () => {
|
|
||||||
it("returns the sole session recipient", async () => {
|
|
||||||
const now = Date.now();
|
|
||||||
const store = await makeSessionStore({
|
|
||||||
main: { updatedAt: now, lastChannel: "whatsapp", lastTo: "+1000" },
|
|
||||||
});
|
|
||||||
const cfg: ClawdisConfig = {
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+1999"],
|
|
||||||
},
|
|
||||||
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 makeSessionStore({
|
|
||||||
main: { updatedAt: now, lastChannel: "whatsapp", lastTo: "+1000" },
|
|
||||||
alt: { updatedAt: now - 10, lastChannel: "whatsapp", lastTo: "+2000" },
|
|
||||||
});
|
|
||||||
const cfg: ClawdisConfig = {
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+1999"],
|
|
||||||
},
|
|
||||||
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 makeSessionStore({});
|
|
||||||
const cfg: ClawdisConfig = {
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["*"],
|
|
||||||
},
|
|
||||||
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 makeSessionStore({
|
|
||||||
main: { updatedAt: now, lastChannel: "whatsapp", lastTo: "+1000" },
|
|
||||||
});
|
|
||||||
const cfg: ClawdisConfig = {
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+1999"],
|
|
||||||
},
|
|
||||||
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("partial reply gating", () => {
|
describe("partial reply gating", () => {
|
||||||
it("does not send partial replies for WhatsApp surface", async () => {
|
it("does not send partial replies for WhatsApp surface", async () => {
|
||||||
const reply = vi.fn().mockResolvedValue(undefined);
|
const reply = vi.fn().mockResolvedValue(undefined);
|
||||||
@@ -387,249 +239,6 @@ describe("partial reply gating", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("runWebHeartbeatOnce", () => {
|
|
||||||
it("skips when heartbeat token returned", async () => {
|
|
||||||
const store = await makeSessionStore();
|
|
||||||
const sender: typeof sendMessageWhatsApp = vi.fn();
|
|
||||||
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
|
||||||
await runWebHeartbeatOnce({
|
|
||||||
cfg: {
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+1555"],
|
|
||||||
},
|
|
||||||
session: { store: store.storePath },
|
|
||||||
},
|
|
||||||
to: "+1555",
|
|
||||||
verbose: false,
|
|
||||||
sender,
|
|
||||||
replyResolver: resolver,
|
|
||||||
});
|
|
||||||
expect(resolver).toHaveBeenCalled();
|
|
||||||
expect(sender).not.toHaveBeenCalled();
|
|
||||||
await store.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends when alert text present", async () => {
|
|
||||||
const store = await makeSessionStore();
|
|
||||||
const sender: typeof sendMessageWhatsApp = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
|
||||||
const resolver = vi.fn(async () => ({ text: "ALERT" }));
|
|
||||||
await runWebHeartbeatOnce({
|
|
||||||
cfg: {
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+1555"],
|
|
||||||
},
|
|
||||||
session: { store: store.storePath },
|
|
||||||
},
|
|
||||||
to: "+1555",
|
|
||||||
verbose: false,
|
|
||||||
sender,
|
|
||||||
replyResolver: resolver,
|
|
||||||
});
|
|
||||||
expect(sender).toHaveBeenCalledWith("+1555", "ALERT", { verbose: false });
|
|
||||||
await store.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to most recent session when no to is provided", async () => {
|
|
||||||
const store = await makeSessionStore();
|
|
||||||
const storePath = store.storePath;
|
|
||||||
const sender: typeof sendMessageWhatsApp = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
|
||||||
const resolver = vi.fn(async () => ({ text: "ALERT" }));
|
|
||||||
const now = Date.now();
|
|
||||||
const sessionEntries = {
|
|
||||||
"+1222": { sessionId: "s1", updatedAt: now - 1000 },
|
|
||||||
"+1333": { sessionId: "s2", updatedAt: now },
|
|
||||||
};
|
|
||||||
await fs.writeFile(storePath, JSON.stringify(sessionEntries));
|
|
||||||
await runWebHeartbeatOnce({
|
|
||||||
cfg: {
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+1999"],
|
|
||||||
},
|
|
||||||
session: { store: storePath },
|
|
||||||
},
|
|
||||||
to: "+1999",
|
|
||||||
verbose: false,
|
|
||||||
sender,
|
|
||||||
replyResolver: resolver,
|
|
||||||
});
|
|
||||||
expect(sender).toHaveBeenCalledWith("+1999", "ALERT", { verbose: false });
|
|
||||||
await store.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not refresh updatedAt when heartbeat is skipped", async () => {
|
|
||||||
const tmpDir = await fs.mkdtemp(
|
|
||||||
path.join(os.tmpdir(), "clawdis-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 sendMessageWhatsApp = vi.fn();
|
|
||||||
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
|
||||||
setLoadConfigMock({
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+1555"],
|
|
||||||
},
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("heartbeat reuses existing session id when last inbound is present", async () => {
|
|
||||||
const tmpDir = await fs.mkdtemp(
|
|
||||||
path.join(os.tmpdir(), "clawdis-heartbeat-session-"),
|
|
||||||
);
|
|
||||||
const storePath = path.join(tmpDir, "sessions.json");
|
|
||||||
const sessionId = "sess-keep";
|
|
||||||
await fs.writeFile(
|
|
||||||
storePath,
|
|
||||||
JSON.stringify({
|
|
||||||
main: { sessionId, updatedAt: Date.now(), systemSent: false },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
setLoadConfigMock(() => ({
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+4367"],
|
|
||||||
},
|
|
||||||
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: ClawdisConfig = {
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+4367"],
|
|
||||||
},
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("heartbeat honors session-id override and seeds store", async () => {
|
|
||||||
const tmpDir = await fs.mkdtemp(
|
|
||||||
path.join(os.tmpdir(), "clawdis-heartbeat-override-"),
|
|
||||||
);
|
|
||||||
const storePath = path.join(tmpDir, "sessions.json");
|
|
||||||
await fs.writeFile(storePath, JSON.stringify({}));
|
|
||||||
|
|
||||||
const sessionId = "override-123";
|
|
||||||
setLoadConfigMock(() => ({
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+1999"],
|
|
||||||
},
|
|
||||||
session: { store: storePath, idleMinutes: 60 },
|
|
||||||
}));
|
|
||||||
|
|
||||||
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
|
||||||
const cfg: ClawdisConfig = {
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+1999"],
|
|
||||||
},
|
|
||||||
session: { store: storePath, idleMinutes: 60 },
|
|
||||||
};
|
|
||||||
await runWebHeartbeatOnce({
|
|
||||||
cfg,
|
|
||||||
to: "+1999",
|
|
||||||
verbose: false,
|
|
||||||
replyResolver: resolver,
|
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const heartbeatCall = resolver.mock.calls.find(
|
|
||||||
(call) => call[0]?.Body === HEARTBEAT_PROMPT,
|
|
||||||
);
|
|
||||||
expect(heartbeatCall?.[0]?.MessageSid).toBe(sessionId);
|
|
||||||
const raw = await fs.readFile(storePath, "utf-8");
|
|
||||||
const stored = raw ? JSON.parse(raw) : {};
|
|
||||||
expect(stored.main?.sessionId).toBe(sessionId);
|
|
||||||
expect(stored.main?.updatedAt).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends overrideBody directly and skips resolver", async () => {
|
|
||||||
const store = await makeSessionStore();
|
|
||||||
const sender: typeof sendMessageWhatsApp = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
|
||||||
const resolver = vi.fn();
|
|
||||||
await runWebHeartbeatOnce({
|
|
||||||
cfg: {
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+1555"],
|
|
||||||
},
|
|
||||||
session: { store: store.storePath },
|
|
||||||
},
|
|
||||||
to: "+1555",
|
|
||||||
verbose: false,
|
|
||||||
sender,
|
|
||||||
replyResolver: resolver,
|
|
||||||
overrideBody: "manual ping",
|
|
||||||
});
|
|
||||||
expect(sender).toHaveBeenCalledWith("+1555", "manual ping", {
|
|
||||||
verbose: false,
|
|
||||||
});
|
|
||||||
expect(resolver).not.toHaveBeenCalled();
|
|
||||||
await store.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dry-run overrideBody prints and skips send", async () => {
|
|
||||||
const store = await makeSessionStore();
|
|
||||||
const sender: typeof sendMessageWhatsApp = vi.fn();
|
|
||||||
const resolver = vi.fn();
|
|
||||||
await runWebHeartbeatOnce({
|
|
||||||
cfg: {
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+1555"],
|
|
||||||
},
|
|
||||||
session: { store: store.storePath },
|
|
||||||
},
|
|
||||||
to: "+1555",
|
|
||||||
verbose: false,
|
|
||||||
sender,
|
|
||||||
replyResolver: resolver,
|
|
||||||
overrideBody: "dry",
|
|
||||||
dryRun: true,
|
|
||||||
});
|
|
||||||
expect(sender).not.toHaveBeenCalled();
|
|
||||||
expect(resolver).not.toHaveBeenCalled();
|
|
||||||
await store.cleanup();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("web auto-reply", () => {
|
describe("web auto-reply", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -746,153 +355,6 @@ describe("web auto-reply", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it("skips reply heartbeat when requests are running", async () => {
|
|
||||||
const tmpDir = await fs.mkdtemp(
|
|
||||||
path.join(os.tmpdir(), "clawdis-heartbeat-queue-"),
|
|
||||||
);
|
|
||||||
const storePath = path.join(tmpDir, "sessions.json");
|
|
||||||
await fs.writeFile(storePath, JSON.stringify({}));
|
|
||||||
|
|
||||||
const queueSpy = vi.spyOn(commandQueue, "getQueueSize").mockReturnValue(2);
|
|
||||||
const replyResolver = vi.fn();
|
|
||||||
const listenerFactory = vi.fn(async () => {
|
|
||||||
const onClose = new Promise<void>(() => {
|
|
||||||
// stay open until aborted
|
|
||||||
});
|
|
||||||
return { close: vi.fn(), onClose };
|
|
||||||
});
|
|
||||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never;
|
|
||||||
|
|
||||||
setLoadConfigMock(() => ({
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+1555"],
|
|
||||||
},
|
|
||||||
session: { store: storePath },
|
|
||||||
}));
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const run = monitorWebProvider(
|
|
||||||
false,
|
|
||||||
listenerFactory,
|
|
||||||
true,
|
|
||||||
replyResolver,
|
|
||||||
runtime,
|
|
||||||
controller.signal,
|
|
||||||
{ replyHeartbeatEvery: "1m", replyHeartbeatNow: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.resolve();
|
|
||||||
controller.abort();
|
|
||||||
await run;
|
|
||||||
expect(replyResolver).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
queueSpy.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to main recipient when last inbound is a group chat", async () => {
|
|
||||||
const now = Date.now();
|
|
||||||
const store = await makeSessionStore({
|
|
||||||
main: {
|
|
||||||
sessionId: "sid-main",
|
|
||||||
updatedAt: now,
|
|
||||||
lastChannel: "whatsapp",
|
|
||||||
lastTo: "+1555",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const replyResolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
|
||||||
let capturedOnMessage:
|
|
||||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
|
||||||
| undefined;
|
|
||||||
const listenerFactory = vi.fn(
|
|
||||||
async (opts: {
|
|
||||||
onMessage: (
|
|
||||||
msg: import("./inbound.js").WebInboundMessage,
|
|
||||||
) => Promise<void>;
|
|
||||||
}) => {
|
|
||||||
capturedOnMessage = opts.onMessage;
|
|
||||||
const onClose = new Promise<void>(() => {
|
|
||||||
// stay open until aborted
|
|
||||||
});
|
|
||||||
return { close: vi.fn(), onClose };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never;
|
|
||||||
|
|
||||||
setLoadConfigMock(() => ({
|
|
||||||
routing: {
|
|
||||||
allowFrom: ["+1555"],
|
|
||||||
groupChat: { requireMention: true, mentionPatterns: ["@clawd"] },
|
|
||||||
},
|
|
||||||
session: { store: store.storePath },
|
|
||||||
}));
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const run = monitorWebProvider(
|
|
||||||
false,
|
|
||||||
listenerFactory,
|
|
||||||
true,
|
|
||||||
replyResolver,
|
|
||||||
runtime,
|
|
||||||
controller.signal,
|
|
||||||
{ replyHeartbeatEvery: "10000m" },
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(capturedOnMessage).toBeDefined();
|
|
||||||
|
|
||||||
await capturedOnMessage?.({
|
|
||||||
body: "hello group",
|
|
||||||
from: "123@g.us",
|
|
||||||
to: "+1555",
|
|
||||||
id: "g1",
|
|
||||||
sendComposing: vi.fn(),
|
|
||||||
reply: vi.fn(),
|
|
||||||
sendMedia: vi.fn(),
|
|
||||||
chatType: "group",
|
|
||||||
conversationId: "123@g.us",
|
|
||||||
chatId: "123@g.us",
|
|
||||||
});
|
|
||||||
|
|
||||||
// No mention => no auto-reply for the group message.
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
expect(
|
|
||||||
replyResolver.mock.calls.some(
|
|
||||||
(call) => call[0]?.Body !== HEARTBEAT_PROMPT,
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
requestReplyHeartbeatNow({ coalesceMs: 0 });
|
|
||||||
let heartbeatCall = replyResolver.mock.calls.find(
|
|
||||||
(call) =>
|
|
||||||
call[0]?.Body === HEARTBEAT_PROMPT &&
|
|
||||||
call[0]?.MessageSid === "sid-main",
|
|
||||||
);
|
|
||||||
const deadline = Date.now() + 1000;
|
|
||||||
while (!heartbeatCall && Date.now() < deadline) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
heartbeatCall = replyResolver.mock.calls.find(
|
|
||||||
(call) =>
|
|
||||||
call[0]?.Body === HEARTBEAT_PROMPT &&
|
|
||||||
call[0]?.MessageSid === "sid-main",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
controller.abort();
|
|
||||||
await run;
|
|
||||||
|
|
||||||
expect(heartbeatCall).toBeDefined();
|
|
||||||
expect(heartbeatCall?.[0]?.From).toBe("+1555");
|
|
||||||
expect(heartbeatCall?.[0]?.To).toBe("+1555");
|
|
||||||
expect(heartbeatCall?.[0]?.MessageSid).toBe("sid-main");
|
|
||||||
} finally {
|
|
||||||
controller.abort();
|
|
||||||
await store.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("processes inbound messages without batching and preserves timestamps", async () => {
|
it("processes inbound messages without batching and preserves timestamps", async () => {
|
||||||
const originalTz = process.env.TZ;
|
const originalTz = process.env.TZ;
|
||||||
process.env.TZ = "Europe/Vienna";
|
process.env.TZ = "Europe/Vienna";
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import {
|
|||||||
parseActivationCommand,
|
parseActivationCommand,
|
||||||
} from "../auto-reply/group-activation.js";
|
} from "../auto-reply/group-activation.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
|
import {
|
||||||
|
HEARTBEAT_PROMPT,
|
||||||
|
stripHeartbeatToken,
|
||||||
|
} from "../auto-reply/heartbeat.js";
|
||||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import { parseDurationMs } from "../cli/parse-duration.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 {
|
import {
|
||||||
@@ -22,7 +25,6 @@ import { isVerbose, logVerbose } from "../globals.js";
|
|||||||
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { createSubsystemLogger, getChildLogger } from "../logging.js";
|
import { createSubsystemLogger, getChildLogger } from "../logging.js";
|
||||||
import { getQueueSize } from "../process/command-queue.js";
|
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js";
|
import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js";
|
||||||
import { setActiveWebListener } from "./active-listener.js";
|
import { setActiveWebListener } from "./active-listener.js";
|
||||||
@@ -37,8 +39,6 @@ import {
|
|||||||
resolveReconnectPolicy,
|
resolveReconnectPolicy,
|
||||||
sleepWithAbort,
|
sleepWithAbort,
|
||||||
} from "./reconnect.js";
|
} from "./reconnect.js";
|
||||||
import type { ReplyHeartbeatWakeResult } from "./reply-heartbeat-wake.js";
|
|
||||||
import { setReplyHeartbeatWakeHandler } from "./reply-heartbeat-wake.js";
|
|
||||||
import { formatError, getWebAuthAgeMs, readWebSelfId } from "./session.js";
|
import { formatError, getWebAuthAgeMs, readWebSelfId } from "./session.js";
|
||||||
|
|
||||||
const WEB_TEXT_LIMIT = 4000;
|
const WEB_TEXT_LIMIT = 4000;
|
||||||
@@ -48,11 +48,6 @@ const whatsappInboundLog = whatsappLog.child("inbound");
|
|||||||
const whatsappOutboundLog = whatsappLog.child("outbound");
|
const whatsappOutboundLog = whatsappLog.child("outbound");
|
||||||
const whatsappHeartbeatLog = whatsappLog.child("heartbeat");
|
const whatsappHeartbeatLog = whatsappLog.child("heartbeat");
|
||||||
|
|
||||||
let heartbeatsEnabled = true;
|
|
||||||
export function setHeartbeatsEnabled(enabled: boolean) {
|
|
||||||
heartbeatsEnabled = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send via the active gateway-backed listener. The monitor already owns the single
|
// Send via the active gateway-backed listener. The monitor already owns the single
|
||||||
// Baileys session, so use its send API directly.
|
// Baileys session, so use its send API directly.
|
||||||
async function sendWithIpcFallback(
|
async function sendWithIpcFallback(
|
||||||
@@ -73,8 +68,6 @@ type WebInboundMsg = Parameters<
|
|||||||
export type WebMonitorTuning = {
|
export type WebMonitorTuning = {
|
||||||
reconnect?: Partial<ReconnectPolicy>;
|
reconnect?: Partial<ReconnectPolicy>;
|
||||||
heartbeatSeconds?: number;
|
heartbeatSeconds?: number;
|
||||||
replyHeartbeatEvery?: string;
|
|
||||||
replyHeartbeatNow?: boolean;
|
|
||||||
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||||
statusSink?: (status: WebProviderStatus) => void;
|
statusSink?: (status: WebProviderStatus) => void;
|
||||||
};
|
};
|
||||||
@@ -82,8 +75,7 @@ export type WebMonitorTuning = {
|
|||||||
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`;
|
||||||
|
|
||||||
export const HEARTBEAT_PROMPT = "HEARTBEAT";
|
export { HEARTBEAT_PROMPT, HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN };
|
||||||
export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN };
|
|
||||||
|
|
||||||
export type WebProviderStatus = {
|
export type WebProviderStatus = {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
@@ -188,41 +180,7 @@ function debugMention(
|
|||||||
return { wasMentioned: result, details };
|
return { wasMentioned: result, details };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveReplyHeartbeatIntervalMs(
|
export { stripHeartbeatToken };
|
||||||
cfg: ReturnType<typeof loadConfig>,
|
|
||||||
overrideEvery?: string,
|
|
||||||
) {
|
|
||||||
const raw = overrideEvery ?? cfg.agent?.heartbeat?.every;
|
|
||||||
if (!raw) return null;
|
|
||||||
const trimmed = String(raw).trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
let ms: number;
|
|
||||||
try {
|
|
||||||
ms = parseDurationMs(trimmed, { defaultUnit: "m" });
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (ms <= 0) return null;
|
|
||||||
return ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 hadToken = trimmed.includes(HEARTBEAT_TOKEN);
|
|
||||||
let withoutToken = trimmed.replaceAll(HEARTBEAT_TOKEN, "").trim();
|
|
||||||
if (hadToken && withoutToken) {
|
|
||||||
// LLMs sometimes echo malformed HEARTBEAT_OK_OK... tails; strip trailing OK runs to avoid spam.
|
|
||||||
withoutToken = withoutToken.replace(/[\s_]*OK(?:[\s_]*OK)*$/gi, "").trim();
|
|
||||||
}
|
|
||||||
const shouldSkip = withoutToken.length === 0;
|
|
||||||
return {
|
|
||||||
shouldSkip,
|
|
||||||
text: shouldSkip ? "" : withoutToken || trimmed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSilentReply(payload?: ReplyPayload): boolean {
|
function isSilentReply(payload?: ReplyPayload): boolean {
|
||||||
if (!payload) return false;
|
if (!payload) return false;
|
||||||
@@ -427,27 +385,6 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFallbackRecipient(cfg: ReturnType<typeof loadConfig>) {
|
|
||||||
const sessionCfg = cfg.session;
|
|
||||||
const storePath = resolveStorePath(sessionCfg?.store);
|
|
||||||
const store = loadSessionStore(storePath);
|
|
||||||
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
|
||||||
const main = store[mainKey];
|
|
||||||
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
|
|
||||||
const lastChannel = main?.lastChannel;
|
|
||||||
|
|
||||||
if (lastChannel === "whatsapp" && lastTo) {
|
|
||||||
return normalizeE164(lastTo);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowFrom =
|
|
||||||
Array.isArray(cfg.routing?.allowFrom) && cfg.routing.allowFrom.length > 0
|
|
||||||
? cfg.routing.allowFrom.filter((v) => v !== "*")
|
|
||||||
: [];
|
|
||||||
if (allowFrom.length === 0) return null;
|
|
||||||
return allowFrom[0] ? normalizeE164(allowFrom[0]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
|
function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
const scope = sessionCfg?.scope ?? "per-sender";
|
const scope = sessionCfg?.scope ?? "per-sender";
|
||||||
@@ -775,10 +712,6 @@ export async function monitorWebProvider(
|
|||||||
cfg,
|
cfg,
|
||||||
tuning.heartbeatSeconds,
|
tuning.heartbeatSeconds,
|
||||||
);
|
);
|
||||||
const replyHeartbeatIntervalMs = resolveReplyHeartbeatIntervalMs(
|
|
||||||
cfg,
|
|
||||||
tuning.replyHeartbeatEvery,
|
|
||||||
);
|
|
||||||
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
||||||
const mentionConfig = buildMentionConfig(cfg);
|
const mentionConfig = buildMentionConfig(cfg);
|
||||||
const sessionStorePath = resolveStorePath(cfg.session?.store);
|
const sessionStorePath = resolveStorePath(cfg.session?.store);
|
||||||
@@ -940,7 +873,6 @@ 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 watchdogTimer: NodeJS.Timeout | null = null;
|
let watchdogTimer: NodeJS.Timeout | null = null;
|
||||||
let lastMessageAt: number | null = null;
|
let lastMessageAt: number | null = null;
|
||||||
let handledMessages = 0;
|
let handledMessages = 0;
|
||||||
@@ -1346,9 +1278,7 @@ export async function monitorWebProvider(
|
|||||||
|
|
||||||
const closeListener = async () => {
|
const closeListener = async () => {
|
||||||
setActiveWebListener(null);
|
setActiveWebListener(null);
|
||||||
setReplyHeartbeatWakeHandler(null);
|
|
||||||
if (heartbeat) clearInterval(heartbeat);
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
if (replyHeartbeatTimer) clearInterval(replyHeartbeatTimer);
|
|
||||||
if (watchdogTimer) clearInterval(watchdogTimer);
|
if (watchdogTimer) clearInterval(watchdogTimer);
|
||||||
if (backgroundTasks.size > 0) {
|
if (backgroundTasks.size > 0) {
|
||||||
await Promise.allSettled(backgroundTasks);
|
await Promise.allSettled(backgroundTasks);
|
||||||
@@ -1363,7 +1293,6 @@ export async function monitorWebProvider(
|
|||||||
|
|
||||||
if (keepAlive) {
|
if (keepAlive) {
|
||||||
heartbeat = setInterval(() => {
|
heartbeat = setInterval(() => {
|
||||||
if (!heartbeatsEnabled) return;
|
|
||||||
const authAgeMs = getWebAuthAgeMs();
|
const authAgeMs = getWebAuthAgeMs();
|
||||||
const minutesSinceLastMessage = lastMessageAt
|
const minutesSinceLastMessage = lastMessageAt
|
||||||
? Math.floor((Date.now() - lastMessageAt) / 60000)
|
? Math.floor((Date.now() - lastMessageAt) / 60000)
|
||||||
@@ -1420,240 +1349,6 @@ export async function monitorWebProvider(
|
|||||||
}, WATCHDOG_CHECK_MS);
|
}, WATCHDOG_CHECK_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
const runReplyHeartbeat = async (): Promise<ReplyHeartbeatWakeResult> => {
|
|
||||||
const started = Date.now();
|
|
||||||
if (!heartbeatsEnabled) {
|
|
||||||
return { status: "skipped", reason: "disabled" };
|
|
||||||
}
|
|
||||||
const queued = getQueueSize();
|
|
||||||
if (queued > 0) {
|
|
||||||
heartbeatLogger.info(
|
|
||||||
{ connectionId, reason: "requests-in-flight", queued },
|
|
||||||
"reply heartbeat skipped",
|
|
||||||
);
|
|
||||||
if (isVerbose()) {
|
|
||||||
whatsappHeartbeatLog.debug("heartbeat skipped (requests in flight)");
|
|
||||||
}
|
|
||||||
return { status: "skipped", reason: "requests-in-flight" };
|
|
||||||
}
|
|
||||||
if (!replyHeartbeatIntervalMs) {
|
|
||||||
return { status: "skipped", reason: "disabled" };
|
|
||||||
}
|
|
||||||
let heartbeatInboundMsg = lastInboundMsg;
|
|
||||||
if (heartbeatInboundMsg?.chatType === "group") {
|
|
||||||
// Heartbeats should never target group chats. If the last inbound activity
|
|
||||||
// was in a group, fall back to the main/direct session recipient instead
|
|
||||||
// of skipping heartbeats entirely.
|
|
||||||
heartbeatLogger.info(
|
|
||||||
{ connectionId, reason: "last-inbound-group" },
|
|
||||||
"reply heartbeat falling back",
|
|
||||||
);
|
|
||||||
heartbeatInboundMsg = null;
|
|
||||||
}
|
|
||||||
const tickStart = Date.now();
|
|
||||||
if (!heartbeatInboundMsg) {
|
|
||||||
const fallbackTo = getFallbackRecipient(cfg);
|
|
||||||
if (!fallbackTo) {
|
|
||||||
heartbeatLogger.info(
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
reason: "no-recent-inbound",
|
|
||||||
durationMs: Date.now() - tickStart,
|
|
||||||
},
|
|
||||||
"reply heartbeat skipped",
|
|
||||||
);
|
|
||||||
if (isVerbose()) {
|
|
||||||
whatsappHeartbeatLog.debug("heartbeat skipped (no recent inbound)");
|
|
||||||
}
|
|
||||||
return { status: "skipped", reason: "no-recent-inbound" };
|
|
||||||
}
|
|
||||||
const snapshot = getSessionSnapshot(cfg, fallbackTo, true);
|
|
||||||
if (!snapshot.entry) {
|
|
||||||
heartbeatLogger.info(
|
|
||||||
{ connectionId, to: fallbackTo, reason: "no-session-for-fallback" },
|
|
||||||
"reply heartbeat skipped",
|
|
||||||
);
|
|
||||||
if (isVerbose()) {
|
|
||||||
whatsappHeartbeatLog.debug(
|
|
||||||
"heartbeat skipped (no session to resume)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { status: "skipped", reason: "no-session-for-fallback" };
|
|
||||||
}
|
|
||||||
if (isVerbose()) {
|
|
||||||
heartbeatLogger.info(
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
to: fallbackTo,
|
|
||||||
reason: "fallback-session",
|
|
||||||
sessionId: snapshot.entry?.sessionId ?? null,
|
|
||||||
sessionFresh: snapshot.fresh,
|
|
||||||
},
|
|
||||||
"reply heartbeat start",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await runWebHeartbeatOnce({
|
|
||||||
cfg,
|
|
||||||
to: fallbackTo,
|
|
||||||
verbose,
|
|
||||||
replyResolver,
|
|
||||||
sessionId: snapshot.entry.sessionId,
|
|
||||||
});
|
|
||||||
heartbeatLogger.info(
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
to: fallbackTo,
|
|
||||||
...snapshot,
|
|
||||||
durationMs: Date.now() - tickStart,
|
|
||||||
},
|
|
||||||
"reply heartbeat sent (fallback session)",
|
|
||||||
);
|
|
||||||
return { status: "ran", durationMs: Date.now() - started };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const snapshot = getSessionSnapshot(cfg, heartbeatInboundMsg.from);
|
|
||||||
if (isVerbose()) {
|
|
||||||
heartbeatLogger.info(
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
to: heartbeatInboundMsg.from,
|
|
||||||
intervalMs: replyHeartbeatIntervalMs,
|
|
||||||
sessionKey: snapshot.key,
|
|
||||||
sessionId: snapshot.entry?.sessionId ?? null,
|
|
||||||
sessionFresh: snapshot.fresh,
|
|
||||||
},
|
|
||||||
"reply heartbeat start",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
|
||||||
{
|
|
||||||
Body: HEARTBEAT_PROMPT,
|
|
||||||
From: heartbeatInboundMsg.from,
|
|
||||||
To: heartbeatInboundMsg.to,
|
|
||||||
MessageSid: snapshot.entry?.sessionId,
|
|
||||||
MediaPath: undefined,
|
|
||||||
MediaUrl: undefined,
|
|
||||||
MediaType: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onReplyStart: heartbeatInboundMsg.sendComposing,
|
|
||||||
isHeartbeat: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const replyPayload = Array.isArray(replyResult)
|
|
||||||
? replyResult[0]
|
|
||||||
: replyResult;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!replyPayload ||
|
|
||||||
(!replyPayload.text &&
|
|
||||||
!replyPayload.mediaUrl &&
|
|
||||||
!replyPayload.mediaUrls?.length)
|
|
||||||
) {
|
|
||||||
heartbeatLogger.info(
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
durationMs: Date.now() - tickStart,
|
|
||||||
reason: "empty-reply",
|
|
||||||
},
|
|
||||||
"reply heartbeat skipped",
|
|
||||||
);
|
|
||||||
if (isVerbose()) {
|
|
||||||
whatsappHeartbeatLog.debug("heartbeat ok (empty reply)");
|
|
||||||
}
|
|
||||||
return { status: "ran", durationMs: Date.now() - started };
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripped = stripHeartbeatToken(replyPayload.text);
|
|
||||||
const hasMedia = Boolean(
|
|
||||||
replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0,
|
|
||||||
);
|
|
||||||
if (stripped.shouldSkip && !hasMedia) {
|
|
||||||
heartbeatLogger.info(
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
durationMs: Date.now() - tickStart,
|
|
||||||
reason: "heartbeat-token",
|
|
||||||
rawLength: replyPayload.text?.length ?? 0,
|
|
||||||
},
|
|
||||||
"reply heartbeat skipped",
|
|
||||||
);
|
|
||||||
if (isVerbose()) {
|
|
||||||
whatsappHeartbeatLog.debug("heartbeat ok (HEARTBEAT_OK)");
|
|
||||||
}
|
|
||||||
return { status: "ran", durationMs: Date.now() - started };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply response prefix if configured (same as regular messages)
|
|
||||||
let finalText = stripped.text;
|
|
||||||
const responsePrefix = cfg.messages?.responsePrefix;
|
|
||||||
if (
|
|
||||||
responsePrefix &&
|
|
||||||
finalText &&
|
|
||||||
!finalText.startsWith(responsePrefix)
|
|
||||||
) {
|
|
||||||
finalText = `${responsePrefix} ${finalText}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanedReply: ReplyPayload = {
|
|
||||||
...replyPayload,
|
|
||||||
text: finalText,
|
|
||||||
};
|
|
||||||
|
|
||||||
await deliverWebReply({
|
|
||||||
replyResult: cleanedReply,
|
|
||||||
msg: heartbeatInboundMsg,
|
|
||||||
maxMediaBytes,
|
|
||||||
replyLogger,
|
|
||||||
connectionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const durationMs = Date.now() - tickStart;
|
|
||||||
whatsappHeartbeatLog.info(
|
|
||||||
`heartbeat alert sent (${formatDuration(durationMs)})`,
|
|
||||||
);
|
|
||||||
heartbeatLogger.info(
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
durationMs,
|
|
||||||
hasMedia,
|
|
||||||
chars: stripped.text?.length ?? 0,
|
|
||||||
},
|
|
||||||
"reply heartbeat sent",
|
|
||||||
);
|
|
||||||
return { status: "ran", durationMs: Date.now() - started };
|
|
||||||
} catch (err) {
|
|
||||||
const durationMs = Date.now() - tickStart;
|
|
||||||
heartbeatLogger.warn(
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
error: formatError(err),
|
|
||||||
durationMs,
|
|
||||||
},
|
|
||||||
"reply heartbeat failed",
|
|
||||||
);
|
|
||||||
whatsappHeartbeatLog.warn(
|
|
||||||
`heartbeat failed (${formatDuration(durationMs)})`,
|
|
||||||
);
|
|
||||||
return { status: "failed", reason: formatError(err) };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setReplyHeartbeatWakeHandler(async () => runReplyHeartbeat());
|
|
||||||
|
|
||||||
if (replyHeartbeatIntervalMs && !replyHeartbeatTimer) {
|
|
||||||
const intervalMs = replyHeartbeatIntervalMs;
|
|
||||||
replyHeartbeatTimer = setInterval(() => {
|
|
||||||
if (!heartbeatsEnabled) return;
|
|
||||||
void runReplyHeartbeat();
|
|
||||||
}, intervalMs);
|
|
||||||
if (tuning.replyHeartbeatNow) {
|
|
||||||
void runReplyHeartbeat();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
whatsappLog.info(
|
whatsappLog.info(
|
||||||
"Listening for personal WhatsApp inbound messages. Ctrl+C to stop.",
|
"Listening for personal WhatsApp inbound messages. Ctrl+C to stop.",
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user