// ==UserScript==
// @name discord-translate
// @namespace http://tampermonkey.net/
// @version 2025-07-01
// @description Translation utilities for discord.
// @author Patrick Learn
// @match https://discord.com/channels/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @license MIT
// ==/UserScript==
(async () => {
"use strict";
const CONFIG = {
async load() {
this.translationEngine = await GM_getValue("translationEngine", "llm");
this.llmApiKey = await GM_getValue("llmApiKey", "YOUR_API_KEY");
this.llmApiEndpoint = await GM_getValue(
"llmApiEndpoint",
"https://api.deepseek.com/v1/chat/completions"
);
this.llmModel = await GM_getValue("llmModel", "deepseek-chat");
this.llmChannelInstructions = await GM_getValue(
"llmChannelInstructions",
"You are a professional translator. Translate to English. Do not use markdown. Keep it minimal. You may include some translation notes. Do not ask me questions."
);
this.llmInputBarInstructions = await GM_getValue(
"llmInputBarInstructions",
"You are a professional translator. Translate to Danish. Do not include anything extra, just write it as it should be."
);
this.libreTranslateEndpoint = await GM_getValue(
"libreTranslateEndpoint",
"https://libretranslate.com/translate"
);
this.libreChannelFromLang = await GM_getValue("libreChannelFromLang", "auto");
this.libreChannelToLang = await GM_getValue("libreChannelToLang", "en");
this.libreInputBarFromLang = await GM_getValue("libreInputBarFromLang", "auto");
this.libreInputBarToLang = await GM_getValue("libreInputBarToLang", "da");
this.deeplApiKey = await GM_getValue("deeplApiKey", "YOUR_API_KEY");
this.deeplApiEndpoint = await GM_getValue(
"deeplApiEndpoint",
"https://api.deepl.com/v2/translate"
);
this.deeplChannelFromLang = await GM_getValue("deeplChannelFromLang", "auto");
this.deeplChannelToLang = await GM_getValue("deeplChannelToLang", "EN");
this.deeplInputBarFromLang = await GM_getValue("deeplInputBarFromLang", "auto");
this.deeplInputBarToLang = await GM_getValue("deeplInputBarToLang", "DA");
},
async save(key, value) {
await GM_setValue(key, value);
this[key] = value;
},
};
const Translator = (() => {
const MODE = Object.freeze({ CHANNEL: 0, INPUTBAR: 1 });
function gmRequest({ url, method = "POST", headers = {}, body }) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url,
headers,
data: body,
onload: resolve,
onerror: reject,
});
});
}
async function translateWithLLM(text, mode) {
const url = CONFIG.llmApiEndpoint;
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${CONFIG.llmApiKey}`,
};
const instructions =
mode === Translator.MODE.CHANNEL
? CONFIG.llmChannelInstructions
: CONFIG.llmInputBarInstructions;
const body = JSON.stringify({
model: CONFIG.llmModel,
messages: [
{ role: "system", content: instructions },
{ role: "user", content: text },
],
temperature: 0.2,
});
const resp = await gmRequest({ url, headers, body });
if (resp.status < 200 || resp.status >= 300) {
throw new Error(`LLM HTTP ${resp.status}`);
}
const data = JSON.parse(resp.responseText);
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error("Unexpected LLM response");
return content.trim();
}
async function translateWithLibre(text, mode) {
const url = CONFIG.libreTranslateEndpoint;
const headers = { "Content-Type": "application/json" };
const source =
mode === Translator.MODE.CHANNEL
? CONFIG.libreChannelFromLang
: CONFIG.libreInputBarFromLang;
const target =
mode === Translator.MODE.CHANNEL
? CONFIG.libreChannelToLang
: CONFIG.libreInputBarToLang;
const body = JSON.stringify({
q: text,
source,
target,
format: "text",
});
const resp = await gmRequest({ url, headers, body });
if (resp.status < 200 || resp.status >= 300) {
throw new Error(`Libre HTTP ${resp.status}`);
}
const data = JSON.parse(resp.responseText);
if (!data.translatedText) throw new Error("Unexpected Libre response");
return data.translatedText.trim();
}
async function translateWithDeepL(text, mode) {
const url = CONFIG.deeplApiEndpoint;
const source =
mode === Translator.MODE.CHANNEL
? CONFIG.deeplChannelFromLang
: CONFIG.deeplInputBarFromLang;
const target =
mode === Translator.MODE.CHANNEL
? CONFIG.deeplChannelToLang
: CONFIG.deeplInputBarToLang;
const body = JSON.stringify({
text: [text],
target_lang: target,
...(source && source.toLowerCase() !== "auto" ? { source_lang: source } : {}),
});
const headers = {
"Content-Type": "application/json",
Authorization: `DeepL-Auth-Key ${CONFIG.deeplApiKey}`,
};
console.log("DeepL request payload:", JSON.parse(body));
console.log("DeepL request headers:", headers);
const resp = await gmRequest({ url, method: "POST", headers, body });
if (resp.status < 200 || resp.status >= 300) {
throw new Error(`DeepL HTTP ${resp.status}`);
}
const data = JSON.parse(resp.responseText);
if (!data.translations || !data.translations[0]?.text) {
throw new Error("Unexpected DeepL response");
}
return data.translations[0].text.trim();
}
async function translate(text, mode = Translator.MODE.INPUTBAR) {
if (mode !== Translator.MODE.CHANNEL && mode !== Translator.MODE.INPUTBAR) {
throw new Error(`Invalid mode: ${mode}`);
}
try {
switch (CONFIG.translationEngine) {
case "llm":
return await translateWithLLM(text, mode);
case "libre":
return await translateWithLibre(text, mode);
case "deepl":
return await translateWithDeepL(text, mode);
default:
throw new Error(`Unknown engine: ${CONFIG.translationEngine}`);
}
} catch (err) {
console.error("Translation failed:", err);
alert(`Translation failed: ${err.message}`);
throw err;
}
}
return { translate, MODE };
})();
const UI = (() => {
const translateSVG = `
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="24" height="24" fill="currentColor" viewBox="0 0 512 512">
<path d="M478.33,433.6l-90-218a22,22,0,0,0-40.67,0l-90,218a22,22,0,1,0,40.67,16.79L316.66,406H419.33l18.33,44.39A22,22,0,0,0,458,464a22,22,0,0,0,20.32-30.4ZM334.83,362,368,281.65,401.17,362Z"/>
<path d="M267.84,342.92a22,22,0,0,0-4.89-30.7c-.2-.15-15-11.13-36.49-34.73,39.65-53.68,62.11-114.75,71.27-143.49H330a22,22,0,0,0,0-44H214V70a22,22,0,0,0-44,0V90H54a22,22,0,0,0,0,44H251.25c-9.52,26.95-27.05,69.5-53.79,108.36-31.41-41.68-43.08-68.65-43.17-68.87a22,22,0,0,0-40.58,17c.58,1.38,14.55,34.23,52.86,83.93.92,1.19,1.83,2.35,2.74,3.51-39.24,44.35-77.74,71.86-93.85,80.74a22,22,0,1,0,21.07,38.63c2.16-1.18,48.6-26.89,101.63-85.59,22.52,24.08,38,35.44,38.93,36.1a22,22,0,0,0,30.75-4.9Z"/>
</svg>
`;
function openConfigPanel() {
if (document.getElementById("config-panel")) return;
const panel = document.createElement("div");
panel.id = "config-panel";
panel.style.position = "fixed";
panel.style.top = "50px";
panel.style.right = "50px";
panel.style.zIndex = "9999";
panel.style.background = "#2f3136";
panel.style.color = "#fff";
panel.style.padding = "20px";
panel.style.border = "1px solid #555";
panel.style.borderRadius = "8px";
panel.style.boxShadow = "0 0 10px rgba(0,0,0,0.5)";
panel.style.width = "400px";
panel.innerHTML = `
<label>Translation Engine:<br>
<select id="cfg-translationEngine" style="width:100%">
<option value="llm" ${
CONFIG.translationEngine === "llm" ? "selected" : ""
}>LLM</option>
<option value="libre" ${
CONFIG.translationEngine === "libre" ? "selected" : ""
}>LibreTranslate</option>
<option value="deepl" ${
CONFIG.translationEngine === "deepl" ? "selected" : ""
}>DeepL</option>
</select>
</label><br><br>
<hr style="border-color:#555;"><br>
<div id="config-section"></div>
<button id="cfg-save" style="margin-right:10px">Save</button>
<button id="cfg-close">Close</button>
`;
document.body.appendChild(panel);
const configSection = panel.querySelector("#config-section");
const llmConfig = `
<label><strong>API Configuration:</strong></label><br><br>
<label>API Key:<br>
<input type="password" id="cfg-llmApiKey" value="${CONFIG.llmApiKey}" style="width:100%">
</label><br><br>
<label>Endpoint:<br>
<input type="text" id="cfg-llmApiEndpoint" value="${CONFIG.llmApiEndpoint}" style="width:100%">
</label><br><br>
<label>Model:<br>
<input type="text" id="cfg-llmModel" value="${CONFIG.llmModel}" style="width:100%">
</label><br><br>
<hr style="border-color:#555;"><br>
<label><strong>Instructions:</strong></label><br><br>
<label>Channel Instructions (Reading):<br>
<textarea id="cfg-llmChannelInstructions" style="width:100%" rows="2">${CONFIG.llmChannelInstructions}</textarea>
</label><br><br>
<label>Input Bar Instructions (Writing):<br>
<textarea id="cfg-llmInputBarInstructions" style="width:100%" rows="2">${CONFIG.llmInputBarInstructions}</textarea>
</label><br><br>
`;
const libreConfig = `
<label><strong>Endpoint:</strong></label><br><br>
<label>Endpoint URL:<br>
<input type="text" id="cfg-libreTranslateEndpoint" value="${CONFIG.libreTranslateEndpoint}" style="width:100%">
</label><br><br>
<hr style="border-color:#555;"><br>
<label><strong>Channel Translation (Reading):</strong></label><br><br>
<label>Source Language:<br>
<input type="text" id="cfg-libreChannelFromLang" value="${CONFIG.libreChannelFromLang}" style="width:100%">
</label><br><br>
<label>Target Language:<br>
<input type="text" id="cfg-libreChannelToLang" value="${CONFIG.libreChannelToLang}" style="width:100%">
</label><br><br>
<hr style="border-color:#555;"><br>
<label><strong>Input Bar Translation (Writing):</strong></label><br><br>
<label>Source Language:<br>
<input type="text" id="cfg-libreInputBarFromLang" value="${CONFIG.libreInputBarFromLang}" style="width:100%">
</label><br><br>
<label>Target Language:<br>
<input type="text" id="cfg-libreInputBarToLang" value="${CONFIG.libreInputBarToLang}" style="width:100%">
</label><br><br>
`;
const deeplConfig = `
<label><strong>API Configuration:</strong></label><br><br>
<label>DeepL API Key:<br>
<input type="password" id="cfg-deeplApiKey" value="${CONFIG.deeplApiKey}" style="width:100%">
</label><br><br>
<label>DeepL Endpoint:<br>
<input type="text" id="cfg-deeplApiEndpoint" value="${CONFIG.deeplApiEndpoint}" style="width:100%">
</label><br><br>
<hr style="border-color:#555;"><br>
<label><strong>Channel Translation (Reading):</strong></label><br><br>
<label>Source Language:<br>
<input type="text" id="cfg-deeplChannelFromLang" value="${CONFIG.deeplChannelFromLang}" style="width:100%">
</label><br><br>
<label>Target Language:<br>
<input type="text" id="cfg-deeplChannelToLang" value="${CONFIG.deeplChannelToLang}" style="width:100%">
</label><br><br>
<hr style="border-color:#555;"><br>
<label><strong>Input Bar Translation (Writing):</strong></label><br><br>
<label>Source Language:<br>
<input type="text" id="cfg-deeplInputBarFromLang" value="${CONFIG.deeplInputBarFromLang}" style="width:100%">
</label><br><br>
<label>Target Language:<br>
<input type="text" id="cfg-deeplInputBarToLang" value="${CONFIG.deeplInputBarToLang}" style="width:100%">
</label><br><br>
`;
function updateConfigSection() {
const selectedEngine = document.getElementById("cfg-translationEngine").value;
if (selectedEngine === "llm") {
configSection.innerHTML = llmConfig;
} else if (selectedEngine === "libre") {
configSection.innerHTML = libreConfig;
} else if (selectedEngine === "deepl") {
configSection.innerHTML = deeplConfig;
} else {
configSection.innerHTML = "<p>Unsupported engine selected.</p>";
}
}
updateConfigSection();
document
.getElementById("cfg-translationEngine")
.addEventListener("change", updateConfigSection);
document.getElementById("cfg-save").addEventListener("click", async () => {
const selectedEngine = document.getElementById("cfg-translationEngine").value;
await CONFIG.save("translationEngine", selectedEngine);
if (selectedEngine === "llm") {
await CONFIG.save(
"llmApiKey",
document.getElementById("cfg-llmApiKey").value
);
await CONFIG.save(
"llmApiEndpoint",
document.getElementById("cfg-llmApiEndpoint").value
);
await CONFIG.save("llmModel", document.getElementById("cfg-llmModel").value);
await CONFIG.save(
"llmChannelInstructions",
document.getElementById("cfg-llmChannelInstructions").value
);
await CONFIG.save(
"llmInputBarInstructions",
document.getElementById("cfg-llmInputBarInstructions").value
);
} else if (selectedEngine === "libre") {
await CONFIG.save(
"libreTranslateEndpoint",
document.getElementById("cfg-libreTranslateEndpoint").value
);
await CONFIG.save(
"libreChannelFromLang",
document.getElementById("cfg-libreChannelFromLang").value
);
await CONFIG.save(
"libreChannelToLang",
document.getElementById("cfg-libreChannelToLang").value
);
await CONFIG.save(
"libreInputBarFromLang",
document.getElementById("cfg-libreInputBarFromLang").value
);
await CONFIG.save(
"libreInputBarToLang",
document.getElementById("cfg-libreInputBarToLang").value
);
} else if (selectedEngine === "deepl") {
await CONFIG.save(
"deeplApiKey",
document.getElementById("cfg-deeplApiKey").value
);
await CONFIG.save(
"deeplApiEndpoint",
document.getElementById("cfg-deeplApiEndpoint").value
);
await CONFIG.save(
"deeplChannelFromLang",
document.getElementById("cfg-deeplChannelFromLang").value
);
await CONFIG.save(
"deeplChannelToLang",
document.getElementById("cfg-deeplChannelToLang").value
);
await CONFIG.save(
"deeplInputBarFromLang",
document.getElementById("cfg-deeplInputBarFromLang").value
);
await CONFIG.save(
"deeplInputBarToLang",
document.getElementById("cfg-deeplInputBarToLang").value
);
}
panel.remove();
});
document
.getElementById("cfg-close")
.addEventListener("click", () => panel.remove());
}
function createHeaderButton() {
const button = document.createElement("button");
button.id = "my-custom-header-button";
button.style.background = "transparent";
button.style.border = "none";
button.style.cursor = "pointer";
button.style.padding = "4px";
button.style.display = "flex";
button.style.alignItems = "center";
button.style.justifyContent = "center";
button.style.color = "#ffffff";
button.innerHTML = `
<div class="contents__201d5 button__24af7 button__74017">
<div class="buttonWrapper__24af7">
${translateSVG}
</div>
</div>
`;
button.addEventListener("click", () => {
openConfigPanel();
});
return button;
}
function createMessageButton() {
const button = document.createElement("div");
button.classList.add("hoverBarButton_f84418", "button_f7ecac");
button.setAttribute("aria-label", "Translate message");
button.setAttribute("role", "button");
button.setAttribute("tabindex", "0");
button.innerHTML = `
<div class="contents__201d5 button__24af7 button__74017">
<div class="buttonWrapper__24af7">
${translateSVG}
</div>
</div>
`;
button.addEventListener("click", async () => {
const messageContainer = button.closest(".message__5126c");
if (!messageContainer) {
alert("Could not find the message container.");
return;
}
const contentElements = messageContainer.querySelectorAll(
".messageContent_c19a55"
);
if (contentElements.length === 0) {
alert("Could not find any message text.");
return;
}
const contentElement = contentElements[contentElements.length - 1];
const textToTranslate = contentElement.innerText || contentElement.textContent;
if (!textToTranslate) {
alert("No text found to translate.");
return;
}
console.log("Translating message text:", textToTranslate);
const originalButtonHTML = button.innerHTML;
button.innerHTML = `<svg width="24" height="24" viewBox="0 0 100 100" fill="#9aff99" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="35" stroke="currentColor" stroke-width="10" fill="none" stroke-dasharray="164.93361431346415" stroke-dashoffset="164.93361431346415">
<animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;360 50 50" keyTimes="0;1"/>
<animate attributeName="stroke-dashoffset" repeatCount="indefinite" dur="1s" values="164.93361431346415;0" keyTimes="0;1"/>
</circle>
</svg>`;
try {
const translated = await Translator.translate(
textToTranslate,
Translator.MODE.CHANNEL
);
if (translated) {
const oldTranslation = messageContainer.querySelector(".my-translation");
if (oldTranslation) oldTranslation.remove();
const separator = document.createElement("div");
separator.className = "my-translation-separator";
separator.style.marginTop = "5px";
separator.style.borderTop = "1px dashed rgba(255, 255, 255, 0.2)";
separator.style.height = "1px";
separator.style.width = "100%";
const translationDiv = document.createElement("div");
translationDiv.className = "my-translation";
translationDiv.style.marginTop = "5px";
translationDiv.style.opacity = "0.9";
translationDiv.style.color = "#9aff99";
translationDiv.innerText = translated;
contentElement.appendChild(separator);
contentElement.appendChild(translationDiv);
}
} catch (error) {
console.error("Translation failed:", error);
} finally {
button.innerHTML = originalButtonHTML;
}
});
return button;
}
function createInputBarButton() {
const button = document.createElement("button");
button.id = "my-custom-input-button";
button.type = "button";
button.setAttribute("aria-label", "Translate input");
button.className = "button__201d5 lookBlank__201d5 colorBrand__201d5 grow__201d5";
button.style.display = "flex";
button.style.alignItems = "center";
button.style.justifyContent = "center";
button.style.background = "transparent";
button.style.border = "none";
button.style.cursor = "pointer";
button.style.padding = "4px";
button.innerHTML = `
<div class="contents__201d5 button__24af7 button__74017">
<div class="buttonWrapper__24af7">
${translateSVG}
</div>
</div>
`;
button.addEventListener("click", async () => {
const textArea = document.querySelector(".textArea__74017");
if (!textArea) {
alert("No text area found.");
return;
}
const currentText = textArea.value || textArea.textContent;
if (!currentText) {
alert("No text to translate!");
return;
}
console.log("Translating input text:", currentText);
const originalButtonHTML = button.innerHTML;
button.innerHTML = `<svg width="24" height="24" viewBox="0 0 100 100" fill="#9aff99" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="35" stroke="#ffff" stroke-width="10" fill="none" stroke-dasharray="164.93361431346415" stroke-dashoffset="164.93361431346415">
<animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;360 50 50" keyTimes="0;1"/>
<animate attributeName="stroke-dashoffset" repeatCount="indefinite" dur="1s" values="164.93361431346415;0" keyTimes="0;1"/>
</circle>
</svg>`;
try {
const translated = await Translator.translate(
currentText,
Translator.MODE.INPUTBAR
);
if (translated) {
try {
await navigator.clipboard.writeText(translated);
const notification = document.createElement("div");
notification.innerText = "Translation copied! Press Ctrl+V to paste.";
notification.style.position = "fixed";
notification.style.bottom = "20px";
notification.style.right = "20px";
notification.style.background = "#2f3136";
notification.style.color = "#fff";
notification.style.padding = "10px 15px";
notification.style.borderRadius = "6px";
notification.style.boxShadow = "0 2px 10px rgba(0,0,0,0.4)";
notification.style.zIndex = "9999";
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
} catch (error) {
console.error("Clipboard copy failed:", error);
alert("Failed to copy translation. Please try manually.");
}
}
} catch (error) {
console.error("Translation failed:", error);
} finally {
button.innerHTML = originalButtonHTML;
}
});
return button;
}
function watchMessages() {
const observer = new MutationObserver(() => {
// Channel header
const header = document.querySelector(
'section[aria-label="Channel header"] .toolbar__9293f'
);
if (header && !document.getElementById("my-custom-header-button")) {
header.appendChild(createHeaderButton());
}
// Message hover toolbars
const buttonsContainers = document.querySelectorAll(
".buttonsInner__5126c:not(.has-translate-button)"
);
buttonsContainers.forEach((container) => {
container.classList.add("has-translate-button");
const messageButton = createMessageButton();
const separators = container.querySelectorAll(".separator_f84418");
const lastSeparator = separators[separators.length - 1];
container.insertBefore(
messageButton,
lastSeparator ? lastSeparator.nextSibling : null
);
});
// Input bar
const inputBarButtons = document.querySelector(".buttons__74017");
if (inputBarButtons && !document.getElementById("my-custom-input-button")) {
const inputButton = createInputBarButton();
if (inputBarButtons.firstChild) {
inputBarButtons.insertBefore(inputButton, inputBarButtons.firstChild);
} else {
inputBarButtons.appendChild(inputButton);
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
function init() {
watchMessages();
}
return { init };
})();
await CONFIG.load();
UI.init();
})();