revenge-plugins/docs/superpowers/specs/2026-08-16-split-messages-design.md

56 lines
5.6 KiB
Markdown
Raw Normal View History

2026-08-16 10:07:42 -04:00
# Split Messages - Design Spec
## Goal
When a composed message exceeds Discord's character limit (2000, or 4000 with Nitro), automatically split it into multiple sequential messages instead of showing Discord's native "message too long" popup and blocking the send.
Built for classic (Vendetta-compat, `/root/revenge-plugins`) first, to validate the concept before porting to Next.
## Reference
Ported from `sunnniee/vendetta-plugins`'s `SplitLargeMessages` (https://github.com/sunnniee/vendetta-plugins/tree/master/plugins/SplitLargeMessages), live-verified against a connected classic client on this session's Discord build:
- `findByProps("MAX_MESSAGE_LENGTH")` exists, with `MAX_MESSAGE_LENGTH: 2000` and `MAX_MESSAGE_LENGTH_PREMIUM: 4000`.
- `findByProps("sendMessage", "editMessage")._sendMessage` exists as a callable function.
- `UserStore.getCurrentUser().premiumType` reflects Nitro status (2 = full Nitro).
## Architecture
On `onLoad`:
1. Locate `Constants = findByProps("MAX_MESSAGE_LENGTH")` and raise `MAX_MESSAGE_LENGTH`/`MAX_MESSAGE_LENGTH_PREMIUM` to a very large number (`2 ** 30`). This is what stops Discord's own composer from ever showing the native "too long" popup or blocking the send button - the check that shows that popup reads these constants directly, not any `MessageActions` function (confirmed live: neither `sendMessage` nor `validateMessage` were called at all when a real over-limit send was attempted before this fix).
2. Patch `before` on `MessageActions.sendMessage`. Inside the hook:
- Read the real limit for this send: `UserStore.getCurrentUser()?.premiumType === 2 ? 4000 : 2000`.
- If `content.length` is under that limit, return immediately (no-op, real send proceeds normally).
- Otherwise, run the splitting algorithm (below) to get an ordered list of chunks.
- If splitting fails (an unsplittable token longer than the limit), clear `args[1].content` to `""`, show an error toast, and return - nothing sends.
- Otherwise, mutate `args[1].content` to the FIRST chunk only, so the original `sendMessage` call proceeds as a normal send for chunk 1.
- Fire an async loop (not awaited by the hook itself) that sends the remaining chunks in order via `MessageActions._sendMessage(channelId, { ...same invalidEmojis/validNonShortcutEmojis/tts, content: chunk }, {})`, sleeping `Math.max(channel.rateLimitPerUser, 1000)` ms (channel slowmode, 1s floor) between each.
On `onUnload`: restore `Constants.MAX_MESSAGE_LENGTH = 2000` and `MAX_MESSAGE_LENGTH_PREMIUM = 4000`, and unpatch the hook, so nothing is left in a broken state if disabled.
## Splitting algorithm
Input: `content: string`, `maxLength: number`. Output: `string[] | false` (false = unsplittable).
1. **Segment** the content into an ordered list of pieces by extracting fenced code blocks (` ```...``` `, including the fence lines and any language tag) as atomic units, with everything between/around them as plain-text pieces. A code block piece is never split internally unless it alone exceeds `maxLength` (see step 3).
2. **Plain-text pieces**: split by paragraph first (newline-separated), greedily packing paragraphs into sub-chunks up to `maxLength` characters (matching the reference's reduce-based packing). If any resulting sub-chunk is still over `maxLength` (a single paragraph longer than the limit with no newlines to split on), fall back to packing whole words (space-separated) instead, for that piece.
3. **Code-block pieces**: if the whole block (fences included) fits within `maxLength`, keep it as one atomic piece. If it doesn't fit even alone, split its inner lines into groups that fit (accounting for the extra ` ``` ` fence overhead added to each group), and re-wrap each group with its own opening/closing fence so every resulting message still renders as a code block.
4. **Pack**: greedily accumulate the ordered pieces (text sub-chunks and code-block pieces) into final chunks up to `maxLength`, same greedy strategy as the reference - never split a code-block piece across this final packing step.
5. **Failure**: if any single atomic piece from steps 2-3 is still longer than `maxLength` on its own (e.g. one word/URL/token with no break points), splitting fails - return `false`.
Setting: `storage.splitOnWords` (boolean, default `false`) - when true, skip the paragraph-first pass for plain text and go straight to word-based splitting, matching the reference's existing toggle.
## Explicitly out of scope
- Editing an existing message into an over-limit state (matches the reference - only sending is handled).
- Any "split into N messages" notification - silent by design, per user's call.
- The Next port - this spec covers classic only; Next's plugin architecture differs enough (different module system, different `MessageActions` equivalent) that it needs its own investigation once this is validated.
## Testing
No automated test suite exists for this repo's plugins (matches the rest of the codebase - verification is live, against a connected devtools client). Verification plan:
- Live-verify the raised constants actually suppress the native popup for an over-2000/over-4000 character message.
- Live-verify a plain-text-only over-limit message splits and sends correctly, with all chunks arriving in order.
- Live-verify a message containing a code block that would otherwise straddle a split point instead keeps the code block intact in one message.
- Live-verify the unsplittable-token failure case (a single 5000-character no-space token) shows the error toast and sends nothing.
- Live-verify `onUnload` restores native behavior (native popup returns for an over-limit message after disabling the plugin).