@@ -403,46 +403,9 @@ def api_send_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:
|
||||
return jsonify({'success': False, 'message': '不支持的消息类型'})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"发送消息失败: {e}")
|
||||
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
|
||||
|
||||
@@ -11,7 +11,6 @@ from flask import Flask, send_from_directory
|
||||
from loguru import logger
|
||||
|
||||
from db.contacts_db import ContactsDBOperator
|
||||
from db.emoji_asset_db import EmojiAssetDBOperator
|
||||
from db.member_context_db import MemberContextDBOperator
|
||||
from db.message_storage import MessageStorageDB
|
||||
from db.stats_db import StatsDBOperator
|
||||
@@ -44,7 +43,6 @@ class DashboardServer:
|
||||
self.db_manager = robot_instance.db_manager
|
||||
self.stats_db = StatsDBOperator(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.member_context_db = MemberContextDBOperator(self.db_manager)
|
||||
self.task_db: TaskDBOperator = TaskDBOperator(self.db_manager)
|
||||
|
||||
@@ -190,9 +190,9 @@
|
||||
<el-button size="mini" type="success" @click="refreshCurrentGroupContexts">刷新本群摘要</el-button>
|
||||
</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 label="成员" min-width="280" show-overflow-tooltip>
|
||||
<el-table-column label="成员" min-width="300">
|
||||
<template slot-scope="scope">
|
||||
<div class="entity-cell">
|
||||
<el-avatar size="small" :src="getHeadImage(scope.row.wxid)" @error="() => true">
|
||||
@@ -205,7 +205,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</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">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini" :type="scope.row.status === 1 ? 'success' : 'info'">
|
||||
@@ -213,14 +213,14 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</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>
|
||||
</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>
|
||||
</el-table-column>
|
||||
<el-table-column label="后台摘要" width="110" align="center">
|
||||
<el-table-column label="后台摘要" width="130" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="primary" plain @click="openMemberContextDialog(scope.row)">查看</el-button>
|
||||
</template>
|
||||
@@ -390,7 +390,6 @@
|
||||
<div class="message-content">
|
||||
<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 === '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 === '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>
|
||||
@@ -400,31 +399,12 @@
|
||||
{% endraw %}
|
||||
</div>
|
||||
<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>
|
||||
<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="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-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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -471,7 +451,6 @@
|
||||
memberContextDialogVisible: false, memberContextLoading: false, memberContext: null, currentContextMember: {},
|
||||
memberContextEnabled: false,
|
||||
chatDialogVisible: false, currentChatUser: null, messageInput: '', chatMessages: [],
|
||||
emojiPanelVisible: false, emojiAssetsLoading: false, emojiAssets: [],
|
||||
linkDialogVisible: false,
|
||||
linkForm: { url: '', title: '', description: '' }
|
||||
};
|
||||
@@ -608,13 +587,7 @@
|
||||
this.$message.error('刷新成员交互摘要失败');
|
||||
}).finally(() => { this.memberContextLoading = false; });
|
||||
},
|
||||
openChatDialog(user) {
|
||||
this.currentChatUser = user;
|
||||
this.chatDialogVisible = true;
|
||||
this.chatMessages = [];
|
||||
this.emojiPanelVisible = false;
|
||||
this.loadEmojiAssets();
|
||||
},
|
||||
openChatDialog(user) { this.currentChatUser = user; this.chatDialogVisible = true; this.chatMessages = []; },
|
||||
async sendTextMessage() {
|
||||
if (!this.messageInput.trim()) return;
|
||||
try {
|
||||
@@ -634,56 +607,6 @@
|
||||
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('发送视频失败'); }
|
||||
},
|
||||
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; },
|
||||
async sendLinkMessage() {
|
||||
if (!this.linkForm.url) { this.$message.warning('请输入链接'); return; }
|
||||
@@ -730,14 +653,6 @@
|
||||
.action-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.pagination-container { margin-top: 20px; text-align: right; }
|
||||
.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 {
|
||||
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;
|
||||
@@ -767,34 +682,5 @@
|
||||
.message-time { font-size: 12px; color: #94a3b8; margin-top: 5px; }
|
||||
.input-area { padding: 20px 0 0; }
|
||||
.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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -92,12 +92,6 @@
|
||||
<img v-if="scope.row.message_thumb" :src="scope.row.message_thumb" class="message-thumb" @click="showVideo(scope.row)">
|
||||
</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">
|
||||
{% raw %}{{ scope.row.content || `【消息类型: ${scope.row.message_type}】` }}{% endraw %}
|
||||
</div>
|
||||
@@ -133,10 +127,9 @@
|
||||
<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 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-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>
|
||||
</el-descriptions-item>
|
||||
|
||||
@@ -320,18 +313,12 @@
|
||||
this.selectedMessage = message;
|
||||
this.detailDialogVisible = true;
|
||||
},
|
||||
isEmojiMessage(message) {
|
||||
if (!message) return false;
|
||||
return String(message.message_type) === '47' || String(message.message_type) === '1090519089';
|
||||
},
|
||||
getMessageTypeName(type) {
|
||||
const typeMap = {
|
||||
1: '文本消息',
|
||||
3: '图片消息',
|
||||
43: '视频消息',
|
||||
47: '动画表情',
|
||||
49: '链接消息',
|
||||
1090519089: '大表情'
|
||||
49: '链接消息'
|
||||
};
|
||||
return typeMap[type] || `未知类型(${type})`;
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -338,22 +338,6 @@ class MessageStorageDB(BaseDBOperator):
|
||||
print(f"更新消息图片文件路径出错: {e}")
|
||||
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]:
|
||||
"""获取指定群组的按小时消息趋势数据
|
||||
|
||||
@@ -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
|
||||
5
main.py
5
main.py
@@ -150,11 +150,6 @@ def jobs(robot: Robot):
|
||||
if hasattr(robot, 'message_storage') and robot.message_storage:
|
||||
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__":
|
||||
|
||||
@@ -24,7 +24,6 @@ from utils.string_utils import remove_trailing_content
|
||||
from utils.wechat.contact_manager import ContactManager
|
||||
from utils.wechat.message_to_db import MessageStorage
|
||||
from wechat_ipad import WechatAPIClient
|
||||
from db.message_summary_db import MessageSummaryDBOperator
|
||||
|
||||
|
||||
class MessageSummaryPlugin(MessagePluginInterface):
|
||||
@@ -69,7 +68,6 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.message_storage = None
|
||||
self.summary_db = None
|
||||
self.revoke = None
|
||||
# 注册功能权限
|
||||
self.feature = self.register_feature()
|
||||
@@ -84,7 +82,6 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
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.message_storage = MessageStorage()
|
||||
self.summary_db = MessageSummaryDBOperator(context["db_manager"])
|
||||
|
||||
self.LOG.debug(f"初始化 {self.name} 插件成功")
|
||||
return True
|
||||
@@ -199,7 +196,6 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
|
||||
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)
|
||||
# 限制长度为15个字符
|
||||
@@ -210,47 +206,6 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
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]]:
|
||||
"""生成总结"""
|
||||
# Dify API配置
|
||||
@@ -422,18 +377,6 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
# 生成总结
|
||||
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:
|
||||
# 图片生成成功,发送图片
|
||||
await self.bot.send_image_message(group_id, Path(image_path))
|
||||
|
||||
@@ -5,13 +5,14 @@ import xml.etree.ElementTree as ET
|
||||
import concurrent.futures # 添加线程池支持
|
||||
import os
|
||||
import base64
|
||||
import imghdr
|
||||
|
||||
import re
|
||||
from threading import Lock
|
||||
from typing import Dict
|
||||
|
||||
from db.connection import DBConnectionManager
|
||||
from db.contacts_db import ContactsDBOperator
|
||||
from db.emoji_asset_db import EmojiAssetDBOperator
|
||||
from db.levels_db import LevelsDBOperator
|
||||
from db.message_storage import MessageStorageDB
|
||||
# 导入积分系统
|
||||
@@ -32,7 +33,6 @@ class MessageStorage:
|
||||
self.db_manager = DBConnectionManager.get_instance()
|
||||
self.message_db = MessageStorageDB(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)
|
||||
# 初始化本地缓存字典,使用 group_id 作为键
|
||||
@@ -60,9 +60,6 @@ class MessageStorage:
|
||||
# 正则(替代 XML 解析)
|
||||
self._aeskey_re = re.compile(r'aeskey="(.*?)"')
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
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):
|
||||
"""图片消息已通过 archive_message 存入数据库,不再实时处理
|
||||
改为定时任务批量处理,减少对主流程的影响和数据库锁竞争
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import imghdr
|
||||
import aiofiles
|
||||
|
||||
import aiohttp
|
||||
@@ -13,19 +12,19 @@ from wechat_ipad.client.base import WechatAPIClientBase, Proxy
|
||||
|
||||
|
||||
class ToolMixin(WechatAPIClientBase):
|
||||
async def download_cdn_file(self, aeskey: str, file_url: str) -> str:
|
||||
"""通用 CDN 文件下载。
|
||||
async def download_image(self, aeskey: str, cdnmidimgurl: str) -> str:
|
||||
"""CDN下载高清图片。
|
||||
{
|
||||
"Wxid": "string",
|
||||
"FileNo": "string",
|
||||
"FileAesKey": "string"
|
||||
}
|
||||
Args:
|
||||
aeskey (str): 文件的AES密钥
|
||||
file_url (str): 文件的CDN URL
|
||||
aeskey (str): 图片的AES密钥
|
||||
cdnmidimgurl (str): 图片的CDN URL
|
||||
|
||||
Returns:
|
||||
str: 文件的base64编码字符串
|
||||
str: 图片的base64编码字符串
|
||||
|
||||
Raises:
|
||||
UserLoggedOut: 未登录时调用
|
||||
@@ -35,7 +34,7 @@ class ToolMixin(WechatAPIClientBase):
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
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)
|
||||
json_resp = await response.json()
|
||||
|
||||
@@ -44,10 +43,6 @@ class ToolMixin(WechatAPIClientBase):
|
||||
else:
|
||||
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:
|
||||
"""下载语音文件。
|
||||
|
||||
@@ -270,42 +265,6 @@ class ToolMixin(WechatAPIClientBase):
|
||||
except Exception as e:
|
||||
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
|
||||
async def file_to_base64(file_path: str) -> str:
|
||||
"""将文件转换为base64字符串。
|
||||
|
||||
Reference in New Issue
Block a user