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..e925a8c09 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,53 @@ 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; + 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) { - 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 +351,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 +415,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: (