// ==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
// ==/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" +
" - 动态表情: `<a:emoji_name:emoji_id>`\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。
* 主要通过查找消息内容区域内的用户头像 `<img>` 标签,并从其 `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> 或 <a: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(/^<a?:([a-zA-Z0-9_]+):([0-9]+)>$/); // <a:name:id> 或 <: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 => { // 遍历所有被添加的节点
// 检查新增节点是否是消息列表项 <li>
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) 结束