This commit is contained in:
vilkasdev 2026-01-30 17:09:04 +01:00 committed by GitHub
commit b73ef57460
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 98 additions and 4 deletions

View File

@ -47,6 +47,13 @@ export type HumanDelayConfig = {
maxMs?: number; maxMs?: number;
}; };
export type ChunkDelayConfig = {
perCharMs?: number;
baseMs?: number;
maxMs?: number;
jitter?: number;
};
export type SessionSendPolicyAction = "allow" | "deny"; export type SessionSendPolicyAction = "allow" | "deny";
export type SessionSendPolicyMatch = { export type SessionSendPolicyMatch = {
channel?: string; channel?: string;

View File

@ -1,5 +1,6 @@
import type { import type {
BlockStreamingCoalesceConfig, BlockStreamingCoalesceConfig,
ChunkDelayConfig,
DmPolicy, DmPolicy,
GroupPolicy, GroupPolicy,
MarkdownConfig, MarkdownConfig,
@ -84,6 +85,8 @@ export type SignalAccountConfig = {
reactionLevel?: SignalReactionLevel; reactionLevel?: SignalReactionLevel;
/** Heartbeat visibility settings for this channel. */ /** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig; heartbeat?: ChannelHeartbeatVisibilityConfig;
/** Chunk delay config for realistic typing delays between message chunks. */
chunkDelay?: ChunkDelayConfig;
}; };
export type SignalConfig = { export type SignalConfig = {

View File

@ -239,6 +239,15 @@ export const HumanDelaySchema = z
}) })
.strict(); .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 export const CliBackendSchema = z
.object({ .object({
command: z.string(), command: z.string(),

View File

@ -3,6 +3,7 @@ import { z } from "zod";
import { import {
BlockStreamingChunkSchema, BlockStreamingChunkSchema,
BlockStreamingCoalesceSchema, BlockStreamingCoalesceSchema,
ChunkDelaySchema,
DmConfigSchema, DmConfigSchema,
DmPolicySchema, DmPolicySchema,
ExecutableTokenSchema, ExecutableTokenSchema,
@ -534,6 +535,7 @@ export const SignalAccountSchemaBase = z
.optional(), .optional(),
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema, heartbeat: ChannelHeartbeatVisibilitySchema,
chunkDelay: ChunkDelaySchema.optional(),
}) })
.strict(); .strict();

View File

@ -1,6 +1,7 @@
import { chunkTextWithMode, resolveChunkMode, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { chunkTextWithMode, resolveChunkMode, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js"; import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js";
import type { ReplyPayload } from "../auto-reply/types.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 type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import type { SignalReactionNotificationMode } from "../config/types.js"; import type { SignalReactionNotificationMode } from "../config/types.js";
@ -13,7 +14,7 @@ import { signalCheck, signalRpcRequest } from "./client.js";
import { spawnSignalDaemon } from "./daemon.js"; import { spawnSignalDaemon } from "./daemon.js";
import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js"; import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js";
import { createSignalEventHandler } from "./monitor/event-handler.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"; import { runSignalSseLoop } from "./sse-reconnect.js";
type SignalReactionMessage = { type SignalReactionMessage = {
@ -206,6 +207,34 @@ async function fetchAttachment(params: {
return { path: saved.path, contentType: saved.contentType }; 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: { async function deliverReplies(params: {
replies: ReplyPayload[]; replies: ReplyPayload[];
target: string; target: string;
@ -216,15 +245,53 @@ async function deliverReplies(params: {
maxBytes: number; maxBytes: number;
textLimit: number; textLimit: number;
chunkMode: "length" | "newline"; chunkMode: "length" | "newline";
chunkDelay?: ChunkDelayConfig;
}) { }) {
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } = const {
params; replies,
target,
baseUrl,
account,
accountId,
runtime,
maxBytes,
textLimit,
chunkMode,
chunkDelay,
} = params;
let isFirstPayload = true;
for (const payload of replies) { for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? ""; const text = payload.text ?? "";
if (!text && mediaList.length === 0) continue; 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) { 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, { await sendMessageSignal(target, chunk, {
baseUrl, baseUrl,
account, account,
@ -284,6 +351,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024; const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false; const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts); const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts);
const chunkDelay = accountInfo.config.chunkDelay;
const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl; const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl;
const startupTimeoutMs = Math.min( const startupTimeoutMs = Math.min(
@ -347,6 +415,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
ignoreAttachments, ignoreAttachments,
sendReadReceipts, sendReadReceipts,
readReceiptsViaDaemon, readReceiptsViaDaemon,
chunkDelay,
fetchAttachment, fetchAttachment,
deliverReplies: (params) => deliverReplies({ ...params, chunkMode }), deliverReplies: (params) => deliverReplies({ ...params, chunkMode }),
resolveSignalReactionTargets, resolveSignalReactionTargets,

View File

@ -207,6 +207,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
runtime: deps.runtime, runtime: deps.runtime,
maxBytes: deps.mediaMaxBytes, maxBytes: deps.mediaMaxBytes,
textLimit: deps.textLimit, textLimit: deps.textLimit,
chunkDelay: deps.chunkDelay,
}); });
}, },
onError: (err, info) => { onError: (err, info) => {

View File

@ -1,6 +1,7 @@
import type { HistoryEntry } from "../../auto-reply/reply/history.js"; import type { HistoryEntry } from "../../auto-reply/reply/history.js";
import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyPayload } from "../../auto-reply/types.js";
import type { OpenClawConfig } from "../../config/config.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 { DmPolicy, GroupPolicy, SignalReactionNotificationMode } from "../../config/types.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import type { SignalSender } from "../identity.js"; import type { SignalSender } from "../identity.js";
@ -78,6 +79,7 @@ export type SignalEventHandlerDeps = {
ignoreAttachments: boolean; ignoreAttachments: boolean;
sendReadReceipts: boolean; sendReadReceipts: boolean;
readReceiptsViaDaemon: boolean; readReceiptsViaDaemon: boolean;
chunkDelay?: ChunkDelayConfig;
fetchAttachment: (params: { fetchAttachment: (params: {
baseUrl: string; baseUrl: string;
account?: string; account?: string;
@ -95,6 +97,7 @@ export type SignalEventHandlerDeps = {
runtime: RuntimeEnv; runtime: RuntimeEnv;
maxBytes: number; maxBytes: number;
textLimit: number; textLimit: number;
chunkDelay?: ChunkDelayConfig;
}) => Promise<void>; }) => Promise<void>;
resolveSignalReactionTargets: (reaction: SignalReactionMessage) => SignalReactionTarget[]; resolveSignalReactionTargets: (reaction: SignalReactionMessage) => SignalReactionTarget[];
isSignalReactionMessage: ( isSignalReactionMessage: (