feat: keep typing indicators alive during commands
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 || "<unknown>"} 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user