// ==UserScript== // @name Discord API Emoji Reactor // @name:zh-CN Discord API Emoji 反应器 // @namespace http://tampermonkey.net/ // @version 1.0 // @description 通过用户提供的Authorization Token直接调用Discord API添加Emoji反应。支持多方案、用户黑白名单(ID或用户名混用)、ID未知时追溯、为特定用户(ID/用户名)指定不同反应方案并可独立开关、配置导入导出。菜单视觉优化。 // @description:zh-CN 通过用户提供的Authorization Token直接调用Discord API为新消息添加Emoji反应。支持保存和切换多个全局Emoji配置方案,菜单项通过模拟树状结构优化视觉。新增用户黑/白名单过滤功能(支持ID与用户名混合配置),可配置当消息发送者ID未知时进行追溯或按规则处理。可为特定用户ID或用户名指定专属的Emoji反应方案,且每个专属方案可独立开关。新增配置导入导出功能。包含详细帮助信息。请在脚本菜单中配置。 // @author qwerty // @match *://discord.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_xmlhttpRequest // @connect discord.com // @run-at document-idle // @license MIT // @icon https://www.google.com/s2/favicons?sz=64&domain=discord.com // @downloadURL https://update.greasyfork.icu/scripts/535222/Discord%20API%20Emoji%20Reactor.user.js // @updateURL https://update.greasyfork.icu/scripts/535222/Discord%20API%20Emoji%20Reactor.meta.js // ==/UserScript== (function() { 'use strict'; // 启用 JavaScript 的严格模式,有助于捕捉常见错误 // --- 全局配置对象 (config) --- // 该对象用于存储脚本的所有运行时配置和用户设置。 // 配置项会通过 GM_getValue 从油猴存储中加载,并通过 GM_setValue 保存。 let config = { // 布尔值,控制脚本是否启用自动反应功能。 enabled: GM_getValue('apiReact_enabled', false), // 是否启用脚本 // 对象,存储所有全局 Emoji 配置方案。 // 键是方案名称 (字符串),值是该方案的 Emoji 字符串 (例如 "👍;🎉,❤️")。 emojiProfiles: {}, // 将在 loadEmojiProfiles 中加载 // 字符串,当前活动的全局 Emoji 方案的名称。默认为 '默认'。 activeProfileName: '默认', // 将在 loadEmojiProfiles 中加载或确认 // 数组,存储目标频道的ID。如果为空,则脚本对所有频道生效。 targetChannelIds: GM_getValue('apiReact_targetChannelIds', '').split(',').map(id => id.trim()).filter(id => id), // 字符串或null,用户的 Discord Authorization Token。脚本核心功能依赖此 Token。 authToken: GM_getValue('apiReact_authToken', null), // 字符串,用户过滤模式。可选值: 'none' (不过滤), 'blacklist' (黑名单模式), 'whitelist' (白名单模式)。 userFilterMode: GM_getValue('apiReact_userFilterMode', 'none'), // 数组,存储黑名单项目。每个项目可以是用户ID (字符串) 或用户名 (字符串,不区分大小写)。 blacklistItems: GM_getValue('apiReact_blacklistItems', '').split(',').map(item => item.trim()).filter(item => item), // 数组,存储白名单项目。规则同黑名单。 whitelistItems: GM_getValue('apiReact_whitelistItems', '').split(',').map(item => item.trim()).filter(item => item), // 字符串,当无法直接从消息中获取发送者ID时的行为模式。 // 'trace': (默认) 尝试追溯上一条消息的发送者ID。若追溯失败,则该消息不反应。 // 'in_list': 视为在当前过滤模式的名单内 (黑名单不反应, 白名单反应)。 // 'not_in_list': 视为在当前过滤模式的名单外 (黑名单反应, 白名单不反应)。 unknownIdBehaviorMode: GM_getValue('apiReact_unknownIdBehaviorMode', 'trace'), // 对象,存储用户专属的 Emoji 方案映射。 // 键可以是用户ID (字符串) 或小写用户名 (字符串)。 // 值是一个对象: { profileName: string (全局方案名), enabled: boolean (此规则是否启用) } userSpecificProfiles: {}, // 将在 loadUserSpecificProfiles 中加载 }; // 数组,用于存储所有已注册的油猴菜单命令的ID。 // 在重新注册菜单时,会先使用这些ID来注销旧的命令,防止重复。 let menuCommandIds = []; /** * @function loadEmojiProfiles * @description 加载或初始化全局 Emoji 配置方案。 * 从油猴存储中读取 'apiReact_emojiProfiles'。如果不存在或解析失败,则创建一组默认方案。 * 同时加载并验证 'apiReact_activeProfileName' (当前活动方案名)。 */ function loadEmojiProfiles() { // 尝试从 GM 存储加载原始的 Emoji 方案数据 (通常是 JSON 字符串) let rawProfiles = GM_getValue('apiReact_emojiProfiles', null); let loadedProfiles = {}; // 用于存储解析后的方案对象 if (rawProfiles) { // 如果存储中有数据 try { loadedProfiles = JSON.parse(rawProfiles); //尝试解析 JSON // 基本类型检查,确保解析出的是一个对象而不是数组或null等 if (typeof loadedProfiles !== 'object' || loadedProfiles === null || Array.isArray(loadedProfiles)) { console.warn("[API React] 全局 Emoji 方案配置格式不正确,已重置。原始数据:", rawProfiles); loadedProfiles = {}; // 如果格式不对,重置为空对象 } } catch (e) { // 如果 JSON 解析失败 console.error("[API React] 解析全局 Emoji 方案配置失败,已重置:", e); loadedProfiles = {}; // 解析失败也重置为空对象 } } // 如果加载后没有方案 (无论是初次运行还是解析失败),则创建默认方案 if (Object.keys(loadedProfiles).length === 0) { loadedProfiles = { '默认': '👍;🎉,❤️,😄', // 默认方案:随机选择 👍 或序列 🎉,❤️,😄 '开心': '😀;😂;🥳', // 开心方案 '悲伤': '😢;😭' // 悲伤方案 }; GM_setValue('apiReact_emojiProfiles', JSON.stringify(loadedProfiles)); // 保存默认方案到存储 console.log("[API React] 未找到全局 Emoji 方案配置,已创建默认方案。"); } config.emojiProfiles = loadedProfiles; // 将加载或创建的方案赋值给全局配置 // 加载或设置全局默认活动方案名称 let savedActiveProfileName = GM_getValue('apiReact_activeProfileName', '默认'); // 检查已保存的活动方案名是否存在于当前加载的方案列表中 if (!config.emojiProfiles[savedActiveProfileName]) { const profileNames = Object.keys(config.emojiProfiles); // 获取所有可用方案的名称 // 如果保存的活动方案名无效,则使用列表中的第一个方案;若列表也为空(理论上不会,因为上面已创建默认),则用 '默认' config.activeProfileName = profileNames.length > 0 ? profileNames[0] : '默认'; // 再次检查,如果连 '默认' 方案都不存在于 emojiProfiles (极小概率,除非手动篡改存储),则强制创建一个 if (!config.emojiProfiles[config.activeProfileName]) { config.emojiProfiles[config.activeProfileName] = '👍'; // 创建一个最简单的默认方案 } GM_setValue('apiReact_activeProfileName', config.activeProfileName); // 保存新的/验证过的默认活动方案名 console.warn(`[API React] 保存的活动方案名 "${savedActiveProfileName}" 无效,已重置为 "${config.activeProfileName}"。`); } else { config.activeProfileName = savedActiveProfileName; // 保存的活动方案名有效,直接使用 } } /** * @function loadUserSpecificProfiles * @description 加载或初始化用户专属 Emoji 方案映射。 * 新结构: { "identifier": { profileName: "profileName", enabled: true } } * (无旧版数据迁移逻辑,按全新安装处理) */ function loadUserSpecificProfiles() { let rawUserProfiles = GM_getValue('apiReact_userSpecificProfiles', null); // 原始JSON字符串 if (rawUserProfiles) { try { config.userSpecificProfiles = JSON.parse(rawUserProfiles); // 基本类型检查 if (typeof config.userSpecificProfiles !== 'object' || config.userSpecificProfiles === null || Array.isArray(config.userSpecificProfiles)) { console.warn("[API React] 用户专属方案配置格式不正确,已重置。原始数据:", rawUserProfiles); config.userSpecificProfiles = {}; } // 对加载的数据进行一次结构验证和清理 (确保每个规则都有 profileName 和 enabled) for (const idOrName in config.userSpecificProfiles) { if (config.userSpecificProfiles.hasOwnProperty(idOrName)) { const rule = config.userSpecificProfiles[idOrName]; if (typeof rule !== 'object' || rule === null || typeof rule.profileName !== 'string' || typeof rule.enabled !== 'boolean') { console.warn(`[API React] 用户专属方案中发现格式不正确的规则,已移除: ${idOrName}`, rule); delete config.userSpecificProfiles[idOrName]; // 移除格式错误的规则 } } } } catch (e) { console.error("[API React] 解析用户专属方案配置失败,已重置:", e); config.userSpecificProfiles = {}; } } else { config.userSpecificProfiles = {}; // 如果没有保存过,初始化为空对象 } } // 脚本启动时,首先加载所有配置信息 loadEmojiProfiles(); loadUserSpecificProfiles(); /** * @function isUserId * @description 判断给定的字符串是否可能是 Discord 用户ID。 * Discord 用户ID 通常是17到20位纯数字。 * @param {string} item - 待检查的字符串。 * @returns {boolean} 如果字符串符合用户ID格式则返回 true,否则 false。 */ function isUserId(item) { // 确保 item 是字符串类型再进行正则匹配 return typeof item === 'string' && /^\d{17,20}$/.test(item); } /** * @function getEffectiveProfileNameForUser * @description 获取指定用户上下文应使用的 Emoji 方案名称。 * 优先查找该用户的专属配置 (ID优先,然后是用户名),如果存在、有效且已启用,则返回专属方案名。 * 否则,返回全局默认的活动方案名。 * @param {string|null} authorId - 用户的ID。 * @param {string|null} authorUsername - 用户名 (脚本会自动转小写比较)。 * @returns {string} 最终应使用的 Emoji 方案的名称。 */ function getEffectiveProfileNameForUser(authorId, authorUsername) { // 1. 尝试ID匹配 (如果 authorId 存在且有效) if (authorId && config.userSpecificProfiles[authorId]) { const userRule = config.userSpecificProfiles[authorId]; // 确保这个键确实是ID规则 (虽然存储时没有显式type,但我们假定数字键为ID) // 并且规则已启用,且指向的全局方案名在 config.emojiProfiles 中存在 if (userRule.enabled && config.emojiProfiles[userRule.profileName]) { return userRule.profileName; } else if (userRule.enabled && !config.emojiProfiles[userRule.profileName]) { // 规则启用,但指向的全局方案名无效 (可能被删或重命名了) console.warn(`[API React] 用户ID ${authorId} 的专属方案 "${userRule.profileName}" (已启用) 在全局方案中未找到,将使用全局默认方案 "${config.activeProfileName}"。`); } // 如果规则被禁用 (userRule.enabled === false),或者指向的全局方案无效,则会继续向下尝试用户名匹配或返回全局默认 } // 2. 尝试用户名匹配 (如果ID未匹配到有效规则,或者无ID,或者ID规则被禁用) const lowerUsername = authorUsername ? authorUsername.toLowerCase() : null; // 用户名统一转小写进行比较和查找 if (lowerUsername && config.userSpecificProfiles[lowerUsername]) { const userRule = config.userSpecificProfiles[lowerUsername]; // 确保这个键不是ID格式 (从而认定为用户名规则),并且规则已启用,且指向的全局方案有效 if (!isUserId(lowerUsername) && userRule.enabled && config.emojiProfiles[userRule.profileName]) { return userRule.profileName; } else if (!isUserId(lowerUsername) && userRule.enabled && !config.emojiProfiles[userRule.profileName]) { // 用户名规则启用,但指向的全局方案无效 console.warn(`[API React] 用户名 "${lowerUsername}" 的专属方案 "${userRule.profileName}" (已启用) 在全局方案中未找到,将使用全局默认方案 "${config.activeProfileName}"。`); } } // 3. 如果以上都没有匹配到有效的专属规则,则返回全局默认的活动方案名 return config.activeProfileName; } /** * @function getCurrentEmojiString * @description 获取当前消息上下文最终应使用的 Emoji 字符串。 * 它会调用 getEffectiveProfileNameForUser 来确定方案名,然后从 config.emojiProfiles 中获取该方案的 Emoji 字符串。 * 如果方案无效或内容为空,则返回一个默认的 "👍"。 * @param {string|null} authorId - 消息发送者的用户ID (如果能获取到)。 * @param {string|null} authorUsername - 消息发送者的用户名 (如果能获取到)。 * @returns {string} Emoji 字符串。 */ function getCurrentEmojiString(authorId, authorUsername) { const profileName = getEffectiveProfileNameForUser(authorId, authorUsername); // 获取有效的方案名 return config.emojiProfiles[profileName] || '👍'; // 从全局方案中查找,找不到或为空则返回 "👍" } /** * @function showCustomEmojiHelp * @description 显示一个弹窗,指导用户如何获取和配置服务器自定义表情给脚本使用。 */ function showCustomEmojiHelp() { alert( "如何为脚本配置服务器自定义表情:\n\n" + "1. 在 Discord 聊天输入框中,正常输入或选择你想要的服务器自定义表情,例如输入 `:your_emoji_name:` 后选择它。\n" + "2. 【不要发送消息!】此时观察输入框,自定义表情会显示为一段特殊的代码,格式通常是:\n" + " - 动态表情: ``\n" + " - 静态表情: `<:emoji_name:emoji_id>`\n" + " 例如:`<:mycoolemote:123456789012345678>`\n\n" + "3. 将这个【完整的代码】(包括尖括号 `<` 和 `>` 以及冒号)从输入框中复制下来。\n" + "4. 将复制的这段完整代码粘贴到本脚本的 Emoji 配置方案的 Emoji 字符串中,作为你想要反应的表情之一。\n\n" + "例如,如果你的某个方案的 Emoji 字符串是:\n" + "`👍;<:mycoolemote:123456789012345678>,🎉`\n" + "当脚本选中这一组反应时,它就会尝试使用标准的 👍,或者你指定的自定义表情 `<:mycoolemote:123456789012345678>`,或者标准的 🎉 进行反应。\n\n" + "重要提示:\n" + "- 你【必须】提供完整的 `<...>` 格式。脚本内部会自动提取出API所需的 `name:id` 部分。\n" + "- 请确保你的 Discord 账户有权限在该服务器和频道中使用你所配置的自定义表情,否则API反应会失败(通常是“Unknown Emoji”错误)。" ); } /** * @function registerAllMenuCommands * @description 注册(或重新注册)所有油猴脚本菜单命令。 * 使用空格和特殊符号(如 📂, ├─, └─)来模拟菜单的视觉层级感,提高可读性。 * 每次调用前会先注销所有已存在的命令ID,以防止重复。 */ function registerAllMenuCommands() { // 遍历已记录的菜单命令ID,尝试注销它们。 menuCommandIds.forEach(id => { try { GM_unregisterMenuCommand(id); } catch (e) { /* console.warn("[API React] 注销菜单命令失败:", id, e); */ } }); menuCommandIds = []; // 清空已记录的命令ID列表,准备重新填充 let id; // 临时变量,用于存储 GM_registerMenuCommand 返回的命令ID // --- 根级别命令 --- id = GM_registerMenuCommand(`${config.enabled ? '✅' : '❌'} 切换API自动反应 (当前: ${config.enabled ? '开启' : '关闭'})`, toggleEnable); menuCommandIds.push(id); id = GM_registerMenuCommand(`设置 Discord Auth Token (当前: ${config.authToken ? '已设置' : '未设置'})`, setAuthToken); menuCommandIds.push(id); // --- 模块:全局 Emoji 方案管理 --- id = GM_registerMenuCommand(`📂 全局 Emoji 方案管理`, () => {}); menuCommandIds.push(id); id = GM_registerMenuCommand(` ├─ 选择默认方案 (当前: ${config.activeProfileName})`, selectEmojiProfile); menuCommandIds.push(id); id = GM_registerMenuCommand(` ├─ 添加新方案`, addEmojiProfile); menuCommandIds.push(id); const activeGlobalEmojisPreview = (config.emojiProfiles[config.activeProfileName] || '').substring(0, 10); // 获取当前活动方案的Emoji预览(最多10字符) const ellipsis = (config.emojiProfiles[config.activeProfileName] || '').length > 10 ? '...' : ''; // 如果超过10字符则加省略号 id = GM_registerMenuCommand(` ├─ 编辑当前方案Emojis (${config.activeProfileName}: ${activeGlobalEmojisPreview}${ellipsis})`, editCurrentProfileEmojis); menuCommandIds.push(id); id = GM_registerMenuCommand(` ├─ 重命名当前方案 (${config.activeProfileName})`, renameCurrentProfile); menuCommandIds.push(id); id = GM_registerMenuCommand(` └─ 删除当前方案 (${config.activeProfileName})`, deleteCurrentProfile); menuCommandIds.push(id); // --- 模块:用户过滤设置 --- id = GM_registerMenuCommand(`👥 用户过滤设置`, () => {}); menuCommandIds.push(id); const filterModeTextInternal = { none: '不过滤', blacklist: '黑名单', whitelist: '白名单' }; // 内部文本映射 id = GM_registerMenuCommand(` ├─ 切换过滤模式 (当前: ${filterModeTextInternal[config.userFilterMode]})`, toggleUserFilterMode); menuCommandIds.push(id); id = GM_registerMenuCommand(` ├─ 编辑黑名单 (ID/名) (当前: ${config.blacklistItems.length}项)`, setBlacklistItems); menuCommandIds.push(id); id = GM_registerMenuCommand(` ├─ 编辑白名单 (ID/名) (当前: ${config.whitelistItems.length}项)`, setWhitelistItems); menuCommandIds.push(id); const unknownIdBehaviorTextMap = { trace: '追溯 (失败不反应)', in_list: '按名单内处理', not_in_list: '按名单外处理' }; id = GM_registerMenuCommand(` └─ ID未知时行为 (当前: ${unknownIdBehaviorTextMap[config.unknownIdBehaviorMode]})`, toggleUnknownIdBehaviorMode); menuCommandIds.push(id); // --- 模块:用户专属方案 (ID/用户名) --- const specificRuleCount = Object.keys(config.userSpecificProfiles).length; // 获取当前专属规则数量 id = GM_registerMenuCommand(`👤 用户专属方案 (ID/用户名) (当前: ${specificRuleCount}条规则)`, () => {}); menuCommandIds.push(id); id = GM_registerMenuCommand(` ├─ ✨ 添加/修改专属方案 (可批量)`, addOrUpdateUserSpecificRules); menuCommandIds.push(id); id = GM_registerMenuCommand(` ├─ ⚙️ 切换规则启用/禁用状态`, toggleUserSpecificRuleEnable); menuCommandIds.push(id); id = GM_registerMenuCommand(` ├─ 🗑️ 移除指定专属方案规则`, removeUserSpecificRule); menuCommandIds.push(id); id = GM_registerMenuCommand(` ├─ 🗑️ 清空所有专属方案规则`, clearAllUserSpecificRules); menuCommandIds.push(id); id = GM_registerMenuCommand(` └─ 📋 查看所有专属方案规则`, listAllUserSpecificRules); menuCommandIds.push(id); // --- 模块:其他设置与数据管理 --- id = GM_registerMenuCommand(`⚙️ 其他设置与数据管理`, () => {}); menuCommandIds.push(id); id = GM_registerMenuCommand(` ├─ 设置目标频道ID (当前: ${config.targetChannelIds.join(', ') || '所有频道'})`, setTargetChannelIds); menuCommandIds.push(id); id = GM_registerMenuCommand(` ├─ 📥 导出脚本配置`, exportFullConfig); menuCommandIds.push(id); // 导出配置 id = GM_registerMenuCommand(` └─ 📤 导入脚本配置`, importFullConfig); menuCommandIds.push(id); // 导入配置 // --- 模块:帮助信息 --- id = GM_registerMenuCommand(`❓ 帮助信息`, () => {}); menuCommandIds.push(id); id = GM_registerMenuCommand(` ├─ 如何获取 Auth Token?`, showTokenHelp); menuCommandIds.push(id); id = GM_registerMenuCommand(` ├─ 用户过滤与专属方案配置说明`, showUserFilterAndSpecificsHelp); menuCommandIds.push(id); // 合并后的帮助 id = GM_registerMenuCommand(` └─ 如何使用服务器自定义表情?`, showCustomEmojiHelp); menuCommandIds.push(id); } // --- 菜单回调函数:脚本启用/禁用 --- /** * @function toggleEnable * @description 切换脚本的启用/禁用状态。 * 更新配置、保存到油猴存储、刷新菜单、并根据状态启动或停止 MutationObserver。 * 如果启用时 Auth Token 未设置,会弹窗提示并自动禁用。 */ function toggleEnable() { config.enabled = !config.enabled; // 切换状态 GM_setValue('apiReact_enabled', config.enabled); // 保存新状态 registerAllMenuCommands(); // 刷新菜单以反映新状态 if (config.enabled) { // 如果是启用脚本 if (!config.authToken) { // 检查 Auth Token 是否已设置 alert("Auth Token 未设置!脚本无法工作,已自动禁用。\n请在菜单中设置您的 Discord Auth Token。"); config.enabled = false; // 强制禁用 GM_setValue('apiReact_enabled', false); // 再次保存禁用状态 registerAllMenuCommands(); // 再次刷新菜单 return; // 结束函数 } setupObserver(); // Token 存在,启动 MutationObserver 监听新消息 } else { // 如果是禁用脚本 if (observer) observer.disconnect(); // 如果观察者存在,则停止它 console.log("[API React] 观察者已停止 (用户手动禁用)。"); } } // --- 菜单回调函数:全局 Emoji 方案管理 --- /** * @function selectEmojiProfile * @description 允许用户从已有的全局 Emoji 方案中选择一个作为默认活动方案。 */ function selectEmojiProfile() { const profileNames = Object.keys(config.emojiProfiles); // 获取所有方案名称 if (profileNames.length === 0) { // 如果没有任何方案 alert("目前没有可用的全局 Emoji 配置方案。请先添加一个。"); addEmojiProfile(); // 引导用户去添加方案 return; } let promptMessage = "请选择一个新的全局默认 Emoji 配置方案 (输入方案对应的序号或完整的方案名称):\n"; profileNames.forEach((name, index) => { promptMessage += `${index + 1}. ${name}\n`; }); // 构建选择列表 promptMessage += `\n当前全局默认方案是: ${config.activeProfileName}`; const choice = prompt(promptMessage); // 弹出选择框 if (choice === null) return; // 用户点击了“取消” let selectedName = null; const choiceNum = parseInt(choice.trim(), 10); // 尝试将输入解析为数字 (序号) // 检查是否是有效的序号选择 if (!isNaN(choiceNum) && choiceNum >= 1 && choiceNum <= profileNames.length) { selectedName = profileNames[choiceNum - 1]; // 根据序号获取方案名 } else if (config.emojiProfiles[choice.trim()]) { // 检查是否是直接输入的有效方案名 selectedName = choice.trim(); } if (selectedName) { // 如果成功选择了方案 config.activeProfileName = selectedName; // 更新配置中的活动方案名 GM_setValue('apiReact_activeProfileName', config.activeProfileName); // 保存到存储 alert(`全局默认 Emoji 方案已成功切换为: ${config.activeProfileName}`); registerAllMenuCommands(); // 刷新菜单以显示新的当前方案 } else { alert(`无效的选择: "${choice}"。请输入正确的序号或已存在的方案名称。`); } } /** * @function addEmojiProfile * @description 允许用户添加一个新的全局 Emoji 配置方案。 * 用户需要输入方案名称和对应的 Emoji 字符串。 * 添加后会询问是否将其设为默认活动方案。 */ function addEmojiProfile() { const newProfileName = prompt("请输入新全局 Emoji 配置方案的名称 (例如: '庆祝专用'):"); if (!newProfileName || newProfileName.trim() === "") { // 检查名称是否为空 alert("方案名称不能为空。添加失败。"); return; } const trimmedName = newProfileName.trim(); // 去除首尾空格 if (config.emojiProfiles[trimmedName]) { // 检查名称是否已存在 alert(`名为 "${trimmedName}" 的全局方案已经存在。请使用其他名称。`); return; } const newEmojis = prompt(`请输入方案 "${trimmedName}" 的反应 Emojis:\n(使用分号 ";" 分隔不同的随机反应组,使用逗号 "," 分隔同一组内的序列反应表情)\n例如: 👍🎉;💯 或 <:myemote:123>;🥳,✨`); if (newEmojis === null) return; // 用户点击了“取消” config.emojiProfiles[trimmedName] = newEmojis.trim(); // 添加新方案到配置中 GM_setValue('apiReact_emojiProfiles', JSON.stringify(config.emojiProfiles)); // 保存更新后的方案列表 alert(`全局 Emoji 配置方案 "${trimmedName}" 已成功添加。`); registerAllMenuCommands(); // 刷新菜单 // 询问是否将新添加的方案设为默认活动方案 if (confirm(`是否要将新方案 "${trimmedName}" 设置为当前的全局默认活动方案?`)) { config.activeProfileName = trimmedName; GM_setValue('apiReact_activeProfileName', config.activeProfileName); alert(`"${trimmedName}" 已被设为全局默认活动方案。`); registerAllMenuCommands(); // 再次刷新菜单以反映此更改 } } /** * @function editCurrentProfileEmojis * @description 允许用户编辑当前活动的全局 Emoji 方案的 Emoji 字符串。 */ function editCurrentProfileEmojis() { const profileToEdit = config.activeProfileName; // 要编辑的是当前活动的方案 if (!config.emojiProfiles[profileToEdit]) { // 安全检查 alert(`错误:当前活动方案 "${profileToEdit}" 未找到。请尝试重新选择默认方案。`); return; } const currentEmojis = config.emojiProfiles[profileToEdit] || ''; // 获取当前的Emoji字符串 const newEmojis = prompt(`正在编辑全局方案 "${profileToEdit}" 的 Emojis:\n(分号 ";" 分隔随机组, 逗号 "," 分隔序列表情)\n当前内容: ${currentEmojis}`, currentEmojis); if (newEmojis !== null) { // 用户没有点击“取消” config.emojiProfiles[profileToEdit] = newEmojis.trim(); // 更新配置 GM_setValue('apiReact_emojiProfiles', JSON.stringify(config.emojiProfiles)); // 保存 alert(`全局方案 "${profileToEdit}" 的 Emojis 已更新。`); registerAllMenuCommands(); // 刷新菜单 } } /** * @function renameCurrentProfile * @description 允许用户重命名当前活动的全局 Emoji 方案。 * 如果重命名成功,会同时更新所有用户专属配置中对旧方案名的引用。 */ function renameCurrentProfile() { const oldName = config.activeProfileName; // 当前要重命名的方案名 if (Object.keys(config.emojiProfiles).length === 0) { // 如果没有方案可重命名 alert("没有全局方案可以重命名。"); return; } const newName = prompt(`请输入全局方案 "${oldName}" 的新名称:`, oldName); if (!newName || newName.trim() === "") { // 新名称不能为空 alert("新名称不能为空。重命名失败。"); return; } const trimmedNewName = newName.trim(); if (trimmedNewName === oldName) return; // 名称未改变 if (config.emojiProfiles[trimmedNewName]) { // 新名称已被其他方案使用 alert(`名称 "${trimmedNewName}" 已经被另一个全局方案使用。请选择其他名称。`); return; } // 执行重命名操作 config.emojiProfiles[trimmedNewName] = config.emojiProfiles[oldName]; // 赋给新名称 delete config.emojiProfiles[oldName]; // 删除旧名称条目 config.activeProfileName = trimmedNewName; // 更新活动方案名为新名称 // 更新所有用户专属配置中引用了旧方案名的地方 for (const idOrName in config.userSpecificProfiles) { if (config.userSpecificProfiles.hasOwnProperty(idOrName)) { if (config.userSpecificProfiles[idOrName].profileName === oldName) { config.userSpecificProfiles[idOrName].profileName = trimmedNewName; } } } // 保存所有更改 GM_setValue('apiReact_emojiProfiles', JSON.stringify(config.emojiProfiles)); GM_setValue('apiReact_activeProfileName', config.activeProfileName); GM_setValue('apiReact_userSpecificProfiles', JSON.stringify(config.userSpecificProfiles)); alert(`全局方案 "${oldName}" 已成功重命名为 "${trimmedNewName}"。\n相关的用户专属设置已同步更新。`); registerAllMenuCommands(); // 刷新菜单 } /** * @function deleteCurrentProfile * @description 允许用户删除当前活动的全局 Emoji 方案。 * 删除后,会自动选择列表中的第一个剩余方案作为新的默认活动方案。 */ function deleteCurrentProfile() { const profileNameToDelete = config.activeProfileName; // 要删除的方案名 if (Object.keys(config.emojiProfiles).length <= 1) { // 至少保留一个 alert("至少需要保留一个全局 Emoji 配置方案,无法删除最后一个方案。"); return; } if (confirm(`您确定要删除全局 Emoji 配置方案 "${profileNameToDelete}" 吗?\n\n注意:如果任何用户专属方案正在使用此方案,它们将会回退到脚本的全局默认活动方案。`)) { delete config.emojiProfiles[profileNameToDelete]; // 删除方案 // 自动选择剩下的第一个方案作为新的全局默认 config.activeProfileName = Object.keys(config.emojiProfiles)[0]; // 用户专属配置中引用了已删除方案名的地方,在 getEffectiveProfileNameForUser 中会自动处理回退, // 无需在此显式修改 userSpecificProfiles 的值,但如果想清理无效引用可以遍历检查。 GM_setValue('apiReact_emojiProfiles', JSON.stringify(config.emojiProfiles)); // 保存更新的方案列表 GM_setValue('apiReact_activeProfileName', config.activeProfileName); // 保存新的活动方案名 alert(`全局方案 "${profileNameToDelete}" 已成功删除。\n新的全局默认活动方案已设置为 "${config.activeProfileName}"。`); registerAllMenuCommands(); // 刷新菜单 } } // --- 菜单回调函数:用户过滤设置 --- /** * @function toggleUserFilterMode * @description 循环切换用户过滤模式 ('none', 'blacklist', 'whitelist')。 */ function toggleUserFilterMode() { const modes = ['none', 'blacklist', 'whitelist']; // 定义所有可用模式 let currentIndex = modes.indexOf(config.userFilterMode); // 获取当前模式的索引 config.userFilterMode = modes[(currentIndex + 1) % modes.length]; // 循环切换 GM_setValue('apiReact_userFilterMode', config.userFilterMode); // 保存新模式 const filterModeTextInternal = { none: '不过滤', blacklist: '黑名单模式', whitelist: '白名单模式' }; alert(`用户过滤模式已切换为: ${filterModeTextInternal[config.userFilterMode]}`); registerAllMenuCommands(); // 刷新菜单 } /** * @function setBlacklistItems * @description 允许用户编辑黑名单项目 (ID或用户名,逗号分隔)。 */ function setBlacklistItems() { const newItemsRaw = prompt( `请输入黑名单项目 (可以是用户ID或用户名,不区分大小写)。\n多个项目请用英文逗号 "," 分隔。\n留空则清空黑名单。\n\n当前黑名单内容: ${config.blacklistItems.join(', ')}`, config.blacklistItems.join(', ') // 默认显示当前列表 ); if (newItemsRaw !== null) { // 用户没有点击“取消” config.blacklistItems = newItemsRaw.split(',').map(item => item.trim()).filter(item => item); GM_setValue('apiReact_blacklistItems', config.blacklistItems.join(',')); // 保存 alert(`黑名单已更新。当前包含 ${config.blacklistItems.length} 个项目。`); registerAllMenuCommands(); // 刷新菜单 } } /** * @function setWhitelistItems * @description 允许用户编辑白名单项目 (ID或用户名,逗号分隔)。 */ function setWhitelistItems() { const newItemsRaw = prompt( `请输入白名单项目 (可以是用户ID或用户名,不区分大小写)。\n多个项目请用英文逗号 "," 分隔。\n留空则清空白名单。\n\n当前白名单内容: ${config.whitelistItems.join(', ')}`, config.whitelistItems.join(', ') ); if (newItemsRaw !== null) { config.whitelistItems = newItemsRaw.split(',').map(item => item.trim()).filter(item => item); GM_setValue('apiReact_whitelistItems', config.whitelistItems.join(',')); alert(`白名单已更新。当前包含 ${config.whitelistItems.length} 个项目。`); registerAllMenuCommands(); } } /** * @function toggleUnknownIdBehaviorMode * @description 循环切换当消息发送者ID未知时的行为模式。 */ function toggleUnknownIdBehaviorMode() { const modes = ['trace', 'in_list', 'not_in_list']; // 定义所有可用模式 let currentIndex = modes.indexOf(config.unknownIdBehaviorMode); config.unknownIdBehaviorMode = modes[(currentIndex + 1) % modes.length]; // 循环切换 GM_setValue('apiReact_unknownIdBehaviorMode', config.unknownIdBehaviorMode); const behaviorTextMap = { // 用于弹窗提示的文本 trace: '追溯上一条消息的发送者ID (若追溯失败,则该消息不反应)。', in_list: '按“名单内”处理 (黑名单模式下不反应, 白名单模式下反应)。', not_in_list: '按“名单外”处理 (黑名单模式下反应, 白名单模式下不反应)。' }; alert(`当无法直接从消息中获取发送者ID时的行为模式已设置为:\n${behaviorTextMap[config.unknownIdBehaviorMode]}`); registerAllMenuCommands(); // 刷新菜单 } /** * @function showUserFilterAndSpecificsHelp * @description 显示关于用户过滤、专属方案配置及ID/用户名获取的帮助。 */ function showUserFilterAndSpecificsHelp() { alert( "用户过滤与专属方案配置说明:\n\n" + "【通用概念】:\n" + "- ID: Discord用户的唯一数字标识,最为稳定和推荐。通过在Discord中开启开发者模式后,右键点击用户头像或名称,选择“复制ID”获得。\n" + "- 用户名: 您在Discord聊天中看到的用户名。脚本在匹配用户名时不区分大小写。注意:如果用户更改用户名,基于旧用户名的规则会失效。\n" + "- 列表配置: 在黑/白名单或批量添加专属方案时,多个用户ID或用户名之间请使用英文逗号 \",\" 进行分隔。\n\n" + "【全局用户过滤】 (黑名单/白名单):\n" + "- 模式: '不过滤' / '黑名单模式' (匹配的用户发消息则不反应) / '白名单模式' (只有匹配的用户发消息才反应)。\n" + "- ID未知时行为: 此设置决定当脚本无法从消息中获取发送者ID(例如对方使用默认头像或为连续消息)时的处理方式:\n" + " - '追溯': (默认) 脚本尝试查找这条消息之前的发言者ID。若追溯失败,则不反应。\n" + " - '按名单内处理': 黑名单模式下不反应;白名单模式下反应。\n" + " - '按名单外处理': 黑名单模式下反应;白名单模式下不反应。\n" + " (在“不过滤”模式下,此设置无效,脚本总会尝试反应。)\n\n" + "【用户专属Emoji方案】:\n" + "- 功能: 允许您为特定的【用户ID】或【用户名】指定一个不同于全局默认的Emoji反应方案。\n" + "- 标识符: 可以是用户ID,也可以是用户名。脚本会优先尝试匹配ID规则,如果ID规则未命中或不存在,再尝试匹配用户名规则。\n" + "- 独立开关: 您可以为每一个专属方案规则单独设置【启用】或【禁用】状态。禁用后,该用户将使用全局默认方案,而无需删除规则。\n" + "- 批量添加: 支持一次为多个用户ID/用户名设置同一个专属方案,方便快捷。\n\n" + "【重要提示 - 关于ID获取】:\n" + "- 脚本主要通过解析用户头像的URL来获取用户ID。如果用户使用的是【Discord默认头像】(通常是彩色背景带白色Discord logo的头像),脚本将【无法】从此类头像中获取到用户ID。\n" + "- 对于同一用户连续发送的多条消息,后续消息通常不显示头像和用户名,脚本也无法直接从这些消息节点获取信息(但“追溯”功能可能会帮助获取前序发言者的信息)。\n" + "- 在这些情况下:\n" + " - 基于ID的过滤规则或专属方案将对这些消息无效。\n" + " - 如果您配置了基于【用户名】的过滤规则或专属方案,并且脚本能从消息中(或通过追溯)获取到用户名,那么用户名规则仍可能生效。\n" + "- 建议:对于您希望精确控制的用户,如果他们使用默认头像,请尽量获取其用户名进行配置,或者请求他们设置一个自定义头像以确保ID可被脚本获取。" ); } // --- 菜单回调函数:用户专属 Emoji 方案管理 --- /** * @function listAllUserSpecificRules * @description 以弹窗形式列出所有已配置的用户专属方案规则及其状态。 */ function listAllUserSpecificRules() { const rules = config.userSpecificProfiles; // 获取当前所有专属规则 if (Object.keys(rules).length === 0) { // 如果没有任何规则 alert("当前没有设置任何用户专属 Emoji 方案规则。"); return; } let message = "当前已配置的用户专属 Emoji 方案规则:\n\n"; let count = 0; // 用于给规则编号 for (const identifier in rules) { // 遍历所有规则 if (rules.hasOwnProperty(identifier)) { // 确保是自身的属性 const rule = rules[identifier]; // 获取规则对象 {profileName, enabled} const type = isUserId(identifier) ? "ID" : "用户名"; // 判断是ID还是用户名 message += `${++count}. ${type}: ${identifier}\n` + // 显示类型和标识符 ` 方案: "${rule.profileName}"\n` + // 显示使用的全局方案名 ` 状态: ${rule.enabled ? '已启用' : '已禁用'}\n\n`; // 显示启用/禁用状态 } } alert(message); // 一次性弹窗显示所有规则 } /** * @function addOrUpdateUserSpecificRules * @description 添加新的用户专属方案规则,或修改现有规则的方案。支持批量操作。 * 用户输入ID或用户名,然后选择一个全局方案应用到这些标识符上。 * 如果标识符已存在规则,则更新其profileName,enabled状态不变。新规则默认启用。 */ function addOrUpdateUserSpecificRules() { const identifiersRaw = prompt( // 提示用户输入ID或用户名 "请输入要配置专属方案的【用户ID】或【用户名】。\n" + "可输入单个,或多个用英文逗号 \",\" 分隔 (用户名不区分大小写)。\n" + "例如: 123456789 或 SomeUser 或 123,anotheruser,456" ); if (identifiersRaw === null || identifiersRaw.trim() === "") return; // 用户取消或输入为空 // 处理输入的标识符:分割、去空格、过滤空值、用户名转小写 const identifiers = identifiersRaw.split(',') .map(item => item.trim()) // 去除首尾空格 .filter(item => item) // 过滤掉空字符串 .map(item => isUserId(item) ? item : item.toLowerCase()); // 如果不是ID,则视为用户名并转小写 if (identifiers.length === 0) { // 如果没有有效的标识符 alert("未输入有效的用户ID或用户名。操作已取消。"); return; } const profileNames = Object.keys(config.emojiProfiles); // 获取所有可用的全局方案名 if (profileNames.length === 0) { // 如果没有全局方案可选 alert("错误:系统中没有可用的全局 Emoji 方案。请先在“全局 Emoji 方案管理”中添加至少一个。"); return; } // 构建选择全局方案的提示信息 let profilePrompt = `为以下 ${identifiers.length} 个标识符选择要绑定的全局 Emoji 方案 (请输入方案对应的序号):\n`; identifiers.forEach(idOrName => { profilePrompt += `- ${isUserId(idOrName) ? 'ID' : '用户名'}: ${idOrName}\n`; }); profilePrompt += "\n可用的全局方案:\n"; profileNames.forEach((name, index) => { profilePrompt += `${index + 1}. ${name}\n`; }); const choice = prompt(profilePrompt); // 弹出选择框 if (choice === null) return; // 用户取消 let selectedProfileName = null; // 存储用户选择的方案名 const choiceNum = parseInt(choice.trim(), 10); // 尝试解析为序号 if (!isNaN(choiceNum) && choiceNum >= 1 && choiceNum <= profileNames.length) { selectedProfileName = profileNames[choiceNum - 1]; // 根据序号获取方案名 } else { alert(`选择的方案无效: "${choice}"。请输入正确的序号。操作已取消。`); return; } let addedCount = 0; // 记录新增规则数量 let updatedCount = 0; // 记录更新规则数量 identifiers.forEach(idOrName => { // 遍历所有用户输入的有效标识符 if (config.userSpecificProfiles[idOrName]) { // 如果该标识符已存在规则 config.userSpecificProfiles[idOrName].profileName = selectedProfileName; // 更新其方案名 // 注意:这里不改变原有的 enabled 状态。如果需要修改时强制启用,可以取消下面一行的注释。 // config.userSpecificProfiles[idOrName].enabled = true; updatedCount++; } else { // 如果是新的标识符 config.userSpecificProfiles[idOrName] = { // 创建新规则 profileName: selectedProfileName, // 使用选定的方案名 enabled: true // 新增的规则默认启用 }; addedCount++; } }); GM_setValue('apiReact_userSpecificProfiles', JSON.stringify(config.userSpecificProfiles)); // 保存更新后的专属规则 alert( `操作完成:\n` + `- ${addedCount} 条新规则已添加 (方案: "${selectedProfileName}", 状态: 默认启用)。\n` + `- ${updatedCount} 条现有规则已将其方案更新为 "${selectedProfileName}" (其启用/禁用状态保持不变)。` ); registerAllMenuCommands(); // 刷新菜单(例如更新规则计数) } /** * @function toggleUserSpecificRuleEnable * @description 允许用户选择一个已存在的专属方案规则,并切换其启用/禁用状态。 */ function toggleUserSpecificRuleEnable() { const rules = config.userSpecificProfiles; // 获取所有专属规则 if (Object.keys(rules).length === 0) { // 如果没有规则 alert("当前没有用户专属方案规则可以切换状态。"); return; } // 构建选择规则的提示信息,包含序号、标识符、方案名和当前状态 let promptMessage = "请选择要切换启用/禁用状态的规则 (请输入规则对应的序号,或直接输入完整的用户ID/用户名):\n\n"; const ruleIdentifiers = Object.keys(rules); // 获取所有规则的标识符 (ID或用户名) ruleIdentifiers.forEach((identifier, index) => { // 遍历并格式化显示 const rule = rules[identifier]; const type = isUserId(identifier) ? "ID" : "用户名"; promptMessage += `${index + 1}. ${type}: ${identifier} (方案: "${rule.profileName}", 当前状态: ${rule.enabled ? '已启用' : '已禁用'})\n`; }); const choice = prompt(promptMessage); // 弹出选择框 if (choice === null || choice.trim() === "") return; // 用户取消或输入为空 let targetIdentifier = null; // 用于存储找到的目标规则的标识符 const choiceTrimmed = choice.trim(); const choiceNum = parseInt(choiceTrimmed, 10); // 尝试解析为序号 const lowerChoice = choiceTrimmed.toLowerCase(); // 用于不区分大小写的用户名匹配 // 判断用户输入的是序号还是直接的ID/用户名 if (!isNaN(choiceNum) && choiceNum >= 1 && choiceNum <= ruleIdentifiers.length) { targetIdentifier = ruleIdentifiers[choiceNum - 1]; // 通过序号获取标识符 } else if (rules[choiceTrimmed]) { // 尝试直接匹配ID (大小写敏感) 或已存的小写用户名 targetIdentifier = choiceTrimmed; } else if (rules[lowerChoice] && !isUserId(lowerChoice)) { // 尝试匹配小写后的用户名 (确保不是ID格式) targetIdentifier = lowerChoice; } if (targetIdentifier && rules[targetIdentifier]) { // 如果找到了有效的规则 rules[targetIdentifier].enabled = !rules[targetIdentifier].enabled; // 切换其启用状态 GM_setValue('apiReact_userSpecificProfiles', JSON.stringify(config.userSpecificProfiles)); // 保存更改 alert(`规则 "${targetIdentifier}" (针对 ${isUserId(targetIdentifier) ? 'ID' : '用户名'}) 的状态已成功切换为: ${rules[targetIdentifier].enabled ? '已启用' : '已禁用'}`); registerAllMenuCommands(); // 刷新菜单 } else { alert(`无效的选择或未找到规则: "${choice}"。请输入正确的序号或已存在的ID/用户名。`); } } /** * @function removeUserSpecificRule * @description 允许用户选择并移除一个已存在的用户专属方案规则。 */ function removeUserSpecificRule() { const rules = config.userSpecificProfiles; // 获取所有专属规则 if (Object.keys(rules).length === 0) { // 如果没有规则 alert("当前没有用户专属方案规则可以移除。"); return; } // 构建选择规则的提示信息 let promptMessage = "请选择要移除的专属方案规则 (请输入规则对应的序号,或直接输入完整的用户ID/用户名):\n\n"; const ruleIdentifiers = Object.keys(rules); ruleIdentifiers.forEach((identifier, index) => { const rule = rules[identifier]; const type = isUserId(identifier) ? "ID" : "用户名"; promptMessage += `${index + 1}. ${type}: ${identifier} (使用方案: "${rule.profileName}", 状态: ${rule.enabled ? '已启用' : '已禁用'})\n`; }); const choice = prompt(promptMessage); // 弹出选择框 if (choice === null || choice.trim() === "") return; // 用户取消或输入为空 let targetIdentifier = null; // 存储目标规则的标识符 const choiceTrimmed = choice.trim(); const choiceNum = parseInt(choiceTrimmed, 10); const lowerChoice = choiceTrimmed.toLowerCase(); // 判断用户输入 if (!isNaN(choiceNum) && choiceNum >= 1 && choiceNum <= ruleIdentifiers.length) { targetIdentifier = ruleIdentifiers[choiceNum - 1]; } else if (rules[choiceTrimmed]) { targetIdentifier = choiceTrimmed; } else if (rules[lowerChoice] && !isUserId(lowerChoice)) { targetIdentifier = lowerChoice; } if (targetIdentifier && rules[targetIdentifier]) { // 如果找到规则 if (confirm(`您确定要移除针对 "${targetIdentifier}" (${isUserId(targetIdentifier) ? 'ID' : '用户名'}) 的专属 Emoji 方案规则 (当前使用方案: "${rules[targetIdentifier].profileName}") 吗?`)) { delete rules[targetIdentifier]; // 删除规则 GM_setValue('apiReact_userSpecificProfiles', JSON.stringify(config.userSpecificProfiles)); // 保存更改 alert(`规则 "${targetIdentifier}" 已成功移除。`); registerAllMenuCommands(); // 刷新菜单 } } else { alert(`无效的选择或未找到规则: "${choice}"。请输入正确的序号或已存在的ID/用户名。`); } } /** * @function clearAllUserSpecificRules * @description 清空所有已配置的用户专属方案规则。 */ function clearAllUserSpecificRules() { if (Object.keys(config.userSpecificProfiles).length === 0) { // 如果没有规则 alert("当前没有用户专属方案规则可以清除。"); return; } if (confirm("您确定要清除【所有】用户专属 Emoji 方案规则吗? 这将移除所有用户的特殊配置。")) { config.userSpecificProfiles = {}; // 清空专属规则对象 GM_setValue('apiReact_userSpecificProfiles', JSON.stringify(config.userSpecificProfiles)); // 保存 alert("所有用户专属 Emoji 方案规则已成功清除。"); registerAllMenuCommands(); // 刷新菜单 } } // --- 核心工具函数:获取Token/ID、API调用等 --- /** * @function showTokenHelp * @description 显示一个弹窗,指导用户如何获取他们的 Discord Authorization Token。 * 强调 Token 的敏感性和安全风险。 */ function showTokenHelp() { alert( "如何获取 Discord Authorization Token (授权令牌):\n\n" + "1. 使用浏览器打开 Discord 网页版 (discord.com/app) 并登录,或者打开 Discord 桌面客户端。\n" + "2. 按下键盘快捷键打开开发者工具:\n" + " - Windows/Linux: Ctrl+Shift+I\n" + " - macOS: Cmd+Option+I (⌘+⌥+I)\n" + "3. 在打开的开发者工具面板中,找到并切换到 \"网络 (Network)\" 标签页。\n" + "4. 在网络标签页的过滤器 (Filter) 输入框中,可以输入 `/api` 或 `library` 或 `/science` 来筛选 Discord API 请求,这样更容易找到目标。\n" + "5. 此时,在 Discord 界面中进行一些操作,例如发送一条消息、切换到一个频道或服务器,或者点击加载更多消息。这将产生网络请求。\n" + "6. 在开发者工具的网络请求列表中,查找一个发往 `discord.com/api/...` 的请求。常见的请求名称可能是 `messages`, `typing`, `channels`, `guilds` 等。\点击其中任意一个。\n" + "7. 点击后,在右侧(或下方)会显示该请求的详细信息。找到 \"标头 (Headers)\" 或 \"请求标头 (Request Headers)\" 部分。\n" + "8. 在请求标头列表中,仔细查找名为 `Authorization` 的条目。它的值就是你需要的 Token。\n" + " 这个 Token 通常是一段非常长的、由字母、数字和特殊符号组成的字符串。\n\n" + "【非常重要】:\n" + "- Authorization Token 等同于你的账户密码,【绝对不要】泄露给任何人或任何不信任的第三方脚本/应用!\n" + "- 本脚本会将 Token 存储在你的浏览器本地 (通过油猴的 GM_setValue),脚本作者无法访问它。但请确保你的计算机和浏览器环境安全。\n" + "- 如果 Token 泄露,他人可能控制你的 Discord 账户。\n" + "- 频繁或不当使用 API (例如通过脚本发送过多请求) 可能违反 Discord 服务条款,并可能导致你的账户受到限制或封禁。请合理、谨慎地使用此脚本。" ); } /** * @function getCurrentChannelId * @description 从当前浏览器窗口的 URL 中提取 Discord 频道的ID。 * @returns {string|null} 如果成功提取到频道ID,则返回ID字符串;否则返回 null。 */ function getCurrentChannelId() { // Discord URL 结构通常是: /channels/SERVER_ID/CHANNEL_ID 或 /channels/@me/DM_CHANNEL_ID const match = window.location.pathname.match(/\/channels\/(@me|\d+)\/(\d+)/); return match ? match[2] : null; // match[2] 是 CHANNEL_ID } /** * @function getMessageIdFromNode * @description 从给定的消息 DOM 节点的 ID 属性中提取消息的数字ID。 * @param {HTMLElement} node - 消息的 DOM 节点。 * @returns {string|null} 如果成功提取,返回消息ID字符串;否则返回 null。 */ function getMessageIdFromNode(node) { if (node && node.id) { // 确保节点存在且有ID属性 const parts = node.id.split('-'); // 按 '-' 分割ID字符串 return parts[parts.length - 1]; // 消息ID是最后一部分 } return null; } /** * @function getAuthorIdFromMessageNode * @description 尝试从给定的消息 DOM 节点中提取消息发送者的用户ID。 * 主要通过查找消息内容区域内的用户头像 `` 标签,并从其 `src` URL 中解析ID。 * @param {HTMLElement} messageNode - 消息的 DOM 节点。 * @returns {string|null} 如果成功提取,返回用户ID字符串;否则返回 null。 */ function getAuthorIdFromMessageNode(messageNode) { if (!messageNode) return null; // 如果节点无效,直接返回 // 尝试多种选择器以提高兼容性 let authorAvatarImg = messageNode.querySelector('img.avatar_c19a55, img[class*="avatar_"]'); // 优先尝试已知或通用类名 if (!authorAvatarImg) { // 如果第一种失败,尝试更具体的层级结构 authorAvatarImg = messageNode.querySelector('div[class*="contents_"] > img[class*="avatar_"]'); } // 还可以添加更多备用选择器 if (authorAvatarImg && authorAvatarImg.src) { // 如果找到了头像图片并且它有 src 属性 const src = authorAvatarImg.src; // 获取头像图片的 URL // Discord 用户头像 URL 格式: https://cdn.discordapp.com/avatars/USER_ID/AVATAR_HASH.webp const match = src.match(/\/avatars\/(\d{17,20})\//); // 正则捕获USER_ID if (match && match[1]) { // 如果匹配成功 return match[1]; // 返回用户ID } } return null; // 未能提取ID } /** * @function getAuthorUsernameFromMessageNode * @description 尝试从给定的消息 DOM 节点中提取消息发送者的用户名。 * @param {HTMLElement} messageNode - 消息的 DOM 节点。 * @returns {string|null} 如果成功提取,返回用户名字符串;否则返回 null。 */ function getAuthorUsernameFromMessageNode(messageNode) { if (!messageNode) return null; // 尝试多种选择器 let usernameElement = messageNode.querySelector('span.username_c19a55, span[class*="username_"]'); if (!usernameElement) { // 尝试更精确的层级,通常在消息头部 usernameElement = messageNode.querySelector('div[class*="contents_"] h3[class*="header_"] span[class*="username_"]'); } // 还可以加入对 `data-author-id` 元素的兄弟节点中的用户名查找 if (usernameElement && usernameElement.textContent) { // 如果找到元素且有文本 return usernameElement.textContent.trim(); // 返回去除首尾空格的用户名 } return null; // 未能提取用户名 } /** * @function getReactionTasks * @description 根据当前上下文(发送者ID和用户名),决定本次应该反应哪些 Emoji。 * 1. 获取有效的 Emoji 方案(用户专属或全局默认)。 * 2. 解析方案的 Emoji 字符串:按分号 ";" 分割成组,随机选一组,若组内有逗号 "," 则为序列。 * @param {string|null} authorId - 消息发送者的用户ID。 * @param {string|null} authorUsername - 消息发送者的用户名。 * @returns {string[]} 一个包含一个或多个待反应 Emoji 字符串的数组。 */ function getReactionTasks(authorId, authorUsername) { const emojisString = getCurrentEmojiString(authorId, authorUsername); // 获取最终的Emoji配置字符串 // 按分号分割成不同的“反应组”,去除空格并过滤空组 const emojiGroups = emojisString.split(';').map(g => g.trim()).filter(g => g); if (emojiGroups.length === 0) { // 如果没有有效的反应组 const profileName = getEffectiveProfileNameForUser(authorId, authorUsername); // 获取方案名用于日志 console.warn(`[API React] 方案 "${profileName}" 的 Emoji 配置为空或格式不正确。将使用默认 Emoji: 👍`); return ['👍']; // 返回默认表情 } // 从所有有效的反应组中随机选择一个 const randomGroupString = emojiGroups[Math.floor(Math.random() * emojiGroups.length)]; // 检查选中的组是否包含逗号,以确定是序列反应还是单个反应 if (randomGroupString.includes(',')) { // 按逗号分割成多个表情,去除空格并过滤空表情 return randomGroupString.split(',').map(e => e.trim()).filter(e => e); } else { // 单个表情(或自定义表情代码) return [randomGroupString.trim()].filter(e => e); // 包装成单元素数组并过滤 } } /** * @function addReactionViaAPI * @description 通过 Discord API 为指定消息添加单个 Emoji 反应。 * 处理标准 Unicode Emoji 和自定义 Emoji (格式如 <:name:id> 或 或 :name:id:)。 * @param {string} channelId - 目标消息所在的频道ID。 * @param {string} messageId - 目标消息的ID。 * @param {string} emojiToReact - 要反应的 Emoji 字符串。 * @returns {Promise<{success: boolean, status: number, error?: string}>} 操作结果的Promise。 */ function addReactionViaAPI(channelId, messageId, emojiToReact) { return new Promise((resolve) => { if (!config.authToken) { // Token 检查 console.error("[API React] Auth Token 未设置,无法发送API请求。"); resolve({ success: false, error: "No Auth Token", status: 0 }); return; } let apiEmoji; // 存储发送给API的、格式化和编码后的Emoji // 尝试匹配各种自定义Emoji格式 const customMatchDiscordFormat = emojiToReact.match(/^$/); // 或 <:name:id> const customMatchColonFormat = emojiToReact.match(/^:([a-zA-Z0-9_]+):([0-9]+):$/); // :name:id: (某些工具可能用) if (customMatchDiscordFormat) { // Discord客户端复制的格式 apiEmoji = `${customMatchDiscordFormat[1]}:${customMatchDiscordFormat[2]}`; // API需要 name:id } else if (customMatchColonFormat) { apiEmoji = `${customMatchColonFormat[1]}:${customMatchColonFormat[2]}`; // API需要 name:id } else { // 否则假定为标准 Unicode Emoji 或只输入了自定义表情名 (无ID,通常API不支持) apiEmoji = encodeURIComponent(emojiToReact); // Unicode表情和简单文本名都需要编码 } // 构建 Discord API 端点 URL const apiUrl = `https://discord.com/api/v9/channels/${channelId}/messages/${messageId}/reactions/${apiEmoji}/%40me?location=Message&type=0`; // 发送 GM_xmlhttpRequest GM_xmlhttpRequest({ method: "PUT", // 添加反应是 PUT 请求 url: apiUrl, headers: { "Authorization": config.authToken, // 授权Token "Content-Type": "application/json" // 即使无请求体也最好设置 }, onload: function(response) { // 请求成功完成 (无论HTTP状态码) if (response.status === 204) { // HTTP 204 No Content 表示成功 resolve({ success: true, status: response.status }); } else { // 其他状态码表示失败 console.error(`[API React] 消息(${messageId}) 添加反应 "${emojiToReact}" (API格式: ${apiEmoji}) 失败。\n` + `状态码: ${response.status}, API响应: ${response.responseText}`); if (response.status === 401 || response.status === 403) { // Token无效或权限不足 if (!window.apiTokenErrorAlerted) { // 防止短时间内重复弹窗 alert("API Token 无效或权限不足 (错误码 401/403)。\n脚本将自动禁用。请检查或更新您的 Auth Token。"); window.apiTokenErrorAlerted = true; // 标记已弹窗 setTimeout(() => { window.apiTokenErrorAlerted = false; }, 30000); // 30秒后允许再次弹窗 } config.authToken = null; GM_setValue('apiReact_authToken', null); // 清除无效Token if (config.enabled) toggleEnable(); // 如果脚本启用则禁用它 } resolve({ success: false, status: response.status, error: response.responseText }); } }, onerror: function(response) { // 网络层面错误 console.error(`[API React] 消息(${messageId}) 添加反应 "${emojiToReact}" 发生网络错误:`, response); resolve({ success: false, error: "Network Error or CORS issue", status: 0 }); } }); }); } /** * @function traceBackAuthorInfo * @description 尝试从当前消息节点向上追溯DOM,查找最近的前序消息的发送者信息 (ID 和 用户名)。 * @param {HTMLElement} currentMessageNode - 当前正在处理的、可能没有直接作者信息的消息节点。 * @param {number} [maxDepth=3] - 最大向上追溯的消息条数。 * @returns {{authorId: string|null, authorUsername: string|null }} 追溯到的信息。 */ function traceBackAuthorInfo(currentMessageNode, maxDepth = 3) { let tracedNode = currentMessageNode; // 从当前节点开始 for (let i = 0; i < maxDepth; i++) { // 循环追溯 const previousSibling = tracedNode.previousElementSibling; // 获取前一个兄弟元素 // 检查兄弟元素是否存在且是Discord消息项 if (!previousSibling || !previousSibling.matches('li[id^="chat-messages-"]')) { break; // 停止追溯 } tracedNode = previousSibling; // 移到前序消息 const tracedAuthorId = getAuthorIdFromMessageNode(tracedNode); // 尝试获取ID if (tracedAuthorId) { // 如果获取到ID const tracedAuthorUsername = getAuthorUsernameFromMessageNode(tracedNode); // 顺便获取用户名 return { authorId: tracedAuthorId, authorUsername: tracedAuthorUsername }; // 返回信息 } } return { authorId: null, authorUsername: null }; // 追溯失败 } /** * @function handleReactionTasksForMessage * @description 核心处理函数:协调对一条新消息进行所有计划的反应。 * 包括:获取消息和作者信息,执行用户过滤逻辑(ID、用户名、追溯),获取Emoji任务,并通过API添加反应。 * @param {HTMLElement} messageNode - 代表新消息的 DOM 节点。 */ async function handleReactionTasksForMessage(messageNode) { // --- 步骤 0: 基本检查和信息获取 --- if (!config.enabled || !config.authToken) return; // 脚本禁用或Token无效则不操作 const currentChannelId = getCurrentChannelId(); // 获取当前频道ID if (!currentChannelId) return; // 未能获取频道ID // 如果设置了目标频道ID列表,且当前频道不在列表中,则跳过 if (config.targetChannelIds.length > 0 && !config.targetChannelIds.includes(currentChannelId)) { return; } const messageId = getMessageIdFromNode(messageNode); // 获取消息ID if (!messageId) return; // 未能获取消息ID // --- 步骤 1: 获取作者信息 (直接获取 + 尝试追溯) --- let authorId = getAuthorIdFromMessageNode(messageNode); // 尝试直接获取作者ID let authorUsername = getAuthorUsernameFromMessageNode(messageNode); // 尝试直接获取作者用户名 let wasTracedAndSuccessful = false; // 标记ID是否通过追溯成功获取 // 如果直接获取ID失败,并且配置的未知ID行为是“追溯” if (!authorId && config.unknownIdBehaviorMode === 'trace') { const tracedInfo = traceBackAuthorInfo(messageNode, 3); // 尝试追溯 if (tracedInfo.authorId) { // 如果追溯成功 authorId = tracedInfo.authorId; // 使用追溯到的ID // 用户名也尝试使用追溯到的,如果追溯到的节点没有用户名,则保留原始解析的 (如果有) authorUsername = tracedInfo.authorUsername || authorUsername; wasTracedAndSuccessful = true; // 标记追溯成功 } } // --- 步骤 2: 用户过滤逻辑 --- let shouldReact = true; // 默认打算反应 const lowerAuthorUsername = authorUsername ? authorUsername.toLowerCase() : null; // 用户名转小写 if (config.userFilterMode !== 'none') { // 只有在设置了过滤模式时才执行 let isBlacklisted = false; // 标记是否在黑名单中 let isWhitelisted = false; // 标记是否在白名单中 // 子步骤 2.1: 基于ID的过滤 (如果 authorId 已知) if (authorId) { if (config.userFilterMode === 'blacklist') { isBlacklisted = config.blacklistItems.some(item => isUserId(item) && item === authorId); } else if (config.userFilterMode === 'whitelist') { isWhitelisted = config.whitelistItems.some(item => isUserId(item) && item === authorId); } } // 子步骤 2.2: 基于用户名的补充过滤 (如果ID未匹配或未知,且用户名存在) if (lowerAuthorUsername && ((!authorId && !wasTracedAndSuccessful) || // 情况A: ID未知且追溯失败 (authorId && ((config.userFilterMode === 'blacklist' && !isBlacklisted) || // 情况B: ID已知但在黑名单ID中未找到 (config.userFilterMode === 'whitelist' && !isWhitelisted))) // 情况C: ID已知但在白名单ID中未找到 ) ) { if (config.userFilterMode === 'blacklist') { if (!isBlacklisted) { // 只有在ID未确定为黑名单时,才检查用户名 isBlacklisted = config.blacklistItems.some(item => !isUserId(item) && item.toLowerCase() === lowerAuthorUsername); } } else if (config.userFilterMode === 'whitelist') { if (!isWhitelisted) { // 只有在ID未确定为白名单时,才检查用户名 isWhitelisted = config.whitelistItems.some(item => !isUserId(item) && item.toLowerCase() === lowerAuthorUsername); } } } // 子步骤 2.3: 根据过滤模式、匹配结果及未知ID处理规则,最终决定 shouldReact if (config.userFilterMode === 'blacklist') { if (isBlacklisted) { // 如果ID或用户名匹配到黑名单 shouldReact = false; } else if (!authorId && !wasTracedAndSuccessful) { // ID未知且追溯失败 (此时isBlacklisted必为false) if (config.unknownIdBehaviorMode === 'in_list') shouldReact = false; // 视为在黑名单内 (不反应) else if (config.unknownIdBehaviorMode === 'trace') shouldReact = false; // 追溯模式下,追溯失败则不反应 // else 'not_in_list', shouldReact 保持 true (视为不在黑名单内) } } else if (config.userFilterMode === 'whitelist') { if (config.whitelistItems.length > 0) { // 白名单列表有内容时 if (!isWhitelisted) { // ID和用户名都没有匹配到白名单 shouldReact = false; } // 如果 isWhitelisted 为 true, shouldReact 保持 true } else { // 白名单列表为空 shouldReact = false; // 在白名单模式下,空名单意味着不反应任何消息 } // 如果初步结论是应反应,但ID未知且追溯失败,再次应用 unknownIdBehaviorMode if (shouldReact && !authorId && !wasTracedAndSuccessful) { if (config.whitelistItems.length > 0) { // 仅当白名单非空时 if (config.unknownIdBehaviorMode === 'not_in_list') shouldReact = false; // 视为不在白名单内 (不反应) else if (config.unknownIdBehaviorMode === 'trace') shouldReact = false; // 追溯模式下,追溯失败则不反应 // else 'in_list', shouldReact 保持 true (视为在白名单内) } else { // ID未知,追溯失败,且白名单为空,则不反应 shouldReact = false; } } } } // --- 用户过滤逻辑结束 --- if (!shouldReact) return; // 如果最终决定不反应,则结束处理 // --- 步骤 3: 获取并执行 Emoji 反应任务 --- // 使用最终确定的 authorId 和 authorUsername 来获取Emoji方案 const reactionTasks = getReactionTasks(authorId, authorUsername); if (!reactionTasks || reactionTasks.length === 0) { return; } // 依次执行计划中的每一个 Emoji 反应 for (let i = 0; i < reactionTasks.length; i++) { const emojiToReact = reactionTasks[i]; // 当前要反应的 Emoji const result = await addReactionViaAPI(currentChannelId, messageId, emojiToReact); // 发送API请求 if (!result.success) { // 如果添加反应失败 // 特殊处理 "Unknown Emoji" 错误 (Discord API code 10014) if (result.status === 400 && result.error && typeof result.error === 'string' && result.error.includes('"code": 10014')) { console.warn(`[API React] Emoji "${emojiToReact}" 无法识别或无权限使用 (可能是无效的自定义表情),跳过此Emoji。`); } else { // 其他API错误 (如Token失效,网络问题,速率限制等),中断这条消息的其余反应 break; } } // 如果序列中还有更多Emoji要反应,稍微延迟 if (i < reactionTasks.length - 1) { await new Promise(r => setTimeout(r, 350 + Math.random() * 250)); // 350ms基础 + 0-250ms随机延迟 } } } // --- MutationObserver 和消息队列逻辑 (用于异步、有序地处理新消息) --- let observer; // MutationObserver 实例 let observedChatArea = null; // 当前正在被观察的聊天区域 DOM 元素 let initialMessagesProcessed = false; // 标记是否已过“初始消息加载期” let messageQueue = []; // 存储待处理新消息节点的队列 let processingQueue = false; // 标记当前是否正在处理消息队列,防止并发 let scriptStartTime = Date.now(); // 记录脚本(或观察者重置时)的启动时间 /** * @async * @function processMessageQueue * @description 异步处理消息队列中的消息。 * 从队列头取出一个消息节点,调用 handleReactionTasksForMessage 处理它。 * 处理完毕后,如果队列中还有消息,则设置延迟继续处理。 */ async function processMessageQueue() { if (processingQueue || messageQueue.length === 0) return; // 正在处理或队列为空则返回 processingQueue = true; // 标记为正在处理 const node = messageQueue.shift(); // 取出队头消息节点 if (node) { // 如果成功取出 try { await handleReactionTasksForMessage(node); // 异步等待处理完成 } catch (e) { // 捕获意外错误 console.error("[API React] 在 processMessageQueue 中处理消息时发生严重错误:", e, "对应消息节点:", node); } } processingQueue = false; // 解除处理标记 // 如果队列中还有消息,设置随机延迟后再次调用,平滑API请求 if (messageQueue.length > 0) { setTimeout(processMessageQueue, 600 + Math.random() * 400); // 600ms基础 + 0-400ms随机延迟 } } /** * @function setupObserver * @description 设置并启动 MutationObserver 来监听聊天区域的新消息。 * 如果脚本未启用或Token无效,则不启动。 * 会处理观察目标的动态变化(例如Discord加载聊天区域)。 */ function setupObserver() { if (!config.enabled) { // 如果脚本被禁用 if (observer) observer.disconnect(); // 确保观察者停止 return; } if (!config.authToken) { // 如果 Auth Token 未设置 if (config.enabled) toggleEnable(); // 调用toggleEnable处理禁用和提示 return; } // Discord 聊天消息列表的 DOM 选择器 const chatAreaSelector = 'ol[data-list-id="chat-messages"]'; const chatArea = document.querySelector(chatAreaSelector); // 尝试获取元素 if (chatArea) { // 如果找到聊天区域 // 检查是否已在观察相同区域,是则无需重复设置 if (observedChatArea === chatArea && observer) return; if (observer) observer.disconnect(); // 停止旧观察 observedChatArea = chatArea; // 更新当前观察区域 initialMessagesProcessed = false; // 重置“初始消息加载期”标记 scriptStartTime = Date.now(); // 重置启动时间 messageQueue = []; // 清空消息队列 // 设置延迟,期间到达的消息视为“初始加载” setTimeout(() => { initialMessagesProcessed = true; // 标记初始期结束 if (messageQueue.length > 0 && !processingQueue) { // 如果有缓冲消息 processMessageQueue(); // 开始处理 } }, 3000); // 3秒后认为初始消息已过 // 创建 MutationObserver 实例 observer = new MutationObserver((mutationsList) => { // 保险措施:如果还没过初始期,但脚本启动时间已超阈值,也强制认为初始期结束 if (!initialMessagesProcessed && (Date.now() - scriptStartTime > 3000)) { initialMessagesProcessed = true; if (messageQueue.length > 0 && !processingQueue) processMessageQueue(); } // 遍历所有DOM变动记录 for (const mutation of mutationsList) { // 只关心子节点列表变化 (type === 'childList'),且有节点被添加 if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(node => { // 遍历所有被添加的节点 // 检查新增节点是否是消息列表项
  • if (node.nodeType === Node.ELEMENT_NODE && // 必须是元素节点 (node.matches('li[id^="chat-messages-"]') || // ID以 "chat-messages-" 开头 (node.matches('li') && node.className && typeof node.className.includes === 'function' && node.className.includes("messageListItem__"))) // 或 className 包含 "messageListItem__" ){ // 防止重复入队 if (node.dataset.queuedForApiReaction === 'true' && initialMessagesProcessed) return; node.dataset.queuedForApiReaction = 'true'; // 打上已入队标记 // 初始期限制入队数量,避免处理大量历史消息 if (!initialMessagesProcessed && messageQueue.length >= 1) { return; // 例如,初始期只缓冲1条 } messageQueue.push(node); // 加入待处理队列 if (!processingQueue) { // 如果当前没有在处理队列 processMessageQueue(); // 启动处理流程 } } }); } } }); // 开始观察聊天区域的直接子节点变化 observer.observe(chatArea, { childList: true, subtree: false }); } else { // 未找到聊天区域元素 if (observer) observer.disconnect(); // 停止旧观察者 observedChatArea = null; // 清除记录 // console.warn(`[API React] 未找到聊天区域DOM元素 (${chatAreaSelector})。将在1秒后重试设置观察者。`); setTimeout(setupObserver, 1000); // 延迟1秒后重试 (页面可能仍在加载) } } // --- 脚本初始化与URL变化监听 --- registerAllMenuCommands(); // 脚本首次加载时,立即注册所有菜单命令 console.log(`[API React] Discord API Emoji Reactor 脚本已加载 (版本 ${GM_info.script.version})。\n` + `当前全局默认 Emoji 方案: "${config.activeProfileName}".\n` + `脚本状态: ${config.enabled ? '已启用' : '已禁用'}. ` + `如需更改设置,请点击油猴图标访问脚本菜单。`); if (config.enabled) { // 如果上次脚本是启用状态 if (config.authToken) { // 并且 Auth Token 已设置 setupObserver(); // 则尝试启动观察者 } else { toggleEnable(); // Token不存在,调用toggleEnable处理禁用和提示 } } // 监听Discord单页应用URL变化,以重置观察者 let lastUrl = location.href; // 记录当前URL new MutationObserver(() => { // 观察 document.body 的变化 const currentUrl = location.href; // 获取变化后的URL if (currentUrl === lastUrl) return; // URL未改变则不操作 lastUrl = currentUrl; // 更新 lastUrl // console.log('[API React] 检测到URL变化,可能已切换频道。将重置消息观察者。'); if (observer) observer.disconnect(); // 停止当前观察者 // 清空消息队列和相关状态,为新聊天环境做准备 messageQueue = []; processingQueue = false; initialMessagesProcessed = false; scriptStartTime = Date.now(); observedChatArea = null; // 延迟一段时间后重新设置观察者 (等待新频道内容加载) setTimeout(setupObserver, 1500); // 例如1.5秒 }).observe(document.body, { subtree: true, childList: true }); // 观察整个 body 及其所有子孙节点的子列表变化 // --- 其他配置相关的菜单回调函数 --- /** * @function setTargetChannelIds * @description 允许用户设置脚本生效的目标频道ID列表。 */ function setTargetChannelIds() { const newChannelsRaw = prompt( `请输入脚本将要作用的目标频道ID (Channel ID)。\n` + `多个频道ID请用英文逗号 "," 分隔。\n` + `如果留空,则脚本会对所有频道生效。\n\n` + `当前已设置的目标频道ID: ${config.targetChannelIds.join(', ') || '(无,作用于所有频道)'}`, config.targetChannelIds.join(', ') // 默认显示当前设置 ); if (newChannelsRaw !== null) { // 用户没有点击“取消” // 处理输入:分割、去空格、过滤空值和非数字ID config.targetChannelIds = newChannelsRaw.split(',') .map(id => id.trim()) .filter(id => id && /^\d+$/.test(id)); // 确保ID是纯数字且非空 GM_setValue('apiReact_targetChannelIds', config.targetChannelIds.join(',')); // 保存 alert(`目标频道ID已更新。脚本现在将作用于: ${config.targetChannelIds.length > 0 ? config.targetChannelIds.join(', ') : '所有频道'}`); registerAllMenuCommands(); // 刷新菜单 } } /** * @function setAuthToken * @description 允许用户设置或更新他们的 Discord Authorization Token。 * 如果输入为空,则视为清除已设置的 Token。 */ function setAuthToken() { const newToken = prompt("请输入您的 Discord Authorization Token:", config.authToken || ""); // 默认显示当前Token或空 if (newToken !== null) { // 用户没有点击“取消” config.authToken = newToken.trim() || null; // 去首尾空格,空则为null (清除) GM_setValue('apiReact_authToken', config.authToken); // 保存 alert(config.authToken ? "Authorization Token 已成功更新。" : "Authorization Token 已被清除。脚本可能需要重新启用或配置。"); registerAllMenuCommands(); // 刷新菜单 // 如果脚本当前启用,Token更改可能影响观察者状态 if (config.enabled) { if (observer) observer.disconnect(); // 停止旧观察者 setupObserver(); // 尝试用新Token状态重启观察者 (内部会检查Token) } } } // --- 配置导入/导出功能 --- /** * @function exportFullConfig * @description 导出整个脚本的配置为JSON文件。 * 用户将被询问是否在导出中包含Auth Token。 */ function exportFullConfig() { let tempConfig = JSON.parse(JSON.stringify(config)); // 深拷贝一份配置用于导出,避免直接修改运行时config // 询问用户是否包含Auth Token const includeTokenChoice = prompt( "是否在导出的配置中包含您的 Auth Token?\n" + "【警告】: Auth Token 非常敏感,等同于您的账户密码!\n" + "如果您选择包含,请务必妥善保管导出的文件,【绝对不要】分享给不信任的人。\n" + "如果您只是想分享Emoji方案等非敏感配置,建议选择不包含,或手动编辑导出的JSON文件删除 'authToken' 字段。\n\n" + "请输入 'y' 或 'yes' 来包含Token,其他任意输入或直接按“取消”则不包含Token:" ); // 根据用户选择处理Auth Token if (includeTokenChoice && ['y', 'yes'].includes(includeTokenChoice.trim().toLowerCase())) { // 用户选择包含Token,tempConfig.authToken 已是当前值,无需更改 alert("Auth Token 将包含在导出的配置中。请务必注意文件安全!"); } else { tempConfig.authToken = null; // 用户选择不包含,或取消了prompt,则设为null // 或者 delete tempConfig.authToken; 效果类似,导入时需要判断hasOwnProperty alert("Auth Token 将【不会】包含在导出的配置中。"); } const jsonString = JSON.stringify(tempConfig, null, 2); // 格式化JSON字符串,带缩进易读 const blob = new Blob([jsonString], { type: 'application/json' }); // 创建Blob对象 const url = URL.createObjectURL(blob); // 创建对象URL const a = document.createElement('a'); // 创建隐藏的下载链接 a.href = url; // 生成包含版本号和日期的文件名,方便管理 const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD a.download = `DiscordReactor_config_v${GM_info.script.version}_${timestamp}.json`; document.body.appendChild(a); // 添加到页面 a.click(); // 模拟点击触发下载 document.body.removeChild(a); // 从页面移除 URL.revokeObjectURL(url); // 释放对象URL资源 alert(`配置已导出为名为 "${a.download}" 的文件。\n请检查您的浏览器下载文件夹。`); } /** * @function importFullConfig * @description 允许用户选择一个本地的JSON文件来导入脚本配置。 * 会有确认提示,导入会覆盖现有设置。 */ function importFullConfig() { if (!confirm("导入配置将会覆盖当前所有设置 (除了Auth Token,除非导入文件中包含且有效)。\n您确定要继续吗?")) { return; // 用户取消导入 } // 创建一个隐藏的文件输入元素 const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json'; // 只接受JSON文件 // 当用户选择了文件后的处理逻辑 input.onchange = e => { const file = e.target.files[0]; // 获取选择的第一个文件 if (!file) { alert("未选择任何文件。"); return; } const reader = new FileReader(); // 创建FileReader来读取文件内容 reader.onload = res => { // 文件读取成功完成时 try { const importedData = JSON.parse(res.target.result); // 解析JSON字符串为对象 applyImportedConfig(importedData); // 调用函数应用导入的配置 } catch (err) { // JSON解析失败或应用配置时出错 alert( "导入失败:文件内容不是有效的JSON格式,或在解析配置时发生错误。\n" + "请确保您选择的文件是从本脚本导出的有效配置文件。\n" + "错误详情: " + err.message ); } }; reader.onerror = err => alert("导入失败:读取文件时发生错误。请检查文件权限或重试。"); // 文件读取出错 reader.readAsText(file); // 以文本形式读取文件内容 }; input.click(); // 模拟点击文件输入元素,弹出文件选择对话框 } /** * @function applyImportedConfig * @description 将从JSON文件解析出的配置数据安全地应用到当前脚本的运行时配置 (`config`) 和油猴存储中。 * @param {object} importedData - 从JSON文件解析出的配置对象。 */ function applyImportedConfig(importedData) { if (typeof importedData !== 'object' || importedData === null) { // 基本的数据类型检查 alert("导入失败:配置数据格式不正确,期望得到一个对象。"); return; } let changesMade = false; // 标记是否有配置项被实际更改 // --- 逐个检查并应用配置项 --- // 对每个配置项,先检查导入数据中是否存在该项,并且类型是否基本正确,然后才应用。 // enabled (boolean) if (typeof importedData.enabled === 'boolean') { config.enabled = importedData.enabled; GM_setValue('apiReact_enabled', config.enabled); changesMade = true; } // emojiProfiles (object) if (typeof importedData.emojiProfiles === 'object' && importedData.emojiProfiles !== null && !Array.isArray(importedData.emojiProfiles)) { config.emojiProfiles = importedData.emojiProfiles; GM_setValue('apiReact_emojiProfiles', JSON.stringify(config.emojiProfiles)); changesMade = true; } // activeProfileName (string, 且必须存在于新的 emojiProfiles 中) if (typeof importedData.activeProfileName === 'string') { if (config.emojiProfiles[importedData.activeProfileName]) { // 检查导入的活动方案名是否有效 config.activeProfileName = importedData.activeProfileName; } else { // 如果无效,则尝试使用新 emojiProfiles 中的第一个,或回退到 '默认' const firstProfile = Object.keys(config.emojiProfiles)[0]; config.activeProfileName = firstProfile || '默认'; // 如果连第一个都没有,则用'默认' console.warn(`[API React Import] 导入的 activeProfileName "${importedData.activeProfileName}" 在新的 emojiProfiles 中未找到,已自动设置为 "${config.activeProfileName}"。`); } GM_setValue('apiReact_activeProfileName', config.activeProfileName); changesMade = true; } // targetChannelIds (array of strings) if (Array.isArray(importedData.targetChannelIds)) { config.targetChannelIds = importedData.targetChannelIds .map(id => String(id).trim()) // 确保是字符串并去空格 .filter(id => id && /^\d+$/.test(id)); // 过滤空值和非数字ID GM_setValue('apiReact_targetChannelIds', config.targetChannelIds.join(',')); changesMade = true; } // authToken (string or null) - 特殊处理,仅当导入文件中包含非空字符串Token时才覆盖 if (importedData.hasOwnProperty('authToken')) { // 检查导入数据中是否有authToken字段 if (typeof importedData.authToken === 'string' && importedData.authToken.trim() !== "") { config.authToken = importedData.authToken.trim(); // 应用导入的非空Token GM_setValue('apiReact_authToken', config.authToken); changesMade = true; alert("提示: 导入的配置中包含有效的 Auth Token,已应用。"); } else if (importedData.authToken === null || (typeof importedData.authToken === 'string' && importedData.authToken.trim() === "")) { // 如果导入文件明确将authToken设为null或空字符串,则清除当前Token config.authToken = null; GM_setValue('apiReact_authToken', config.authToken); changesMade = true; alert("提示: 导入的配置中 Auth Token 为空,当前已存储的 Token (如果存在) 已被清除。"); } // 如果导入的authToken字段存在但不是有效字符串或null/空串,则不改变当前authToken } // userFilterMode (string from specific set) if (['none', 'blacklist', 'whitelist'].includes(importedData.userFilterMode)) { config.userFilterMode = importedData.userFilterMode; GM_setValue('apiReact_userFilterMode', config.userFilterMode); changesMade = true; } // blacklistItems (array of strings) if (Array.isArray(importedData.blacklistItems)) { config.blacklistItems = importedData.blacklistItems.map(item => String(item).trim()).filter(item => item); GM_setValue('apiReact_blacklistItems', config.blacklistItems.join(',')); changesMade = true; } // whitelistItems (array of strings) if (Array.isArray(importedData.whitelistItems)) { config.whitelistItems = importedData.whitelistItems.map(item => String(item).trim()).filter(item => item); GM_setValue('apiReact_whitelistItems', config.whitelistItems.join(',')); changesMade = true; } // unknownIdBehaviorMode (string from specific set) if (['trace', 'in_list', 'not_in_list'].includes(importedData.unknownIdBehaviorMode)) { config.unknownIdBehaviorMode = importedData.unknownIdBehaviorMode; GM_setValue('apiReact_unknownIdBehaviorMode', config.unknownIdBehaviorMode); changesMade = true; } // userSpecificProfiles (object of objects, with validation) if (typeof importedData.userSpecificProfiles === 'object' && importedData.userSpecificProfiles !== null && !Array.isArray(importedData.userSpecificProfiles)) { const validUserProfiles = {}; // 存储验证通过的专属规则 for (const idOrName in importedData.userSpecificProfiles) { // 遍历导入的专属规则 if (importedData.userSpecificProfiles.hasOwnProperty(idOrName)) { const rule = importedData.userSpecificProfiles[idOrName]; // 验证规则结构是否正确,并且其引用的 profileName 是否存在于(新导入的)全局方案中 if (typeof rule === 'object' && rule !== null && typeof rule.profileName === 'string' && typeof rule.enabled === 'boolean' && config.emojiProfiles[rule.profileName]) { // 关键:检查profileName的有效性 // 如果是用户名,则存储小写形式 validUserProfiles[isUserId(idOrName) ? idOrName : idOrName.toLowerCase()] = rule; } else { console.warn(`[API React Import] 导入的用户专属方案规则 "${idOrName}" 格式无效或其指向的全局方案 "${rule ? rule.profileName : '未知'}" 在当前(或新导入的)全局方案列表中未找到,该规则已被跳过。`); } } } config.userSpecificProfiles = validUserProfiles; // 应用验证后的专属规则 GM_setValue('apiReact_userSpecificProfiles', JSON.stringify(config.userSpecificProfiles)); changesMade = true; } // --- 应用导入后的收尾工作 --- if (changesMade) { // 重新加载/验证一些可能受其他配置项影响的派生配置或状态 // 例如,如果emojiProfiles被修改,activeProfileName可能需要重新验证 (上面已做) registerAllMenuCommands(); // 刷新油猴菜单以反映所有新配置 if (config.enabled) { // 如果脚本在导入后是启用状态 if (observer) observer.disconnect(); // 先停止旧的观察者 setupObserver(); // 尝试用新的配置(尤其是authToken)重新启动观察者 } alert("配置已成功导入并应用!\n部分更改(如脚本启用状态、Token、目标频道)可能需要您刷新页面或切换频道后才能完全生效。"); } else { alert("导入的文件中未找到有效的配置项,或者导入的配置与当前脚本的设置完全相同,未做任何更改。"); } } })(); // 立即执行的函数表达式 (IIFE) 结束