改动结果:
聊天窗口工具栏新增了“表情”按钮,打开表情库弹窗。 表情库会从历史“已下载落盘的表情消息”里自动聚合。 选中后直接通过 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:
@@ -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': '不支持的消息类型'})
|
||||
|
||||
|
||||
@@ -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)); }
|
||||
}
|
||||
|
||||
@@ -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]:
|
||||
"""按日期范围获取消息(支持按天总结)
|
||||
|
||||
Reference in New Issue
Block a user