Add BaseConverter plugin#4222
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the BaseConverter plugin, which allows users to encode and decode messages in various formats, including AES-256-GCM, directly within the Discord interface. The review feedback primarily focuses on improving the robustness of the encoding and decoding logic by utilizing TextEncoder and TextDecoder to correctly handle multi-byte Unicode characters and emojis. Additionally, the reviewer suggested refining the AES key resolution logic for group DMs, replacing legacy base64 decoding hacks, and addressing the fragility of manual DOM manipulation for reply previews to ensure consistent markdown parsing.
| function decodeBinary(text: string): string { | ||
| const parts = text.trim().split(/\s+/); | ||
| if (!parts.every(p => /^[01]{1,8}$/.test(p))) throw new Error("Invalid binary"); | ||
| return parts.map(b => String.fromCharCode(parseInt(b, 2))).join(""); | ||
| } | ||
|
|
||
| function decodeOctal(text: string): string { | ||
| const parts = text.trim().split(/\s+/); | ||
| if (!parts.every(p => /^[0-7]+$/.test(p))) throw new Error("Invalid octal"); | ||
| return parts.map(o => String.fromCharCode(parseInt(o, 8))).join(""); | ||
| } | ||
|
|
||
| function decodeDecimal(text: string): string { | ||
| const parts = text.trim().split(/\s+/); | ||
| if (!parts.every(p => /^\d+$/.test(p))) throw new Error("Invalid decimal"); | ||
| return parts.map(d => String.fromCharCode(Number(d))).join(""); | ||
| } | ||
|
|
||
| function decodeHex(text: string): string { | ||
| const stripped = text.trim().replace(/0x/gi, ""); | ||
| const cleaned = /\s/.test(stripped) | ||
| ? stripped.split(/\s+/).map(s => s.padStart(2, "0")).join("") | ||
| : stripped; | ||
| if (!/^[0-9a-fA-F]+$/.test(cleaned) || cleaned.length % 2 !== 0) | ||
| throw new Error("Invalid hex"); | ||
| return cleaned.match(/.{2}/g)!.map(h => String.fromCharCode(parseInt(h, 16))).join(""); | ||
| } |
There was a problem hiding this comment.
The decodeBinary, decodeOctal, decodeDecimal, and decodeHex functions use String.fromCharCode on the parsed integer values. This treats each encoded unit as a single UTF-16 code point (effectively Latin-1), which will fail to correctly reconstruct multi-byte Unicode characters (like emojis or non-Latin scripts) if they were encoded as UTF-8 bytes.
Instead, you should collect the values into a Uint8Array and use TextDecoder to properly decode the resulting byte sequence as UTF-8.
function decodeBinary(text: string): string {
const parts = text.trim().split(/\s+/);
if (!parts.every(p => /^[01]{1,8}$/.test(p))) throw new Error("Invalid binary");
const bytes = new Uint8Array(parts.map(b => parseInt(b, 2)));
return new TextDecoder().decode(bytes);
}
function decodeOctal(text: string): string {
const parts = text.trim().split(/\s+/);
if (!parts.every(p => /^[0-7]+$/.test(p))) throw new Error("Invalid octal");
const bytes = new Uint8Array(parts.map(o => parseInt(o, 8)));
return new TextDecoder().decode(bytes);
}
function decodeDecimal(text: string): string {
const parts = text.trim().split(/\s+/);
if (!parts.every(p => /^\d+$/.test(p))) throw new Error("Invalid decimal");
const bytes = new Uint8Array(parts.map(d => Number(d)));
return new TextDecoder().decode(bytes);
}
function decodeHex(text: string): string {
const stripped = text.trim().replace(/0x/gi, "");
const cleaned = /\s/.test(stripped)
? stripped.split(/\s+/).map(s => s.padStart(2, "0")).join("")
: stripped;
if (!/^[0-9a-fA-F]+$/.test(cleaned) || cleaned.length % 2 !== 0)
throw new Error("Invalid hex");
const bytes = new Uint8Array(cleaned.match(/.{2}/g)!.map(h => parseInt(h, 16)));
return new TextDecoder().decode(bytes);
}| function encodeBinary(text: string): string { | ||
| return Array.from(text).map(c => c.charCodeAt(0).toString(2).padStart(8, "0")).join(" "); | ||
| } | ||
|
|
||
| function encodeOctal(text: string): string { | ||
| return Array.from(text).map(c => c.charCodeAt(0).toString(8)).join(" "); | ||
| } | ||
|
|
||
| function encodeDecimal(text: string): string { | ||
| return Array.from(text).map(c => String(c.charCodeAt(0))).join(" "); | ||
| } | ||
|
|
||
| function encodeHex(text: string): string { | ||
| return Array.from(text).map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join(" "); | ||
| } | ||
|
|
There was a problem hiding this comment.
Similar to the decoders, the encodeBinary, encodeOctal, encodeDecimal, and encodeHex functions use charCodeAt(0), which only returns the first 16-bit code unit of a character. This breaks for any characters outside the Basic Multilingual Plane (e.g., emojis).
You should first convert the string to UTF-8 bytes using TextEncoder, then iterate over those bytes to generate the encoded string.
function encodeBinary(text: string): string {
const bytes = new TextEncoder().encode(text);
return Array.from(bytes).map(b => b.toString(2).padStart(8, "0")).join(" ");
}
function encodeOctal(text: string): string {
const bytes = new TextEncoder().encode(text);
return Array.from(bytes).map(b => b.toString(8)).join(" ");
}
function encodeDecimal(text: string): string {
const bytes = new TextEncoder().encode(text);
return Array.from(bytes).map(b => String(b)).join(" ");
}
function encodeHex(text: string): string {
const bytes = new TextEncoder().encode(text);
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join(" ");
}| if (authorId && authorId === currentUserId) { | ||
| const channel = ChannelStore.getChannel(message.channel_id); | ||
| const partnerRaw = (channel as any)?.recipients?.[0]; | ||
| const partnerId: string | undefined = typeof partnerRaw === "string" ? partnerRaw : partnerRaw?.id; | ||
| effectiveKey = (partnerId && userKeys?.[partnerId]) ? userKeys[partnerId] : aesSecret; |
There was a problem hiding this comment.
The logic for resolving the effectiveKey when the current user is the author assumes that recipients[0] is the intended partner. This is true for 1-on-1 DMs, but in Group DMs, recipients contains multiple users. This could lead to using the wrong per-user key (or falling back to the global secret incorrectly) when viewing your own sent messages in a group context.
It is safer to check if the channel is a 1-on-1 DM before attempting to resolve a partner key.
| if (authorId && authorId === currentUserId) { | |
| const channel = ChannelStore.getChannel(message.channel_id); | |
| const partnerRaw = (channel as any)?.recipients?.[0]; | |
| const partnerId: string | undefined = typeof partnerRaw === "string" ? partnerRaw : partnerRaw?.id; | |
| effectiveKey = (partnerId && userKeys?.[partnerId]) ? userKeys[partnerId] : aesSecret; | |
| if (authorId && authorId === currentUserId) { | |
| const channel = ChannelStore.getChannel(message.channel_id); | |
| const recipients = (channel as any)?.recipients; | |
| const partnerId = recipients?.length === 1 ? recipients[0] : null; | |
| const partnerIdStr = typeof partnerId === "string" ? partnerId : partnerId?.id; | |
| effectiveKey = (partnerIdStr && userKeys?.[partnerIdStr]) ? userKeys[partnerIdStr] : aesSecret; | |
| } else { |
| useEffect(() => { | ||
| if (!referenceResult) return; | ||
|
|
||
| const listItem = document.querySelector<HTMLElement>( | ||
| `li[id*="${message.id}"], [data-list-item-id*="${message.id}"]` | ||
| ); | ||
| if (!listItem) return; | ||
|
|
||
| const replyContent = listItem.querySelector<HTMLElement>( | ||
| "[class*='repliedMessage'] [class*='messageContent']" | ||
| ); | ||
| if (!replyContent || !replyContent.parentElement) return; | ||
|
|
||
| replyContent.style.display = "none"; | ||
| const decoded = document.createElement("span"); | ||
| decoded.textContent = referenceResult.text; | ||
| replyContent.parentElement.insertBefore(decoded, replyContent); | ||
|
|
||
| return () => { | ||
| replyContent.style.display = ""; | ||
| decoded.remove(); | ||
| }; | ||
| }, [referenceResult]); |
There was a problem hiding this comment.
Manually manipulating the DOM to insert decoded text into the reply bar is fragile. Discord's DOM structure and class names are subject to frequent changes, which could break the querySelector logic. Additionally, using textContent (line 125) means that any markdown in the decoded referenced message will not be rendered, unlike the main message accessory which uses Parser.parse.
While patching the reply bar is difficult, consider if there is a more idiomatic way to hook into the message rendering or at least use the Parser for the reply preview text.
| function decodeBase64(text: string): string { | ||
| try { | ||
| return decodeURIComponent(escape(atob(text.trim()))); | ||
| } catch { | ||
| return atob(text.trim()); | ||
| } | ||
| } |
There was a problem hiding this comment.
The decodeBase64 function uses a legacy hack (decodeURIComponent(escape(atob(...)))) to handle UTF-8. Since you already have a base64ToUint8 helper and TextDecoder is available, you should use them for a cleaner and more robust implementation.
| function decodeBase64(text: string): string { | |
| try { | |
| return decodeURIComponent(escape(atob(text.trim()))); | |
| } catch { | |
| return atob(text.trim()); | |
| } | |
| } | |
| function decodeBase64(text: string): string { | |
| try { | |
| const bytes = base64ToUint8(text); | |
| return new TextDecoder().decode(bytes); | |
| } catch { | |
| return atob(text.trim()); | |
| } | |
| } |
Summary
Adds a
BaseConverterplugin that lets users encode outgoing messages and decode received messages in a variety of encoding schemes, with optional AES-256-GCM end-to-end encryption.Features
Test plan
🤖 Generated with Claude Code