Revert "完善表情资产后台能力并补充群总结落库"

This reverts commit 2a54650a6f.
This commit is contained in:
liuwei
2026-04-02 17:54:35 +08:00
parent 2a54650a6f
commit 57bb46bb21
11 changed files with 17 additions and 671 deletions

View File

@@ -403,46 +403,9 @@ def api_send_message():
'message': '消息发送中' 'message': '消息发送中'
}) })
elif msg_type == 'emoji':
md5 = content.get('md5') if isinstance(content, dict) else None
total_length = int((content or {}).get('total_length') or 0) if isinstance(content, dict) else 0
if not md5 or total_length <= 0:
return jsonify({'success': False, 'message': '缺少表情参数'})
send_message_in_thread(server.client.send_emoji_message, wxid, md5, total_length)
server.emoji_asset_db.mark_sent(md5)
return jsonify({
'success': True,
'message': '表情发送中'
})
else: else:
return jsonify({'success': False, 'message': '不支持的消息类型'}) return jsonify({'success': False, 'message': '不支持的消息类型'})
except Exception as e: except Exception as e:
logger.exception(f"发送消息失败: {e}") logger.exception(f"发送消息失败: {e}")
return jsonify({'success': False, 'message': str(e)}), 500 return jsonify({'success': False, 'message': str(e)}), 500
@contacts_bp.route('/api/emoji_assets', methods=['GET'])
@login_required
def api_emoji_assets():
"""获取表情资产列表API"""
try:
server = current_app.dashboard_server
limit = min(max(int(request.args.get("limit", 60) or 60), 1), 200)
roomid = request.args.get("roomid", "").strip()
assets = server.emoji_asset_db.list_assets(limit=limit, chatroom_id=roomid)
for asset in assets:
source_wxid = asset.get("source_wxid", "")
asset["source_name"] = server.contact_manager.get_nickname(source_wxid) or source_wxid
source_chatroom_id = asset.get("source_chatroom_id", "")
asset["source_chatroom_name"] = server.contact_manager.get_nickname(source_chatroom_id) or source_chatroom_id
return jsonify({
"success": True,
"data": {
"assets": assets
}
})
except Exception as e:
logger.error(f"获取表情资产列表失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500

View File

@@ -11,7 +11,6 @@ from flask import Flask, send_from_directory
from loguru import logger from loguru import logger
from db.contacts_db import ContactsDBOperator from db.contacts_db import ContactsDBOperator
from db.emoji_asset_db import EmojiAssetDBOperator
from db.member_context_db import MemberContextDBOperator from db.member_context_db import MemberContextDBOperator
from db.message_storage import MessageStorageDB from db.message_storage import MessageStorageDB
from db.stats_db import StatsDBOperator from db.stats_db import StatsDBOperator
@@ -44,7 +43,6 @@ class DashboardServer:
self.db_manager = robot_instance.db_manager self.db_manager = robot_instance.db_manager
self.stats_db = StatsDBOperator(self.db_manager) self.stats_db = StatsDBOperator(self.db_manager)
self.message_storage = MessageStorageDB(self.db_manager) self.message_storage = MessageStorageDB(self.db_manager)
self.emoji_asset_db = EmojiAssetDBOperator(self.db_manager)
self.contact_db: ContactsDBOperator = ContactsDBOperator(self.db_manager) self.contact_db: ContactsDBOperator = ContactsDBOperator(self.db_manager)
self.member_context_db = MemberContextDBOperator(self.db_manager) self.member_context_db = MemberContextDBOperator(self.db_manager)
self.task_db: TaskDBOperator = TaskDBOperator(self.db_manager) self.task_db: TaskDBOperator = TaskDBOperator(self.db_manager)

View File

@@ -190,9 +190,9 @@
<el-button size="mini" type="success" @click="refreshCurrentGroupContexts">刷新本群摘要</el-button> <el-button size="mini" type="success" @click="refreshCurrentGroupContexts">刷新本群摘要</el-button>
</div> </div>
</div> </div>
<el-table :data="filteredGroupMembers" style="width: 100%" v-loading="groupMembersLoading" class="group-members-table"> <el-table :data="filteredGroupMembers" style="width: 100%" v-loading="groupMembersLoading">
<el-table-column type="index" width="54"></el-table-column> <el-table-column type="index" width="54"></el-table-column>
<el-table-column label="成员" min-width="280" show-overflow-tooltip> <el-table-column label="成员" min-width="300">
<template slot-scope="scope"> <template slot-scope="scope">
<div class="entity-cell"> <div class="entity-cell">
<el-avatar size="small" :src="getHeadImage(scope.row.wxid)" @error="() => true"> <el-avatar size="small" :src="getHeadImage(scope.row.wxid)" @error="() => true">
@@ -205,7 +205,7 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="display_name" label="群昵称" min-width="180" show-overflow-tooltip></el-table-column> <el-table-column prop="display_name" label="群昵称" width="240"></el-table-column>
<el-table-column label="状态" width="100" align="center"> <el-table-column label="状态" width="100" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-tag size="mini" :type="scope.row.status === 1 ? 'success' : 'info'"> <el-tag size="mini" :type="scope.row.status === 1 ? 'success' : 'info'">
@@ -213,14 +213,14 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="latest_active_time" label="活跃时间" width="168" show-overflow-tooltip> <el-table-column prop="latest_active_time" label="活跃时间">
<template slot-scope="scope">{% raw %}{{ scope.row.latest_active_time || '-' }}{% endraw %}</template> <template slot-scope="scope">{% raw %}{{ scope.row.latest_active_time || '-' }}{% endraw %}</template>
</el-table-column> </el-table-column>
<el-table-column prop="activity_level" label="互动强度" width="110"></el-table-column> <el-table-column prop="activity_level" label="互动强度" width="110"></el-table-column>
<el-table-column label="回复建议" min-width="260" show-overflow-tooltip> <el-table-column label="回复建议" min-width="220" show-overflow-tooltip>
<template slot-scope="scope">{% raw %}{{ scope.row.response_style_hint || '-' }}{% endraw %}</template> <template slot-scope="scope">{% raw %}{{ scope.row.response_style_hint || '-' }}{% endraw %}</template>
</el-table-column> </el-table-column>
<el-table-column label="后台摘要" width="110" align="center"> <el-table-column label="后台摘要" width="130" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-button size="mini" type="primary" plain @click="openMemberContextDialog(scope.row)">查看</el-button> <el-button size="mini" type="primary" plain @click="openMemberContextDialog(scope.row)">查看</el-button>
</template> </template>
@@ -390,7 +390,6 @@
<div class="message-content"> <div class="message-content">
<div v-if="msg.type === 'text'" v-text="msg.content"></div> <div v-if="msg.type === 'text'" v-text="msg.content"></div>
<div v-else-if="msg.type === 'image'"><img :src="msg.content" style="max-width: 200px; max-height: 200px;"></div> <div v-else-if="msg.type === 'image'"><img :src="msg.content" style="max-width: 200px; max-height: 200px;"></div>
<div v-else-if="msg.type === 'emoji'"><img :src="msg.content" class="chat-emoji-preview"></div>
<div v-else-if="msg.type === 'voice'"><audio controls :src="msg.content"></audio></div> <div v-else-if="msg.type === 'voice'"><audio controls :src="msg.content"></audio></div>
<div v-else-if="msg.type === 'video'"><video controls :src="msg.content" style="max-width: 200px;"></video></div> <div v-else-if="msg.type === 'video'"><video controls :src="msg.content" style="max-width: 200px;"></video></div>
<div v-else-if="msg.type === 'link'"><a :href="msg.content.url" target="_blank" v-text="msg.content.title"></a><p v-text="msg.content.description"></p></div> <div v-else-if="msg.type === 'link'"><a :href="msg.content.url" target="_blank" v-text="msg.content.title"></a><p v-text="msg.content.description"></p></div>
@@ -400,31 +399,12 @@
{% endraw %} {% endraw %}
</div> </div>
<div class="input-area"> <div class="input-area">
<div v-if="emojiPanelVisible" class="emoji-panel" v-loading="emojiAssetsLoading">
<div class="emoji-panel-header">
<span>后台表情资产</span>
<el-button size="mini" type="text" @click="loadEmojiAssets">刷新</el-button>
</div>
<el-empty v-if="!emojiAssetsLoading && !emojiAssets.length" description="暂无可用表情资产"></el-empty>
<div v-else class="emoji-grid">
{% raw %}
<div v-for="asset in emojiAssets" :key="asset.md5" class="emoji-card" @click="sendEmojiMessage(asset)">
<img :src="asset.file_path" class="emoji-thumb">
<div class="emoji-meta">
<div class="emoji-meta-line">{{ asset.source_name || asset.source_wxid || '未知来源' }}</div>
<div class="emoji-meta-line">{{ asset.source_chatroom_name || asset.source_chatroom_id || '全局资产' }}</div>
</div>
</div>
{% endraw %}
</div>
</div>
<el-input type="textarea" :rows="3" placeholder="请输入消息..." v-model="messageInput" @keyup.enter.native="sendTextMessage"></el-input> <el-input type="textarea" :rows="3" placeholder="请输入消息..." v-model="messageInput" @keyup.enter.native="sendTextMessage"></el-input>
<div class="toolbar"> <div class="toolbar">
<el-upload class="upload-demo" action="#" :http-request="uploadImage" :show-file-list="false"><el-button size="small" type="primary">图片</el-button></el-upload> <el-upload class="upload-demo" action="#" :http-request="uploadImage" :show-file-list="false"><el-button size="small" type="primary">图片</el-button></el-upload>
<el-upload class="upload-demo" action="#" :http-request="uploadVoice" :show-file-list="false"><el-button size="small" type="primary">语音</el-button></el-upload> <el-upload class="upload-demo" action="#" :http-request="uploadVoice" :show-file-list="false"><el-button size="small" type="primary">语音</el-button></el-upload>
<el-upload class="upload-demo" action="#" :http-request="uploadVideo" :show-file-list="false"><el-button size="small" type="primary">视频</el-button></el-upload> <el-upload class="upload-demo" action="#" :http-request="uploadVideo" :show-file-list="false"><el-button size="small" type="primary">视频</el-button></el-upload>
<el-button size="small" type="primary" @click="showLinkDialog">链接</el-button> <el-button size="small" type="primary" @click="showLinkDialog">链接</el-button>
<el-button size="small" :type="emojiPanelVisible ? 'warning' : 'primary'" @click="toggleEmojiPanel">表情</el-button>
<el-button size="small" type="success" @click="sendTextMessage">发送</el-button> <el-button size="small" type="success" @click="sendTextMessage">发送</el-button>
</div> </div>
</div> </div>
@@ -471,7 +451,6 @@
memberContextDialogVisible: false, memberContextLoading: false, memberContext: null, currentContextMember: {}, memberContextDialogVisible: false, memberContextLoading: false, memberContext: null, currentContextMember: {},
memberContextEnabled: false, memberContextEnabled: false,
chatDialogVisible: false, currentChatUser: null, messageInput: '', chatMessages: [], chatDialogVisible: false, currentChatUser: null, messageInput: '', chatMessages: [],
emojiPanelVisible: false, emojiAssetsLoading: false, emojiAssets: [],
linkDialogVisible: false, linkDialogVisible: false,
linkForm: { url: '', title: '', description: '' } linkForm: { url: '', title: '', description: '' }
}; };
@@ -608,13 +587,7 @@
this.$message.error('刷新成员交互摘要失败'); this.$message.error('刷新成员交互摘要失败');
}).finally(() => { this.memberContextLoading = false; }); }).finally(() => { this.memberContextLoading = false; });
}, },
openChatDialog(user) { openChatDialog(user) { this.currentChatUser = user; this.chatDialogVisible = true; this.chatMessages = []; },
this.currentChatUser = user;
this.chatDialogVisible = true;
this.chatMessages = [];
this.emojiPanelVisible = false;
this.loadEmojiAssets();
},
async sendTextMessage() { async sendTextMessage() {
if (!this.messageInput.trim()) return; if (!this.messageInput.trim()) return;
try { try {
@@ -634,56 +607,6 @@
const formData = new FormData(); formData.append('file', options.file); formData.append('wxid', this.currentChatUser.wxid); formData.append('type', 'video'); const formData = new FormData(); formData.append('file', options.file); formData.append('wxid', this.currentChatUser.wxid); formData.append('type', 'video');
try { const response = await axios.post('/contacts/api/send_message', formData); if (response.data.success) { this.chatMessages.push({ type: 'video', content: response.data.url, isSelf: true, time: new Date().toLocaleTimeString() }); this.$nextTick(() => { this.scrollToBottom(); }); } } catch (error) { this.$message.error('发送视频失败'); } try { const response = await axios.post('/contacts/api/send_message', formData); if (response.data.success) { this.chatMessages.push({ type: 'video', content: response.data.url, isSelf: true, time: new Date().toLocaleTimeString() }); this.$nextTick(() => { this.scrollToBottom(); }); } } catch (error) { this.$message.error('发送视频失败'); }
}, },
toggleEmojiPanel() {
this.emojiPanelVisible = !this.emojiPanelVisible;
if (this.emojiPanelVisible && !this.emojiAssets.length) {
this.loadEmojiAssets();
}
},
async loadEmojiAssets() {
this.emojiAssetsLoading = true;
const roomid = this.currentChatUser && this.currentChatUser.wxid && this.currentChatUser.wxid.endsWith('@chatroom')
? this.currentChatUser.wxid
: '';
try {
const response = await axios.get('/contacts/api/emoji_assets', { params: { limit: 80, roomid } });
if (response.data.success) {
this.emojiAssets = response.data.data.assets || [];
} else {
this.$message.error('加载表情资产失败');
}
} catch (error) {
console.error('加载表情资产失败:', error);
this.$message.error('加载表情资产失败');
} finally {
this.emojiAssetsLoading = false;
}
},
async sendEmojiMessage(asset) {
if (!asset || !asset.md5 || !asset.total_length) {
this.$message.warning('表情资产参数不完整');
return;
}
try {
const response = await axios.post('/contacts/api/send_message', {
wxid: this.currentChatUser.wxid,
type: 'emoji',
content: {
md5: asset.md5,
total_length: asset.total_length
}
});
if (response.data.success) {
this.chatMessages.push({ type: 'emoji', content: asset.file_path, isSelf: true, time: new Date().toLocaleTimeString() });
this.$nextTick(() => { this.scrollToBottom(); });
} else {
this.$message.error(response.data.message || '发送表情失败');
}
} catch (error) {
console.error('发送表情失败:', error);
this.$message.error('发送表情失败');
}
},
showLinkDialog() { this.linkForm = { url: '', title: '', description: '' }; this.linkDialogVisible = true; }, showLinkDialog() { this.linkForm = { url: '', title: '', description: '' }; this.linkDialogVisible = true; },
async sendLinkMessage() { async sendLinkMessage() {
if (!this.linkForm.url) { this.$message.warning('请输入链接'); return; } if (!this.linkForm.url) { this.$message.warning('请输入链接'); return; }
@@ -730,14 +653,6 @@
.action-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .action-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.pagination-container { margin-top: 20px; text-align: right; } .pagination-container { margin-top: 20px; text-align: right; }
.group-members-section { margin-top: 20px; } .group-members-section { margin-top: 20px; }
.group-members-table .entity-cell { min-width: 0; }
.group-members-table .entity-copy { min-width: 0; overflow: hidden; }
.group-members-table .entity-title,
.group-members-table .entity-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section-title { .section-title {
margin: 20px 0 15px 0; border-bottom: 1px solid rgba(148,163,184,0.12); padding-bottom: 10px; margin: 20px 0 15px 0; border-bottom: 1px solid rgba(148,163,184,0.12); padding-bottom: 10px;
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
@@ -767,34 +682,5 @@
.message-time { font-size: 12px; color: #94a3b8; margin-top: 5px; } .message-time { font-size: 12px; color: #94a3b8; margin-top: 5px; }
.input-area { padding: 20px 0 0; } .input-area { padding: 20px 0 0; }
.toolbar { margin-top: 10px; display: flex; gap: 10px; flex-wrap: wrap; } .toolbar { margin-top: 10px; display: flex; gap: 10px; flex-wrap: wrap; }
.chat-emoji-preview { width: 96px; height: 96px; object-fit: contain; display: block; }
.emoji-panel {
margin-bottom: 12px; padding: 12px; border: 1px solid rgba(148,163,184,0.14);
border-radius: 16px; background: rgba(248,250,252,0.86);
}
.emoji-panel-header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;
color: #334155; font-size: 13px; font-weight: 600;
}
.emoji-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px;
max-height: 240px; overflow-y: auto;
}
.emoji-card {
padding: 10px; border-radius: 14px; background: #ffffff; border: 1px solid rgba(148,163,184,0.12);
cursor: pointer; transition: all .2s ease;
}
.emoji-card:hover {
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
border-color: rgba(79,70,229,0.24);
}
.emoji-thumb { width: 72px; height: 72px; display: block; margin: 0 auto 8px; object-fit: contain; }
.emoji-meta { font-size: 11px; color: #64748b; line-height: 1.4; }
.emoji-meta-line {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style> </style>
{% endblock %} {% endblock %}

View File

@@ -92,12 +92,6 @@
<img v-if="scope.row.message_thumb" :src="scope.row.message_thumb" class="message-thumb" @click="showVideo(scope.row)"> <img v-if="scope.row.message_thumb" :src="scope.row.message_thumb" class="message-thumb" @click="showVideo(scope.row)">
</div> </div>
<div v-else-if="isEmojiMessage(scope.row)" class="message-media-preview">
<div class="message-media-label">【表情消息】</div>
<img v-if="scope.row.image_path" :src="getImageUrl(scope.row.image_path)" class="message-thumb" @click="showImage(scope.row)">
<div v-else class="message-text-preview is-muted">表情下载中或暂无预览</div>
</div>
<div v-else class="message-text-preview is-muted"> <div v-else class="message-text-preview is-muted">
{% raw %}{{ scope.row.content || `【消息类型: ${scope.row.message_type}】` }}{% endraw %} {% raw %}{{ scope.row.content || `【消息类型: ${scope.row.message_type}】` }}{% endraw %}
</div> </div>
@@ -133,10 +127,9 @@
<el-descriptions-item label="消息类型">{% raw %}{{ getMessageTypeName(selectedMessage.message_type) }}{% endraw %}</el-descriptions-item> <el-descriptions-item label="消息类型">{% raw %}{{ getMessageTypeName(selectedMessage.message_type) }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="内容">{% raw %}{{ selectedMessage.content }}{% endraw %}</el-descriptions-item> <el-descriptions-item label="内容">{% raw %}{{ selectedMessage.content }}{% endraw %}</el-descriptions-item>
<el-descriptions-item v-if="selectedMessage.message_type == 3 || selectedMessage.message_type == 43 || isEmojiMessage(selectedMessage)" label="媒体内容"> <el-descriptions-item v-if="selectedMessage.message_type == 3 || selectedMessage.message_type == 43" label="媒体内容">
<img v-if="selectedMessage.message_type == 3 && selectedMessage.image_path" :src="getImageUrl(selectedMessage.image_path)" style="max-width: 100%; border-radius: 16px;"> <img v-if="selectedMessage.message_type == 3 && selectedMessage.image_path" :src="getImageUrl(selectedMessage.image_path)" style="max-width: 100%; border-radius: 16px;">
<img v-else-if="selectedMessage.message_type == 3 && selectedMessage.message_thumb" :src="selectedMessage.message_thumb" style="max-width: 100%; border-radius: 16px;"> <img v-else-if="selectedMessage.message_type == 3 && selectedMessage.message_thumb" :src="selectedMessage.message_thumb" style="max-width: 100%; border-radius: 16px;">
<img v-else-if="isEmojiMessage(selectedMessage) && selectedMessage.image_path" :src="getImageUrl(selectedMessage.image_path)" style="max-width: 100%; border-radius: 16px;">
<video v-if="selectedMessage.message_type == 43 && selectedMessage.attachment_url" :src="selectedMessage.attachment_url" controls style="max-width: 100%; border-radius: 16px;"></video> <video v-if="selectedMessage.message_type == 43 && selectedMessage.attachment_url" :src="selectedMessage.attachment_url" controls style="max-width: 100%; border-radius: 16px;"></video>
</el-descriptions-item> </el-descriptions-item>
@@ -320,18 +313,12 @@
this.selectedMessage = message; this.selectedMessage = message;
this.detailDialogVisible = true; this.detailDialogVisible = true;
}, },
isEmojiMessage(message) {
if (!message) return false;
return String(message.message_type) === '47' || String(message.message_type) === '1090519089';
},
getMessageTypeName(type) { getMessageTypeName(type) {
const typeMap = { const typeMap = {
1: '文本消息', 1: '文本消息',
3: '图片消息', 3: '图片消息',
43: '视频消息', 43: '视频消息',
47: '动画表情', 49: '链接消息'
49: '链接消息',
1090519089: '大表情'
}; };
return typeMap[type] || `未知类型(${type})`; return typeMap[type] || `未知类型(${type})`;
}, },

View File

@@ -1,141 +0,0 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Dict, List, Optional
from db.base import BaseDBOperator
from db.connection import DBConnectionManager
class EmojiAssetDBOperator(BaseDBOperator):
"""表情资产数据库操作"""
def __init__(self, db_manager: DBConnectionManager):
super().__init__(db_manager)
self._create_tables()
def _create_tables(self):
try:
self.execute_update("""
CREATE TABLE IF NOT EXISTS t_emoji_asset (
id INT AUTO_INCREMENT PRIMARY KEY,
md5 VARCHAR(64) NOT NULL COMMENT '表情MD5',
total_length INT NOT NULL DEFAULT 0 COMMENT '表情长度',
file_path VARCHAR(255) NOT NULL COMMENT '本地访问路径',
file_ext VARCHAR(16) DEFAULT '' COMMENT '文件扩展名',
source_message_id BIGINT DEFAULT NULL COMMENT '来源消息ID',
source_chatroom_id VARCHAR(64) DEFAULT '' COMMENT '来源群ID',
source_wxid VARCHAR(64) DEFAULT '' COMMENT '来源发送人',
usage_count INT NOT NULL DEFAULT 0 COMMENT '使用次数',
last_used_at DATETIME DEFAULT NULL COMMENT '最近采集时间',
last_sent_at DATETIME DEFAULT NULL COMMENT '最近发送时间',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY idx_emoji_asset_md5 (md5),
KEY idx_emoji_asset_recent (update_time),
KEY idx_emoji_asset_group (source_chatroom_id, update_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='表情资产表';
""")
except Exception as e:
self.LOG.error(f"创建表情资产表失败: {e}")
def save_asset(self, asset: Dict) -> bool:
try:
sql = """
INSERT INTO t_emoji_asset (
md5, total_length, file_path, file_ext,
source_message_id, source_chatroom_id, source_wxid,
usage_count, last_used_at, last_sent_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
total_length = VALUES(total_length),
file_path = VALUES(file_path),
file_ext = VALUES(file_ext),
source_message_id = COALESCE(VALUES(source_message_id), source_message_id),
source_chatroom_id = CASE
WHEN VALUES(source_chatroom_id) IS NULL OR VALUES(source_chatroom_id) = '' THEN source_chatroom_id
ELSE VALUES(source_chatroom_id)
END,
source_wxid = CASE
WHEN VALUES(source_wxid) IS NULL OR VALUES(source_wxid) = '' THEN source_wxid
ELSE VALUES(source_wxid)
END,
usage_count = usage_count + 1,
last_used_at = VALUES(last_used_at)
"""
now = asset.get("last_used_at") or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
params = (
asset.get("md5", ""),
int(asset.get("total_length", 0) or 0),
asset.get("file_path", ""),
asset.get("file_ext", ""),
asset.get("source_message_id"),
asset.get("source_chatroom_id", ""),
asset.get("source_wxid", ""),
int(asset.get("usage_count", 1) or 1),
now,
asset.get("last_sent_at"),
)
return self.execute_update(sql, params)
except Exception as e:
self.LOG.error(f"保存表情资产失败: {e}")
return False
def list_assets(self, limit: int = 60, chatroom_id: str = "") -> List[Dict]:
try:
sql = """
SELECT id, md5, total_length, file_path, file_ext, source_message_id,
source_chatroom_id, source_wxid, usage_count, last_used_at,
last_sent_at, create_time, update_time
FROM t_emoji_asset
WHERE file_path IS NOT NULL AND file_path <> ''
"""
params = []
if chatroom_id:
sql += " AND source_chatroom_id = %s "
params.append(chatroom_id)
sql += " ORDER BY COALESCE(last_sent_at, last_used_at, update_time) DESC LIMIT %s "
params.append(limit)
rows = self.execute_query(sql, tuple(params)) or []
return [self._serialize_row(row) for row in rows]
except Exception as e:
self.LOG.error(f"查询表情资产失败: {e}")
return []
def get_asset_by_md5(self, md5: str) -> Optional[Dict]:
try:
sql = """
SELECT id, md5, total_length, file_path, file_ext, source_message_id,
source_chatroom_id, source_wxid, usage_count, last_used_at,
last_sent_at, create_time, update_time
FROM t_emoji_asset
WHERE md5 = %s
LIMIT 1
"""
row = self.execute_query(sql, (md5,), fetch_one=True)
return self._serialize_row(row) if row else None
except Exception as e:
self.LOG.error(f"查询表情资产详情失败: {e}")
return None
def mark_sent(self, md5: str) -> bool:
try:
sql = """
UPDATE t_emoji_asset
SET last_sent_at = NOW()
WHERE md5 = %s
"""
return self.execute_update(sql, (md5,))
except Exception as e:
self.LOG.error(f"更新表情发送时间失败: {e}")
return False
@staticmethod
def _serialize_row(row: Dict) -> Dict:
if not row:
return row
for key in ("last_used_at", "last_sent_at", "create_time", "update_time"):
value = row.get(key)
if isinstance(value, datetime):
row[key] = value.strftime("%Y-%m-%d %H:%M:%S")
return row

View File

@@ -338,22 +338,6 @@ class MessageStorageDB(BaseDBOperator):
print(f"更新消息图片文件路径出错: {e}") print(f"更新消息图片文件路径出错: {e}")
return False return False
def get_pending_emoji_messages(self, minutes_ago: int = 1440, limit: int = 50) -> List[Dict]:
"""获取最近N分钟内未处理表情的消息"""
sql = """
SELECT message_id, group_id, sender, message_xml, timestamp, attachment_url, message_type
FROM messages
WHERE message_type IN ('47', '1090519089')
AND image_path IS NULL
AND timestamp >= DATE_SUB(NOW(), INTERVAL %s MINUTE)
AND attachment_url IS NOT NULL
AND attachment_url != ''
ORDER BY timestamp ASC
LIMIT %s
"""
params = (minutes_ago, limit)
return self.execute_query(sql, params) or []
def get_hourly_message_trend(self, group_id: str = None, days: int = 1) -> List[Dict]: def get_hourly_message_trend(self, group_id: str = None, days: int = 1) -> List[Dict]:
"""获取指定群组的按小时消息趋势数据 """获取指定群组的按小时消息趋势数据

View File

@@ -1,113 +0,0 @@
# -*- coding: utf-8 -*-
import json
from datetime import datetime
from typing import Dict, Optional
from db.base import BaseDBOperator
from db.connection import DBConnectionManager
class MessageSummaryDBOperator(BaseDBOperator):
"""群消息总结数据库操作"""
def __init__(self, db_manager: DBConnectionManager):
super().__init__(db_manager)
self._create_tables()
def _create_tables(self):
try:
self.execute_update("""
CREATE TABLE IF NOT EXISTS t_message_summary (
id INT AUTO_INCREMENT PRIMARY KEY,
chatroom_id VARCHAR(64) NOT NULL COMMENT '群聊ID',
group_name VARCHAR(128) DEFAULT '' COMMENT '群名称',
summary_type VARCHAR(16) NOT NULL COMMENT '总结类型 daily|manual',
period_key VARCHAR(32) NOT NULL COMMENT '周期主键,如 2026-04-01',
period_start DATETIME NULL COMMENT '总结周期开始时间',
period_end DATETIME NULL COMMENT '总结周期结束时间',
source_message_count INT NOT NULL DEFAULT 0 COMMENT '源消息数量',
summary_text LONGTEXT COMMENT '总结文本',
image_path VARCHAR(255) DEFAULT NULL COMMENT '总结图片路径',
meta_json LONGTEXT COMMENT '附加元数据JSON',
last_generated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最后生成时间',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY idx_message_summary (chatroom_id, summary_type, period_key),
KEY idx_message_summary_lookup (chatroom_id, period_end)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='群消息总结表';
""")
except Exception as e:
self.LOG.error(f"创建群消息总结表失败: {e}")
def save_summary(self, summary: Dict) -> bool:
try:
data = {
"chatroom_id": summary.get("chatroom_id", ""),
"group_name": summary.get("group_name", ""),
"summary_type": summary.get("summary_type", "daily"),
"period_key": summary.get("period_key", ""),
"period_start": summary.get("period_start"),
"period_end": summary.get("period_end"),
"source_message_count": int(summary.get("source_message_count", 0) or 0),
"summary_text": summary.get("summary_text", ""),
"image_path": summary.get("image_path"),
"meta_json": json.dumps(summary.get("meta", {}), ensure_ascii=False),
"last_generated_at": summary.get(
"last_generated_at",
datetime.now().strftime("%Y-%m-%d %H:%M:%S")
),
}
fields = ", ".join(data.keys())
placeholders = ", ".join(["%s"] * len(data))
update_clause = ", ".join(
[
f"{key}=VALUES({key})"
for key in data.keys()
if key not in ("chatroom_id", "summary_type", "period_key")
]
)
sql = f"""
INSERT INTO t_message_summary ({fields})
VALUES ({placeholders})
ON DUPLICATE KEY UPDATE {update_clause}
"""
return self.execute_update(sql, tuple(data.values()))
except Exception as e:
self.LOG.error(f"保存群消息总结失败: {e}")
return False
def get_summary(self, chatroom_id: str, summary_type: str, period_key: str) -> Optional[Dict]:
try:
sql = """
SELECT *
FROM t_message_summary
WHERE chatroom_id = %s AND summary_type = %s AND period_key = %s
LIMIT 1
"""
row = self.execute_query(sql, (chatroom_id, summary_type, period_key), fetch_one=True)
return self._deserialize_row(row)
except Exception as e:
self.LOG.error(f"获取群消息总结失败: {e}")
return None
@staticmethod
def _deserialize_row(row: Optional[Dict]) -> Optional[Dict]:
if not row:
return row
meta_json = row.get("meta_json")
if meta_json:
try:
row["meta_json"] = json.loads(meta_json)
except Exception:
row["meta_json"] = {}
else:
row["meta_json"] = {}
for key in ("period_start", "period_end", "last_generated_at", "create_time", "update_time"):
value = row.get(key)
if isinstance(value, datetime):
row[key] = value.strftime("%Y-%m-%d %H:%M:%S")
row["meta"] = row.get("meta_json", {})
return row

View File

@@ -150,11 +150,6 @@ def jobs(robot: Robot):
if hasattr(robot, 'message_storage') and robot.message_storage: if hasattr(robot, 'message_storage') and robot.message_storage:
await robot.message_storage.process_pending_images(minutes_ago=10, batch_size=20) await robot.message_storage.process_pending_images(minutes_ago=10, batch_size=20)
@async_job.every_minutes(5)
async def process_pending_emojis_job():
if hasattr(robot, 'message_storage') and robot.message_storage:
await robot.message_storage.process_pending_emojis(minutes_ago=60 * 24 * 7, batch_size=30)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -24,7 +24,6 @@ from utils.string_utils import remove_trailing_content
from utils.wechat.contact_manager import ContactManager from utils.wechat.contact_manager import ContactManager
from utils.wechat.message_to_db import MessageStorage from utils.wechat.message_to_db import MessageStorage
from wechat_ipad import WechatAPIClient from wechat_ipad import WechatAPIClient
from db.message_summary_db import MessageSummaryDBOperator
class MessageSummaryPlugin(MessagePluginInterface): class MessageSummaryPlugin(MessagePluginInterface):
@@ -69,7 +68,6 @@ class MessageSummaryPlugin(MessagePluginInterface):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.message_storage = None self.message_storage = None
self.summary_db = None
self.revoke = None self.revoke = None
# 注册功能权限 # 注册功能权限
self.feature = self.register_feature() self.feature = self.register_feature()
@@ -84,7 +82,6 @@ class MessageSummaryPlugin(MessagePluginInterface):
self._api_key = api_config.get("api_key", "app-McGLzBhBjeBCSEi7n83MtuTo") self._api_key = api_config.get("api_key", "app-McGLzBhBjeBCSEi7n83MtuTo")
self._api_url = api_config.get("api_url", "http://192.168.2.240/v1/chat-messages") self._api_url = api_config.get("api_url", "http://192.168.2.240/v1/chat-messages")
self.message_storage = MessageStorage() self.message_storage = MessageStorage()
self.summary_db = MessageSummaryDBOperator(context["db_manager"])
self.LOG.debug(f"初始化 {self.name} 插件成功") self.LOG.debug(f"初始化 {self.name} 插件成功")
return True return True
@@ -199,7 +196,6 @@ class MessageSummaryPlugin(MessagePluginInterface):
def _sanitize_group_name(self, group_name: str) -> str: def _sanitize_group_name(self, group_name: str) -> str:
"""处理群名,去除特殊字符并限制长度""" """处理群名,去除特殊字符并限制长度"""
group_name = group_name or ""
# 去除特殊字符,只保留字母、数字、中文和基本标点 # 去除特殊字符,只保留字母、数字、中文和基本标点
sanitized_name = re.sub(r'[^\w\s\u4e00-\u9fff,.,。]', '', group_name) sanitized_name = re.sub(r'[^\w\s\u4e00-\u9fff,.,。]', '', group_name)
# 限制长度为15个字符 # 限制长度为15个字符
@@ -210,47 +206,6 @@ class MessageSummaryPlugin(MessagePluginInterface):
sanitized_name = "群聊" sanitized_name = "群聊"
return sanitized_name return sanitized_name
def _save_daily_summary(
self,
group_id: str,
group_name: str,
summary_text: str,
image_path: Optional[str],
yesterday_start: datetime,
yesterday_end: datetime,
message_count: int,
chat_content_length: int
) -> bool:
"""保存每日总结结果,便于后续知识库提取。"""
if not self.summary_db:
self.LOG.warning("消息总结数据库未初始化,跳过保存每日总结")
return False
period_key = yesterday_start.strftime("%Y-%m-%d")
meta = {
"source": "message_summary_plugin",
"api_url": self._api_url,
"has_image": bool(image_path),
"chat_content_length": int(chat_content_length or 0),
}
saved = self.summary_db.save_summary({
"chatroom_id": group_id,
"group_name": group_name,
"summary_type": "daily",
"period_key": period_key,
"period_start": yesterday_start.strftime("%Y-%m-%d %H:%M:%S"),
"period_end": yesterday_end.strftime("%Y-%m-%d %H:%M:%S"),
"source_message_count": message_count,
"summary_text": summary_text,
"image_path": image_path,
"meta": meta,
})
if saved:
self.LOG.info(f"已保存群 {group_id} 的每日总结到数据库: {period_key}")
else:
self.LOG.error(f"保存群 {group_id} 的每日总结失败: {period_key}")
return saved
async def _generate_summary(self, chat_content: str, group_name: str) -> Tuple[str, Optional[str]]: async def _generate_summary(self, chat_content: str, group_name: str) -> Tuple[str, Optional[str]]:
"""生成总结""" """生成总结"""
# Dify API配置 # Dify API配置
@@ -422,18 +377,6 @@ class MessageSummaryPlugin(MessagePluginInterface):
# 生成总结 # 生成总结
summary, image_path = await self._generate_summary(chat_content, group_name) summary, image_path = await self._generate_summary(chat_content, group_name)
if summary and len(summary.strip()) > 0:
self._save_daily_summary(
group_id=group_id,
group_name=group_name,
summary_text=summary,
image_path=image_path,
yesterday_start=yesterday_start,
yesterday_end=yesterday_end,
message_count=message_count,
chat_content_length=len(chat_content),
)
if image_path: if image_path:
# 图片生成成功,发送图片 # 图片生成成功,发送图片
await self.bot.send_image_message(group_id, Path(image_path)) await self.bot.send_image_message(group_id, Path(image_path))

View File

@@ -5,13 +5,14 @@ import xml.etree.ElementTree as ET
import concurrent.futures # 添加线程池支持 import concurrent.futures # 添加线程池支持
import os import os
import base64 import base64
import imghdr
import re import re
from threading import Lock from threading import Lock
from typing import Dict from typing import Dict
from db.connection import DBConnectionManager from db.connection import DBConnectionManager
from db.contacts_db import ContactsDBOperator from db.contacts_db import ContactsDBOperator
from db.emoji_asset_db import EmojiAssetDBOperator
from db.levels_db import LevelsDBOperator from db.levels_db import LevelsDBOperator
from db.message_storage import MessageStorageDB from db.message_storage import MessageStorageDB
# 导入积分系统 # 导入积分系统
@@ -32,7 +33,6 @@ class MessageStorage:
self.db_manager = DBConnectionManager.get_instance() self.db_manager = DBConnectionManager.get_instance()
self.message_db = MessageStorageDB(self.db_manager) self.message_db = MessageStorageDB(self.db_manager)
self.contacts_db = ContactsDBOperator(self.db_manager) self.contacts_db = ContactsDBOperator(self.db_manager)
self.emoji_asset_db = EmojiAssetDBOperator(self.db_manager)
self.points_db = PointsDBOperator(self.db_manager) self.points_db = PointsDBOperator(self.db_manager)
# 初始化本地缓存字典,使用 group_id 作为键 # 初始化本地缓存字典,使用 group_id 作为键
@@ -60,9 +60,6 @@ class MessageStorage:
# 正则(替代 XML 解析) # 正则(替代 XML 解析)
self._aeskey_re = re.compile(r'aeskey="(.*?)"') self._aeskey_re = re.compile(r'aeskey="(.*?)"')
self._cdn_re = re.compile(r'cdnthumburl="(.*?)"') self._cdn_re = re.compile(r'cdnthumburl="(.*?)"')
self._emoji_cdn_re = re.compile(r'cdnurl="(.*?)"')
self._emoji_encrypt_re = re.compile(r'encrypturl="(.*?)"')
self._emoji_thumb_re = re.compile(r'thumburl="(.*?)"')
# 修改为项目根目录下的 static/images # 修改为项目根目录下的 static/images
self.image_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "images") self.image_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "images")
@@ -122,118 +119,6 @@ class MessageStorage:
'error': str(e) 'error': str(e)
} }
def _extract_emoji_download_info(self, xml_content: str) -> Dict:
if not xml_content:
return {}
aeskey_match = self._aeskey_re.search(xml_content)
if not aeskey_match:
return {}
url_match = (
self._emoji_cdn_re.search(xml_content)
or self._emoji_encrypt_re.search(xml_content)
or self._emoji_thumb_re.search(xml_content)
)
if not url_match:
return {}
md5_match = re.search(r'md5="(.*?)"', xml_content)
length_match = re.search(r'len="(\d+)"', xml_content)
return {
"aeskey": aeskey_match.group(1),
"url": url_match.group(1),
"md5": md5_match.group(1) if md5_match else "",
"length": int(length_match.group(1)) if length_match else 0,
}
async def _process_emoji_record(self, msg_record: Dict) -> bool:
if not self.client:
logger.warning("表情消息未处理,微信客户端未初始化")
return False
message_id = int(msg_record.get("message_id") or 0)
room_id = msg_record.get("group_id") or "unknown"
sender = msg_record.get("sender", "")
xml_content = msg_record.get("attachment_url") or msg_record.get("message_xml") or ""
emoji_info = self._extract_emoji_download_info(xml_content)
if not emoji_info:
logger.warning(f"表情消息解析失败,未提取到下载参数: msg_id={message_id}")
return False
try:
base64_str = await self.client.download_cdn_file(emoji_info["aeskey"], emoji_info["url"])
if not base64_str:
logger.warning(f"表情下载返回为空: msg_id={message_id}")
return False
group_dir = os.path.join(self.image_dir, room_id)
file_stem = f"{message_id}_emoji"
file_path = await self.client.base64_to_file_autoext(
base64_str,
file_stem=file_stem,
file_path=group_dir,
default_ext=".bin",
)
ext = os.path.splitext(file_path)[1] or ".bin"
file_name = os.path.basename(file_path)
web_path = f"/static/images/{room_id}/{file_name}"
updated = self.message_db.update_message_image_file_path(message_id, web_path)
if updated:
if emoji_info.get("md5"):
self.emoji_asset_db.save_asset({
"md5": emoji_info.get("md5", ""),
"total_length": emoji_info.get("length", 0),
"file_path": web_path,
"file_ext": ext,
"source_message_id": message_id,
"source_chatroom_id": room_id,
"source_wxid": sender,
})
else:
logger.warning(f"表情已落盘但缺少md5跳过资产入库: msg_id={message_id}")
logger.info(
f"表情处理成功: msg_id={message_id}, roomid={room_id}, ext={ext}, path={web_path}"
)
else:
logger.warning(
f"表情文件已落盘但数据库未更新: msg_id={message_id}, roomid={room_id}, path={web_path}"
)
return updated
except Exception as e:
logger.exception(f"处理表情消息出错: msg_id={message_id}, error={e}")
return False
async def process_pending_emojis(self, minutes_ago: int = 1440, batch_size: int = 20):
"""定时处理最近未落盘的表情消息"""
if not self.client:
logger.warning("微信客户端未初始化,跳过表情处理")
return
try:
pending_messages = self.message_db.get_pending_emoji_messages(minutes_ago, batch_size)
if not pending_messages:
logger.debug(f"未发现待处理的表情消息(最近{minutes_ago}分钟)")
return
logger.info(f"开始处理 {len(pending_messages)} 条待处理表情消息")
success_count = 0
fail_count = 0
for msg_record in pending_messages:
if await self._process_emoji_record(msg_record):
success_count += 1
else:
fail_count += 1
logger.info(
f"表情处理完成: 成功={success_count}, 失败={fail_count}, 总计={len(pending_messages)}"
)
except Exception as e:
logger.exception(f"定时处理表情任务出错: {e}")
def process_image(self, msg: WxMessage): def process_image(self, msg: WxMessage):
"""图片消息已通过 archive_message 存入数据库,不再实时处理 """图片消息已通过 archive_message 存入数据库,不再实时处理
改为定时任务批量处理,减少对主流程的影响和数据库锁竞争 改为定时任务批量处理,减少对主流程的影响和数据库锁竞争

View File

@@ -1,7 +1,6 @@
import base64 import base64
import io import io
import os import os
import imghdr
import aiofiles import aiofiles
import aiohttp import aiohttp
@@ -13,19 +12,19 @@ from wechat_ipad.client.base import WechatAPIClientBase, Proxy
class ToolMixin(WechatAPIClientBase): class ToolMixin(WechatAPIClientBase):
async def download_cdn_file(self, aeskey: str, file_url: str) -> str: async def download_image(self, aeskey: str, cdnmidimgurl: str) -> str:
"""通用 CDN 文件下载 """CDN下载高清图片
{ {
"Wxid": "string", "Wxid": "string",
"FileNo": "string", "FileNo": "string",
"FileAesKey": "string" "FileAesKey": "string"
} }
Args: Args:
aeskey (str): 文件的AES密钥 aeskey (str): 图片的AES密钥
file_url (str): 文件的CDN URL cdnmidimgurl (str): 图片的CDN URL
Returns: Returns:
str: 文件的base64编码字符串 str: 图片的base64编码字符串
Raises: Raises:
UserLoggedOut: 未登录时调用 UserLoggedOut: 未登录时调用
@@ -35,7 +34,7 @@ class ToolMixin(WechatAPIClientBase):
raise UserLoggedOut("请先登录") raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
json_param = {"Wxid": self.wxid, "FileAesKey": aeskey, "FileNo": file_url} json_param = {"Wxid": self.wxid, "FileAesKey": aeskey, "FileNo": cdnmidimgurl}
response = await session.post(f'http://{self.ip}:{self.port}/api/Tools/CdnDownloadImage', json=json_param) response = await session.post(f'http://{self.ip}:{self.port}/api/Tools/CdnDownloadImage', json=json_param)
json_resp = await response.json() json_resp = await response.json()
@@ -44,10 +43,6 @@ class ToolMixin(WechatAPIClientBase):
else: else:
self.error_handler(json_resp) self.error_handler(json_resp)
async def download_image(self, aeskey: str, cdnmidimgurl: str) -> str:
"""CDN下载高清图片。"""
return await self.download_cdn_file(aeskey, cdnmidimgurl)
async def download_voice(self, msg_id: str, voiceurl: str, length: int) -> str: async def download_voice(self, msg_id: str, voiceurl: str, length: int) -> str:
"""下载语音文件。 """下载语音文件。
@@ -270,42 +265,6 @@ class ToolMixin(WechatAPIClientBase):
except Exception as e: except Exception as e:
return False return False
@staticmethod
def guess_file_extension(file_bytes: bytes, default_ext: str = ".bin") -> str:
"""根据文件头猜测扩展名。"""
if not file_bytes:
return default_ext
if file_bytes.startswith(b"GIF87a") or file_bytes.startswith(b"GIF89a"):
return ".gif"
if file_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
return ".png"
if file_bytes.startswith(b"RIFF") and file_bytes[8:12] == b"WEBP":
return ".webp"
if file_bytes.startswith(b"\xff\xd8\xff"):
return ".jpg"
detected = imghdr.what(None, h=file_bytes)
if detected:
return f".{detected}"
return default_ext
@staticmethod
async def base64_to_file_autoext(base64_str: str, file_stem: str, file_path: str,
default_ext: str = ".bin") -> str:
"""将base64写入文件并自动识别扩展名。"""
os.makedirs(file_path, exist_ok=True)
if ',' in base64_str:
base64_str = base64_str.split(',')[1]
file_bytes = base64.b64decode(base64_str)
ext = ToolMixin.guess_file_extension(file_bytes, default_ext=default_ext)
full_path = os.path.join(file_path, f"{file_stem}{ext}")
async with aiofiles.open(full_path, 'wb') as f:
await f.write(file_bytes)
return full_path
@staticmethod @staticmethod
async def file_to_base64(file_path: str) -> str: async def file_to_base64(file_path: str) -> str:
"""将文件转换为base64字符串。 """将文件转换为base64字符串。