Split Messages: auto-split over-limit messages
This commit is contained in:
parent
d1a72178a2
commit
e813ad2608
4 changed files with 185 additions and 0 deletions
8
plugins/split-messages/manifest.json
Normal file
8
plugins/split-messages/manifest.json
Normal 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"
|
||||
}
|
||||
69
plugins/split-messages/src/index.ts
Normal file
69
plugins/split-messages/src/index.ts
Normal 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,
|
||||
};
|
||||
82
plugins/split-messages/src/lib/split.ts
Normal file
82
plugins/split-messages/src/lib/split.ts
Normal 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);
|
||||
}
|
||||
26
plugins/split-messages/src/ui/Settings.tsx
Normal file
26
plugins/split-messages/src/ui/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue