改动结果:

聊天窗口工具栏新增了“表情”按钮,打开表情库弹窗。
表情库会从历史“已下载落盘的表情消息”里自动聚合。
选中后直接通过 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,让媒体下载功能先抓几条表情就会出现。
This commit is contained in:
liuwei
2026-04-15 11:21:32 +08:00
parent 96f50d929b
commit 47f8bd5717
3 changed files with 191 additions and 1 deletions

View File

@@ -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': '不支持的消息类型'})

View File

@@ -679,6 +679,7 @@
<el-upload class="upload-demo" action="#" :http-request="uploadImage" :show-file-list="false" accept="image/*">
<el-button size="small" type="primary" plain icon="el-icon-picture-outline">图片</el-button>
</el-upload>
<el-button size="small" type="primary" plain icon="el-icon-star-off" @click="openEmojiDialog">表情</el-button>
<el-upload class="upload-demo" action="#" :http-request="uploadVoice" :show-file-list="false" accept=".mp3,.wav,audio/*">
<el-button size="small" type="primary" plain icon="el-icon-microphone">语音</el-button>
</el-upload>
@@ -703,6 +704,25 @@
<el-button type="primary" @click="sendLinkMessage">发送</el-button>
</span>
</el-dialog>
<el-dialog title="表情库" :visible.sync="emojiDialogVisible" width="52%">
<div class="emoji-toolbar">
<el-input v-model="emojiKeyword" clearable placeholder="搜索 md5..." size="small"></el-input>
<el-button size="small" icon="el-icon-refresh" :loading="emojiLibraryLoading" @click="loadEmojiLibrary">刷新</el-button>
</div>
<div class="emoji-grid" v-loading="emojiLibraryLoading">
{% raw %}
<div v-if="!filteredEmojiLibrary.length" class="emoji-empty">暂无可用表情,先在群里让媒体下载插件抓取几条表情。</div>
<div v-for="item in filteredEmojiLibrary" :key="item.md5" class="emoji-card">
<img class="emoji-thumb" :src="getChatMediaUrl(item.preview_url)" />
<div class="emoji-md5">{{ item.md5 }}</div>
<div class="emoji-actions">
<el-button type="primary" size="mini" @click="sendEmojiItem(item)">发送</el-button>
</div>
</div>
{% endraw %}
</div>
</el-dialog>
</div>
{% 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)); }
}

View File

@@ -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]:
"""按日期范围获取消息(支持按天总结)