// ==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 = `

盒子IM 增强 Plus xingk.xyz

① 翻译

首次使用请先在下方填写翻译 API(接口地址 / Key / 模型),否则翻译功能不可用。

② 美化主题

③ 消息增强

④ 通知效率

⑤ 隐私

说明:盒子IM web 端并不主动上报“在线/正在输入”,这两项无法拦截;能拦的只有“已读回执”。勾选后立即生效,无需刷新。
`; 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'); }); })();