From e813ad2608475300239e0ab0551a5d0f13f8268d Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Sun, 16 Aug 2026 10:11:55 -0400 Subject: [PATCH] Split Messages: auto-split over-limit messages --- plugins/split-messages/manifest.json | 8 +++ plugins/split-messages/src/index.ts | 69 ++++++++++++++++++ plugins/split-messages/src/lib/split.ts | 82 ++++++++++++++++++++++ plugins/split-messages/src/ui/Settings.tsx | 26 +++++++ 4 files changed, 185 insertions(+) create mode 100644 plugins/split-messages/manifest.json create mode 100644 plugins/split-messages/src/index.ts create mode 100644 plugins/split-messages/src/lib/split.ts create mode 100644 plugins/split-messages/src/ui/Settings.tsx diff --git a/plugins/split-messages/manifest.json b/plugins/split-messages/manifest.json new file mode 100644 index 0000000..9e47a49 --- /dev/null +++ b/plugins/split-messages/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "Split Messages", + "description": "Automatically splits messages over Discord's character limit into multiple sequential messages instead of blocking the send.", + "authors": [ + { "name": "Rosie" } + ], + "main": "src/index.ts" +} diff --git a/plugins/split-messages/src/index.ts b/plugins/split-messages/src/index.ts new file mode 100644 index 0000000..72f21dc --- /dev/null +++ b/plugins/split-messages/src/index.ts @@ -0,0 +1,69 @@ +import { findByProps, findByStoreName } from "@vendetta/metro"; +import { before } from "@vendetta/patcher"; +import { storage } from "@vendetta/plugin"; +import { getAssetIDByName } from "@vendetta/ui/assets"; +import { showToast } from "@vendetta/ui/toasts"; +import { intoChunks } from "./lib/split"; +import Settings from "./ui/Settings"; + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +let unpatch: (() => boolean) | undefined; + +export default { + onLoad() { + storage.splitOnWords ??= false; + + const Constants = findByProps("MAX_MESSAGE_LENGTH"); + const MessageActions = findByProps("sendMessage", "editMessage"); + const UserStore = findByStoreName("UserStore"); + const ChannelStore = findByStoreName("ChannelStore"); + + Constants.MAX_MESSAGE_LENGTH = 2 ** 30; + Constants.MAX_MESSAGE_LENGTH_PREMIUM = 2 ** 30; + + unpatch = before("sendMessage", MessageActions, (args: any[]) => { + const [channelId, message] = args; + const content: string = message?.content ?? ""; + const maxLength = UserStore.getCurrentUser()?.premiumType === 2 ? 4000 : 2000; + if (content.length <= maxLength) return; + + const chunks = intoChunks(content, maxLength, storage.splitOnWords); + if (!chunks) { + message.content = ""; + showToast("Message too long to split", getAssetIDByName("Small")); + return; + } + + message.content = chunks.shift(); + + const channel = ChannelStore.getChannel(channelId); + const delay = Math.max((channel?.rateLimitPerUser ?? 0) * 1000, 1000); + + (async () => { + for (const chunk of chunks) { + await sleep(delay); + await MessageActions._sendMessage( + channelId, + { + invalidEmojis: message.invalidEmojis, + validNonShortcutEmojis: message.validNonShortcutEmojis, + tts: false, + content: chunk, + }, + {} + ); + } + })(); + }); + }, + onUnload() { + unpatch?.(); + const Constants = findByProps("MAX_MESSAGE_LENGTH"); + Constants.MAX_MESSAGE_LENGTH = 2000; + Constants.MAX_MESSAGE_LENGTH_PREMIUM = 4000; + }, + settings: Settings, +}; diff --git a/plugins/split-messages/src/lib/split.ts b/plugins/split-messages/src/lib/split.ts new file mode 100644 index 0000000..ab038d4 --- /dev/null +++ b/plugins/split-messages/src/lib/split.ts @@ -0,0 +1,82 @@ +const CODE_BLOCK_RE = /```[\s\S]*?```/g; + +interface Piece { + text: string; + isCode: boolean; +} + +function segment(content: string): Piece[] { + const pieces: Piece[] = []; + let lastIndex = 0; + for (const match of content.matchAll(CODE_BLOCK_RE)) { + const index = match.index ?? 0; + if (index > lastIndex) pieces.push({ text: content.slice(lastIndex, index), isCode: false }); + pieces.push({ text: match[0], isCode: true }); + lastIndex = index + match[0].length; + } + if (lastIndex < content.length) pieces.push({ text: content.slice(lastIndex), isCode: false }); + return pieces; +} + +function packByLine(lines: string[], maxLength: number, sep: string): string[] { + const chunks: string[] = []; + let current = ""; + for (const line of lines) { + const candidate = current ? current + sep + line : line; + if (candidate.length > maxLength) { + if (current) chunks.push(current); + current = line; + } else { + current = candidate; + } + } + if (current) chunks.push(current); + return chunks; +} + +function splitText(text: string, maxLength: number, splitOnWords: boolean): string[] { + if (!splitOnWords) { + const chunks = packByLine(text.split("\n"), maxLength, "\n"); + if (chunks.length && !chunks.some((c) => c.length > maxLength)) return chunks; + } + return packByLine(text.split(" "), maxLength, " "); +} + +function splitCodeBlock(block: string, maxLength: number): string[] { + if (block.length <= maxLength) return [block]; + + const fenceMatch = block.match(/^```(\S*)\n/); + const lang = fenceMatch?.[1] ?? ""; + const innerStart = fenceMatch ? fenceMatch[0].length : 3; + const inner = block.slice(innerStart, block.length - 3); + const fenceOverhead = lang.length + 8; + + return packByLine(inner.split("\n"), maxLength - fenceOverhead, "\n").map( + (c) => "```" + lang + "\n" + c + "\n```" + ); +} + +export function intoChunks(content: string, maxLength: number, splitOnWords: boolean): string[] | false { + const atoms: string[] = []; + for (const piece of segment(content)) { + if (!piece.text) continue; + if (piece.isCode) atoms.push(...splitCodeBlock(piece.text, maxLength)); + else atoms.push(...splitText(piece.text, maxLength, splitOnWords)); + } + if (atoms.some((a) => a.length > maxLength)) return false; + + const chunks: string[] = []; + let current = ""; + for (const atom of atoms) { + const candidate = current ? current + "\n" + atom : atom; + if (candidate.length > maxLength) { + if (current) chunks.push(current); + current = atom; + } else { + current = candidate; + } + } + if (current) chunks.push(current); + + return chunks.map((c) => c.trim()).filter(Boolean); +} diff --git a/plugins/split-messages/src/ui/Settings.tsx b/plugins/split-messages/src/ui/Settings.tsx new file mode 100644 index 0000000..5bdde5a --- /dev/null +++ b/plugins/split-messages/src/ui/Settings.tsx @@ -0,0 +1,26 @@ +import { storage } from "@vendetta/plugin"; +import { useProxy } from "@vendetta/storage"; +import SettingsScaffold from "@shared/ui/SettingsScaffold"; +import NoteBox from "@shared/ui/NoteBox"; +import { TableRowGroup, TableSwitchRow } from "@shared/ui/table"; + +export default function Settings() { + useProxy(storage); + + return ( + + + Messages over Discord's character limit (2000, or 4000 with Nitro) are split into + multiple messages and sent in order automatically. + + + { storage.splitOnWords = v; }} + /> + + + ); +}