From 3f26c871561a927d0a7a3d64438dd3b55f9336d7 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 30 Jan 2026 08:53:22 +0000 Subject: [PATCH] feat(signal): add chunkDelay for realistic typing delays between message chunks When a long message is split into multiple chunks, this adds configurable delays between sends to simulate natural typing speed. Config example: channels.signal.chunkDelay = { mode: 'natural' } Options: - mode: 'off' | 'natural' | 'custom' (default: 'off') - perCharMs: ms per character (default: 30, ~40 WPM) - baseMs: minimum delay between chunks (default: 500ms) - maxMs: delay cap (default: 5000ms) - jitter: randomness factor 0-1 (default: 0.2) During the delay, a typing indicator is sent so the recipient sees 'typing...' between chunks. Closes #TBD --- src/config/types.base.ts | 8 ++++ src/config/types.signal.ts | 3 ++ src/config/zod-schema.core.ts | 10 ++++ src/config/zod-schema.providers-core.ts | 2 + src/signal/monitor.ts | 57 +++++++++++++++++++++-- src/signal/monitor/event-handler.ts | 1 + src/signal/monitor/event-handler.types.ts | 3 ++ 7 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/config/types.base.ts b/src/config/types.base.ts index a84736571..1a9ce6fec 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -47,6 +47,14 @@ export type HumanDelayConfig = { maxMs?: number; }; +export type ChunkDelayConfig = { + mode?: "off" | "natural" | "custom"; + perCharMs?: number; + baseMs?: number; + maxMs?: number; + jitter?: number; +}; + export type SessionSendPolicyAction = "allow" | "deny"; export type SessionSendPolicyMatch = { channel?: string; diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index 94cb82f3d..d12558c75 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -1,5 +1,6 @@ import type { BlockStreamingCoalesceConfig, + ChunkDelayConfig, DmPolicy, GroupPolicy, MarkdownConfig, @@ -66,6 +67,8 @@ export type SignalAccountConfig = { reactionAllowlist?: Array; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Chunk delay config for realistic typing delays between message chunks. */ + chunkDelay?: ChunkDelayConfig; }; export type SignalConfig = { diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 0517df43d..6260d7e5d 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -221,6 +221,16 @@ export const HumanDelaySchema = z }) .strict(); +export const ChunkDelaySchema = z + .object({ + mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(), + perCharMs: z.number().int().nonnegative().optional(), + baseMs: z.number().int().nonnegative().optional(), + maxMs: z.number().int().nonnegative().optional(), + jitter: z.number().min(0).max(1).optional(), + }) + .strict(); + export const CliBackendSchema = z .object({ command: z.string(), diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 37a0825b6..ca1f1be9b 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { BlockStreamingChunkSchema, BlockStreamingCoalesceSchema, + ChunkDelaySchema, DmConfigSchema, DmPolicySchema, ExecutableTokenSchema, @@ -430,6 +431,7 @@ export const SignalAccountSchemaBase = z reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + chunkDelay: ChunkDelaySchema.optional(), }) .strict(); diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index e8f7570ab..776328c0f 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -1,6 +1,7 @@ import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js"; import type { ReplyPayload } from "../auto-reply/types.js"; +import type { ChunkDelayConfig } from "../config/types.base.js"; import type { ClawdbotConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import type { SignalReactionNotificationMode } from "../config/types.js"; @@ -13,7 +14,7 @@ import { signalCheck, signalRpcRequest } from "./client.js"; import { spawnSignalDaemon } from "./daemon.js"; import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js"; import { createSignalEventHandler } from "./monitor/event-handler.js"; -import { sendMessageSignal } from "./send.js"; +import { sendMessageSignal, sendTypingSignal } from "./send.js"; import { runSignalSseLoop } from "./sse-reconnect.js"; type SignalReactionMessage = { @@ -205,6 +206,37 @@ async function fetchAttachment(params: { return { path: saved.path, contentType: saved.contentType }; } +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const CHUNK_DELAY_DEFAULTS = { + perCharMs: 30, + baseMs: 500, + maxMs: 5000, + jitter: 0.2, +}; + +function computeChunkDelay(config: ChunkDelayConfig, chunkLength: number): number { + const mode = config.mode ?? "off"; + if (mode === "off") return 0; + + const perCharMs = config.perCharMs ?? CHUNK_DELAY_DEFAULTS.perCharMs; + const baseMs = config.baseMs ?? CHUNK_DELAY_DEFAULTS.baseMs; + const maxMs = config.maxMs ?? CHUNK_DELAY_DEFAULTS.maxMs; + const jitter = config.jitter ?? CHUNK_DELAY_DEFAULTS.jitter; + + let delayMs = baseMs + chunkLength * perCharMs; + delayMs = Math.min(delayMs, maxMs); + + // Apply jitter: random variance within ±jitter range + if (jitter > 0) { + const variance = delayMs * jitter; + delayMs += (Math.random() * 2 - 1) * variance; + delayMs = Math.max(0, Math.round(delayMs)); + } + + return delayMs; +} + async function deliverReplies(params: { replies: ReplyPayload[]; target: string; @@ -214,14 +246,31 @@ async function deliverReplies(params: { runtime: RuntimeEnv; maxBytes: number; textLimit: number; + chunkDelay?: ChunkDelayConfig; }) { - const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit } = params; + const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkDelay } = + params; for (const payload of replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const text = payload.text ?? ""; if (!text && mediaList.length === 0) continue; if (mediaList.length === 0) { - for (const chunk of chunkText(text, textLimit)) { + const chunks = chunkText(text, textLimit); + let isFirst = true; + for (const chunk of chunks) { + if (!isFirst && chunkDelay) { + const delayMs = computeChunkDelay(chunkDelay, chunk.length); + if (delayMs > 0) { + // Send typing indicator during delay + try { + await sendTypingSignal(target, { baseUrl, account, accountId }); + } catch { + /* typing failure is non-fatal */ + } + await sleep(delayMs); + } + } + isFirst = false; await sendMessageSignal(target, chunk, { baseUrl, account, @@ -280,6 +329,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024; const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false; const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts); + const chunkDelay = accountInfo.config.chunkDelay; const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl; const readReceiptsViaDaemon = Boolean(autoStart && sendReadReceipts); @@ -339,6 +389,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi ignoreAttachments, sendReadReceipts, readReceiptsViaDaemon, + chunkDelay, fetchAttachment, deliverReplies, resolveSignalReactionTargets, diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 72195ff78..7267b8848 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -207,6 +207,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { runtime: deps.runtime, maxBytes: deps.mediaMaxBytes, textLimit: deps.textLimit, + chunkDelay: deps.chunkDelay, }); }, onError: (err, info) => { diff --git a/src/signal/monitor/event-handler.types.ts b/src/signal/monitor/event-handler.types.ts index 153bdf501..3775850d8 100644 --- a/src/signal/monitor/event-handler.types.ts +++ b/src/signal/monitor/event-handler.types.ts @@ -1,6 +1,7 @@ import type { HistoryEntry } from "../../auto-reply/reply/history.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ClawdbotConfig } from "../../config/config.js"; +import type { ChunkDelayConfig } from "../../config/types.base.js"; import type { DmPolicy, GroupPolicy, SignalReactionNotificationMode } from "../../config/types.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { SignalSender } from "../identity.js"; @@ -78,6 +79,7 @@ export type SignalEventHandlerDeps = { ignoreAttachments: boolean; sendReadReceipts: boolean; readReceiptsViaDaemon: boolean; + chunkDelay?: ChunkDelayConfig; fetchAttachment: (params: { baseUrl: string; account?: string; @@ -95,6 +97,7 @@ export type SignalEventHandlerDeps = { runtime: RuntimeEnv; maxBytes: number; textLimit: number; + chunkDelay?: ChunkDelayConfig; }) => Promise; resolveSignalReactionTargets: (reaction: SignalReactionMessage) => SignalReactionTarget[]; isSignalReactionMessage: (