Skip to content

Add BaseConverter plugin#4222

Closed
secretnarwhal wants to merge 1 commit into
Vendicated:mainfrom
secretnarwhal:plugin/base-converter
Closed

Add BaseConverter plugin#4222
secretnarwhal wants to merge 1 commit into
Vendicated:mainfrom
secretnarwhal:plugin/base-converter

Conversation

@secretnarwhal
Copy link
Copy Markdown

@secretnarwhal secretnarwhal commented May 15, 2026

Summary

Adds a BaseConverter plugin 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

  • Chat bar button opens the encoder/decoder modal
  • Encoding options (send & receive): Binary, Octal, Decimal, Hex, Base32, Base64, UTF-8 Bytes, AES-256-GCM
  • Auto-encode outgoing messages before sending — toggle with shift+click or right-click on the chat bar icon (default: off)
  • Auto-decode incoming messages using the configured receive encoding (default: off, uses Auto-Detect unless a specific scheme is chosen)
  • Manual decode via right-click context menu on any message → "Decode Message"
  • AES-256-GCM encryption: shared secret configured in the modal; all crypto runs locally via the Web Crypto API — the key never leaves the device and is stored only in local Vencord settings
  • Per-user AES keys: right-click a username → "Set AES Secret Key" to assign a key for that specific user, overriding the global secret for messages with that person
  • Smart DM key resolution: when viewing your own sent messages in a DM, the plugin resolves the recipient's per-user key rather than the sender's (since the message was encoded for the recipient)
  • Inline visual replacement: decoded text replaces the encoded content; a toggle lets you reveal the original
  • Reply bar decoding: the reply reference preview also shows the decoded version of the referenced message

Test plan

  • Send a binary-encoded message, confirm the recipient can decode it via context menu with the plugin enabled
  • Enable auto-decode and verify incoming encoded messages are decoded automatically
  • Set a per-user AES key and confirm messages to that user encrypt/decrypt with the correct key
  • In a DM, confirm your own sent AES messages decode correctly using the partner's per-user key
  • Confirm reply bars show the decoded preview of referenced messages
  • Confirm non-encoded messages are unaffected

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +125 to +151
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("");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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

Comment on lines +215 to +230
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(" ");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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(" ");
}

Comment on lines +52 to +56
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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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 {

Comment on lines +110 to +132
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]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Comment on lines +171 to +177
function decodeBase64(text: string): string {
try {
return decodeURIComponent(escape(atob(text.trim())));
} catch {
return atob(text.trim());
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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());
}
}

@Vendicated Vendicated closed this May 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants