From d871dad85f7a7f8fe389aa575c2739dc55fee47c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Nov 2025 00:05:11 +0100 Subject: [PATCH] feat: keep typing indicators alive during commands --- CHANGELOG.md | 1 + README.md | 1 + src/auto-reply/reply.ts | 50 ++++++++++++++++++++++++++++++++++++----- src/config/config.ts | 26 ++++++++++++--------- src/index.core.test.ts | 43 +++++++++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 068493ae4..6f11a2b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Web auto-replies now resize/recompress media and honor `inbound.reply.mediaMaxMb` in `~/.warelay/warelay.json` (default 5 MB) to avoid provider/API limits. - Web provider now detects media kind (image/audio/video/document), logs the source path, and enforces provider caps: images ≤6 MB, audio/video ≤16 MB, documents ≤100 MB; images still target the configurable cap above with resize + JPEG recompress. - Sessions can now send the system prompt only once: set `inbound.reply.session.sendSystemOnce` (optional `sessionIntro` for the first turn) to avoid re-sending large prompts every message. +- While commands run, typing indicators refresh every 30s by default (tune with `inbound.reply.typingIntervalSeconds`); helps keep WhatsApp “composing” visible during longer Claude runs. - Optional voice-note transcription: set `inbound.transcribeAudio.command` (e.g., OpenAI Whisper CLI) to turn inbound audio into text before templating/Claude; verbose logs surface when transcription runs. Prompts now include the original media path plus a `Transcript:` block so models see both. ## 1.0.4 — 2025-11-25 diff --git a/README.md b/README.md index 194d2a3bc..b15f4a8df 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a | `inbound.reply.session.store` | `string` (default: `~/.warelay/sessions.json`) | Custom session store path. | | `inbound.reply.session.sendSystemOnce` | `boolean` (default: `false`) | If `true`, only include the system prompt/template on the first turn of a session. | | `inbound.reply.session.sessionIntro` | `string` | Optional intro text sent once per new session (prepended before the body when `sendSystemOnce` is used). | +| `inbound.reply.typingIntervalSeconds` | `number` (default: `30` for command replies) | How often to refresh typing indicators while the command/Claude run is in flight. | | `inbound.reply.session.sessionArgNew` | `string[]` (default: `["--session-id","{{SessionId}}"]`) | Args injected for a new session run. | | `inbound.reply.session.sessionArgResume` | `string[]` (default: `["--resume","{{SessionId}}"]`) | Args for resumed sessions. | | `inbound.reply.session.sessionArgBeforeBody` | `boolean` (default: `true`) | Place session args before final body arg. | diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 854525222..5656b1bc4 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -105,10 +105,35 @@ export async function getReplyFromConfig( const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1); const timeoutMs = timeoutSeconds * 1000; let started = false; + const triggerTyping = async () => { + await opts?.onReplyStart?.(); + }; const onReplyStart = async () => { if (started) return; started = true; - await opts?.onReplyStart?.(); + await triggerTyping(); + }; + let typingTimer: NodeJS.Timeout | undefined; + const typingIntervalMs = + reply?.mode === "command" + ? (reply.typingIntervalSeconds ?? + reply?.session?.typingIntervalSeconds ?? + 30) * 1000 + : 0; + const cleanupTyping = () => { + if (typingTimer) { + clearInterval(typingTimer); + typingTimer = undefined; + } + }; + const startTypingLoop = async () => { + if (!opts?.onReplyStart) return; + if (typingIntervalMs <= 0) return; + if (typingTimer) return; + await triggerTyping(); + typingTimer = setInterval(() => { + void triggerTyping(); + }, typingIntervalMs); }; let transcribedText: string | undefined; @@ -193,10 +218,13 @@ export async function getReplyFromConfig( logVerbose( `Skipping auto-reply: sender ${from || ""} not in allowFrom list`, ); + cleanupTyping(); return undefined; } } + await startTypingLoop(); + // Optional prefix injected before Body for templating/command prompts. const sendSystemOnce = sessionCfg?.sendSystemOnce === true; const isFirstTurnInSession = isNewSession || !systemSent; @@ -262,16 +290,19 @@ export async function getReplyFromConfig( }; if (!reply) { logVerbose("No inbound.reply configured; skipping auto-reply"); + cleanupTyping(); return undefined; } if (reply.mode === "text" && reply.text) { await onReplyStart(); logVerbose("Using text auto-reply from config"); - return { + const result = { text: applyTemplate(reply.text, templatingCtx), mediaUrl: reply.mediaUrl, }; + cleanupTyping(); + return result; } if (reply.mode === "command" && reply.command?.length) { @@ -425,9 +456,12 @@ export async function getReplyFromConfig( return undefined; } const mediaUrl = mediaFromCommand ?? reply.mediaUrl; - return trimmed || mediaUrl - ? { text: trimmed || undefined, mediaUrl } - : undefined; + const result = + trimmed || mediaUrl + ? { text: trimmed || undefined, mediaUrl } + : undefined; + cleanupTyping(); + return result; } catch (err) { const elapsed = Date.now() - started; const anyErr = err as { killed?: boolean; signal?: string }; @@ -452,16 +486,20 @@ export async function getReplyFromConfig( const text = partialSnippet ? `${baseMsg}\n\nPartial output before timeout:\n${partialSnippet}` : baseMsg; - return { text }; + const result = { text }; + cleanupTyping(); + return result; } else { logError( `Command auto-reply failed after ${elapsed}ms: ${String(err)}`, ); } + cleanupTyping(); return undefined; } } + cleanupTyping(); return undefined; } diff --git a/src/config/config.ts b/src/config/config.ts index 3f0fa14e5..21584b75e 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -14,12 +14,13 @@ export type SessionConfig = { resetTriggers?: string[]; idleMinutes?: number; store?: string; - sessionArgNew?: string[]; - sessionArgResume?: string[]; - sessionArgBeforeBody?: boolean; - sendSystemOnce?: boolean; - sessionIntro?: string; -}; + sessionArgNew?: string[]; + sessionArgResume?: string[]; + sessionArgBeforeBody?: boolean; + sendSystemOnce?: boolean; + sessionIntro?: string; + typingIntervalSeconds?: number; + }; export type LoggingConfig = { level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace"; @@ -43,13 +44,14 @@ export type WarelayConfig = { template?: string; // prepend template string when building command/prompt timeoutSeconds?: number; // optional command timeout; defaults to 600s bodyPrefix?: string; // optional string prepended to Body before templating - mediaUrl?: string; // optional media attachment (path or URL) - session?: SessionConfig; - claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format - mediaMaxMb?: number; // optional cap for outbound media (default 5MB) - }; + mediaUrl?: string; // optional media attachment (path or URL) + session?: SessionConfig; + claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format + mediaMaxMb?: number; // optional cap for outbound media (default 5MB) + typingIntervalSeconds?: number; // how often to refresh typing indicator while command runs }; }; +}; export const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json"); @@ -64,6 +66,7 @@ const ReplySchema = z bodyPrefix: z.string().optional(), mediaUrl: z.string().optional(), mediaMaxMb: z.number().positive().optional(), + typingIntervalSeconds: z.number().int().positive().optional(), session: z .object({ scope: z @@ -77,6 +80,7 @@ const ReplySchema = z sessionArgBeforeBody: z.boolean().optional(), sendSystemOnce: z.boolean().optional(), sessionIntro: z.string().optional(), + typingIntervalSeconds: z.number().int().positive().optional(), }) .optional(), claudeOutputFormat: z diff --git a/src/index.core.test.ts b/src/index.core.test.ts index dd3f43aaa..87973e74d 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -601,6 +601,49 @@ describe("config and templating", () => { expect(secondArgv[secondArgv.length - 1]).toBe("[sys] next"); }); + it("refreshes typing indicator while command runs", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(); + const runSpy = vi + .spyOn(index, "runCommandWithTimeout") + .mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + stdout: "done\n", + stderr: "", + code: 0, + signal: null, + killed: false, + }), + 35_000, + ), + ), + ); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + typingIntervalSeconds: 10, + }, + }, + }; + + const promise = index.getReplyFromConfig( + { Body: "hi", From: "+1", To: "+2" }, + { onReplyStart }, + cfg, + runSpy, + ); + await vi.advanceTimersByTimeAsync(35_000); + await promise; + expect(onReplyStart.mock.calls.length).toBeGreaterThanOrEqual(4); + vi.useRealTimers(); + }); + it("injects Claude output format + print flag when configured", async () => { const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ stdout: "ok",