/** * Emoji 表情回复工具模块 */ import type { Message } from 'node-karin' import { logger } from 'node-karin' import { Config } from './Config' import { isOneBotLike, isQqFamily, platformOf } from './ImageSlice' /** * 各平台表情 ID 配置 */ const PLATFORM_EMOJI_IDS = { qq: { /** 已读/注意:https://koishi.js.org/QFace/#/qqnt/%F0%9F%91%80 */ EYES: 128064, /** 处理中:https://koishi.js.org/QFace/#/qqnt/366 */ PROCESSING: 366, /** 成功完成:https://koishi.js.org/QFace/#/qqnt/389 */ SUCCESS: 389, /** 失败:https://koishi.js.org/QFace/#/qqnt/379 */ ERROR: 379 }, wechat: { /** 已读/注意 - 占位符 */ EYES: 'WECHAT_EYES_PLACEHOLDER', /** 处理中 - 占位符 */ PROCESSING: 'WECHAT_PROCESSING_PLACEHOLDER', /** 成功完成 - 占位符 */ SUCCESS: 'WECHAT_SUCCESS_PLACEHOLDER', /** 失败 - 占位符 */ ERROR: 'WECHAT_ERROR_PLACEHOLDER' }, telegram: { /** 已读/注意 - 占位符 */ EYES: 'TELEGRAM_EYES_PLACEHOLDER', /** 处理中 - 占位符 */ PROCESSING: 'TELEGRAM_PROCESSING_PLACEHOLDER', /** 成功完成 - 占位符 */ SUCCESS: 'TELEGRAM_SUCCESS_PLACEHOLDER', /** 失败 - 占位符 */ ERROR: 'TELEGRAM_ERROR_PLACEHOLDER' }, discord: { /** 已读/注意 */ EYES: '👀', /** 处理中 */ PROCESSING: '⏳', /** 成功完成 */ SUCCESS: '✅', /** 失败 */ ERROR: '❌' }, koko: { /** 已读/注意 - 占位符 */ EYES: 'KOKO_EYES_PLACEHOLDER', /** 处理中 - 占位符 */ PROCESSING: 'KOKO_PROCESSING_PLACEHOLDER', /** 成功完成 - 占位符 */ SUCCESS: 'KOKO_SUCCESS_PLACEHOLDER', /** 失败 - 占位符 */ ERROR: 'KOKO_ERROR_PLACEHOLDER' }, other: { /** 已读/注意 - 占位符 */ EYES: 'OTHER_EYES_PLACEHOLDER', /** 处理中 - 占位符 */ PROCESSING: 'OTHER_PROCESSING_PLACEHOLDER', /** 成功完成 - 占位符 */ SUCCESS: 'OTHER_SUCCESS_PLACEHOLDER', /** 失败 - 占位符 */ ERROR: 'OTHER_ERROR_PLACEHOLDER' } } as const /** * 表情类型 */ export type EmojiType = 'EYES' | 'PROCESSING' | 'SUCCESS' | 'ERROR' /** * 根据平台和表情类型获取表情 ID * @param e 消息事件对象 * @param type 表情类型 * @returns 表情 ID */ export function getEmojiId(e: Message, type: EmojiType): string | number { /** * ## ⚠️ 平台名不能读 `e.bot.adapter.platform` * `e.bot` 是 **KkkBot 包装**,它的 `.adapter` 是**适配器信息**(name / version, * 见 `compat/adapter-info`)—— **上面没有 platform 字段**,于是 `|| 'other'` 永远生效: * 连官方 QQ 都会落到占位符那一档。 * * OneBot(NapCat)上的实锤:日志里那条 * `set_msg_emoji_like { emoji_id: 'OTHER_PROCESSING_PLACEHOLDER', set: false }` 就是这么来的 —— * 拿一串占位符当表情 id 发给协议端,协议端根本不认,等于一直在打无效请求。 * * 统一改用 `platformOf`(项目里的标准取法,能穿透 KkkBot 包装拿到真实平台名)。 */ const platform = platformOf(e) /** * ## OneBot 系要用 **QQ 那一套** * OneBot(NapCat / Lagrange…)跑的就是 QQ,`set_msg_emoji_like` 认的也是 * **QQ 系统表情 id** —— 和清晰度面板那排(301 / 320 / 333…、478 / 479)同一套。 * 只有真不认 QQ 表情的平台(微信 / Telegram / KOOK…)才给占位符。 */ if (isOneBotLike(platform) || isQqFamily(platform)) return PLATFORM_EMOJI_IDS.qq[type] // node-karin 的 AdapterPlatform 联合里有本表未覆盖的取值(如 dingtalk), // 索引前先按「表里有没有这个键」收窄,缺的一律落到 other const platformEmojis = platform in PLATFORM_EMOJI_IDS ? PLATFORM_EMOJI_IDS[platform as keyof typeof PLATFORM_EMOJI_IDS] : PLATFORM_EMOJI_IDS.other return platformEmojis[type] } /** * 表情 ID 常量(向后兼容,使用 QQ 平台的 ID) * @deprecated 建议使用 getEmojiId 函数获取平台特定的表情 ID */ export const EMOJI_IDS = PLATFORM_EMOJI_IDS.qq /** * 设置消息表情回复 * @param e 消息事件对象 * @param emojiId 表情ID * @param isSet 是否设置表情(true=设置,false=取消),默认为 true * @returns 是否成功设置表情 */ export async function setEmojiReaction(e: Message, emojiId: string | number, isSet: boolean = true): Promise { // 如果未启用表情回复功能,直接返回 if (!Config.app.EmojiReply) { return false } // 私聊不发送表情回复 if (e.isPrivate) { return false } try { await e.bot.setMsgReaction(e.contact, e.messageId, emojiId, isSet) return true } catch (err) { // 始终忽略错误,不影响主流程 logger.debug('[EmojiReaction] 设置表情回复失败(已忽略):', err) return false } } /** * 表情回复管理器 */ export class EmojiReactionManager { private e: Message private emojiIds: Set = new Set() constructor(e: Message) { this.e = e } /** * 根据表情类型获取平台特定的表情 ID * @param type 表情类型 * @returns 表情 ID */ private getPlatformEmojiId(type: EmojiType): string | number { return getEmojiId(this.e, type) } /** * 规范化表情 ID(如果是表情类型字符串,则转换为平台特定的 ID) * @param emojiId 表情ID 或表情类型 * @returns 实际的表情 ID */ private normalizeEmojiId(emojiId: string | number | EmojiType): string | number { return typeof emojiId === 'string' && ['EYES', 'PROCESSING', 'SUCCESS', 'ERROR'].includes(emojiId) ? this.getPlatformEmojiId(emojiId as EmojiType) : emojiId } /** * 添加表情 * @param emojiId 表情ID 或表情类型 * @returns 是否成功 */ async add(emojiId: string | number | EmojiType): Promise { const actualEmojiId = this.normalizeEmojiId(emojiId) const success = await setEmojiReaction(this.e, actualEmojiId, true) if (success) { this.emojiIds.add(actualEmojiId) } return success } /** * 移除表情 * @param emojiId 表情ID 或表情类型 * @returns 是否成功 */ async remove(emojiId: string | number | EmojiType): Promise { const actualEmojiId = this.normalizeEmojiId(emojiId) const success = await setEmojiReaction(this.e, actualEmojiId, false) if (success) { this.emojiIds.delete(actualEmojiId) } return success } /** * 替换表情(先添加新的,等待指定时间后移除旧的) * @param oldEmojiId 旧表情ID 或表情类型 * @param newEmojiId 新表情ID 或表情类型 * @param delayMs 添加新表情后等待多少毫秒再移除旧表情,默认 2000ms * @returns 是否成功 */ async replace( oldEmojiId: string | number | EmojiType, newEmojiId: string | number | EmojiType, delayMs: number = 2000 ): Promise { // 先添加新表情 const addSuccess = await this.add(newEmojiId) // 等待指定时间 await new Promise((resolve) => setTimeout(resolve, delayMs)) // 再移除旧表情 await this.remove(oldEmojiId) return addSuccess } /** * 清除所有表情 * @returns 成功清除的数量 */ async clearAll(): Promise { let count = 0 for (const emojiId of this.emojiIds) { const success = await setEmojiReaction(this.e, emojiId, false) if (success) count++ } this.emojiIds.clear() return count } /** * 只保留指定的表情,移除其他所有表情 * @param keepEmojiIds 要保留的表情ID列表 * @returns 移除的表情数量 */ async keepOnly(keepEmojiIds: (string | number)[]): Promise { const keepSet = new Set(keepEmojiIds) let removedCount = 0 for (const emojiId of this.emojiIds) { if (!keepSet.has(emojiId)) { const success = await this.remove(emojiId) if (success) removedCount++ } } // 添加需要保留但当前不存在的表情 for (const emojiId of keepEmojiIds) { if (!this.emojiIds.has(emojiId)) { await this.add(emojiId) } } return removedCount } /** * 获取当前所有表情ID */ getCurrentEmojiIds(): (string | number)[] { return Array.from(this.emojiIds) } /** * 检查是否有指定的表情 */ has(emojiId: string | number): boolean { return this.emojiIds.has(emojiId) } /** * 获取当前表情数量 */ count(): number { return this.emojiIds.size } }