// ==UserScript==
// @name 盒子IM 增强 Plus(翻译 + 美化 + 消息增强 + 通知 + 隐私)
// @namespace https://xingk.xyz/boxim-plus
// @version 1.2
// @description 盒子IM 一站式增强:发送前/接收自动翻译、UI 多主题美化、防撤回、会话导出、全局搜索、@我/关键词提醒与桌面通知、离屏防窥与上报拦截。
// @author 归鸿
// @homepage https://xingk.xyz
// @supportURL https://xingk.xyz
// @license MIT
// @match https://www.boximchat.com/*
// @match https://boximchat.com/*
// @connect *
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @run-at document-idle
// ==/UserScript==
/*
* 盒子IM 增强 Plus
* 作者:归鸿
* 联系方式:manager@xingk.xyz
* 团队主页:https://xingk.xyz
* 开源许可证:MIT License
*
* 本程序基于 MIT 许可证发布,允许在符合以下条件的前提下自由使用、复制、修改与再分发:
* 1. 上述版权声明与许可证声明须保留在所有副本中;
* 2. 本程序按“原样”提供,作者不对任何使用后果承担责任,亦不作任何明示或暗示担保。
* 许可证全文见 https://opensource.org/licenses/MIT
*/
(function () {
'use strict';
/* ===================================================================
* 配置(默认值)
* =================================================================== */
const DEFAULTS = {
// —— 翻译 ——(API 三要素不预填,首次使用请在设置面板自行填写)
enabled: true, // 发送前译为英文
recvEnabled: true, // 对方外文自动译中
endpoint: '',
apiKey: '',
model: '',
mode: 'cjk', // cjk: 仅中文→翻译;nonascii: 任意非英文→翻译
selector: '',
sysPrompt:
'You are a professional translator. Translate the user input into natural, fluent English. ' +
'If the input is already in English or has nothing to translate, return it unchanged. ' +
'Output ONLY the translation: no quotes, no explanations, no extra text.',
sysZhPrompt:
'You are a professional translator. Translate the following text into natural, fluent Simplified Chinese. ' +
'Output ONLY the translated text: no quotes, no explanations. ' +
'If the text is already in Chinese or has nothing to translate, return it unchanged.',
// —— 美化 ——
bxui_theme: 'light', // light | dark | compact | off
// —— 消息增强 ——
antiRecall: true, // 防撤回
antiRecallPersist: true, // 防撤回:撤回内容存本地,刷新后仍可还原
exportEnabled: true, // 允许导出
// —— 通知 ——
notifyEnabled: false, // 桌面通知(需授权)
notifyOnMention: true, // @我 / 关键词 才突出提醒
notifyKeywords: '', // 额外关键词(逗号分隔),命中则高亮+提示
// —— 隐私 ——
privacyBlur: true, // 离屏/失焦时模糊聊天区(防窥屏)
privacyBlockReport: false, // 拦截已读回执上报(默认关)
blockRegex: 'read|receipt|typing|online|presence', // PUT/POST 上报 URL 关键字(与已读端点并用)
};
Object.keys(DEFAULTS).forEach((k) => {
if (GM_getValue(k) === undefined) GM_setValue(k, DEFAULTS[k]);
});
const cfg = (k) => GM_getValue(k);
const set = (k, v) => GM_setValue(k, v);
const get = (k) => GM_getValue(k);
/* ===================================================================
* 工具函数
* =================================================================== */
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
const isVisible = (el) =>
el && el.offsetWidth > 0 && el.offsetHeight > 0 && getComputedStyle(el).visibility !== 'hidden';
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])
);
}
function needsTranslation(text) {
if (!text || !text.trim()) return false;
return cfg('mode') === 'cjk' ? /[一-鿿]/.test(text) : /[^\x00-\x7F]/.test(text);
}
function looksForeign(text) {
if (!text || text.trim().length < 2) return false;
if (/[一-鿿]/.test(text)) return false;
return /[A-Za-z]{2,}/.test(text);
}
function getComposerValue(el) {
if (!el) return '';
if (el.isContentEditable || el.contentEditable === 'true') return el.innerText || '';
return el.value || '';
}
function setComposerValue(el, val) {
if (!el) return;
if (el.isContentEditable || el.contentEditable === 'true') {
el.innerText = val;
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' }));
return;
}
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
setter.call(el, val);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
/* ===================================================================
* 模块 1:翻译
* =================================================================== */
function translate(text, cb, sysOverride) {
const url = (cfg('endpoint') || '').replace(/\/+$/, '').replace(/\/chat\/completions$/, '') + '/chat/completions';
GM_xmlhttpRequest({
method: 'POST',
url,
timeout: 30000,
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + cfg('apiKey') },
data: JSON.stringify({
model: cfg('model'),
temperature: 0.3,
messages: [
{ role: 'system', content: sysOverride || cfg('sysPrompt') },
{ role: 'user', content: text },
],
}),
onload: (r) => {
try {
const j = JSON.parse(r.responseText);
if (j && j.error) return cb(new Error('API错误:' + (j.error.message || j.error.code || r.status)), text);
const content = j?.choices?.[0]?.message?.content;
if (content && content.trim()) cb(null, content.trim());
else cb(new Error('空响应(status ' + r.status + ')'), text);
} catch (e) {
cb(new Error('响应解析失败(status ' + r.status + '):' + r.responseText.slice(0, 120)), text);
}
},
onerror: () => cb(new Error('网络错误'), text),
ontimeout: () => cb(new Error('超时'), text),
});
}
// —— 接收端自动翻译 ——
let recvObserver = null;
function ensureRecvBox(wrapper) {
let box = wrapper.__bxRecvBox;
if (!box) {
box = document.createElement('div');
box.className = 'bx-recv-translate';
const host = wrapper.closest('.bottom') || wrapper.parentNode;
if (host) host.appendChild(box);
wrapper.__bxRecvBox = box;
}
return box;
}
function renderRecvBox(wrapper, zh, loading, tip) {
const box = ensureRecvBox(wrapper);
box.innerHTML = loading
? '
翻译中…
'
: '' + escapeHtml(zh) + '
' + (tip || '自动翻译') + '
';
}
function processIncoming(wrapper) {
if (!wrapper || !wrapper.isConnected) return;
const st = wrapper.__bxRecvState;
if (st === 'pending' || st === 'done' || st === 'skipped') return;
const item = wrapper.closest('.chat-message-item');
if (item && item.querySelector('.message-normal.message-mine')) {
wrapper.__bxRecvState = 'skipped';
return;
}
const textEl = wrapper.querySelector('.message-text');
const text = textEl ? textEl.innerText.trim() : '';
if (!text) {
setTimeout(() => processIncoming(wrapper), 400);
return;
}
if (!looksForeign(text) || !cfg('recvEnabled')) {
wrapper.__bxRecvState = 'skipped';
return;
}
wrapper.__bxRecvState = 'pending';
renderRecvBox(wrapper, '', true);
translate(
text,
(err, zh) => {
if (err || !zh || zh === text) {
if (wrapper.__bxRecvBox) {
wrapper.__bxRecvBox.remove();
wrapper.__bxRecvBox = null;
}
wrapper.__bxRecvState = null;
return;
}
renderRecvBox(wrapper, zh, false);
wrapper.__bxRecvState = 'done';
},
cfg('sysZhPrompt')
);
}
function scanIncoming() {
$$('.message-content-wrapper').forEach(processIncoming);
}
function startRecv() {
scanIncoming();
if (recvObserver) return;
recvObserver = new MutationObserver((muts) => {
for (const m of muts)
for (const n of m.addedNodes) {
if (n.nodeType !== 1) continue;
if (n.classList && n.classList.contains('message-content-wrapper')) processIncoming(n);
else if (n.querySelector) n.querySelectorAll('.message-content-wrapper').forEach(processIncoming);
}
});
recvObserver.observe(document.body, { childList: true, subtree: true });
}
// —— 右键菜单翻译单条 ——
let pendingItem = null;
let ctxObserver = null;
function translateMessageItem(item) {
if (!item || !item.isConnected) return;
const wrapper = item.querySelector('.message-content-wrapper') || item.querySelector('.message-normal') || item;
const textEl = item.querySelector('.message-text');
const text = textEl ? textEl.innerText.trim() : (wrapper.innerText || '').trim();
if (!text) return showStatus('这条消息没有可翻译的文本', 1600);
renderRecvBox(wrapper, '', true);
const toZh = looksForeign(text);
translate(
text,
(err, res) => {
if (err || !res) {
const box = ensureRecvBox(wrapper);
box.innerHTML = '翻译失败:' + escapeHtml(err && err.message ? err.message : String(err)) + '
';
return;
}
renderRecvBox(wrapper, res, false, '翻译');
},
toZh ? cfg('sysZhPrompt') : cfg('sysPrompt')
);
}
function injectTranslateItem(menu) {
const container = menu.querySelector('.menu-container');
if (!container || container.__bxInjected || !pendingItem) return;
const ref = container.querySelector('.menu-item');
if (!ref) return;
container.__bxInjected = true;
const div = ref.cloneNode(true);
div.className = 'menu-item bx-translate-item';
const txt = div.querySelector('.menu-text');
if (txt) txt.textContent = '翻译';
else div.textContent = '翻译';
div.addEventListener('click', (ev) => {
ev.stopPropagation();
const target = pendingItem;
pendingItem = null;
setTimeout(() => {
const mask = $('.right-menu-mask');
if (mask) mask.remove();
}, 0);
if (target) translateMessageItem(target);
});
container.appendChild(div);
}
function initContextMenuInjector() {
document.addEventListener('contextmenu', (e) => {
pendingItem = e.target.closest ? e.target.closest('.chat-message-item') : null;
}, true);
if (ctxObserver) return;
ctxObserver = new MutationObserver((muts) => {
for (const m of muts)
for (const n of m.addedNodes) {
if (n.nodeType !== 1) continue;
let menu = n.classList && n.classList.contains('right-menu') ? n : n.querySelector ? n.querySelector('.right-menu') : null;
if (menu) injectTranslateItem(menu);
}
});
ctxObserver.observe(document.body, { childList: true, subtree: true });
}
// —— 发送拦截 ——
let composer = null;
let sendBtn = null;
function reTrigger(ta) {
if (sendBtn && isVisible(sendBtn)) return sendBtn.click();
const opt = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
ta.dispatchEvent(new KeyboardEvent('keydown', opt));
ta.dispatchEvent(new KeyboardEvent('keyup', opt));
}
function doTranslateAndSend(ta) {
const original = getComposerValue(ta);
const btn = sendBtn;
showStatus('翻译中…');
translate(original, (err, translated) => {
ta.__bxPending = false;
if (btn) btn.__bxPending = false;
if (err || !translated || translated === original) {
ta.__bxForce = true;
setComposerValue(ta, original);
showStatus('翻译失败: ' + (err && err.message ? err.message : err) + '(已发原文)', 3200);
console.error('[盒子IM+] 翻译失败:', err);
} else {
setComposerValue(ta, translated);
showStatus('已译为英文发送', 1500);
}
reTrigger(ta);
});
}
function onEnterKey(e) {
if (e.key !== 'Enter' || e.shiftKey || e.isComposing || e.keyCode === 229) return;
const ta = e.target;
if (ta.__bxPending) {
e.preventDefault();
e.stopImmediatePropagation();
return;
}
if (ta.__bxForce) {
ta.__bxForce = false;
return;
}
if (!cfg('enabled') || !needsTranslation(getComposerValue(ta))) return;
e.preventDefault();
e.stopImmediatePropagation();
ta.__bxPending = true;
doTranslateAndSend(ta);
}
function onSendClick(e) {
const btn = e.currentTarget;
const ta = composer;
if (btn.__bxPending) {
e.preventDefault();
e.stopImmediatePropagation();
return;
}
if (ta && ta.__bxForce) {
ta.__bxForce = false;
return;
}
if (!cfg('enabled') || !ta || !needsTranslation(getComposerValue(ta))) return;
e.preventDefault();
e.stopImmediatePropagation();
btn.__bxPending = true;
doTranslateAndSend(ta);
}
function bindComposer(ta) {
if (ta.__bxBound) return;
ta.__bxBound = true;
ta.addEventListener('keydown', onEnterKey, true);
ta.addEventListener('keyup', onEnterKey, true);
}
function findSendButton(ta) {
const byText = (scope) =>
scope ? $$('button', scope).find((b) => /发送|send/i.test(b.textContent || '')) || null : null;
let btn =
byText(ta.closest('form')) ||
byText(ta.closest('[class*="chat"],[class*="editor"],[class*="composer"],[class*="input"]')) ||
byText(ta.closest('[class*="footer"],[class*="bottom"],[class*="tool"]'));
if (!btn) {
const all = $$('button').filter((b) => /发送|send/i.test(b.textContent || ''));
btn = all.find((b) => ta.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) || all[0] || null;
}
if (!btn) {
const all = $$('button');
btn = all.filter((b) => ta.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING).pop() || all[all.length - 1] || null;
}
return btn;
}
function bindButton(btn) {
if (btn.__bxBound) return;
btn.__bxBound = true;
btn.addEventListener('click', onSendClick, true);
}
function detectComposer() {
let ta = cfg('selector') ? $(cfg('selector')) : null;
if (!ta) ta = $$('textarea.el-textarea__inner').filter(isVisible)[0] || null;
if (!ta)
ta =
$$('input.el-input__inner[type="text"], input.el-input__inner:not([type])').find(
(el) => isVisible(el) && !/搜索|search|find|filter/i.test(el.placeholder || el.getAttribute('aria-label') || '') && !el.closest('.el-dialog, .el-popover, .el-header, [class*="search"]')
) || null;
if (!ta)
ta =
$$('input[type="text"], textarea').find(
(el) => isVisible(el) && !/搜索|search|find|filter/i.test(el.placeholder || el.className || '') && !el.closest('.el-dialog, .el-popover, .el-header, [class*="search"]')
) || null;
if (!ta)
ta =
$$('[contenteditable="true"]').find((el) => isVisible(el) && !el.closest('.el-dialog, .el-popover, .el-header')) || null;
if (ta && ta !== composer) {
composer = ta;
bindComposer(ta);
sendBtn = findSendButton(ta);
if (sendBtn) bindButton(sendBtn);
}
}
/* ===================================================================
* 模块 2:UI 美化(多主题)
* =================================================================== */
const THEMES = {
off: { label: '关闭美化', css: '' },
light: {
label: '清新毛玻璃',
css: `
html body .resizable-aside{background:linear-gradient(180deg,#2830d3 0%,#4f5bd7 100%)!important;border-right:none!important;}
html body .resizable-aside .header{background:rgba(255,255,255,.08)!important;border-bottom:1px solid rgba(255,255,255,.1)!important;}
html body .resizable-aside .search-text .el-input__wrapper{background:rgba(255,255,255,.12)!important;box-shadow:none!important;border-radius:20px!important;}
html body .resizable-aside .search-text .el-input__inner{color:#fff!important;caret-color:#fff!important;}
html body .resizable-aside .search-text .el-input__inner::placeholder{color:rgba(255,255,255,.55)!important;}
html body .resizable-aside .chat-item{border-radius:12px!important;margin:5px 10px!important;padding:9px 12px!important;transition:background .2s ease,transform .15s ease!important;cursor:pointer!important;}
html body .resizable-aside .chat-item:hover{background:rgba(255,255,255,.09)!important;transform:translateX(1px)!important;}
html body .resizable-aside .chat-item.is-active,html body .resizable-aside .chat-item.active{background:rgba(255,255,255,.16)!important;box-shadow:0 2px 10px rgba(0,0,0,.12)!important;}
html body .resizable-aside .chat-item .chat-name-text,html body .resizable-aside .chat-item .chat-content{color:#fff!important;}
html body .resizable-aside .chat-item .chat-time-text{color:rgba(255,255,255,.65)!important;}
html body .chat-box .el-header{background:rgba(255,255,255,.92)!important;backdrop-filter:blur(10px) saturate(160%)!important;box-shadow:0 2px 12px rgba(0,0,0,.06)!important;border-bottom:none!important;}
html body .chat-box .el-header .title{font-weight:600!important;font-size:16px!important;color:#1f2937!important;}
html body .chat-box .el-header .btn-side,html body .chat-box .el-header .el-icon{color:#6b7280!important;}
html body .chat-box .el-header .btn-side:hover,html body .chat-box .el-header .el-icon:hover{color:#2830d3!important;}
html body .chat-box .el-main{background:#f3f4f8!important;}
html body .chat-box .message-tip{background:rgba(0,0,0,.04)!important;color:#6b7280!important;border-radius:12px!important;padding:3px 10px!important;font-size:12px!important;margin:10px auto!important;display:inline-block!important;}
html body .chat-box .chat-message-item .message-content-wrapper{border-radius:18px!important;box-shadow:0 2px 8px rgba(0,0,0,.08)!important;padding:10px 14px!important;font-size:14px!important;line-height:1.55!important;max-width:70vw!important;transition:transform .15s ease!important;}
html body .chat-box .chat-message-item .message-content-wrapper:hover{transform:translateY(-1px)!important;}
html body .chat-box .chat-message-item .message-normal.message-mine .message-content-wrapper{background:linear-gradient(135deg,#2830d3 0%,#5b6bf7 100%)!important;color:#fff!important;border-radius:18px 18px 4px 18px!important;}
html body .chat-box .chat-message-item .message-normal:not(.message-mine) .message-content-wrapper{background:#fff!important;color:#333!important;border-radius:18px 18px 18px 4px!important;}
html body .chat-box .chat-message-item .avatar .head-image,html body .chat-box .chat-message-item .avatar-image{border-radius:50%!important;box-shadow:0 2px 6px rgba(0,0,0,.12)!important;}
html body .chat-box .im-chat-footer{background:rgba(255,255,255,.95)!important;backdrop-filter:blur(12px) saturate(160%)!important;box-shadow:0 -2px 12px rgba(0,0,0,.06)!important;border-top:none!important;}
html body .chat-box .chat-tool-bar{padding:6px 0!important;}
html body .chat-box .chat-tool-bar .icon{color:#6b7280!important;border-radius:8px!important;transition:all .2s ease!important;padding:6px!important;margin:0 2px!important;}
html body .chat-box .chat-tool-bar .icon:hover{background:rgba(40,48,211,.1)!important;color:#2830d3!important;transform:scale(1.05)!important;}
html body .chat-box .edit-container{background:#f2f3f7!important;border:1px solid #e4e6ef!important;border-radius:20px!important;padding:10px 16px!important;margin-top:6px!important;color:#333!important;min-height:44px!important;transition:border .2s,box-shadow .2s!important;}
html body .chat-box .edit-container:focus-within{border-color:#2830d3!important;box-shadow:0 0 0 3px rgba(40,48,211,.12)!important;background:#fff!important;}
::-webkit-scrollbar{width:6px!important;height:6px!important;} ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.18)!important;border-radius:10px!important;} ::-webkit-scrollbar-track{background:transparent!important;}
`,
},
dark: {
label: '暗夜模式',
css: `
html body .resizable-aside{background:#0f172a!important;border-right:1px solid #1e293b!important;}
html body .resizable-aside .header{background:#0f172a!important;border-bottom:1px solid #1e293b!important;}
html body .resizable-aside .search-text .el-input__wrapper{background:#1e293b!important;box-shadow:none!important;border-radius:20px!important;}
html body .resizable-aside .search-text .el-input__inner{color:#e2e8f0!important;caret-color:#e2e8f0!important;}
html body .resizable-aside .search-text .el-input__inner::placeholder{color:#64748b!important;}
html body .resizable-aside .chat-item{border-radius:12px!important;margin:5px 10px!important;padding:9px 12px!important;transition:background .2s ease!important;}
html body .resizable-aside .chat-item:hover{background:#1e293b!important;}
html body .resizable-aside .chat-item.is-active,html body .resizable-aside .chat-item.active{background:#2830d3!important;}
html body .resizable-aside .chat-item .chat-name-text,html body .resizable-aside .chat-item .chat-content{color:#e2e8f0!important;}
html body .resizable-aside .chat-item .chat-time-text{color:#94a3b8!important;}
html body .chat-box .el-header{background:#1e293b!important;border-bottom:1px solid #334155!important;}
html body .chat-box .el-header .title{font-weight:600!important;color:#e2e8f0!important;}
html body .chat-box .el-header .btn-side,html body .chat-box .el-header .el-icon{color:#94a3b8!important;}
html body .chat-box .el-header .btn-side:hover,html body .chat-box .el-header .el-icon:hover{color:#fff!important;}
html body .chat-box .el-main{background:#0b1120!important;}
html body .chat-box .message-tip{background:rgba(255,255,255,.08)!important;color:#94a3b8!important;border-radius:12px!important;padding:3px 10px!important;font-size:12px!important;}
html body .chat-box .chat-message-item .message-content-wrapper{border-radius:18px!important;box-shadow:0 2px 8px rgba(0,0,0,.25)!important;padding:10px 14px!important;font-size:14px!important;line-height:1.55!important;max-width:70vw!important;}
html body .chat-box .chat-message-item .message-normal.message-mine .message-content-wrapper{background:linear-gradient(135deg,#2830d3 0%,#4f5bd7 100%)!important;color:#fff!important;border-radius:18px 18px 4px 18px!important;}
html body .chat-box .chat-message-item .message-normal:not(.message-mine) .message-content-wrapper{background:#1e293b!important;color:#e2e8f0!important;border-radius:18px 18px 18px 4px!important;}
html body .chat-box .chat-message-item .avatar .head-image,html body .chat-box .chat-message-item .avatar-image{border-radius:50%!important;box-shadow:0 2px 6px rgba(0,0,0,.25)!important;}
html body .chat-box .im-chat-footer{background:#1e293b!important;border-top:1px solid #334155!important;}
html body .chat-box .chat-tool-bar .icon{color:#94a3b8!important;border-radius:8px!important;transition:all .2s ease!important;padding:6px!important;}
html body .chat-box .chat-tool-bar .icon:hover{background:rgba(40,48,211,.2)!important;color:#fff!important;}
html body .chat-box .edit-container{background:#0f172a!important;border:1px solid #334155!important;border-radius:20px!important;padding:10px 16px!important;margin-top:6px!important;color:#e2e8f0!important;}
html body .chat-box .edit-container:focus-within{border-color:#5b6bf7!important;box-shadow:0 0 0 3px rgba(91,107,247,.15)!important;}
::-webkit-scrollbar{width:6px!important;height:6px!important;} ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15)!important;border-radius:10px!important;} ::-webkit-scrollbar-track{background:transparent!important;}
`,
},
compact: {
label: '紧凑模式',
css: `
html body .resizable-aside{width:240px!important;}
html body .resizable-aside .chat-item{margin:2px 8px!important;padding:6px 10px!important;border-radius:10px!important;}
html body .chat-box .chat-message-item .message-content-wrapper{padding:7px 11px!important;font-size:13px!important;border-radius:14px!important;}
html body .chat-box .chat-message-item .message-normal.message-mine .message-content-wrapper{border-radius:14px 14px 3px 14px!important;}
html body .chat-box .chat-message-item .message-normal:not(.message-mine) .message-content-wrapper{border-radius:14px 14px 14px 3px!important;}
html body .chat-box .chat-tool-bar .icon{padding:4px!important;}
html body .chat-box .edit-container{padding:7px 12px!important;min-height:38px!important;border-radius:16px!important;}
`,
},
};
const STYLE_ID = 'bxplus-theme-style';
function applyTheme(name) {
let el = document.getElementById(STYLE_ID);
if (!el) {
el = document.createElement('style');
el.id = STYLE_ID;
(document.head || document.documentElement).appendChild(el);
}
el.textContent = (THEMES[name] || THEMES.light).css;
}
function setTheme(name) {
if (!THEMES[name]) name = 'light';
set('bxui_theme', name);
applyTheme(name);
}
/* ===================================================================
* 模块 3:消息增强(防撤回 / 导出 / 全局搜索)
* =================================================================== */
const RECALL_RE = /撤回了?一?条?消息|recalled/i;
const msgSnap = new WeakMap(); // 消息项 -> 最近一次有效文本(仅当前会话内存)
// —— 撤回内容持久化(刷新后仍可还原)——
const RECALL_STORE_KEY = 'recallStoreV1';
const RECALL_STORE_MAX = 2000; // 超过则整体清空,避免本地存档无限膨胀
function loadRecallStore() {
try { return JSON.parse(get(RECALL_STORE_KEY) || '{}'); } catch (e) { return {}; }
}
function saveRecallStore(store) { set(RECALL_STORE_KEY, JSON.stringify(store)); }
function persistRecall(id, data) {
if (!id) return;
let store = loadRecallStore();
if (Object.keys(store).length >= RECALL_STORE_MAX) store = {}; // 超出上限整体清空旧档
store[id] = data;
saveRecallStore(store);
}
// 从盒子IM(Vue2)渲染的 .chat-message-item 读取 message.id 与发送者
function getMsgMeta(item) {
try {
const inst = item.__vue__ || (item.__vueParentComponent && item.__vueParentComponent.proxy);
const m = inst && (inst.message || (inst.props && inst.props.message));
if (!m) return {};
return { id: m.id, sender: m.sendNickName || m.sendName || '' };
} catch (e) { return {}; }
}
function snapshotMsg(item) {
const t = $('.message-text', item) || item;
const txt = (t.innerText || '').trim();
if (txt && !RECALL_RE.test(txt)) msgSnap.set(item, txt);
}
function buildRecallKeeper(origText, who) {
const box = document.createElement('div');
box.className = 'bx-recall-keep';
box.innerHTML =
'🔒 对方已撤回 · 已为你保留' + (who ? '(' + escapeHtml(who) + ')' : '') + '
' +
'' + escapeHtml(origText) + '
';
return box;
}
function onItemRecalled(item) {
if (item.__bxRecallDone) return;
let orig = msgSnap.get(item);
let who = '';
if (orig) {
// 当前会话内撤回:原文在内存快照中,写入本地持久档供刷新后还原
const meta = getMsgMeta(item);
if (meta.id && cfg('antiRecallPersist')) {
persistRecall(meta.id, { sender: meta.sender || '', text: orig, time: Date.now() });
}
} else if (cfg('antiRecallPersist')) {
// 刷新后内存快照已丢失:按消息 id 从本地持久档找回原文
const meta = getMsgMeta(item);
if (meta.id) {
const rec = loadRecallStore()[meta.id];
if (rec) { orig = rec.text; who = rec.sender || ''; }
}
}
if (!orig) return;
item.__bxRecallDone = true;
const wrap = item.querySelector('.message-content-wrapper') || item;
if (!wrap.querySelector('.bx-recall-keep')) wrap.appendChild(buildRecallKeeper(orig, who));
}
function watchRecall() {
$$('.chat-message-item').forEach(snapshotMsg);
const obs = new MutationObserver((muts) => {
for (const m of muts) {
if (m.type === 'characterData') {
// 部分实现会直接把 .message-text 文本改写成“撤回了…”
const t = m.target;
const item = t.parentElement ? t.parentElement.closest('.chat-message-item') : null;
if (item && RECALL_RE.test(t.textContent || '')) {
snapshotMsg(item);
onItemRecalled(item);
}
continue;
}
if (m.type !== 'childList') continue;
for (const n of m.addedNodes) {
if (n.nodeType === 3) {
// 文本节点被整体替换(如 .message-text 的 textContent 被改写)
const te = n.parentElement;
if (te && te.classList && te.classList.contains('message-text') && RECALL_RE.test(te.textContent || '')) {
const item = te.closest('.chat-message-item');
if (item) { snapshotMsg(item); onItemRecalled(item); }
}
continue;
}
if (n.nodeType !== 1) continue;
// 新消息到达:快照其文本(此时还是正常气泡)
const items = n.classList && n.classList.contains('chat-message-item') ? [n] : (n.querySelectorAll ? n.querySelectorAll('.chat-message-item') : []);
items.forEach((it) => snapshotMsg(it));
// 盒子IM 撤回的本质:同一条 .chat-message-item 内,.message-normal(含 .message-text)
// 被替换为 .message-tip(“X 撤回了一条消息”)。该 .message-tip 就是当前 .chat-message-item 的直接子节点。
if (n.classList && /message-tip|recall|revoke/.test(n.className) && RECALL_RE.test(n.textContent || '')) {
const item = n.closest('.chat-message-item');
if (item) { snapshotMsg(item); onItemRecalled(item); }
}
// 撤回提示也可能包在其它容器里
const tip = n.querySelector ? n.querySelector('.message-tip') : null;
if (tip && RECALL_RE.test(tip.textContent || '')) {
const item = tip.closest('.chat-message-item');
if (item) { snapshotMsg(item); onItemRecalled(item); }
}
}
// 兜底:极少数实现会把整条 .chat-message-item 移除,用缓存重建保留卡片
for (const n of m.removedNodes) {
if (n.nodeType === 1 && n.classList && n.classList.contains('chat-message-item') && msgSnap.has(n)) {
const list = document.querySelector('.chat-message-list') || $('.chat-box .el-main') || document.body;
list.appendChild(buildRecallKeeper(msgSnap.get(n)));
}
}
}
});
obs.observe(document.body, { childList: true, subtree: true, characterData: true });
}
// —— 会话导出 ——
function exportCurrentChat() {
const titleEl = $('.chat-box .el-header .title') || $('.chat-box .el-header');
const title = (titleEl ? titleEl.textContent : '会话').trim().replace(/[\\/:*?"<>|]/g, '_');
const items = $$('.chat-message-item');
if (!items.length) return showStatus('当前没有可导出的消息', 1800);
const lines = ['# ' + title + ' 导出', '_生成时间:' + new Date().toLocaleString() + '_\n'];
items.forEach((it) => {
const mine = !!it.querySelector('.message-normal.message-mine');
const name = mine ? '我' : ($('.chat-user-name', it) ? $('.chat-user-name', it).textContent.trim() : '对方');
const time = $('.message-time', it) ? $('.message-time', it).textContent.trim() : '';
const body = ($('.message-text', it) ? $('.message-text', it).innerText : it.innerText).trim();
lines.push('**' + name + '**' + (time ? ' (' + time + ')' : '') + ':\n' + body + '\n');
});
const md = lines.join('\n');
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'boxim-' + title + '-' + new Date().toISOString().slice(0, 10) + '.md';
document.body.appendChild(a);
a.click();
a.remove();
showStatus('已导出 ' + items.length + ' 条消息', 1800);
}
// —— 全局搜索 ——
let searchHits = [];
function clearSearch() {
searchHits.forEach((el) => el.classList.remove('bx-search-hit'));
searchHits = [];
}
function doSearch(kw) {
clearSearch();
kw = (kw || '').trim();
if (!kw) return;
const items = $$('.chat-message-item');
items.forEach((it) => {
const t = $('.message-text', it);
if (t && t.innerText.includes(kw)) {
it.classList.add('bx-search-hit');
searchHits.push(it);
}
});
if (searchHits.length) {
searchHits[0].scrollIntoView({ block: 'center' });
showStatus('找到 ' + searchHits.length + ' 条匹配', 1800);
} else showStatus('未找到匹配', 1800);
}
/* ===================================================================
* 模块 4:通知效率(@我/关键词提醒 + 桌面通知)
* =================================================================== */
let titleTimer = null;
let originalTitle = document.title;
function flashTitle(text) {
if (titleTimer) return;
document.title = '🔴 ' + text;
let on = true;
titleTimer = setInterval(() => {
document.title = on ? originalTitle : '🔴 ' + text;
on = !on;
}, 800);
setTimeout(stopFlash, 8000);
}
function stopFlash() {
if (titleTimer) clearInterval(titleTimer);
titleTimer = null;
document.title = originalTitle;
}
function myName() {
try {
const raw = localStorage.getItem('userInfo') || localStorage.getItem('user') || localStorage.getItem('profile');
if (raw) {
const o = JSON.parse(raw);
return o.nickname || o.name || o.userName || o.username || '';
}
} catch (e) {}
return '';
}
function notifyNewMessage(item) {
if (!cfg('notifyEnabled')) return;
if (!('Notification' in window)) return;
if (Notification.permission === 'default') Notification.requestPermission();
if (Notification.permission !== 'granted') return;
const name = $('.chat-user-name', item) ? $('.chat-user-name', item).textContent.trim() : '盒子IM';
const body = $('.message-text', item) ? $('.message-text', item).innerText.trim().slice(0, 120) : '';
try {
new Notification('盒子IM · ' + name, { body });
} catch (e) {}
}
function watchNotifications() {
const obs = new MutationObserver((muts) => {
for (const m of muts)
for (const n of m.addedNodes) {
if (n.nodeType !== 1) continue;
const items = n.classList && n.classList.contains('chat-message-item') ? [n] : n.querySelectorAll ? n.querySelectorAll('.chat-message-item') : [];
items.forEach((it) => {
const mine = !!it.querySelector('.message-normal.message-mine');
if (mine) return;
const text = $('.message-text', it) ? $('.message-text', it).innerText : it.innerText || '';
const me = myName();
const kws = (cfg('notifyKeywords') || '').split(',').map((s) => s.trim()).filter(Boolean);
const hitMention = (me && text.includes(me)) || /@(我|所有人|all)/i.test(text) || kws.some((k) => text.includes(k));
if (document.hidden || !document.hasFocus()) notifyNewMessage(it);
if (cfg('notifyOnMention') && hitMention) {
it.classList.add('bx-mention');
flashTitle('新提及:' + (me || '你'));
}
});
}
});
obs.observe(document.body, { childList: true, subtree: true });
}
/* ===================================================================
* 模块 5:隐私(离屏防窥 + 上报拦截)
* 盒子IM web 端真相:已读回执走 REST PUT /message/{private|group}/readed;
* “正在输入”与“在线状态”web 端根本不主动上报(在线由服务端按 WS 连接推断),
* 因此前端能拦的只有“已读回执”。命中后在请求发出前伪造 200 响应,
* 既不让服务端得知已读,也不占用连接池、不弹错误。
* =================================================================== */
function applyBlur(on) {
const main = $('.chat-box .el-main') || $('.chat-box');
if (main) main.classList.toggle('bx-privacy-blur', on);
}
function watchPrivacyBlur() {
const doBlur = (on) => applyBlur(cfg('privacyBlur') && on);
document.addEventListener('visibilitychange', () => doBlur(document.hidden));
window.addEventListener('blur', () => doBlur(true));
window.addEventListener('focus', () => doBlur(false));
doBlur(document.hidden || !document.hasFocus());
}
// 已读上报专用端点(最可靠命中,且与拉取对方已读的 GET maxReadedId 不冲突)
const READED_RE = /\/message\/(private|group)\/readed(\?|$)/i;
// 伪造一次成功的 200 响应,让 axios 正常 settle,避免连接池被挂起请求占满
function fakeSuccess(xhr) {
try {
const body = '{"code":200,"data":null}';
Object.defineProperty(xhr, 'readyState', { configurable: true, get: () => 4 });
Object.defineProperty(xhr, 'status', { configurable: true, get: () => 200 });
Object.defineProperty(xhr, 'statusText', { configurable: true, get: () => 'OK' });
Object.defineProperty(xhr, 'responseText', { configurable: true, get: () => body });
Object.defineProperty(xhr, 'response', { configurable: true, get: () => ({ code: 200, data: null }) });
const ev = new Event('load');
if (typeof xhr.onreadystatechange === 'function') xhr.onreadystatechange(ev);
if (typeof xhr.onload === 'function') xhr.onload(ev);
} catch (e) {}
}
// 仅安装一次;拦截判定在每次请求时实时读取开关,故面板里勾选/取消立即生效,无需刷新
function installReportBlocker() {
if (window.__bxRbPatched) return;
window.__bxRbPatched = true;
const kwRe = () => new RegExp(cfg('blockRegex') || 'read|receipt|typing|online|presence', 'i');
const on = () => cfg('privacyBlockReport');
const _open = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (m, u) {
this.__bxMethod = (m || '').toUpperCase();
this.__bxUrl = typeof u === 'string' ? u : '';
return _open.apply(this, arguments);
};
const _send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (body) {
if (on()) {
const u = this.__bxUrl || '';
const m = this.__bxMethod || '';
const hit = READED_RE.test(u) || (kwRe().test(u) && (m === 'PUT' || m === 'POST'));
if (hit) {
console.log('[盒子IM+] 已拦截已读上报:', u);
setTimeout(() => fakeSuccess(this), 0);
return;
}
}
return _send.apply(this, arguments);
};
const _fetch = window.fetch;
window.fetch = function (u, o) {
if (on()) {
const s = typeof u === 'string' ? u : (u && u.url) || '';
const m = (o && o.method ? o.method : 'GET').toUpperCase();
const hit = READED_RE.test(s) || (kwRe().test(s) && (m === 'PUT' || m === 'POST'));
if (hit) {
console.log('[盒子IM+] 已拦截 fetch 已读上报');
return Promise.resolve(new Response('{"code":200,"data":null}', { status: 200, headers: { 'Content-Type': 'application/json' } }));
}
}
return _fetch.apply(this, arguments);
};
// 原生 WebSocket(cmd 数字协议)。web 端已读/输入/在线均不走 WS,这里仅做 best-effort:
// 丢弃非登录(0)/心跳(1) 且内容含上报关键字的出站帧,绝不误伤连接本身。
const _wsSend = WebSocket.prototype.send;
WebSocket.prototype.send = function (data) {
if (on() && typeof data === 'string') {
try {
const obj = JSON.parse(data);
if (obj && typeof obj.cmd === 'number' && obj.cmd !== 0 && obj.cmd !== 1 && kwRe().test(data)) {
console.log('[盒子IM+] 已拦截 socket 上报');
return;
}
} catch (e) {}
}
return _wsSend.apply(this, arguments);
};
}
/* ===================================================================
* 统一浮动 UI
* =================================================================== */
let panel, statusEl, toggleBtn;
function ensureUI() {
if (panel) return;
const style = document.createElement('style');
style.textContent = `
#bx-plus-root{position:fixed;right:16px;bottom:16px;z-index:999999;font-family:-apple-system,"PingFang SC",sans-serif;}
#bx-plus-toggle{width:48px;height:48px;border-radius:50%;border:none;cursor:pointer;color:#fff;font-size:20px;
background:#2830d3;box-shadow:0 4px 14px rgba(0,0,0,.25);transition:.2s;display:flex;align-items:center;justify-content:center;}
#bx-plus-toggle:hover{transform:scale(1.05);}
#bx-plus-panel{position:absolute;right:0;bottom:60px;width:330px;max-height:78vh;overflow:auto;background:#fff;
border-radius:14px;box-shadow:0 8px 30px rgba(0,0,0,.18);padding:14px;display:none;color:#333;font-size:13px;}
#bx-plus-panel.show{display:block;}
#bx-plus-panel h3{margin:0 0 8px;font-size:15px;display:flex;justify-content:space-between;align-items:center;}
#bx-plus-panel .sec{margin:10px 0;padding:10px;border:1px solid #eef0f4;border-radius:10px;background:#fafbfc;}
#bx-plus-panel .sec h4{margin:0 0 6px;font-size:13px;color:#2830d3;}
#bx-plus-panel label{display:block;margin:5px 0 2px;color:#555;font-size:12px;}
#bx-plus-panel input[type=text],#bx-plus-panel select{width:100%;box-sizing:border-box;padding:6px 8px;border:1px solid #dcdfe6;border-radius:6px;font-size:12px;}
#bx-plus-panel .row{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;}
#bx-plus-panel button.act{flex:1;border:none;border-radius:6px;padding:7px;cursor:pointer;color:#fff;background:#2830d3;font-size:12px;min-width:80px;}
#bx-plus-panel button.ghost{flex:1;border:1px solid #dcdfe6;border-radius:6px;padding:7px;cursor:pointer;background:#fff;font-size:12px;min-width:80px;}
#bx-plus-foot{position:sticky;bottom:0;background:#fff;padding-top:10px;margin-top:6px;border-top:1px solid #eef0f4;display:flex;gap:8px;z-index:2;box-shadow:0 -4px 8px rgba(255,255,255,.9);}
#bx-plus-foot .act{flex:1;border:none;border-radius:6px;padding:8px;cursor:pointer;color:#fff;background:#2830d3;font-size:12px;min-width:80px;}
#bx-plus-foot .ghost{flex:1;border:1px solid #dcdfe6;border-radius:6px;padding:8px;cursor:pointer;background:#fff;font-size:12px;min-width:80px;}
#bx-plus-status{position:fixed;right:16px;bottom:72px;z-index:999999;background:rgba(0,0,0,.78);color:#fff;
padding:6px 12px;border-radius:8px;font-size:12px;display:none;}
.bx-recv-translate{display:block;width:fit-content;max-width:70vw;margin:6px 0 2px;padding:6px 12px;
background:rgba(40,48,211,.06);border-left:3px solid #2830d3;border-radius:8px;font-size:13px;line-height:1.55;color:#333;box-sizing:border-box;clear:both;}
.bx-recv-zh{white-space:pre-wrap;word-break:break-word;} .bx-recv-tip{margin-top:3px;font-size:11px;color:#9aa0a6;text-align:right;} .bx-recv-err{color:#d03050;}
.right-menu .menu-item.bx-translate-item{cursor:pointer;}
.bx-privacy-blur{filter:blur(12px)!important;transition:filter .2s;}
.bx-mention{outline:2px solid #f59e0b!important;outline-offset:2px;border-radius:8px;}
.bx-search-hit{background:rgba(245,158,11,.22)!important;outline:2px solid #f59e0b!important;outline-offset:1px;}
.bx-recall-keep{margin:6px 0;padding:8px 12px;background:rgba(208,48,80,.07);border-left:3px solid #d03050;border-radius:8px;font-size:13px;line-height:1.5;}
.bx-recall-head{font-size:11px;color:#d03050;margin-bottom:3px;} .bx-recall-body{white-space:pre-wrap;word-break:break-word;color:#333;}
`;
document.head.appendChild(style);
panel = document.createElement('div');
panel.id = 'bx-plus-root';
panel.innerHTML = `
`;
document.body.appendChild(panel);
toggleBtn = $('#bx-plus-toggle', panel);
statusEl = $('#bx-plus-status', panel);
const q = (id) => $('#' + id, panel);
const refresh = () => {
q('bx-enabled').checked = cfg('enabled');
q('bx-recvenabled').checked = cfg('recvEnabled');
q('bx-endpoint').value = cfg('endpoint');
q('bx-key').value = cfg('apiKey');
q('bx-model').value = cfg('model');
q('bx-mode').value = cfg('mode');
q('bx-selector').value = cfg('selector');
q('bx-theme').value = cfg('bxui_theme');
q('bx-antirecall').checked = cfg('antiRecall');
q('bx-persistrecall').checked = cfg('antiRecallPersist');
q('bx-notify').checked = cfg('notifyEnabled');
q('bx-mention').checked = cfg('notifyOnMention');
q('bx-notifykw').value = cfg('notifyKeywords');
q('bx-blur').checked = cfg('privacyBlur');
q('bx-block').checked = cfg('privacyBlockReport');
q('bx-blockre').value = cfg('blockRegex');
};
refresh();
toggleBtn.addEventListener('click', () => {
const p = q('bx-plus-panel');
if (p.classList.contains('show')) p.classList.remove('show');
else {
refresh();
p.classList.add('show');
}
});
q('bx-close').addEventListener('click', () => q('bx-plus-panel').classList.remove('show'));
q('bx-theme-apply').addEventListener('click', () => {
setTheme(q('bx-theme').value);
showStatus('已应用主题:' + THEMES[q('bx-theme').value].label, 1400);
});
q('bx-export').addEventListener('click', exportCurrentChat);
q('bx-search-btn').addEventListener('click', () => doSearch(q('bx-search').value));
q('bx-search-clear').addEventListener('click', () => {
clearSearch();
q('bx-search').value = '';
});
q('bx-save').addEventListener('click', () => {
set('enabled', q('bx-enabled').checked);
set('recvEnabled', q('bx-recvenabled').checked);
set('endpoint', q('bx-endpoint').value.trim());
set('apiKey', q('bx-key').value.trim());
set('model', q('bx-model').value.trim());
set('mode', q('bx-mode').value);
set('selector', q('bx-selector').value.trim());
set('antiRecall', q('bx-antirecall').checked);
const persistOn = q('bx-persistrecall').checked;
set('antiRecallPersist', persistOn);
if (!persistOn) saveRecallStore({}); // 关闭持久化即清空本地撤回存档
set('notifyEnabled', q('bx-notify').checked);
set('notifyOnMention', q('bx-mention').checked);
set('notifyKeywords', q('bx-notifykw').value.trim());
set('privacyBlur', q('bx-blur').checked);
set('privacyBlockReport', q('bx-block').checked);
set('blockRegex', q('bx-blockre').value.trim());
refresh();
scanIncoming();
watchPrivacyBlur(); // 立即按新设置刷新模糊
showStatus('已保存', 1200);
});
q('bx-test').addEventListener('click', () => {
if (!cfg('apiKey') || !cfg('endpoint')) {
showStatus('请先填写 API Key 与接口地址', 2500);
return;
}
showStatus('测试中…');
translate('你好,今天天气真好', (err, res) => showStatus(err ? '测试失败:' + err.message : '测试OK: ' + res, 3000));
});
}
let statusTimer;
function showStatus(msg, ms = 0) {
if (!statusEl) return;
statusEl.textContent = msg;
statusEl.style.display = 'block';
clearTimeout(statusTimer);
if (ms) statusTimer = setTimeout(() => (statusEl.style.display = 'none'), ms);
}
/* ===================================================================
* 启动
* =================================================================== */
function start() {
ensureUI();
applyTheme(cfg('bxui_theme'));
detectComposer();
startRecv();
initContextMenuInjector();
if (cfg('antiRecall')) watchRecall();
if (cfg('notifyEnabled') || cfg('notifyOnMention')) watchNotifications();
originalTitle = document.title;
if (cfg('privacyBlur')) watchPrivacyBlur();
installReportBlocker();
// SPA:输入框会随会话切换重渲染
setInterval(detectComposer, 1500);
const mo = new MutationObserver(() => detectComposer());
mo.observe(document.body, { childList: true, subtree: true });
// 防撤回 / 通知 设置变化后重新绑定
setInterval(() => {
if (cfg('antiRecall') && !window.__bxRecallOn) {
window.__bxRecallOn = true;
watchRecall();
}
if ((cfg('notifyEnabled') || cfg('notifyOnMention')) && !window.__bxNotiOn) {
window.__bxNotiOn = true;
watchNotifications();
}
}, 3000);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
// —— 油猴菜单 ——
GM_registerMenuCommand('盒子IM+:切换发送翻译', () => {
const v = !cfg('enabled');
set('enabled', v);
showStatus(v ? '已启用发送翻译' : '已停用发送翻译', 1200);
});
GM_registerMenuCommand('盒子IM+:切换接收翻译', () => {
const v = !cfg('recvEnabled');
set('recvEnabled', v);
scanIncoming();
showStatus(v ? '已开启接收翻译' : '已关闭接收翻译', 1200);
});
GM_registerMenuCommand('盒子IM+:主题·清新毛玻璃', () => setTheme('light'));
GM_registerMenuCommand('盒子IM+:主题·暗夜模式', () => setTheme('dark'));
GM_registerMenuCommand('盒子IM+:主题·紧凑模式', () => setTheme('compact'));
GM_registerMenuCommand('盒子IM+:主题·关闭美化', () => setTheme('off'));
GM_registerMenuCommand('盒子IM+:打开设置面板', () => {
ensureUI();
$('#bx-plus-panel').classList.add('show');
});
})();