// ==UserScript==
// @name 盒子IM 增强 Plus(翻译 + 美化 + 消息增强 + 通知 + 隐私)
// @namespace https://xingk.xyz/boxim-plus
// @version 1.3
// @description 盒子IM 一站式增强:发送前/接收自动翻译、UI 多主题美化、防撤回、会话导出、通知、已读拦截及 WS 隐私模式(API 拉取消息)。
// @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
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
/*
* 盒子IM 增强 Plus
* 作者:归鸿
* 联系方式:manager@xingk.xyz
* 团队主页:https://xingk.xyz
* 开源许可证:MIT License
*
* 本程序基于 MIT 许可证发布,允许在符合以下条件的前提下自由使用、复制、修改与再分发:
* 1. 上述版权声明与许可证声明须保留在所有副本中;
* 2. 本程序按“原样”提供,作者不对任何使用后果承担责任,亦不作任何明示或暗示担保。
* 许可证全文见 https://opensource.org/licenses/MIT
*/
(function () {
'use strict';
// GM 隔离环境下,必须使用页面窗口对象才能接管盒子IM自己的 axios/WS。
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
/* ===================================================================
* 配置(默认值)
* =================================================================== */
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, // 拦截已读回执上报(默认关)
privacyBlockWs: false, // 拦截 WS,改用 API 轮询消息(默认关)
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:隐私(离屏防窥 + 已读拦截 + WS/API 模式)
* 盒子IM web 端已读回执走 REST PUT /message/{private|group}/readed;
* “正在输入”无 web 端上报;在线状态由服务端按 WS 连接推断。
* 已读拦截和 WS/API 模式分开控制,互不影响。
* =================================================================== */
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 (pageWindow.__bxRbPatched) return;
pageWindow.__bxRbPatched = true;
const kwRe = () => new RegExp(cfg('blockRegex') || 'read|receipt|typing|online|presence', 'i');
const on = () => cfg('privacyBlockReport');
const XHR = pageWindow.XMLHttpRequest;
const _open = XHR.prototype.open;
XHR.prototype.open = function (m, u) {
this.__bxMethod = (m || '').toUpperCase();
this.__bxUrl = typeof u === 'string' ? u : '';
return _open.apply(this, arguments);
};
const _send = XHR.prototype.send;
XHR.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 = pageWindow.fetch;
pageWindow.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(pageWindow, arguments);
};
// WS 拦截由 installWsPrivacyMode 独立负责,避免与已读 XHR/fetch 拦截互相影响。
}
/* ===================================================================
* 模块 5B:WS 隐私模式(断开实时连接 + API 增量拉取)
*
* API 文档:https://www.boximchat.com/api/v3/api-docs
* 仅使用已定义的离线增量接口:
* GET /message/private/loadOfflineMessage?minId=...
* GET /message/group/loadOfflineMessage?minId=...
*
* 注意:关闭 WS 后,服务端不会再主动推送在线事件。插件按 1 秒轮询 API
* 补拉消息;只能接收 API 已经可见的消息,语音/视频通话、在线列表等实时功能会停止。
* =================================================================== */
const WS_POLL_INTERVAL = 1000;
const WS_CURSOR_KEY = 'wsPrivacyCursorsV1';
const WS_PENDING_KEY = 'wsPrivacyPendingV1';
const WS_RECENT_KEY = 'wsPrivacyRecentV1';
const WS_RECENT_TTL = 10 * 60 * 1000;
const wsConnections = new Set();
let wsPollTimer = null;
let wsPollBusy = false;
let wsModeApplied = false;
let activeHistorySyncTimer = null;
let activeHistorySyncBusy = false;
function loadWsCursors() {
try { return JSON.parse(get(WS_CURSOR_KEY) || '{"private":0,"group":0}'); } catch (e) { return { private: 0, group: 0 }; }
}
function saveWsCursors(cursors) { set(WS_CURSOR_KEY, JSON.stringify(cursors)); }
function loadWsPending() {
try { return JSON.parse(get(WS_PENDING_KEY) || '[]'); } catch (e) { return []; }
}
function saveWsPending(messages) { set(WS_PENDING_KEY, JSON.stringify(messages.slice(-500))); }
function loadWsRecent() {
try {
const now = Date.now();
const list = JSON.parse(get(WS_RECENT_KEY) || '[]');
return Array.isArray(list) ? list.filter((p) => p && p.raw && now - Number(p.savedAt || now) < WS_RECENT_TTL) : [];
} catch (e) { return []; }
}
function saveWsRecent(messages) {
const now = Date.now();
const list = messages.filter((p) => p && p.raw && now - Number(p.savedAt || now) < WS_RECENT_TTL).slice(-500);
set(WS_RECENT_KEY, JSON.stringify(list));
}
function idGreater(a, b) {
try {
if (/^\d+$/.test(String(a)) && /^\d+$/.test(String(b))) return BigInt(String(a)) > BigInt(String(b));
} catch (e) {}
return String(a) > String(b);
}
function apiBase() { return pageWindow.location.origin.replace(/\/$/, '') + '/api'; }
function apiRequest(path, method, data) {
const token = pageWindow.sessionStorage.getItem('accessToken') || '';
return pageWindow.fetch(apiBase() + path, {
method: method || 'GET',
credentials: 'include',
headers: Object.assign({ 'Content-Type': 'application/json' }, token ? { accessToken: encodeURIComponent(token) } : {}),
body: method && method !== 'GET' ? JSON.stringify(data || {}) : undefined,
}).then((r) => r.json());
}
function apiGet(path) { return apiRequest(path, 'GET'); }
function apiPost(path, data) { return apiRequest(path, 'POST', data); }
function normaliseApiMessage(raw, isGroup) {
const m = Object.assign({}, raw);
m.localId = m.localId || ('bx-api-' + (m.id || m.seqNo || Date.now()));
m.sendTime = typeof m.sendTime === 'string' ? new Date(m.sendTime).getTime() : (m.sendTime || Date.now());
m.selfSend = false;
if (isGroup && !m.sendNickName) m.sendNickName = '群成员';
return m;
}
function getChatVm() {
const root = $('.chat-box');
let vm = root && (root.__vue__ || (root.__vueParentComponent && root.__vueParentComponent.proxy));
// Element UI 容器上可能挂的是子组件,向上寻找真正带 chatStore/conversation 的组件实例。
for (let i = 0; vm && i < 8; i++, vm = vm.$parent || vm.parent) {
if (vm.chatStore && vm.conversation) return vm;
}
// 生产构建里根节点可能没有直接挂 Vue 实例,从消息节点或滚动容器再尝试一次。
const candidates = $$('.chat-message-item, #chatScrollBox, .im-chat-main');
for (const el of candidates) {
vm = el.__vue__ || (el.__vueParentComponent && el.__vueParentComponent.proxy);
for (let i = 0; vm && i < 8; i++, vm = vm.$parent || vm.parent) {
if (vm.chatStore && vm.conversation) return vm;
}
}
return null;
}
function getChatStore() {
const vm = getChatVm();
return vm && vm.chatStore ? vm.chatStore : null;
}
function schedulePendingFlush() {
if (!cfg('privacyBlockWs')) return;
clearTimeout(schedulePendingFlush.timer);
schedulePendingFlush.timer = setTimeout(() => pollWsPrivacyMessages(), 120);
}
function scheduleActiveHistorySync() {
if (!cfg('privacyBlockWs')) return;
clearTimeout(activeHistorySyncTimer);
activeHistorySyncTimer = setTimeout(syncActiveConversationHistory, 260);
}
function activeConversationReady(vm) {
const store = vm && vm.chatStore;
return Boolean(vm && store && vm.conversation && store.activeConversation &&
store.activeConversation.key === vm.conversation.key && !store.loadingMessage && !store.loading);
}
async function syncActiveConversationHistory() {
if (!cfg('privacyBlockWs') || activeHistorySyncBusy) return;
const vm = getChatVm();
if (!activeConversationReady(vm)) return;
const conv = vm.conversation;
const isGroup = Number(conv.type) === 2;
if (Number(conv.type) !== 1 && !isGroup) return;
activeHistorySyncBusy = true;
try {
const payload = isGroup ? { groupId: conv.targetId, minSeqNo: 1, maxSeqNo: 0 } : { friendId: conv.targetId, minSeqNo: 1, maxSeqNo: 0 };
const result = await apiPost(isGroup ? '/message/group/history' : '/message/private/history', payload);
if (!activeConversationReady(vm)) return;
const list = result && Array.isArray(result.data) ? result.data : (Array.isArray(result) ? result : []);
for (const raw of list) await injectApiMessage(raw, isGroup);
} catch (e) {
console.warn('[盒子IM+] 当前会话历史校准失败:', e);
} finally {
activeHistorySyncBusy = false;
}
}
function installChatStoreHooks(chatStore) {
if (!chatStore || chatStore.__bxWsPrivacyHooks) return;
chatStore.__bxWsPrivacyHooks = true;
['setActive', 'resetMessages', 'loadLastPageMessage', 'loadNextPageMessage', 'locateToMessage'].forEach((name) => {
if (typeof chatStore[name] !== 'function') return;
const original = chatStore[name];
chatStore[name] = function () {
const result = original.apply(this, arguments);
Promise.resolve(result).finally(() => {
schedulePendingFlush();
scheduleActiveHistorySync();
});
return result;
};
});
}
function conversationKey(isGroup, targetId) {
return (isGroup ? '2-' : '1-') + targetId;
}
function hasRenderedMessage(id) {
return $$('.chat-message-item').some((item) => {
try {
const vm = item.__vue__ || (item.__vueParentComponent && item.__vueParentComponent.proxy);
const m = vm && (vm.message || (vm.props && vm.props.message));
return String(m && m.id) === String(id);
} catch (e) { return false; }
});
}
function hasStoreMessage(chatStore, id) {
try {
const list = chatStore.messages || [];
return list.some((m) => String(m && m.id) === String(id));
} catch (e) { return false; }
}
async function injectApiMessage(raw, isGroup) {
if (!raw || !raw.id) return false;
const vm = getChatVm();
const chatStore = getChatStore();
if (!vm || !chatStore) return false;
installChatStoreHooks(chatStore);
const mineId = vm.userStore && vm.userStore.userInfo ? vm.userStore.userInfo.id : null;
const targetId = isGroup ? Number(raw.groupId) :
(Number(raw.sendId) === Number(mineId) ? Number(raw.recvId) : Number(raw.sendId));
if (!targetId) return false;
const key = conversationKey(isGroup, targetId);
const convMap = chatStore.conversationMap;
const conv = convMap && (typeof convMap.get === 'function' ? convMap.get(key) : convMap[key]);
// 只在目标会话已经成为当前 active 且历史加载完成后注入,避免和 setActive/resetMessages 竞争。
if (!conv || !chatStore.activeConversation || chatStore.activeConversation.key !== key || chatStore.loadingMessage || chatStore.loading) return false;
if (hasStoreMessage(chatStore, raw.id) || hasRenderedMessage(raw.id)) return true;
const m = normaliseApiMessage(raw, isGroup);
m.selfSend = mineId !== null && Number(m.sendId) === Number(mineId);
try {
if (typeof chatStore.insertMessage !== 'function') return false;
await chatStore.insertMessage(key, m);
if (vm.$eventBus && vm.$eventBus.$emit) vm.$eventBus.$emit('newMessage', m);
scanIncoming();
return true;
} catch (e) {
console.warn('[盒子IM+] API 消息写入失败:', e);
return false;
}
}
function addPendingMessage(pending, raw, isGroup, keepForReplay) {
if (!raw || !raw.id) return;
const exists = pending.some((p) => Boolean(p.isGroup) === Boolean(isGroup) && String(p.raw && p.raw.id) === String(raw.id));
if (!exists) pending.push({ isGroup: Boolean(isGroup), raw: raw, savedAt: Date.now(), keepForReplay: Boolean(keepForReplay) });
}
async function flushPendingMessages(pending) {
const remaining = [];
for (const p of pending) {
if (!(await injectApiMessage(p.raw, Boolean(p.isGroup)))) remaining.push(p);
}
return remaining;
}
async function replayRecentMessages() {
const recent = loadWsRecent();
for (const p of recent) await injectApiMessage(p.raw, Boolean(p.isGroup));
// 成功注入也保留到 TTL 到期:盒子 IM 可能在之后一次异步历史重置中再次清空当前数组。
saveWsRecent(recent);
}
async function pollWsPrivacyMessages() {
if (!cfg('privacyBlockWs') || wsPollBusy) return;
if (!pageWindow.sessionStorage.getItem('accessToken')) return;
wsPollBusy = true;
try {
const cursors = loadWsCursors();
let pending = loadWsPending();
let recent = loadWsRecent();
// 待处理消息成功写入后可以移除;recent 则始终保留一段时间,用于会话历史异步重置后的重放。
pending = await flushPendingMessages(pending);
await replayRecentMessages();
const [pRes, gRes] = await Promise.all([
apiGet('/message/private/loadOfflineMessage?minId=' + encodeURIComponent(cursors.private || 0)),
apiGet('/message/group/loadOfflineMessage?minId=' + encodeURIComponent(cursors.group || 0)),
]);
const privateMessages = pRes && Array.isArray(pRes.data) ? pRes.data : [];
const groupMessages = gRes && Array.isArray(gRes.data) ? gRes.data : [];
// 先入待处理队列,再尝试写入。即使当前没有打开会话,也不会因游标推进而丢消息。
privateMessages.forEach((m) => { addPendingMessage(pending, m, false); addPendingMessage(recent, m, false, true); });
groupMessages.forEach((m) => { addPendingMessage(pending, m, true); addPendingMessage(recent, m, true, true); });
pending = await flushPendingMessages(pending);
await replayRecentMessages();
// 游标只代表 API 已收到;recent 作为短期重放缓存,覆盖会话切换时的消息数组重建。
privateMessages.forEach((m) => { if (idGreater(m.id, cursors.private || 0)) cursors.private = m.id; });
groupMessages.forEach((m) => { if (idGreater(m.id, cursors.group || 0)) cursors.group = m.id; });
saveWsCursors(cursors);
saveWsPending(pending);
saveWsRecent(recent);
} catch (e) {
console.warn('[盒子IM+] API 轮询失败:', e);
} finally {
wsPollBusy = false;
}
}
function closeTrackedSockets() {
wsConnections.forEach((ws) => {
try { ws.close(1000, 'privacy mode'); } catch (e) {}
});
wsConnections.clear();
}
function installWsPrivacyMode() {
if (pageWindow.__bxWsPrivacyPatched) return;
pageWindow.__bxWsPrivacyPatched = true;
const NativeWebSocket = pageWindow.WebSocket;
function TrackedWebSocket(url, protocols) {
const ws = protocols === undefined ? new NativeWebSocket(url) : new NativeWebSocket(url, protocols);
wsConnections.add(ws);
ws.addEventListener('close', () => wsConnections.delete(ws));
// 进入 WS 隐私模式后,新建连接立即关闭,避免服务器把此浏览器会话视作在线。
if (cfg('privacyBlockWs')) setTimeout(() => { try { ws.close(1000, 'privacy mode'); } catch (e) {} }, 0);
return ws;
}
TrackedWebSocket.prototype = NativeWebSocket.prototype;
Object.setPrototypeOf(TrackedWebSocket, NativeWebSocket);
Object.getOwnPropertyNames(NativeWebSocket).forEach((key) => {
if (!(key in TrackedWebSocket)) {
try { Object.defineProperty(TrackedWebSocket, key, Object.getOwnPropertyDescriptor(NativeWebSocket, key)); } catch (e) {}
}
});
pageWindow.WebSocket = TrackedWebSocket;
const nativeSend = NativeWebSocket.prototype.send;
NativeWebSocket.prototype.send = function (data) {
if (cfg('privacyBlockWs')) {
console.log('[盒子IM+] WS 隐私模式:已拦截 WS 出站帧');
return;
}
return nativeSend.apply(this, arguments);
};
}
function syncWsPrivacyMode() {
if (cfg('privacyBlockWs')) {
if (!wsModeApplied) {
wsModeApplied = true;
closeTrackedSockets();
showStatus('WS 已断开,改用 API 拉取消息', 2200);
}
pollWsPrivacyMessages();
if (!wsPollTimer) wsPollTimer = setInterval(pollWsPrivacyMessages, WS_POLL_INTERVAL);
return;
}
if (wsModeApplied) {
wsModeApplied = false;
showStatus('WS 拦截已关闭;刷新页面恢复实时连接', 2400);
}
if (wsPollTimer) {
clearInterval(wsPollTimer);
wsPollTimer = null;
}
}
/* ===================================================================
* 统一浮动 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-blockws').checked = cfg('privacyBlockWs');
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('privacyBlockWs', q('bx-blockws').checked);
set('blockRegex', q('bx-blockre').value.trim());
refresh();
syncWsPrivacyMode();
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();
syncWsPrivacyMode();
// SPA:输入框会随会话切换重渲染
setInterval(detectComposer, 1500);
const mo = new MutationObserver(() => {
detectComposer();
schedulePendingFlush();
scheduleActiveHistorySync();
});
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();
}
syncWsPrivacyMode();
}, 3000);
}
// document-start:必须先于盒子 IM 应用创建连接,才能真正接管 WS。
installReportBlocker();
installWsPrivacyMode();
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');
});
})();