adding twilio heartbeat
This commit is contained in:
parent
ffb9075a32
commit
94833ef0a3
@ -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 module’s 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 hook’s 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
|
||||
@ -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*
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,13 +169,69 @@ 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;
|
||||
|
||||
try {
|
||||
while (iterations < maxIterations) {
|
||||
let messages: ListedMessage[] = [];
|
||||
try {
|
||||
@ -90,6 +253,13 @@ export async function monitorTwilio(
|
||||
(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;
|
||||
@ -98,11 +268,22 @@ export async function monitorTwilio(
|
||||
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 ?? ""}`);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user