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
This commit is contained in:
parent
445b58550c
commit
3f26c87156
@ -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;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
ChunkDelayConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
MarkdownConfig,
|
||||
@ -66,6 +67,8 @@ export type SignalAccountConfig = {
|
||||
reactionAllowlist?: Array<string | number>;
|
||||
/** Heartbeat visibility settings for this channel. */
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
/** Chunk delay config for realistic typing delays between message chunks. */
|
||||
chunkDelay?: ChunkDelayConfig;
|
||||
};
|
||||
|
||||
export type SignalConfig = {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -207,6 +207,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
runtime: deps.runtime,
|
||||
maxBytes: deps.mediaMaxBytes,
|
||||
textLimit: deps.textLimit,
|
||||
chunkDelay: deps.chunkDelay,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
|
||||
@ -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<void>;
|
||||
resolveSignalReactionTargets: (reaction: SignalReactionMessage) => SignalReactionTarget[];
|
||||
isSignalReactionMessage: (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user