adding twilio heartbeat

This commit is contained in:
Kyle Crommett 2025-12-02 17:14:09 -08:00
parent ffb9075a32
commit 94833ef0a3
6 changed files with 556 additions and 1206 deletions

View File

@ -1,807 +0,0 @@
# Plan: Add `heartbeatPreHook` Support to Warelay
**Date:** 2025-12-01
**Status:** Complete
**Feature:** Allow users to run a custom script before each heartbeat that gathers context (like email summaries) to inject into the heartbeat prompt
---
## Overview
### Motivation
Users want to inject dynamic context into heartbeat prompts. The primary use case is fetching unread Office 365 emails since the last heartbeat and summarizing them, so the AI assistant can proactively inform users about important communications.
### Behavior Summary
1. Before each heartbeat fires, run the configured pre-hook command
2. Capture stdout from the command
3. If stdout is non-empty, prepend it to the heartbeat prompt:
- Normal: `HEARTBEAT ultrathink`
- With context: `HEARTBEAT ultrathink\n\n---\nContext from pre-hook:\n{stdout}`
4. If the pre-hook fails or times out, log a warning but still send the basic heartbeat
5. If stdout is empty, just send the normal heartbeat prompt
---
## Plan Updates (2025-12-02)
- Centralize pre-hook execution in `runWebHeartbeatOnce`; callers like `runReplyHeartbeat` should pass `skipPreHook: true` to avoid double runs in fallback flows.
- Run pre-hook only after confirming a heartbeat recipient and after queue/interval guards, so skipped heartbeats do not trigger scripts.
- Add `cfg?` (or injected `loadConfig`) plus `skipPreHook`/`overrideBody` gates to the Twilio path; ensure tests can stub config to avoid reading real user config.
- Add an in-flight guard to prevent overlapping heartbeats when pre-hook runs long.
- Keep the pre-hook modules logging minimal and let callers emit structured heartbeat logs; optionally accept a logger/context parameter.
- Cap injected context size (chars/lines) before prompt injection and include a short stderr preview in warnings for easier debugging.
- Expand tests for skip conditions (queue > 0), fallback double-run prevention, and manual override skipping in both providers.
## Task Breakdown
### Phase 1: Config Schema Changes
#### 1.1 Update TypeScript Types
- [x] Add `heartbeatPreHook?: string[]` to `SessionConfig` type in `src/config/config.ts`
- [x] Add `heartbeatPreHookTimeoutSeconds?: number` to `SessionConfig` type (default: 30)
**File:** `src/config/config.ts`
```typescript
// Add to SessionConfig type (around line 12-25)
export type SessionConfig = {
scope?: SessionScope;
resetTriggers?: string[];
idleMinutes?: number;
heartbeatIdleMinutes?: number;
store?: string;
sessionArgNew?: string[];
sessionArgResume?: string[];
sessionArgBeforeBody?: boolean;
sendSystemOnce?: boolean;
sessionIntro?: string;
typingIntervalSeconds?: number;
heartbeatMinutes?: number;
// NEW:
heartbeatPreHook?: string[]; // Command + args to run before heartbeat
heartbeatPreHookTimeoutSeconds?: number; // Default: 30
};
```
#### 1.2 Update Zod Schema
- [x] Add `heartbeatPreHook` array validation to `ReplySchema.session` object
- [x] Add `heartbeatPreHookTimeoutSeconds` positive integer validation
**File:** `src/config/config.ts`
```typescript
// Add to session schema (around line 90-106)
.object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
resetTriggers: z.array(z.string()).optional(),
idleMinutes: z.number().int().positive().optional(),
heartbeatIdleMinutes: z.number().int().positive().optional(),
store: z.string().optional(),
sessionArgNew: z.array(z.string()).optional(),
sessionArgResume: z.array(z.string()).optional(),
sessionArgBeforeBody: z.boolean().optional(),
sendSystemOnce: z.boolean().optional(),
sessionIntro: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
// NEW:
heartbeatPreHook: z.array(z.string()).optional(),
heartbeatPreHookTimeoutSeconds: z.number().int().positive().optional(),
})
```
---
### Phase 2: Create Shared Pre-Hook Module
#### 2.1 Create New Module
- [x] Create `src/auto-reply/heartbeat-prehook.ts` with shared logic for both providers
- [x] Allow optional logger/context input; keep internal logging minimal (debug-level only); let callers log structured summaries
- [x] Cap stdout before injection (e.g., max chars/lines) and include a short stderr preview in warnings
**New File:** `src/auto-reply/heartbeat-prehook.ts`
```typescript
import { logVerbose, danger } from "../globals.js";
import { logDebug, logWarn } from "../logger.js";
import { runCommandWithTimeout, type SpawnResult } from "../process/exec.js";
import type { WarelayConfig } from "../config/config.js";
export type PreHookResult = {
context?: string; // stdout to prepend to heartbeat
durationMs: number;
error?: string; // error message if failed
timedOut?: boolean;
};
const DEFAULT_PREHOOK_TIMEOUT_SECONDS = 30;
export function buildHeartbeatPrompt(
basePrompt: string,
preHookContext?: string,
): string {
if (!preHookContext?.trim()) {
return basePrompt;
}
return `${basePrompt}\n\n---\nContext from pre-hook:\n${preHookContext.trim()}`;
}
export async function runHeartbeatPreHook(
cfg: WarelayConfig,
commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout,
): Promise<PreHookResult> {
const sessionCfg = cfg.inbound?.reply?.session;
const preHookCommand = sessionCfg?.heartbeatPreHook;
if (!preHookCommand?.length) {
return { durationMs: 0 };
}
const timeoutSeconds = sessionCfg?.heartbeatPreHookTimeoutSeconds ?? DEFAULT_PREHOOK_TIMEOUT_SECONDS;
const timeoutMs = timeoutSeconds * 1000;
const started = Date.now();
logVerbose(`Running heartbeat pre-hook: ${preHookCommand.join(" ")}`);
try {
const result: SpawnResult = await commandRunner(preHookCommand, { timeoutMs });
const durationMs = Date.now() - started;
if (result.killed || result.signal === "SIGKILL") {
logWarn(`Heartbeat pre-hook timed out after ${timeoutSeconds}s`);
return {
durationMs,
timedOut: true,
error: `Pre-hook timed out after ${timeoutSeconds}s`,
};
}
if ((result.code ?? 0) !== 0) {
const errorMsg = `Pre-hook exited with code ${result.code}`;
logWarn(errorMsg);
logVerbose(`Pre-hook stderr: ${result.stderr?.trim() || "(empty)"}`);
return {
durationMs,
error: errorMsg,
};
}
const stdout = result.stdout?.trim();
logVerbose(`Pre-hook completed in ${durationMs}ms, output length: ${stdout?.length ?? 0}`);
if (stdout) {
logDebug(`Pre-hook output: ${stdout.slice(0, 200)}${stdout.length > 200 ? "..." : ""}`);
}
return {
context: stdout || undefined,
durationMs,
};
} catch (err) {
const durationMs = Date.now() - started;
const anyErr = err as { killed?: boolean; signal?: string };
if (anyErr.killed || anyErr.signal === "SIGKILL") {
return {
durationMs,
timedOut: true,
error: `Pre-hook timed out after ${timeoutSeconds}s`,
};
}
const errorMsg = err instanceof Error ? err.message : String(err);
console.error(danger(`Heartbeat pre-hook failed: ${errorMsg}`));
return {
durationMs,
error: errorMsg,
};
}
}
```
---
### Phase 3: Integrate Pre-Hook into Web Provider
#### 3.1 Update Web Heartbeat
- [x] Import `runHeartbeatPreHook` and `buildHeartbeatPrompt` in `src/web/auto-reply.ts`
- [x] Centralize pre-hook execution inside `runWebHeartbeatOnce`; call from `runReplyHeartbeat` with `skipPreHook: true` to avoid double runs in fallback flow
- [x] Run pre-hook only after queue/interval guards and after a recipient is determined
- [x] Add in-flight guard to prevent overlapping heartbeats when pre-hook runs long
- [x] Log pre-hook outcomes via the existing heartbeat logger (structured), keeping the hooks own logging minimal
**File:** `src/web/auto-reply.ts`
Add import at top:
```typescript
import { runHeartbeatPreHook, buildHeartbeatPrompt } from "../auto-reply/heartbeat-prehook.js";
```
Modify `runReplyHeartbeat` function (around line 797):
```typescript
const runReplyHeartbeat = async () => {
const queued = getQueueSize();
if (queued > 0) {
heartbeatLogger.info(
{ connectionId, reason: "requests-in-flight", queued },
"reply heartbeat skipped",
);
console.log(success("heartbeat: skipped (requests in flight)"));
return;
}
if (!replyHeartbeatMinutes) return;
const tickStart = Date.now();
// NEW: Run pre-hook to gather context
const preHookResult = await runHeartbeatPreHook(cfg);
if (preHookResult.error) {
heartbeatLogger.warn(
{ connectionId, error: preHookResult.error, durationMs: preHookResult.durationMs, timedOut: preHookResult.timedOut },
"heartbeat pre-hook failed (continuing with basic heartbeat)",
);
} else if (preHookResult.context) {
heartbeatLogger.info(
{ connectionId, contextLength: preHookResult.context.length, durationMs: preHookResult.durationMs },
"heartbeat pre-hook succeeded",
);
}
// Build heartbeat prompt with optional pre-hook context
const heartbeatPrompt = buildHeartbeatPrompt(HEARTBEAT_PROMPT, preHookResult.context);
// ... rest of function, replace HEARTBEAT_PROMPT with heartbeatPrompt ...
```
Also update `runWebHeartbeatOnce` (around line 98) to support pre-hook:
```typescript
export async function runWebHeartbeatOnce(opts: {
cfg?: ReturnType<typeof loadConfig>;
to: string;
verbose?: boolean;
replyResolver?: typeof getReplyFromConfig;
runtime?: RuntimeEnv;
sender?: typeof sendMessageWeb;
sessionId?: string;
overrideBody?: string;
dryRun?: boolean;
skipPreHook?: boolean; // NEW: allow skipping for manual/override cases
}) {
// ... existing setup code ...
// NEW: Run pre-hook unless skipped or overrideBody provided
let heartbeatPrompt = HEARTBEAT_PROMPT;
if (!overrideBody && !opts.skipPreHook) {
const preHookResult = await runHeartbeatPreHook(cfg);
if (preHookResult.error) {
heartbeatLogger.warn(
{ to, error: preHookResult.error, durationMs: preHookResult.durationMs },
"heartbeat pre-hook failed",
);
}
heartbeatPrompt = buildHeartbeatPrompt(HEARTBEAT_PROMPT, preHookResult.context);
}
// ... use heartbeatPrompt instead of HEARTBEAT_PROMPT in replyResolver call ...
```
---
### Phase 4: Integrate Pre-Hook into Twilio Provider
#### 4.1 Update Twilio Heartbeat
- [x] Import `runHeartbeatPreHook` and `buildHeartbeatPrompt` in `src/twilio/heartbeat.ts`
- [x] Add `cfg?` (or injected `loadConfig`) plus `skipPreHook` and `overrideBody` gates; avoid loading real user config in tests
- [x] Modify `runTwilioHeartbeatOnce()` to call pre-hook (with size-capped stdout) and log via structured logger/context
**File:** `src/twilio/heartbeat.ts`
Add import at top:
```typescript
import { runHeartbeatPreHook, buildHeartbeatPrompt } from "../auto-reply/heartbeat-prehook.js";
import { loadConfig } from "../config/config.js";
```
Modify `runTwilioHeartbeatOnce` function:
```typescript
export async function runTwilioHeartbeatOnce(opts: {
to: string;
verbose?: boolean;
runtime?: RuntimeEnv;
replyResolver?: ReplyResolver;
overrideBody?: string;
dryRun?: boolean;
skipPreHook?: boolean; // NEW
}) {
const {
to,
verbose: _verbose = false,
runtime = defaultRuntime,
overrideBody,
dryRun = false,
skipPreHook = false, // NEW
} = opts;
const replyResolver = opts.replyResolver ?? getReplyFromConfig;
const cfg = loadConfig(); // NEW: load config for pre-hook
// ... existing overrideBody handling ...
// NEW: Run pre-hook unless skipped
let heartbeatPrompt = HEARTBEAT_PROMPT;
if (!skipPreHook) {
const preHookResult = await runHeartbeatPreHook(cfg);
if (preHookResult.error) {
logInfo(`Pre-hook failed: ${preHookResult.error} (continuing)`, runtime);
}
heartbeatPrompt = buildHeartbeatPrompt(HEARTBEAT_PROMPT, preHookResult.context);
}
const replyResult = await replyResolver(
{
Body: heartbeatPrompt, // Use dynamic prompt
From: to,
To: to,
MessageSid: undefined,
},
undefined,
);
// ... rest unchanged ...
```
---
### Phase 5: Unit Tests
#### 5.1 Test Pre-Hook Module
- [x] Create `src/auto-reply/heartbeat-prehook.test.ts`
**New File:** `src/auto-reply/heartbeat-prehook.test.ts`
```typescript
import { describe, expect, it, vi } from "vitest";
import { buildHeartbeatPrompt, runHeartbeatPreHook } from "./heartbeat-prehook.js";
import type { WarelayConfig } from "../config/config.js";
import type { SpawnResult } from "../process/exec.js";
describe("buildHeartbeatPrompt", () => {
it("returns base prompt when no context", () => {
expect(buildHeartbeatPrompt("HEARTBEAT ultrathink")).toBe("HEARTBEAT ultrathink");
expect(buildHeartbeatPrompt("HEARTBEAT ultrathink", "")).toBe("HEARTBEAT ultrathink");
expect(buildHeartbeatPrompt("HEARTBEAT ultrathink", " ")).toBe("HEARTBEAT ultrathink");
});
it("appends context when provided", () => {
const result = buildHeartbeatPrompt("HEARTBEAT ultrathink", "You have 3 unread emails");
expect(result).toBe("HEARTBEAT ultrathink\n\n---\nContext from pre-hook:\nYou have 3 unread emails");
});
it("trims context whitespace", () => {
const result = buildHeartbeatPrompt("HEARTBEAT", " context with spaces ");
expect(result).toContain("context with spaces");
expect(result).not.toContain(" context");
});
});
describe("runHeartbeatPreHook", () => {
it("returns empty result when no pre-hook configured", async () => {
const cfg: WarelayConfig = {};
const result = await runHeartbeatPreHook(cfg);
expect(result.durationMs).toBe(0);
expect(result.context).toBeUndefined();
expect(result.error).toBeUndefined();
});
it("returns stdout as context on success", async () => {
const cfg: WarelayConfig = {
inbound: {
reply: {
mode: "command",
command: ["echo"],
session: {
heartbeatPreHook: ["echo", "email summary"],
},
},
},
};
const mockRunner = vi.fn().mockResolvedValue({
stdout: "email summary\n",
stderr: "",
code: 0,
signal: null,
killed: false,
} satisfies SpawnResult);
const result = await runHeartbeatPreHook(cfg, mockRunner);
expect(result.context).toBe("email summary");
expect(result.error).toBeUndefined();
expect(mockRunner).toHaveBeenCalledWith(
["echo", "email summary"],
{ timeoutMs: 30000 },
);
});
it("returns error on non-zero exit", async () => {
const cfg: WarelayConfig = {
inbound: {
reply: {
mode: "command",
command: ["echo"],
session: {
heartbeatPreHook: ["failing-script"],
},
},
},
};
const mockRunner = vi.fn().mockResolvedValue({
stdout: "",
stderr: "error output",
code: 1,
signal: null,
killed: false,
} satisfies SpawnResult);
const result = await runHeartbeatPreHook(cfg, mockRunner);
expect(result.context).toBeUndefined();
expect(result.error).toContain("exited with code 1");
});
it("handles timeout gracefully", async () => {
const cfg: WarelayConfig = {
inbound: {
reply: {
mode: "command",
command: ["echo"],
session: {
heartbeatPreHook: ["slow-script"],
heartbeatPreHookTimeoutSeconds: 5,
},
},
},
};
const mockRunner = vi.fn().mockResolvedValue({
stdout: "",
stderr: "",
code: null,
signal: "SIGKILL",
killed: true,
} satisfies SpawnResult);
const result = await runHeartbeatPreHook(cfg, mockRunner);
expect(result.timedOut).toBe(true);
expect(result.error).toContain("timed out");
expect(result.context).toBeUndefined();
});
it("uses custom timeout from config", async () => {
const cfg: WarelayConfig = {
inbound: {
reply: {
mode: "command",
command: ["echo"],
session: {
heartbeatPreHook: ["script"],
heartbeatPreHookTimeoutSeconds: 60,
},
},
},
};
const mockRunner = vi.fn().mockResolvedValue({
stdout: "ok",
stderr: "",
code: 0,
signal: null,
killed: false,
} satisfies SpawnResult);
await runHeartbeatPreHook(cfg, mockRunner);
expect(mockRunner).toHaveBeenCalledWith(
["script"],
{ timeoutMs: 60000 },
);
});
it("returns empty context for whitespace-only stdout", async () => {
const cfg: WarelayConfig = {
inbound: {
reply: {
mode: "command",
command: ["echo"],
session: {
heartbeatPreHook: ["script"],
},
},
},
};
const mockRunner = vi.fn().mockResolvedValue({
stdout: " \n\n ",
stderr: "",
code: 0,
signal: null,
killed: false,
} satisfies SpawnResult);
const result = await runHeartbeatPreHook(cfg, mockRunner);
expect(result.context).toBeUndefined();
expect(result.error).toBeUndefined();
});
});
```
#### 5.2 Update Existing Tests
- [x] Add pre-hook tests to `src/twilio/heartbeat.test.ts` (cfg injection, override skips hook)
- [ ] Add pre-hook tests to `src/web/auto-reply.test.ts` (skip on queue>0, no double-run in fallback, override skips hook, in-flight guard)
---
### Phase 6: Documentation
#### 6.1 Update Config Documentation
- [ ] Document new config options in `README.md` or dedicated docs
- [ ] Add example pre-hook script patterns
**Example Config:**
```json5
{
"inbound": {
"reply": {
"mode": "command",
"command": ["claude", "{{Body}}"],
"session": {
"scope": "per-sender",
"heartbeatMinutes": 30,
// NEW: Pre-hook configuration
"heartbeatPreHook": ["./scripts/fetch-unread-emails.sh"],
"heartbeatPreHookTimeoutSeconds": 45
}
}
}
}
```
---
## Office 365 Email Integration Options
### Background
The pre-hook feature is designed to be script-agnostic. Users can write any executable that outputs context to stdout. Below are options for Office 365 email integration.
### Option 1: Microsoft Graph API with Device Code Flow (Recommended for Personal Use)
**Pros:**
- Works without admin consent for personal accounts
- One-time interactive authentication, then refresh tokens
- Full access to mailbox
**Implementation:**
```bash
#!/bin/bash
# fetch-unread-emails.sh
# Uses Azure CLI or custom OAuth token management
# Prerequisites: az login with device code, or store refresh token
ACCESS_TOKEN=$(az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv)
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages?\$filter=isRead eq false&\$top=5&\$select=subject,from,receivedDateTime" \
| jq -r '.value[] | "- \(.receivedDateTime | split("T")[0]): \(.from.emailAddress.name // .from.emailAddress.address): \(.subject)"'
```
**Setup Steps:**
1. Register an Azure AD app (single-tenant or multi-tenant)
2. Add `Mail.Read` delegated permission
3. Use device code flow for initial auth: `az login --scope https://graph.microsoft.com/Mail.Read`
4. Store refresh token securely for unattended use
### Option 2: Application Permissions (Admin Consent Required)
**Pros:**
- No user interaction needed after setup
- Works with client credentials flow
**Cons:**
- Requires Azure AD admin consent
- Grants access to all mailboxes (use with caution)
**Implementation:**
```bash
#!/bin/bash
# fetch-emails-app-auth.sh
CLIENT_ID="your-app-id"
CLIENT_SECRET="your-secret"
TENANT_ID="your-tenant"
USER_EMAIL="user@domain.com"
# Get token
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=https://graph.microsoft.com/.default" \
-d "grant_type=client_credentials" \
| jq -r '.access_token')
# Fetch emails
curl -s -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/users/$USER_EMAIL/mailFolders/inbox/messages?\$filter=isRead eq false&\$top=5" \
| jq -r '.value[] | "- \(.from.emailAddress.name): \(.subject)"'
```
### Option 3: Using `msgraph-cli` (Easiest Setup)
Microsoft provides an official CLI tool:
```bash
# Install
pip install msgraph-cli
# Login (one-time, uses device code)
mgc login --scopes Mail.Read
# Fetch unread emails
mgc users mail-folders messages list \
--user-id me \
--mail-folder-id inbox \
--filter "isRead eq false" \
--top 5 \
--select subject,from,receivedDateTime \
--output json | jq -r '.value[] | "- \(.subject)"'
```
### Option 4: IMAP (Legacy, but Simple)
If OAuth is too complex, IMAP with app passwords works:
```bash
#!/bin/bash
# fetch-imap-emails.sh
# Requires: curl with IMAP support, or python imaplib
python3 << 'EOF'
import imaplib
import email
from email.header import decode_header
mail = imaplib.IMAP4_SSL("outlook.office365.com")
mail.login("user@domain.com", "app-password-here")
mail.select("inbox")
_, messages = mail.search(None, "UNSEEN")
for num in messages[0].split()[:5]:
_, msg = mail.fetch(num, "(RFC822)")
email_msg = email.message_from_bytes(msg[0][1])
subject = decode_header(email_msg["Subject"])[0][0]
if isinstance(subject, bytes):
subject = subject.decode()
print(f"- {email_msg['From']}: {subject}")
mail.logout()
EOF
```
### Recommended Script Structure
```bash
#!/bin/bash
# ~/.warelay/scripts/email-context.sh
# Output format: plain text summary for AI consumption
set -e
# Track last check time
LAST_CHECK_FILE="$HOME/.warelay/last-email-check"
if [ -f "$LAST_CHECK_FILE" ]; then
SINCE=$(cat "$LAST_CHECK_FILE")
else
SINCE=$(date -u -v-1H +"%Y-%m-%dT%H:%M:%SZ") # Default: last hour
fi
# Fetch emails (using your preferred method)
EMAILS=$(fetch_unread_emails_since "$SINCE")
# Update last check time
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$LAST_CHECK_FILE"
# Output summary if any emails found
if [ -n "$EMAILS" ]; then
echo "Unread emails since last heartbeat:"
echo "$EMAILS"
fi
# Exit cleanly even with no emails (empty stdout = no context added)
exit 0
```
---
## Implementation Order
```
Phase 1: Config Schema (1-2 hours)
|
v
Phase 2: Pre-Hook Module (2-3 hours)
|
v
Phase 3: Web Provider Integration (1-2 hours)
|
v
Phase 4: Twilio Provider Integration (1 hour)
|
v
Phase 5: Unit Tests (2-3 hours)
|
v
Phase 6: Documentation (1 hour)
```
**Total Estimated Time:** 8-12 hours
---
## Validation Criteria
### Config Validation
- [ ] `pnpm lint` passes with new schema
- [ ] `pnpm build` compiles without errors
- [ ] Invalid config (e.g., negative timeout) produces clear error message
### Functional Validation
- [ ] Pre-hook runs before each heartbeat when configured
- [ ] Pre-hook stdout appears in Claude's context (visible in verbose logs)
- [ ] Pre-hook timeout doesn't block heartbeat (basic heartbeat still sends)
- [ ] Pre-hook failure doesn't block heartbeat (basic heartbeat still sends)
- [ ] Empty pre-hook output results in normal heartbeat prompt
- [ ] Pre-hook respects configured timeout value
### Test Validation
- [ ] `pnpm test` passes all new tests
- [ ] Coverage thresholds maintained (70% lines/branches/functions/statements)
### Manual Testing
1. Configure a simple pre-hook: `["echo", "Test context"]`
2. Run `warelay relay --provider web --verbose`
3. Wait for heartbeat or trigger with `warelay heartbeat`
4. Verify logs show pre-hook execution and context injection
5. Test timeout with: `["sleep", "60"]` and `heartbeatPreHookTimeoutSeconds: 2`
6. Verify heartbeat still fires after timeout
---
## Code References
### Internal Files to Modify
| File | Purpose |
|------|---------|
| `src/config/config.ts` | Add new config types and Zod schema |
| `src/auto-reply/heartbeat-prehook.ts` | **NEW** - Shared pre-hook logic |
| `src/auto-reply/heartbeat-prehook.test.ts` | **NEW** - Unit tests |
| `src/web/auto-reply.ts` | Integrate pre-hook into web heartbeat |
| `src/twilio/heartbeat.ts` | Integrate pre-hook into Twilio heartbeat |
| `src/twilio/heartbeat.test.ts` | Add pre-hook test cases |
### External References
| Resource | URL |
|----------|-----|
| Microsoft Graph Mail API | https://learn.microsoft.com/en-us/graph/api/resources/mail-api-overview |
| Device Code Flow | https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code |
| Microsoft Graph CLI | https://github.com/microsoftgraph/msgraph-cli |
| Azure CLI | https://learn.microsoft.com/en-us/cli/azure/install-azure-cli |
### Relevant Existing Patterns
| Pattern | File | Purpose |
|---------|------|---------|
| `runCommandWithTimeout` | `src/process/exec.ts` | Execute commands with timeout |
| `loadConfig` | `src/config/config.ts` | Load and validate config |
| Zod schemas | `src/config/config.ts` | Config validation patterns |
| Heartbeat constants | `src/web/auto-reply.ts` | `HEARTBEAT_PROMPT`, `HEARTBEAT_TOKEN` |
---
## Notes
- The pre-hook is intentionally script-agnostic to support any context source (email, calendar, RSS, etc.)
- The `---\nContext from pre-hook:` separator helps Claude distinguish injected context from the heartbeat command
- Pre-hook failures are logged but don't block heartbeats - this is intentional for reliability
- Consider adding `heartbeatPreHookCwd` in the future if users need to run scripts from specific directories

View File

@ -1,347 +0,0 @@
# Plan: Rename warelay → shuvrelay
**Goal:** Rebrand the project from "warelay" to "shuvrelay", update all documentation, npm namespace, CLI binary names, and break the link with the upstream fork.
**Status:** Planning phase
---
## Summary
This plan covers renaming the project from `warelay` to `shuvrelay` across:
- Package identity (npm namespace, binary names)
- CLI branding and help text
- Configuration directories and files
- All documentation
- Git remote/fork relationship
- Internal code references
---
## Phase 1: Package Identity & Build Configuration
### 1.1 package.json
- [ ] Change `"name"` from `"warelay"` to `"shuvrelay"`
- [ ] Update `"description"` to personalize (optional)
- [ ] Update `"bin"` entries:
- `"shuvrelay": "bin/shuvrelay.js"` (primary, only binary)
- Remove `"warely"` alias
- Remove `"wa"` alias
- [ ] Update `"scripts"`:
- `"shuvrelay": "tsx src/index.ts"` (replaces `warelay`)
- Remove `"warely"` script
- Remove `"wa"` script
- [ ] Update `"repository"` URL to your GitHub repo
- [ ] Update `"author"` field
### 1.2 Binary Entry Point
- [ ] Rename `bin/warelay.js``bin/shuvrelay.js`
- [ ] Update import path inside if necessary (currently imports `../dist/index.js` - should still work)
---
## Phase 2: CLI Branding & Help Text
### 2.1 src/cli/program.ts (Main CLI Definition)
- [ ] Change `.name("warelay")``.name("shuvrelay")`
- [ ] Update `TAGLINE` if desired
- [ ] Update `formatIntroLine()` to show `📡 shuvrelay` instead of `📡 warelay`
- [ ] Update all example commands in `addHelpText()`:
- `warelay login``shuvrelay login`
- `warelay send``shuvrelay send`
- `warelay relay``shuvrelay relay`
- `warelay webhook``shuvrelay webhook`
- `warelay status``shuvrelay status`
- `warelay heartbeat``shuvrelay heartbeat`
- [ ] Update error messages referencing CLI commands (e.g., "Run: warelay login")
### 2.2 src/cli/relay_tmux.ts
- [ ] Change `SESSION = "warelay-relay"``SESSION = "shuvrelay-relay"`
- [ ] Update default command from `"pnpm warelay relay --verbose"``"pnpm shuvrelay relay --verbose"`
### 2.3 src/index.ts
- [ ] Update error message prefixes from `"[warelay]"``"[shuvrelay]"`
---
## Phase 3: Configuration Paths & Directories
### 3.1 Config Directory (~/.warelay → ~/.shuvrelay)
Files that define paths:
- [ ] **src/utils.ts**: Change `CONFIG_DIR = "${os.homedir()}/.warelay"``.shuvrelay`
- [ ] **src/config/config.ts**: Change `CONFIG_PATH` from `~/.warelay/warelay.json``~/.shuvrelay/shuvrelay.json`
- [ ] **src/media/store.ts**: Change `MEDIA_DIR` from `~/.warelay/media``~/.shuvrelay/media`
- [ ] **src/web/session.ts**: Change credentials path from `".warelay"``".shuvrelay"`
- [ ] **src/logging.ts**: Update default log path from `/tmp/warelay/``/tmp/shuvrelay/`
### 3.2 Config File Name
- [ ] Update references to `warelay.json``shuvrelay.json` in:
- `src/config/config.ts`
- Documentation
- Comments
### 3.3 Session Storage
- [ ] **src/config/sessions.ts**: `SESSION_STORE_DEFAULT` uses `CONFIG_DIR` which will auto-update
- [ ] Update any docs referencing `~/.warelay/sessions.json`
### 3.4 Migration Note
- [ ] Add migration instructions for users moving from warelay to shuvrelay (copy ~/.warelay to ~/.shuvrelay)
---
## Phase 4: Internal Code References
### 4.1 Error Messages & Logging
Files with `warelay` in strings:
- [ ] **src/index.ts**: `"[warelay] Unhandled promise rejection"``"[shuvrelay]"`
- [ ] **src/twilio/webhook.ts**: `"warelay webhook: not found"``"shuvrelay webhook: not found"`
- [ ] **src/infra/ports.ts**: `"another warelay instance"``"another shuvrelay instance"`
- [ ] **src/infra/tailscale.ts**: Update fallback command example
- [ ] **src/media/host.ts**: Update error message referencing `warelay webhook`
- [ ] **src/web/session.ts**: Update "warelay/cli/VERSION" user agent → "shuvrelay/cli/VERSION"
- [ ] **src/web/auto-reply.ts**: Update `"[warelay]"` default message prefix → `"[shuvrelay]"`
- [ ] **src/web/login.ts**: Update rerun message
### 4.2 Symbol Names (Internal Markers)
- [ ] **src/web/test-helpers.ts**: `Symbol.for("warelay:lastSocket")``Symbol.for("shuvrelay:lastSocket")`
### 4.3 Comments & Documentation Strings
- [ ] **src/version.ts**: Update comment
- [ ] **src/auto-reply/claude.ts**: Update default system prompt (references warelay)
- [ ] **src/config/config.ts**: Update inline comments
---
## Phase 5: Test Files
### 5.1 Test Temporary Directories
Update all temp directory prefixes from `warelay-` to `shuvrelay-`:
- [ ] src/web/auto-reply.test.ts
- [ ] src/web/logout.test.ts
- [ ] src/web/monitor-inbox.test.ts
- [ ] src/web/media.test.ts
- [ ] src/web/inbound.media.test.ts
- [ ] src/utils.test.ts
- [ ] src/media/store.test.ts
- [ ] src/logger.test.ts
- [ ] src/index.core.test.ts
- [ ] src/auto-reply/transcription.test.ts
- [ ] src/auto-reply/command-reply.test.ts
- [ ] src/cli/relay_tmux.test.ts
- [ ] src/cli/program.test.ts
---
## Phase 6: Documentation
### 6.1 README.md (Complete Rewrite)
- [ ] Replace all `warelay``shuvrelay` in CLI examples
- [ ] Update npm install command: `npm install -g shuvrelay`
- [ ] Update GitHub badge URLs to your repo
- [ ] Update npm badge to new package name
- [ ] Remove/update references to @steipete and upstream project
- [ ] Update header image if desired (README-header.png)
- [ ] Update repository links
### 6.2 AGENTS.md
- [ ] Update "What is Warelay?" → "What is Shuvrelay?"
- [ ] Replace all CLI examples
- [ ] Update config paths (`~/.warelay` → `~/.shuvrelay`)
- [ ] Update tmux session name reference
- [ ] Remove references to upstream repo guidelines
### 6.3 CHANGELOG.md
- [ ] Add entry for renaming
- [ ] Consider starting fresh or keeping history with note
### 6.4 docs/clawd.md
- [ ] Replace all `warelay``shuvrelay` (or consider personalizing for your use case)
- [ ] Update or remove @steipete references
- [ ] Update example configs
### 6.5 docs/RELEASING.md
- [ ] Update all `warelay` references → `shuvrelay`
- [ ] Update npm package name
- [ ] Update version check command
### 6.6 Other docs (docs/*.md)
Review and update:
- [ ] docs/audio.md
- [ ] docs/heartbeat.md
- [ ] docs/images.md
- [ ] docs/queue.md
- [ ] docs/tmux.md
- [ ] docs/refactor/web-relay-troubleshooting.md
### 6.7 .env.example
- [ ] Update comments if any reference warelay
---
## Phase 7: Git & Repository
### 7.1 Break Fork Relationship
- [ ] Remove upstream remote if set:
```bash
git remote remove upstream # if exists
```
- [ ] Update origin to your canonical repo:
```bash
git remote set-url origin git@github.com:yourusername/shuvrelay.git
```
### 7.2 Repository Rename (GitHub)
- [ ] Rename repository on GitHub: Settings → Repository name → `shuvrelay`
- [ ] Update local remote URLs after rename
### 7.3 Optional: Detach History
If you want a completely fresh start:
- [ ] Consider squashing history or creating orphan branch
- [ ] Alternative: Keep history but add clear note in CHANGELOG about the fork
---
## Phase 8: CI/CD & Publishing
### 8.1 GitHub Actions (.github/workflows/ci.yml)
- [ ] No changes needed to workflow itself (it's generic)
- [ ] Consider adding npm publish workflow if desired
### 8.2 npm Publishing
- [ ] Verify npm namespace is available: `npm view shuvrelay`
- [ ] If not, choose alternative (e.g., `@shuv/relay`, `shuv-relay`)
- [ ] Update package.json name accordingly
- [ ] First publish: `npm publish --access public`
---
## Phase 9: Validation & Testing
### 9.1 Build Verification
- [ ] Run `pnpm build` - should complete without errors
- [ ] Verify `dist/` output references updated
### 9.2 Test Suite
- [ ] Run `pnpm test` - all tests should pass
- [ ] Run `pnpm test:coverage` - verify coverage thresholds
### 9.3 Lint/Format
- [ ] Run `pnpm lint` - no errors
- [ ] Run `pnpm format` - all files formatted
### 9.4 CLI Verification
- [ ] Test `pnpm shuvrelay --help` shows new branding
- [ ] Test `pnpm shuvrelay --version` shows version
### 9.5 Config Path Verification
- [ ] Verify CLI looks for config in `~/.shuvrelay/shuvrelay.json`
- [ ] Verify credentials path is `~/.shuvrelay/credentials/`
- [ ] Verify media path is `~/.shuvrelay/media/`
- [ ] Verify log path is `/tmp/shuvrelay/shuvrelay.log`
---
## Implementation Order
Recommended sequence to minimize breakage:
1. **Phase 7.1**: Remove upstream remote (safe, no code changes)
2. **Phase 1**: Package identity (package.json, bin/)
3. **Phase 3**: Configuration paths (breaking change for existing users)
4. **Phase 2**: CLI branding
5. **Phase 4**: Internal code references
6. **Phase 5**: Test files
7. **Phase 6**: Documentation
8. **Phase 9**: Validation
9. **Phase 7.2-7.3**: Repository operations
10. **Phase 8**: Publishing
---
## Rollback Plan
If issues are discovered:
1. All changes are in git - can revert commits
2. npm packages can be deprecated/unpublished within 72 hours
3. Keep a branch with the old `warelay` naming as backup
---
## Post-Rename Checklist
- [ ] Update any external references (personal docs, scripts)
- [ ] If previously installed globally as `warelay`, uninstall: `npm uninstall -g warelay`
- [ ] Install new version: `npm install -g shuvrelay`
- [ ] Migrate config: `cp -r ~/.warelay ~/.shuvrelay`
- [ ] Rename config file: `mv ~/.shuvrelay/warelay.json ~/.shuvrelay/shuvrelay.json`
- [ ] Update any systemd/launchd services referencing old paths/names
- [ ] Update tmux scripts if any reference `warelay-relay` session
---
## Files Summary
### Files requiring changes (by category):
**Package/Build (3 files):**
- package.json
- bin/warelay.js → bin/shuvrelay.js
**CLI (3 files):**
- src/cli/program.ts
- src/cli/relay_tmux.ts
- src/index.ts
**Config Paths (5 files):**
- src/utils.ts
- src/config/config.ts
- src/config/sessions.ts (uses CONFIG_DIR)
- src/media/store.ts
- src/web/session.ts
- src/logging.ts
**Internal References (8 files):**
- src/twilio/webhook.ts
- src/infra/ports.ts
- src/infra/tailscale.ts
- src/media/host.ts
- src/web/auto-reply.ts
- src/web/login.ts
- src/web/test-helpers.ts
- src/auto-reply/claude.ts
- src/version.ts
**Test Files (13 files):**
- All *.test.ts files with temp directory prefixes
**Documentation (11 files):**
- README.md
- AGENTS.md
- CHANGELOG.md
- docs/clawd.md
- docs/RELEASING.md
- docs/audio.md
- docs/heartbeat.md
- docs/images.md
- docs/queue.md
- docs/tmux.md
- docs/refactor/web-relay-troubleshooting.md
**Estimated Total: ~45 files**
---
## Notes
- The WebSocket/Baileys user-agent string should be updated to `shuvrelay/cli/VERSION`
- The default message prefix `[warelay]` for unknown senders should become `[shuvrelay]`
- The Clawd system prompt in `src/auto-reply/claude.ts` references warelay - consider personalizing
- Some test mock data may need updating if it contains `warelay` strings
---
*Plan created: 2025-12-01*
*Status: Ready for implementation*

View File

@ -1,10 +1,12 @@
import { autoReplyIfConfigured } from "../auto-reply/reply.js";
import { loadConfig } from "../config/config.js";
import { readEnv } from "../env.js";
import { info } from "../globals.js";
import { ensureBinary } from "../infra/binaries.js";
import { ensurePortAvailable, handlePortError } from "../infra/ports.js";
import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js";
import { ensureMediaHosted } from "../media/host.js";
import { getQueueSize } from "../process/command-queue.js";
import {
logWebSelfId,
monitorWebProvider,
@ -12,6 +14,7 @@ import {
} from "../providers/web/index.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { createClient } from "../twilio/client.js";
import { runTwilioHeartbeatOnce } from "../twilio/heartbeat.js";
import { listRecentMessages } from "../twilio/messages.js";
import { monitorTwilio as monitorTwilioImpl } from "../twilio/monitor.js";
import { sendMessage, waitForFinalStatus } from "../twilio/send.js";
@ -51,6 +54,7 @@ export async function monitorTwilio(
lookbackMinutes: number,
clientOverride?: ReturnType<typeof createClient>,
maxIterations = Infinity,
opts?: { heartbeatNow?: boolean; heartbeatMinutes?: number },
) {
// Adapter that wires default deps/runtime for the Twilio monitor loop.
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
@ -62,8 +66,13 @@ export async function monitorTwilio(
readEnv,
createClient,
sleep,
loadConfig,
runTwilioHeartbeatOnce,
getQueueSize,
},
runtime: defaultRuntime,
heartbeatNow: opts?.heartbeatNow,
heartbeatMinutes: opts?.heartbeatMinutes,
});
}

View File

@ -311,7 +311,7 @@ Examples:
.option("--web-retry-max <ms>", "Max reconnect backoff for web relay (ms)")
.option(
"--heartbeat-now",
"Run a heartbeat immediately when relay starts (web provider)",
"Run a heartbeat immediately when relay starts (web or twilio provider)",
false,
)
.option("--verbose", "Verbose logging", false)
@ -451,7 +451,15 @@ Examples:
ensureTwilioEnv();
logTwilioFrom();
await monitorTwilio(intervalSeconds, lookbackMinutes);
await monitorTwilio(
intervalSeconds,
lookbackMinutes,
undefined,
Infinity,
{
heartbeatNow,
},
);
});
program

View File

@ -1,8 +1,41 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { monitorTwilio } from "./monitor.js";
import {
monitorTwilio,
resetSeenMessageSids,
resolveHeartbeatRecipient,
} from "./monitor.js";
// Base mock deps factory
function createMockDeps(overrides: Record<string, unknown> = {}) {
return {
autoReplyIfConfigured: vi.fn().mockResolvedValue(undefined),
listRecentMessages: vi.fn().mockResolvedValue([]),
readEnv: vi.fn(() => ({
accountSid: "AC",
whatsappFrom: "whatsapp:+1",
auth: { accountSid: "AC", authToken: "t" },
})),
createClient: vi.fn(() => ({ messages: { create: vi.fn() } }) as never),
sleep: vi.fn().mockResolvedValue(undefined),
loadConfig: vi.fn(() => ({})),
runTwilioHeartbeatOnce: vi.fn().mockResolvedValue(undefined),
getQueueSize: vi.fn(() => 0),
...overrides,
};
}
describe("monitorTwilio", () => {
beforeEach(() => {
resetSeenMessageSids();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it("processes inbound messages once with injected deps", async () => {
const listRecentMessages = vi.fn().mockResolvedValue([
{
@ -17,29 +50,300 @@ describe("monitorTwilio", () => {
status: null,
},
]);
const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined);
const readEnv = vi.fn(() => ({
accountSid: "AC",
whatsappFrom: "whatsapp:+1",
auth: { accountSid: "AC", authToken: "t" },
}));
const createClient = vi.fn(
() => ({ messages: { create: vi.fn() } }) as never,
);
const sleep = vi.fn().mockResolvedValue(undefined);
await monitorTwilio(0, 0, {
deps: {
autoReplyIfConfigured,
listRecentMessages,
readEnv,
createClient,
sleep,
},
const deps = createMockDeps({ listRecentMessages });
const monitorPromise = monitorTwilio(0, 0, {
deps,
maxIterations: 1,
});
// Advance timers to complete the iteration
await vi.runAllTimersAsync();
await monitorPromise;
expect(listRecentMessages).toHaveBeenCalledTimes(1);
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
expect(deps.autoReplyIfConfigured).toHaveBeenCalledTimes(1);
});
describe("heartbeat timer setup", () => {
it("sets up heartbeat timer when heartbeatMinutes is configured", async () => {
const setIntervalSpy = vi.spyOn(global, "setInterval");
const deps = createMockDeps({
loadConfig: vi.fn(() => ({
inbound: {
allowFrom: ["+15551234567"], // Provide a recipient
reply: {
mode: "command" as const,
command: ["echo", "test"],
heartbeatMinutes: 1,
},
},
})),
});
const monitorPromise = monitorTwilio(5, 5, {
deps,
maxIterations: 1,
});
await vi.runAllTimersAsync();
await monitorPromise;
// Heartbeat timer should have been set up with 60000ms (1 minute) interval
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 60_000);
});
it("does not set up heartbeat timer when heartbeatMinutes is 0", async () => {
const runTwilioHeartbeatOnce = vi.fn().mockResolvedValue(undefined);
const deps = createMockDeps({
loadConfig: vi.fn(() => ({
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "test"],
heartbeatMinutes: 0,
},
},
})),
runTwilioHeartbeatOnce,
});
const monitorPromise = monitorTwilio(5, 5, {
deps,
maxIterations: 1,
});
// Advance timer past what would be the heartbeat interval
await vi.advanceTimersByTimeAsync(60_000);
await vi.runAllTimersAsync();
await monitorPromise;
// Heartbeat should not have been triggered
expect(runTwilioHeartbeatOnce).not.toHaveBeenCalled();
});
});
describe("heartbeat immediate (heartbeatNow)", () => {
it("runs immediate heartbeat when heartbeatNow is true", async () => {
const runTwilioHeartbeatOnce = vi.fn().mockResolvedValue(undefined);
const deps = createMockDeps({
loadConfig: vi.fn(() => ({
inbound: {
allowFrom: ["+15551234567"],
reply: {
mode: "command" as const,
command: ["echo", "test"],
heartbeatMinutes: 10,
},
},
})),
runTwilioHeartbeatOnce,
});
const monitorPromise = monitorTwilio(5, 5, {
deps,
maxIterations: 1,
heartbeatNow: true,
});
// Run immediate timer callbacks (for the immediate heartbeat)
await vi.runAllTimersAsync();
await monitorPromise;
// Heartbeat should have been called immediately
expect(runTwilioHeartbeatOnce).toHaveBeenCalled();
});
});
describe("heartbeat skips when busy", () => {
it("skips heartbeat when command queue is busy", async () => {
const runTwilioHeartbeatOnce = vi.fn().mockResolvedValue(undefined);
const deps = createMockDeps({
loadConfig: vi.fn(() => ({
inbound: {
allowFrom: ["+15551234567"],
reply: {
mode: "command" as const,
command: ["echo", "test"],
heartbeatMinutes: 1,
},
},
})),
runTwilioHeartbeatOnce,
getQueueSize: vi.fn(() => 1), // Queue is busy
});
const monitorPromise = monitorTwilio(5, 5, {
deps,
maxIterations: 1,
heartbeatNow: true,
});
await vi.runAllTimersAsync();
await monitorPromise;
// Heartbeat should NOT have been called because queue is busy
expect(runTwilioHeartbeatOnce).not.toHaveBeenCalled();
});
});
describe("heartbeat error handling", () => {
it("catches heartbeat errors without crashing", async () => {
const runtimeError = vi.fn();
const runTwilioHeartbeatOnce = vi
.fn()
.mockRejectedValue(new Error("Heartbeat failed"));
const deps = createMockDeps({
loadConfig: vi.fn(() => ({
inbound: {
allowFrom: ["+15551234567"],
reply: {
mode: "command" as const,
command: ["echo", "test"],
heartbeatMinutes: 1,
},
},
})),
runTwilioHeartbeatOnce,
});
const monitorPromise = monitorTwilio(5, 5, {
deps,
maxIterations: 1,
heartbeatNow: true,
runtime: {
log: vi.fn(),
error: runtimeError,
exit: vi.fn() as unknown as (code: number) => never,
},
});
await vi.runAllTimersAsync();
await monitorPromise;
// Should have logged error but not crashed
expect(runtimeError).toHaveBeenCalledWith(
expect.stringContaining("Heartbeat failed"),
);
});
});
describe("heartbeat idle time check", () => {
it("skips heartbeat when not idle long enough", async () => {
const runTwilioHeartbeatOnce = vi.fn().mockResolvedValue(undefined);
const nowMs = Date.now();
const recentInboundTime = nowMs - 2 * 60_000; // 2 minutes ago
const deps = createMockDeps({
listRecentMessages: vi.fn().mockResolvedValue([
{
sid: "m1",
direction: "inbound",
dateCreated: new Date(recentInboundTime),
from: "+15559999999",
to: "+15551234567",
body: "hi",
errorCode: null,
errorMessage: null,
status: null,
},
]),
loadConfig: vi.fn(() => ({
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "test"],
heartbeatMinutes: 1,
session: {
heartbeatIdleMinutes: 5, // Require 5 min idle
},
},
},
})),
runTwilioHeartbeatOnce,
});
const monitorPromise = monitorTwilio(5, 5, {
deps,
maxIterations: 2,
});
// Advance to trigger heartbeat (60s)
await vi.advanceTimersByTimeAsync(60_000);
await vi.runAllTimersAsync();
await monitorPromise;
// Heartbeat should be skipped because last inbound was only 2 min ago (< 5 min)
expect(runTwilioHeartbeatOnce).not.toHaveBeenCalled();
});
});
describe("timer cleanup", () => {
it("clears heartbeat timer on exit", async () => {
const clearIntervalSpy = vi.spyOn(global, "clearInterval");
const deps = createMockDeps({
loadConfig: vi.fn(() => ({
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "test"],
heartbeatMinutes: 1,
},
},
})),
});
const monitorPromise = monitorTwilio(5, 5, {
deps,
maxIterations: 1,
});
await vi.runAllTimersAsync();
await monitorPromise;
// clearInterval should have been called for cleanup
expect(clearIntervalSpy).toHaveBeenCalled();
});
});
});
describe("resolveHeartbeatRecipient", () => {
it("returns lastInboundFrom when available", () => {
const cfg = { inbound: { allowFrom: ["+15551234567"] } };
const result = resolveHeartbeatRecipient(cfg, "whatsapp:+15559999999");
expect(result).toBe("+15559999999");
});
it("strips whatsapp: prefix from lastInboundFrom", () => {
const cfg = {};
const result = resolveHeartbeatRecipient(cfg, "whatsapp:+15559999999");
expect(result).toBe("+15559999999");
});
it("falls back to first non-wildcard allowFrom entry", () => {
const cfg = {
inbound: { allowFrom: ["*", "+15551234567", "+15552222222"] },
};
const result = resolveHeartbeatRecipient(cfg, undefined);
expect(result).toBe("+15551234567");
});
it("returns null when allowFrom is empty", () => {
const cfg = { inbound: { allowFrom: [] } };
const result = resolveHeartbeatRecipient(cfg, undefined);
expect(result).toBeNull();
});
it("returns null when allowFrom contains only wildcards", () => {
const cfg = { inbound: { allowFrom: ["*"] } };
const result = resolveHeartbeatRecipient(cfg, undefined);
expect(result).toBeNull();
});
it("returns null when no allowFrom and no lastInboundFrom", () => {
const cfg = {};
const result = resolveHeartbeatRecipient(cfg, undefined);
expect(result).toBeNull();
});
});

View File

@ -1,11 +1,15 @@
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
import { autoReplyIfConfigured } from "../auto-reply/reply.js";
import { loadConfig, type WarelayConfig } from "../config/config.js";
import { readEnv } from "../env.js";
import { danger } from "../globals.js";
import { logDebug, logInfo, logWarn } from "../logger.js";
import { getQueueSize } from "../process/command-queue.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { sleep, withWhatsAppPrefix } from "../utils.js";
import { normalizeE164, sleep, withWhatsAppPrefix } from "../utils.js";
import { resolveReplyHeartbeatMinutes } from "../web/auto-reply.js";
import { createClient } from "./client.js";
import { runTwilioHeartbeatOnce } from "./heartbeat.js";
type MonitorDeps = {
autoReplyIfConfigured: typeof autoReplyIfConfigured;
@ -17,6 +21,10 @@ type MonitorDeps = {
readEnv: typeof readEnv;
createClient: typeof createClient;
sleep: typeof sleep;
// Heartbeat dependencies
loadConfig: typeof loadConfig;
runTwilioHeartbeatOnce: typeof runTwilioHeartbeatOnce;
getQueueSize: typeof getQueueSize;
};
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
@ -38,6 +46,9 @@ type MonitorOptions = {
maxIterations?: number;
deps?: MonitorDeps;
runtime?: RuntimeEnv;
// Heartbeat options
heartbeatNow?: boolean; // Run heartbeat immediately on start
heartbeatMinutes?: number; // Override config value
};
const defaultDeps: MonitorDeps = {
@ -46,8 +57,104 @@ const defaultDeps: MonitorDeps = {
readEnv,
createClient,
sleep,
// Heartbeat deps
loadConfig,
runTwilioHeartbeatOnce,
getQueueSize,
};
// Lightweight mutex for serializing heartbeat and auto-reply
let inFlightLock: Promise<void> = Promise.resolve();
function acquireLock(): Promise<() => void> {
let release: (() => void) | undefined;
const prev = inFlightLock;
inFlightLock = new Promise<void>((resolve) => {
release = resolve;
});
return prev.then(() => {
if (!release) throw new Error("Lock release function not set");
return release;
});
}
// Resolve recipient for heartbeat: uses lastInboundFrom or first non-wildcard allowFrom entry
function resolveHeartbeatRecipient(
cfg: WarelayConfig,
lastInboundFrom: string | undefined,
): string | null {
// Prefer last inbound sender
if (lastInboundFrom) {
// Strip whatsapp: prefix if present
const cleaned = lastInboundFrom.replace(/^whatsapp:/, "");
return normalizeE164(cleaned);
}
// Fall back to first non-wildcard allowFrom entry
const allowFrom = cfg.inbound?.allowFrom ?? [];
const nonWildcard = allowFrom.filter((v) => v !== "*");
if (nonWildcard.length > 0 && nonWildcard[0]) {
return normalizeE164(nonWildcard[0]);
}
return null;
}
// State tracking for heartbeat
type HeartbeatState = {
lastInboundFrom: string | undefined;
lastInboundAt: number | undefined;
};
// Run heartbeat once with serialization
async function runTwilioHeartbeatLoop(params: {
deps: MonitorDeps;
runtime: RuntimeEnv;
cfg: WarelayConfig;
state: HeartbeatState;
}) {
const { deps, runtime, cfg, state } = params;
const release = await acquireLock();
try {
// Check if command queue is busy
if (deps.getQueueSize() > 0) {
logInfo("heartbeat: skipped (requests in flight)", runtime);
return;
}
const recipient = resolveHeartbeatRecipient(cfg, state.lastInboundFrom);
if (!recipient) {
logInfo(
"heartbeat: skipped (no recipient - configure allowFrom or wait for inbound)",
runtime,
);
return;
}
// Check idle time threshold if configured
const idleMinutes =
cfg.inbound?.reply?.session?.heartbeatIdleMinutes ??
cfg.inbound?.reply?.session?.idleMinutes;
if (idleMinutes && state.lastInboundAt) {
const idleMs = Date.now() - state.lastInboundAt;
if (idleMs < idleMinutes * 60_000) {
logInfo(
`heartbeat: skipped (idle ${Math.floor(idleMs / 60_000)}m < ${idleMinutes}m)`,
runtime,
);
return;
}
}
await deps.runTwilioHeartbeatOnce({
to: recipient,
runtime,
cfg,
});
} finally {
release();
}
}
// Poll Twilio for inbound messages and auto-reply when configured.
export async function monitorTwilio(
pollSeconds: number,
@ -62,47 +169,121 @@ export async function monitorTwilio(
const env = deps.readEnv(runtime);
const from = withWhatsAppPrefix(env.whatsappFrom);
const client = opts?.client ?? deps.createClient(env);
// Load config and resolve heartbeat minutes
const cfg = deps.loadConfig();
const heartbeatMinutes = resolveReplyHeartbeatMinutes(
cfg,
opts?.heartbeatMinutes,
);
// Heartbeat state tracking
const heartbeatState: HeartbeatState = {
lastInboundFrom: undefined,
lastInboundAt: undefined,
};
let heartbeatTimer: NodeJS.Timeout | null = null;
// Cleanup function for the heartbeat timer
const clearHeartbeatTimer = () => {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};
// Log startup info
const heartbeatInfo = heartbeatMinutes
? `Heartbeat: every ${heartbeatMinutes}m`
: "Heartbeat: disabled";
logInfo(
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m) | ${heartbeatInfo}`,
runtime,
);
// Set up heartbeat timer if enabled
if (heartbeatMinutes) {
const intervalMs = heartbeatMinutes * 60_000;
heartbeatTimer = setInterval(() => {
void runTwilioHeartbeatLoop({
deps,
runtime,
cfg,
state: heartbeatState,
}).catch((err) => {
runtime.error(danger(`Heartbeat error: ${String(err)}`));
});
}, intervalMs);
// Run immediate heartbeat if requested
if (opts?.heartbeatNow) {
void runTwilioHeartbeatLoop({
deps,
runtime,
cfg,
state: heartbeatState,
}).catch((err) => {
runtime.error(danger(`Immediate heartbeat error: ${String(err)}`));
});
}
}
let lastSeenSid: string | undefined;
let iterations = 0;
while (iterations < maxIterations) {
let messages: ListedMessage[] = [];
try {
messages =
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
backoffMs = 1_000; // reset after success
} catch (err) {
logWarn(
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
runtime,
try {
while (iterations < maxIterations) {
let messages: ListedMessage[] = [];
try {
messages =
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
backoffMs = 1_000; // reset after success
} catch (err) {
logWarn(
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
runtime,
);
await deps.sleep(backoffMs);
backoffMs = Math.min(backoffMs * 2, 10_000);
continue;
}
const inboundOnly = messages.filter((m) => m.direction === "inbound");
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
const newestFirst = [...inboundOnly].sort(
(a, b) =>
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
);
// Update heartbeat state from newest inbound message
if (newestFirst.length > 0 && newestFirst[0].from) {
heartbeatState.lastInboundFrom = newestFirst[0].from;
heartbeatState.lastInboundAt = newestFirst[0].dateCreated?.getTime();
}
await handleMessages(messages, client, lastSeenSid, deps, runtime);
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
iterations += 1;
if (iterations >= maxIterations) break;
await deps.sleep(
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
);
await deps.sleep(backoffMs);
backoffMs = Math.min(backoffMs * 2, 10_000);
continue;
}
const inboundOnly = messages.filter((m) => m.direction === "inbound");
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
const newestFirst = [...inboundOnly].sort(
(a, b) =>
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
);
await handleMessages(messages, client, lastSeenSid, deps, runtime);
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
iterations += 1;
if (iterations >= maxIterations) break;
await deps.sleep(
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
);
} finally {
clearHeartbeatTimer();
}
}
// Track all seen message SIDs to avoid re-processing
const seenMessageSids = new Set<string>();
// Export for testing - reset seen message SIDs between test runs
export function resetSeenMessageSids() {
seenMessageSids.clear();
}
// Export for testing
export { resolveHeartbeatRecipient };
async function handleMessages(
messages: ListedMessage[],
client: ReturnType<typeof createClient>,
@ -118,7 +299,9 @@ async function handleMessages(
// Limit set size to prevent memory leak
if (seenMessageSids.size > 1000) {
const oldestSids = Array.from(seenMessageSids).slice(0, 500);
oldestSids.forEach((sid) => seenMessageSids.delete(sid));
for (const sid of oldestSids) {
seenMessageSids.delete(sid);
}
}
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);