* feat(bluebubbles): auto-strip markdown from outbound messages (#7402) * fix(security): add timeout to webhook body reading (#6762) Adds 30-second timeout to readBody() in voice-call, bluebubbles, and nostr webhook handlers. Prevents Slow-Loris DoS (CWE-400, CVSS 7.5). Merged with existing maxBytes protection in voice-call. * fix(security): unify Error objects and lint fixes in webhook timeouts (#6762) * fix: prevent plugins from auto-enabling without user consent (#3961) Changes default plugin enabled state from true to false in enablePluginEntry(). Preserves existing enabled:true values. Fixes #3932. * fix: apply hierarchical mediaMaxMb config to all channels (#8749) Generalizes resolveAttachmentMaxBytes() to use account → channel → global config resolution for all channels, not just BlueBubbles. Fixes #7847. * fix(bluebubbles): sanitize attachment filenames against header injection (#10333) Strip ", \r, \n, and \\ from filenames after path.basename() to prevent multipart Content-Disposition header injection (CWE-93, CVSS 5.4). Also adds sanitization to setGroupIconBlueBubbles which had zero filename sanitization. * fix(lint): exclude extensions/ from Oxlint preflight check (#9313) Extensions use PluginRuntime|null patterns that trigger no-redundant-type-constituents because PluginRuntime resolves to any. Excluding extensions/ from Oxlint unblocks user upgrades. Re-applies the approach from closed PR #10087. * fix(bluebubbles): add tempGuid to createNewChatWithMessage payload (#7745) Non-Private-API mode (AppleScript) requires tempGuid in send payloads. The main sendMessageBlueBubbles already had it, but createNewChatWithMessage was missing it, causing 400 errors for new chat creation without Private API. * fix: send stop-typing signal when run ends with NO_REPLY (#8785) Adds onCleanup callback to the typing controller that fires when the controller is cleaned up while typing was active (e.g., after NO_REPLY). Channels using createTypingCallbacks automatically get stop-typing on cleanup. This prevents the typing indicator from lingering in group chats when the agent decides not to reply. * fix(telegram): deduplicate skill commands in multi-agent setup (#5717) Two fixes: 1. Skip duplicate workspace dirs when listing skill commands across agents. Multiple agents sharing the same workspace would produce duplicate commands with _2, _3 suffixes. 2. Clear stale commands via deleteMyCommands before registering new ones. Commands from deleted skills now get cleaned up on restart. * fix: add size limits to unbounded in-memory caches (#4948) Adds max-size caps with oldest-entry eviction to prevent OOM in long-running deployments: - BlueBubbles serverInfoCache: 64 entries (already has TTL) - Google Chat authCache: 32 entries - Matrix directRoomCache: 1024 entries - Discord presenceCache: 5000 entries per account * fix: address review concerns (#11093) - Chain deleteMyCommands → setMyCommands to prevent race condition (#5717) - Rename enablePluginEntry to registerPluginEntry (now sets enabled: false) - Add Slow-Loris timeout test for readJsonBody (#6023)
197 lines
4.4 KiB
TypeScript
197 lines
4.4 KiB
TypeScript
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
|
|
|
export type TypingController = {
|
|
onReplyStart: () => Promise<void>;
|
|
startTypingLoop: () => Promise<void>;
|
|
startTypingOnText: (text?: string) => Promise<void>;
|
|
refreshTypingTtl: () => void;
|
|
isActive: () => boolean;
|
|
markRunComplete: () => void;
|
|
markDispatchIdle: () => void;
|
|
cleanup: () => void;
|
|
};
|
|
|
|
export function createTypingController(params: {
|
|
onReplyStart?: () => Promise<void> | void;
|
|
onCleanup?: () => void;
|
|
typingIntervalSeconds?: number;
|
|
typingTtlMs?: number;
|
|
silentToken?: string;
|
|
log?: (message: string) => void;
|
|
}): TypingController {
|
|
const {
|
|
onReplyStart,
|
|
onCleanup,
|
|
typingIntervalSeconds = 6,
|
|
typingTtlMs = 2 * 60_000,
|
|
silentToken = SILENT_REPLY_TOKEN,
|
|
log,
|
|
} = params;
|
|
let started = false;
|
|
let active = false;
|
|
let runComplete = false;
|
|
let dispatchIdle = false;
|
|
// Important: callbacks (tool/block streaming) can fire late (after the run completed),
|
|
// especially when upstream event emitters don't await async listeners.
|
|
// Once we stop typing, we "seal" the controller so late events can't restart typing forever.
|
|
let sealed = false;
|
|
let typingTimer: NodeJS.Timeout | undefined;
|
|
let typingTtlTimer: NodeJS.Timeout | undefined;
|
|
const typingIntervalMs = typingIntervalSeconds * 1000;
|
|
|
|
const formatTypingTtl = (ms: number) => {
|
|
if (ms % 60_000 === 0) {
|
|
return `${ms / 60_000}m`;
|
|
}
|
|
return `${Math.round(ms / 1000)}s`;
|
|
};
|
|
|
|
const resetCycle = () => {
|
|
started = false;
|
|
active = false;
|
|
runComplete = false;
|
|
dispatchIdle = false;
|
|
};
|
|
|
|
const cleanup = () => {
|
|
if (sealed) {
|
|
return;
|
|
}
|
|
if (typingTtlTimer) {
|
|
clearTimeout(typingTtlTimer);
|
|
typingTtlTimer = undefined;
|
|
}
|
|
if (typingTimer) {
|
|
clearInterval(typingTimer);
|
|
typingTimer = undefined;
|
|
}
|
|
// Notify the channel to stop its typing indicator (e.g., on NO_REPLY).
|
|
// This fires only once (sealed prevents re-entry).
|
|
if (active) {
|
|
onCleanup?.();
|
|
}
|
|
resetCycle();
|
|
sealed = true;
|
|
};
|
|
|
|
const refreshTypingTtl = () => {
|
|
if (sealed) {
|
|
return;
|
|
}
|
|
if (!typingIntervalMs || typingIntervalMs <= 0) {
|
|
return;
|
|
}
|
|
if (typingTtlMs <= 0) {
|
|
return;
|
|
}
|
|
if (typingTtlTimer) {
|
|
clearTimeout(typingTtlTimer);
|
|
}
|
|
typingTtlTimer = setTimeout(() => {
|
|
if (!typingTimer) {
|
|
return;
|
|
}
|
|
log?.(`typing TTL reached (${formatTypingTtl(typingTtlMs)}); stopping typing indicator`);
|
|
cleanup();
|
|
}, typingTtlMs);
|
|
};
|
|
|
|
const isActive = () => active && !sealed;
|
|
|
|
const triggerTyping = async () => {
|
|
if (sealed) {
|
|
return;
|
|
}
|
|
await onReplyStart?.();
|
|
};
|
|
|
|
const ensureStart = async () => {
|
|
if (sealed) {
|
|
return;
|
|
}
|
|
// Late callbacks after a run completed should never restart typing.
|
|
if (runComplete) {
|
|
return;
|
|
}
|
|
if (!active) {
|
|
active = true;
|
|
}
|
|
if (started) {
|
|
return;
|
|
}
|
|
started = true;
|
|
await triggerTyping();
|
|
};
|
|
|
|
const maybeStopOnIdle = () => {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
// Stop only when the model run is done and the dispatcher queue is empty.
|
|
if (runComplete && dispatchIdle) {
|
|
cleanup();
|
|
}
|
|
};
|
|
|
|
const startTypingLoop = async () => {
|
|
if (sealed) {
|
|
return;
|
|
}
|
|
if (runComplete) {
|
|
return;
|
|
}
|
|
// Always refresh TTL when called, even if loop already running.
|
|
// This keeps typing alive during long tool executions.
|
|
refreshTypingTtl();
|
|
if (!onReplyStart) {
|
|
return;
|
|
}
|
|
if (typingIntervalMs <= 0) {
|
|
return;
|
|
}
|
|
if (typingTimer) {
|
|
return;
|
|
}
|
|
await ensureStart();
|
|
typingTimer = setInterval(() => {
|
|
void triggerTyping();
|
|
}, typingIntervalMs);
|
|
};
|
|
|
|
const startTypingOnText = async (text?: string) => {
|
|
if (sealed) {
|
|
return;
|
|
}
|
|
const trimmed = text?.trim();
|
|
if (!trimmed) {
|
|
return;
|
|
}
|
|
if (silentToken && isSilentReplyText(trimmed, silentToken)) {
|
|
return;
|
|
}
|
|
refreshTypingTtl();
|
|
await startTypingLoop();
|
|
};
|
|
|
|
const markRunComplete = () => {
|
|
runComplete = true;
|
|
maybeStopOnIdle();
|
|
};
|
|
|
|
const markDispatchIdle = () => {
|
|
dispatchIdle = true;
|
|
maybeStopOnIdle();
|
|
};
|
|
|
|
return {
|
|
onReplyStart: ensureStart,
|
|
startTypingLoop,
|
|
startTypingOnText,
|
|
refreshTypingTtl,
|
|
isActive,
|
|
markRunComplete,
|
|
markDispatchIdle,
|
|
cleanup,
|
|
};
|
|
}
|