Split Messages: auto-split over-limit messages

This commit is contained in:
Ally Founder 2026-08-16 10:11:55 -04:00
parent d1a72178a2
commit e813ad2608
4 changed files with 185 additions and 0 deletions

View file

@ -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"
}

View file

@ -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<void> {
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,
};

View file

@ -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);
}

View file

@ -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 (
<SettingsScaffold>
<NoteBox>
Messages over Discord's character limit (2000, or 4000 with Nitro) are split into
multiple messages and sent in order automatically.
</NoteBox>
<TableRowGroup title="Splitting">
<TableSwitchRow
label="Split on words instead of paragraphs"
subLabel="Off: keep paragraphs together where possible. On: split at any word boundary"
value={!!storage.splitOnWords}
onValueChange={(v: boolean) => { storage.splitOnWords = v; }}
/>
</TableRowGroup>
</SettingsScaffold>
);
}