From 7b41d33091dc3b2670f4106d3913a147819d6c89 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 30 Jan 2026 08:53:22 +0000 Subject: [PATCH 1/2] 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 | 7 +++ src/config/types.signal.ts | 3 ++ src/config/zod-schema.core.ts | 9 ++++ src/config/zod-schema.providers-core.ts | 2 + src/signal/monitor.ts | 64 +++++++++++++++++++++-- src/signal/monitor/event-handler.ts | 1 + src/signal/monitor/event-handler.types.ts | 3 ++ 7 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/config/types.base.ts b/src/config/types.base.ts index e7da1ecd8..c74557fca 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -47,6 +47,13 @@ export type HumanDelayConfig = { maxMs?: number; }; +export type ChunkDelayConfig = { + 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 014f62841..1db4ec2c7 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -1,5 +1,6 @@ import type { BlockStreamingCoalesceConfig, + ChunkDelayConfig, DmPolicy, GroupPolicy, MarkdownConfig, @@ -84,6 +85,8 @@ export type SignalAccountConfig = { reactionLevel?: SignalReactionLevel; /** 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 4a8c80bcc..d269d08ea 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -239,6 +239,15 @@ export const HumanDelaySchema = z }) .strict(); +export const ChunkDelaySchema = z + .object({ + perCharMs: z.number().min(0).optional(), + baseMs: z.number().min(0).optional(), + maxMs: z.number().min(0).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 ed7dda22a..363d75d10 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, @@ -534,6 +535,7 @@ export const SignalAccountSchemaBase = z .optional(), reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + chunkDelay: ChunkDelaySchema.optional(), }) .strict(); diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index fee510d02..94b4f88ee 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -1,6 +1,7 @@ import { chunkTextWithMode, resolveChunkMode, 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 { OpenClawConfig } 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 = { @@ -206,6 +207,34 @@ 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: 50, + baseMs: 800, + maxMs: 6000, + jitter: 0.25, +}; + +function computeChunkDelay(config: ChunkDelayConfig, chunkLength: number): number { + 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; @@ -216,15 +245,40 @@ async function deliverReplies(params: { maxBytes: number; textLimit: number; chunkMode: "length" | "newline"; + chunkDelay?: ChunkDelayConfig; }) { - const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } = - params; + const { + replies, + target, + baseUrl, + account, + accountId, + runtime, + maxBytes, + textLimit, + chunkMode, + 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 chunkTextWithMode(text, textLimit, chunkMode)) { + const chunks = chunkTextWithMode(text, textLimit, chunkMode); + let isFirst = true; + for (const chunk of chunks) { + if (!isFirst && chunkDelay) { + const delayMs = computeChunkDelay(chunkDelay, chunk.length); + if (delayMs > 0) { + try { + await sendTypingSignal(target, { baseUrl, account, accountId }); + } catch { + /* typing failure is non-fatal */ + } + await sleep(delayMs); + } + } + isFirst = false; await sendMessageSignal(target, chunk, { baseUrl, account, @@ -284,6 +338,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 startupTimeoutMs = Math.min( @@ -347,6 +402,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi ignoreAttachments, sendReadReceipts, readReceiptsViaDaemon, + chunkDelay, fetchAttachment, deliverReplies: (params) => deliverReplies({ ...params, chunkMode }), 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 34b26d876..96b363e0d 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 { OpenClawConfig } 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: ( From f34ee37ef9e7c8457345c8d189f685acd1d6b8dc Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 30 Jan 2026 10:13:16 +0000 Subject: [PATCH 2/2] fix(signal): add chunkDelay between separate reply payloads Previously chunkDelay only applied between chunks of a single text message (split by newline). When the agent sent multiple separate reply payloads in one turn, they all fired immediately with no delay. This adds the same delay logic to the outer reply loop, so consecutive reply payloads also get typing indicators and natural pauses. --- src/signal/monitor.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 94b4f88ee..e925a8c09 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -259,10 +259,23 @@ async function deliverReplies(params: { chunkMode, chunkDelay, } = params; + let isFirstPayload = true; for (const payload of replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const text = payload.text ?? ""; if (!text && mediaList.length === 0) continue; + if (!isFirstPayload && chunkDelay) { + const delayMs = computeChunkDelay(chunkDelay, (text || "").length); + if (delayMs > 0) { + try { + await sendTypingSignal(target, { baseUrl, account, accountId }); + } catch { + /* typing failure is non-fatal */ + } + await sleep(delayMs); + } + } + isFirstPayload = false; if (mediaList.length === 0) { const chunks = chunkTextWithMode(text, textLimit, chunkMode); let isFirst = true;