diff --git a/src/modules/chimeIn.js b/src/modules/chimeIn.js index 8e858342..3d13b9f3 100644 --- a/src/modules/chimeIn.js +++ b/src/modules/chimeIn.js @@ -11,6 +11,7 @@ import { info, warn, error as logError } from '../logger.js'; import { OPENCLAW_URL, OPENCLAW_TOKEN } from './ai.js'; +import { splitMessage, needsSplitting } from '../utils/splitMessage.js'; // ── Per-channel state ────────────────────────────────────────────────────────── // Map, counter: number, lastActive: number, abortController: AbortController|null }> @@ -258,20 +259,8 @@ export async function accumulate(message, config) { warn('ChimeIn suppressed empty response', { channelId }); } else { // Send as a plain channel message (not a reply) - if (response.length > 2000) { - // Split on word boundaries to avoid breaking mid-word/URL/emoji - const chunks = []; - let remaining = response; - while (remaining.length > 0) { - if (remaining.length <= 1990) { - chunks.push(remaining); - break; - } - let splitAt = remaining.lastIndexOf(' ', 1990); - if (splitAt <= 0) splitAt = 1990; - chunks.push(remaining.slice(0, splitAt)); - remaining = remaining.slice(splitAt).trimStart(); - } + if (needsSplitting(response)) { + const chunks = splitMessage(response); for (const chunk of chunks) { await message.channel.send(chunk); } diff --git a/src/modules/events.js b/src/modules/events.js index 59ed7488..14ab4f86 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -7,6 +7,7 @@ import { sendWelcomeMessage } from './welcome.js'; import { isSpam, sendSpamAlert } from './spam.js'; import { generateResponse } from './ai.js'; import { accumulate, resetCounter } from './chimeIn.js'; +import { splitMessage, needsSplitting } from '../utils/splitMessage.js'; /** * Register bot ready event handler @@ -100,8 +101,8 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { ); // Split long responses - if (response.length > 2000) { - const chunks = response.match(/[\s\S]{1,1990}/g) || []; + if (needsSplitting(response)) { + const chunks = splitMessage(response); for (const chunk of chunks) { await message.channel.send(chunk); } diff --git a/src/utils/splitMessage.js b/src/utils/splitMessage.js new file mode 100644 index 00000000..66839963 --- /dev/null +++ b/src/utils/splitMessage.js @@ -0,0 +1,61 @@ +/** + * Split Message Utility + * Splits long messages to fit within Discord's 2000-character limit. + */ + +/** + * Discord's maximum message length. + */ +const DISCORD_MAX_LENGTH = 2000; + +/** + * Safe chunk size leaving room for potential overhead. + */ +const SAFE_CHUNK_SIZE = 1990; + +/** + * Splits a message into chunks that fit within Discord's character limit. + * Attempts to split on word boundaries to avoid breaking words, URLs, or emoji. + * + * @param {string} text - The text to split + * @param {number} [maxLength=1990] - Maximum length per chunk (default 1990 to stay under 2000) + * @returns {string[]} Array of text chunks, each within the specified limit + */ +export function splitMessage(text, maxLength = SAFE_CHUNK_SIZE) { + if (!text || text.length <= maxLength) { + return text ? [text] : []; + } + + const chunks = []; + let remaining = text; + + while (remaining.length > 0) { + if (remaining.length <= maxLength) { + chunks.push(remaining); + break; + } + + // Try to find a space to split on (word boundary) + let splitAt = remaining.lastIndexOf(' ', maxLength); + + // If no space found or it's at the start, force split at maxLength + if (splitAt <= 0) { + splitAt = maxLength; + } + + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).trimStart(); + } + + return chunks; +} + +/** + * Checks if a message exceeds Discord's character limit. + * + * @param {string} text - The text to check + * @returns {boolean} True if the message needs splitting + */ +export function needsSplitting(text) { + return text && text.length > DISCORD_MAX_LENGTH; +}