From 47f8bd5717c899648d27bea7bfdd99096d34ba5b Mon Sep 17 00:00:00 2001 From: liuwei Date: Wed, 15 Apr 2026 11:21:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E5=8A=A8=E7=BB=93=E6=9E=9C=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 聊天窗口工具栏新增了“表情”按钮,打开表情库弹窗。 表情库会从历史“已下载落盘的表情消息”里自动聚合。 选中后直接通过 send_emoji_message(wxid, md5, total_length) 发原生表情,不是当普通图片发。 仍保持你现在的发送通道和聊天刷新逻辑。 主要改动文件: 后端接口与发送支持:contacts.py 表情资源查询:message_storage.py 前端表情面板与发送交互:contacts_management.html 新增接口: GET /contacts/api/emojis:返回聚合后的表情库(md5、total_length、预览图)。 POST /contacts/api/send_message 新增 type=emoji。 我也做了 Python 语法检查,相关后端文件都通过了。 你可以直接在聊天弹窗里点“表情”试一下。如果表情库为空,通常是该群还没落盘到 image_path,让媒体下载功能先抓几条表情就会出现。 --- admin/dashboard/blueprints/contacts.py | 91 +++++++++++++++++++ .../templates/contacts_management.html | 88 +++++++++++++++++- db/message_storage.py | 13 +++ 3 files changed, 191 insertions(+), 1 deletion(-) diff --git a/admin/dashboard/blueprints/contacts.py b/admin/dashboard/blueprints/contacts.py index df5b2b3..0bf39ea 100644 --- a/admin/dashboard/blueprints/contacts.py +++ b/admin/dashboard/blueprints/contacts.py @@ -1,4 +1,6 @@ import asyncio +import os +import re import threading import xml.etree.ElementTree as ET from concurrent.futures import ThreadPoolExecutor @@ -15,6 +17,9 @@ message_thread_pool = ThreadPoolExecutor(max_workers=10, thread_name_prefix="mes # 创建共享的事件循环 shared_loop = None loop_lock = threading.Lock() +_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +_EMOJI_MD5_RE = re.compile(r'md5\s*=\s*[\"\']([0-9a-fA-F]{16,64})[\"\']', re.IGNORECASE) +_EMOJI_TOTALLEN_RE = re.compile(r'(?:totallen|total_len|len)\s*=\s*[\"\'](\d+)[\"\']', re.IGNORECASE) def get_or_create_loop(): """获取或创建共享的事件循环""" @@ -139,6 +144,39 @@ def _compact_media_caption(content: str, fallback: str) -> str: return text +def _extract_emoji_meta(attachment_url: str, image_path: str): + text = _safe_text(attachment_url) + md5 = "" + total_length = 0 + + md5_match = _EMOJI_MD5_RE.search(text) + if md5_match: + md5 = md5_match.group(1).lower() + + len_match = _EMOJI_TOTALLEN_RE.search(text) + if len_match: + try: + total_length = int(len_match.group(1)) + except Exception: + total_length = 0 + + if not md5 and image_path: + filename = os.path.basename(_safe_text(image_path)) + stem = os.path.splitext(filename)[0] + if re.fullmatch(r"[0-9a-fA-F]{16,64}", stem): + md5 = stem.lower() + + if total_length <= 0 and image_path and image_path.startswith("/static/"): + abs_path = os.path.join(_PROJECT_ROOT, image_path.lstrip("/").replace("/", os.sep)) + if os.path.isfile(abs_path): + try: + total_length = int(os.path.getsize(abs_path)) + except Exception: + total_length = 0 + + return md5, total_length + + def _normalize_recent_message(server, raw_message: dict, chat_type: str, target_wxid: str): sender = _safe_text(raw_message.get("sender")).strip() message_type = str(raw_message.get("message_type", "")) @@ -499,6 +537,46 @@ def api_recent_messages(): return jsonify({"success": False, "message": str(e)}), 500 +@contacts_bp.route('/api/emojis', methods=['GET']) +@login_required +def api_emoji_library(): + """获取已下载表情库(从历史消息聚合)。""" + try: + server = current_app.dashboard_server + limit = min(max(int(request.args.get("limit", 200)), 1), 500) + records = server.message_storage.message_db.get_recent_emoji_assets(limit=limit) + dedup = {} + for item in records: + image_path = _safe_text(item.get("image_path")).strip() + if not image_path: + continue + md5, total_length = _extract_emoji_meta(_safe_text(item.get("attachment_url")), image_path) + if not md5 or total_length <= 0: + continue + if md5 in dedup: + continue + dedup[md5] = { + "md5": md5, + "total_length": total_length, + "preview_url": image_path, + "timestamp": _safe_text(item.get("timestamp")), + "group_id": _safe_text(item.get("group_id")), + "message_id": _safe_text(item.get("message_id")), + } + + emojis = list(dedup.values()) + return jsonify({ + "success": True, + "data": { + "emojis": emojis, + "count": len(emojis) + } + }) + except Exception as e: + logger.exception(f"获取表情库失败: {e}") + return jsonify({"success": False, "message": str(e)}), 500 + + @contacts_bp.route('/api/send_message', methods=['POST']) @login_required def api_send_message(): @@ -582,6 +660,19 @@ def api_send_message(): 'message': '消息发送中' }) + elif msg_type == 'emoji': + if not isinstance(content, dict): + return jsonify({'success': False, 'message': '表情参数格式错误'}) + md5 = _safe_text(content.get('md5')).strip().lower() + total_length = int(content.get('total_length') or 0) + if not md5 or total_length <= 0: + return jsonify({'success': False, 'message': '缺少表情 md5 或长度'}) + send_message_in_thread(server.client.send_emoji_message, wxid, md5, total_length) + return jsonify({ + 'success': True, + 'message': '消息发送中' + }) + else: return jsonify({'success': False, 'message': '不支持的消息类型'}) diff --git a/admin/dashboard/templates/contacts_management.html b/admin/dashboard/templates/contacts_management.html index 4261993..09ae659 100644 --- a/admin/dashboard/templates/contacts_management.html +++ b/admin/dashboard/templates/contacts_management.html @@ -679,6 +679,7 @@ 图片 + 表情 语音 @@ -703,6 +704,25 @@ 发送 + + +
+ + 刷新 +
+
+ {% raw %} +
暂无可用表情,先在群里让媒体下载插件抓取几条表情。
+
+ +
{{ item.md5 }}
+
+ 发送 +
+
+ {% endraw %} +
+
{% endblock %} @@ -739,7 +759,11 @@ chatDialogVisible: false, currentChatUser: null, messageInput: '', chatMessages: [], chatLoading: false, chatSending: false, chatType: 'personal', chatHistoryTip: '最近 20 条消息', linkDialogVisible: false, - linkForm: { url: '', title: '', description: '' } + linkForm: { url: '', title: '', description: '' }, + emojiDialogVisible: false, + emojiLibraryLoading: false, + emojiLibrary: [], + emojiKeyword: '' }; }, computed: { @@ -781,6 +805,11 @@ { label: '插件调用', value: overview.plugin_call_count_30d || 0, note: '近30天插件总触发次数' }, { label: '插件种类', value: overview.plugin_count_30d || 0, note: '本群真实使用到的插件数' } ]; + }, + filteredEmojiLibrary() { + const keyword = (this.emojiKeyword || '').trim().toLowerCase(); + if (!keyword) return this.emojiLibrary; + return this.emojiLibrary.filter(item => (item.md5 || '').toLowerCase().includes(keyword)); } }, mounted() { @@ -1319,6 +1348,50 @@ this.linkDialogVisible = false; } }, + openEmojiDialog() { + if (!this.currentChatUser || !this.currentChatUser.wxid) return; + this.emojiDialogVisible = true; + this.loadEmojiLibrary(); + }, + async loadEmojiLibrary() { + this.emojiLibraryLoading = true; + try { + const response = await axios.get('/contacts/api/emojis', { params: { limit: 300 } }); + if (response.data.success) { + const list = (response.data.data && response.data.data.emojis) || []; + this.emojiLibrary = Array.isArray(list) ? list : []; + } else { + this.$message.error(response.data.message || '加载表情库失败'); + } + } catch (error) { + console.error('加载表情库失败:', error); + this.$message.error('加载表情库失败'); + } finally { + this.emojiLibraryLoading = false; + } + }, + async sendEmojiItem(item) { + if (!this.currentChatUser || !this.currentChatUser.wxid) return; + const md5 = item && item.md5; + const totalLength = item && item.total_length; + if (!md5 || !totalLength) { + this.$message.error('该表情缺少发送参数'); + return; + } + const localMessage = this.appendLocalChatMessage({ + displayType: 'image', + content: '[表情]', + mediaUrl: this.getChatMediaUrl(item.preview_url || '') + }); + await this.sendChatPayload({ + wxid: this.currentChatUser.wxid, + type: 'emoji', + content: { + md5: md5, + total_length: totalLength + } + }, '表情消息已提交到 iPad 通道', localMessage.messageId); + }, handleChatInputKeydown(event) { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); @@ -1493,6 +1566,19 @@ .message-self .message-sender, .message-self .message-time, .message-self .message-link-title { color: #ffffff; } .input-area { padding: 20px 0 0; } .toolbar { margin-top: 12px; display: flex; gap: 10px; flex-wrap: wrap; } + .emoji-toolbar { display: flex; gap: 10px; margin-bottom: 12px; } + .emoji-grid { + min-height: 280px; max-height: 520px; overflow-y: auto; + display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; + } + .emoji-card { + border: 1px solid rgba(148,163,184,0.16); border-radius: 12px; padding: 8px; + display: flex; flex-direction: column; gap: 8px; align-items: center; background: #fff; + } + .emoji-thumb { width: 72px; height: 72px; object-fit: contain; border-radius: 8px; background: rgba(148,163,184,0.08); } + .emoji-md5 { font-size: 11px; color: #64748b; word-break: break-all; text-align: center; min-height: 30px; } + .emoji-actions { width: 100%; display: flex; justify-content: center; } + .emoji-empty { color: #94a3b8; padding: 12px; } @media (max-width: 1200px) { .diagnosis-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } diff --git a/db/message_storage.py b/db/message_storage.py index 7c658b6..9edc247 100644 --- a/db/message_storage.py +++ b/db/message_storage.py @@ -570,6 +570,19 @@ class MessageStorageDB(BaseDBOperator): """兼容旧方法名,内部复用统一媒体待处理查询""" return self.get_pending_media_messages(minutes_ago, limit) + def get_recent_emoji_assets(self, limit: int = 200) -> List[Dict]: + """获取已下载落盘的表情资源记录(用于通讯录聊天面板的表情库)""" + sql = """ + SELECT message_id, group_id, sender, timestamp, message_type, attachment_url, image_path + FROM messages + WHERE message_type IN ('47', '1048625', '1090519089') + AND image_path IS NOT NULL + AND image_path <> '' + ORDER BY timestamp DESC + LIMIT %s + """ + return self.execute_query(sql, (limit,)) or [] + def get_messages_by_date_range(self, group_id: str, start_date: str, end_date: str = None, min_content_length: int = 6, max_results: int = 5000) -> List[Dict]: """按日期范围获取消息(支持按天总结)