Files
abot/admin/dashboard/blueprints/contacts.py

855 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import os
import re
import threading
import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import quote
from flask import Blueprint, render_template, jsonify, request, current_app, redirect, send_file
from .auth import login_required
from loguru import logger
# 创建联系人管理蓝图
contacts_bp = Blueprint('contacts', __name__, url_prefix='/contacts')
# 创建线程池
message_thread_pool = ThreadPoolExecutor(max_workers=10, thread_name_prefix="message_sender_")
# 创建共享的事件循环
shared_loop = None
loop_lock = threading.Lock()
contacts_refresh_lock = threading.Lock()
contacts_refresh_running = False
_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():
"""获取或创建共享的事件循环"""
global shared_loop
with loop_lock:
if shared_loop is None:
shared_loop = asyncio.new_event_loop()
# 在新线程中运行事件循环
def run_loop():
asyncio.set_event_loop(shared_loop)
shared_loop.run_forever()
loop_thread = threading.Thread(target=run_loop, daemon=True)
loop_thread.start()
return shared_loop
def send_message_in_thread(func, *args, **kwargs):
"""使用共享事件循环发送消息"""
def run():
try:
loop = get_or_create_loop()
# 创建异步任务
async def send():
try:
await func(*args, **kwargs)
except Exception as e:
logger.error(f"发送消息失败: {e}")
# 在共享事件循环中运行任务
future = asyncio.run_coroutine_threadsafe(send(), loop)
# 等待任务完成,设置超时时间
future.result(timeout=30)
except Exception as e:
logger.error(f"消息发送任务执行失败: {e}")
# 使用线程池提交任务
message_thread_pool.submit(run)
def run_member_context_refresh_in_thread(func, *args, **kwargs):
"""在线程池中异步刷新成员交互摘要,避免阻塞请求线程"""
def run():
try:
func(*args, **kwargs)
except Exception as e:
logger.error(f"成员交互摘要后台刷新失败: {e}")
message_thread_pool.submit(run)
def run_contacts_refresh_in_thread(server):
"""将通讯录刷新放到后台线程执行,避免阻塞后台请求与系统主链路。"""
global contacts_refresh_running
with contacts_refresh_lock:
if contacts_refresh_running:
return False
contacts_refresh_running = True
def run():
global contacts_refresh_running
try:
logger.info("通讯录后台刷新任务开始执行")
asyncio.run(server.robot.refresh_contacts_db())
logger.info("通讯录后台刷新任务执行完成")
except Exception as e:
logger.error(f"通讯录后台刷新任务执行失败: {e}")
finally:
with contacts_refresh_lock:
contacts_refresh_running = False
message_thread_pool.submit(run)
return True
def _safe_text(value):
return "" if value is None else str(value)
def _parse_app_message_payload(content: str):
payload = {
"title": "",
"description": "",
"url": "",
"app_type": ""
}
if not content:
return payload
text = _safe_text(content).strip()
if not text.startswith("<"):
payload["description"] = text
return payload
try:
root = ET.fromstring(text)
payload["title"] = _safe_text(root.findtext('.//title')).strip()
payload["description"] = _safe_text(root.findtext('.//des')).strip()
payload["url"] = _safe_text(root.findtext('.//url')).strip()
payload["app_type"] = _safe_text(root.findtext('.//type')).strip()
except Exception:
payload["description"] = text
return payload
def _parse_sys_message_payload(content: str):
payload = {
"sysmsg_type": "",
"summary": "",
"replace_msg": "",
"session": "",
"msgid": "",
"newmsgid": ""
}
text = _safe_text(content).strip()
if not text.startswith("<sysmsg"):
payload["summary"] = text
return payload
try:
root = ET.fromstring(text)
except Exception:
payload["summary"] = text
return payload
payload["sysmsg_type"] = _safe_text(root.attrib.get("type")).strip()
if payload["sysmsg_type"] == "revokemsg":
revoke_node = root.find("revokemsg")
if revoke_node is not None:
payload["session"] = _safe_text(revoke_node.findtext("session")).strip()
payload["msgid"] = _safe_text(revoke_node.findtext("msgid")).strip()
payload["newmsgid"] = _safe_text(revoke_node.findtext("newmsgid")).strip()
payload["replace_msg"] = _safe_text(revoke_node.findtext("replacemsg")).strip()
payload["summary"] = payload["replace_msg"] or "撤回了一条消息"
return payload
payload["summary"] = _safe_text(root.findtext(".//content")).strip() or text
return payload
def _compact_media_caption(content: str, fallback: str) -> str:
text = _safe_text(content).strip()
if not text:
return fallback
if text.startswith("<"):
return fallback
return text
def _extract_emoji_meta(attachment_url: str, image_path: str):
text = _safe_text(attachment_url)
md5 = ""
total_length = 0
# 只接受 XML 中的参数,不做文件名或文件大小回退,避免参数污染。
if not text.startswith("<"):
return "", 0
try:
root = ET.fromstring(text)
emoji_node = root.find(".//emoji")
if emoji_node is None:
return "", 0
md5 = _safe_text(emoji_node.attrib.get("md5", "")).strip().lower()
for key in ("totallen", "total_len", "totalLen", "len"):
value = _safe_text(emoji_node.attrib.get(key, "")).strip()
if value.isdigit():
total_length = int(value)
break
except Exception:
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
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", ""))
content = _safe_text(raw_message.get("content")).strip()
image_path = _safe_text(raw_message.get("image_path")).strip()
attachment_url = _safe_text(raw_message.get("attachment_url")).strip()
message_thumb = _safe_text(raw_message.get("message_thumb")).strip()
self_wxid = _safe_text(getattr(server.robot, "wxid", "") or getattr(server.client, "wxid", "")).strip()
sender_name = sender or "未知发送者"
if chat_type == "group":
sender_name = server.contact_manager.get_group_name(target_wxid, sender) or sender_name
elif sender:
sender_name = server.contact_manager.get_nickname(sender) or sender_name
display_type = "text"
display_content = content
media_url = image_path or attachment_url or message_thumb
link_payload = None
if message_type == "3":
display_type = "image"
display_content = _compact_media_caption(content, "[图片]")
elif message_type in {"47", "1048625", "1090519089"}:
display_type = "image" if media_url else "text"
display_content = _compact_media_caption(content, "[表情]")
elif message_type == "34":
display_type = "voice"
display_content = _compact_media_caption(content, "[语音]")
elif message_type == "43":
display_type = "video"
display_content = _compact_media_caption(content, "[视频]")
elif message_type == "49":
app_payload = _parse_app_message_payload(content)
if app_payload.get("url") or app_payload.get("title"):
display_type = "link"
link_payload = app_payload
display_content = app_payload.get("title") or app_payload.get("description") or "[链接]"
else:
display_type = "text"
display_content = app_payload.get("description") or content or "[应用消息]"
elif message_type in {"10000", "10002"}:
display_type = "system"
sys_payload = _parse_sys_message_payload(content)
display_content = sys_payload.get("summary") or content or "[系统消息]"
link_payload = sys_payload
return {
"timestamp": _safe_text(raw_message.get("timestamp")),
"sender": sender,
"sender_name": sender_name,
"content": content,
"message_type": message_type,
"display_type": display_type,
"display_content": display_content,
"image_path": image_path,
"attachment_url": attachment_url,
"media_url": media_url,
"message_thumb": message_thumb,
"message_id": raw_message.get("message_id"),
"link_payload": link_payload,
"is_self": bool(self_wxid and sender == self_wxid),
"sysmsg_type": (link_payload or {}).get("sysmsg_type", "") if display_type == "system" else "",
}
def _build_dashboard_head_images(contact_manager):
"""构造后台可直接使用的头像地址映射。
说明:
1. 前端统一访问本蓝图的头像代理接口,这样可以优先命中本地缓存;
2. 头像 URL 哈希会拼到查询参数里,头像变更后浏览器会自然拉取最新版本;
3. 即便本地缓存暂时不存在,代理接口也还能回退到远端头像地址,不影响页面展示。
"""
result = {}
for wxid, remote_url in (contact_manager.get_all_head_images() or {}).items():
if not remote_url:
result[wxid] = ""
continue
version = contact_manager.get_head_image_version(wxid)
avatar_url = f"/contacts/api/avatar/{quote(str(wxid), safe='')}"
if version:
avatar_url = f"{avatar_url}?v={version}"
result[wxid] = avatar_url
return result
# 联系人管理页面
@contacts_bp.route('/')
@login_required
def contacts_management():
"""通讯录管理页面"""
return render_template('contacts_management.html')
# API路由
@contacts_bp.route('/api/all', methods=['GET'])
@login_required
def api_contacts_all():
"""获取所有联系人信息API"""
try:
server = current_app.dashboard_server
contacts = server.contact_manager.get_contacts()
return jsonify({
"success": True,
"data": {
"contacts": contacts
}
})
except Exception as e:
logger.error(f"获取所有联系人信息失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@contacts_bp.route('/api/statistics', methods=['GET'])
@login_required
def api_contacts_statistics():
"""获取联系人统计信息API"""
try:
server = current_app.dashboard_server
# 使用新的联系人分类方法获取统计信息
total, groups, personal, public, official = server.contact_manager.get_contact_statistics()
return jsonify({
"success": True,
"data": {
"total": total,
"groups": groups,
"personal": personal,
"public": public,
"official": official
}
})
except Exception as e:
logger.error(f"获取联系人统计信息失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@contacts_bp.route('/api/groups', methods=['GET'])
@login_required
def api_contacts_groups():
"""获取群组联系人信息API"""
try:
server = current_app.dashboard_server
group_contacts = server.contact_manager.get_group_contacts()
return jsonify({
"success": True,
"data": {
"groups": group_contacts
}
})
except Exception as e:
logger.error(f"获取群组联系人信息失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@contacts_bp.route('/api/personal', methods=['GET'])
@login_required
def api_contacts_personal():
"""获取个人联系人信息API"""
try:
server = current_app.dashboard_server
personal_contacts = server.contact_manager.get_personal_contacts()
return jsonify({
"success": True,
"data": {
"personal": personal_contacts
}
})
except Exception as e:
logger.error(f"获取个人联系人信息失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@contacts_bp.route('/api/official', methods=['GET'])
@login_required
def api_contacts_official():
"""获取公众号联系人信息API"""
try:
server = current_app.dashboard_server
official_accounts = server.contact_manager.get_official_accounts()
return jsonify({
"success": True,
"data": {
"official": official_accounts
}
})
except Exception as e:
logger.error(f"获取公众号联系人信息失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@contacts_bp.route('/api/public', methods=['GET'])
@login_required
def api_contacts_public():
"""获取公共好友信息API"""
try:
server = current_app.dashboard_server
public_contacts = server.contact_manager.get_public_contacts()
return jsonify({
"success": True,
"data": {
"public": public_contacts
}
})
except Exception as e:
logger.error(f"获取公共好友信息失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@contacts_bp.route('/api/head_images', methods=['GET'])
@login_required
def api_head_images():
"""获取联系人头像信息API"""
try:
server = current_app.dashboard_server
# 后台页拿到的是“可展示地址”而不是原始远端 URL
# 这样通讯录页会优先读本地缓存,头像变化时也能自动刷新最新版本。
head_images = _build_dashboard_head_images(server.contact_manager)
return jsonify({
"success": True,
"data": {
"head_images": head_images
}
})
except Exception as e:
logger.error(f"获取联系人头像信息失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@contacts_bp.route('/api/avatar/<path:wxid>', methods=['GET'])
@login_required
def api_contact_avatar(wxid):
"""返回通讯录头像,本地缓存优先,远端地址兜底。"""
try:
server = current_app.dashboard_server
# 先尝试把头像补齐到本地缓存。
# 这样页面首次访问某个联系人时,也能顺手把缓存热起来。
cached_path = server.contact_manager.ensure_head_image_cached(wxid)
if cached_path and os.path.exists(cached_path):
return send_file(cached_path, conditional=True, max_age=86400)
remote_url = str(server.contact_manager.get_head_image(wxid) or "").strip()
if remote_url:
return redirect(remote_url, code=302)
return jsonify({"success": False, "error": "头像不存在"}), 404
except Exception as e:
logger.error(f"读取联系人头像失败 wxid={wxid}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@contacts_bp.route('/api/group_members/<roomid>', methods=['GET'])
@login_required
def api_group_members(roomid):
"""获取指定群的成员列表API
Args:
roomid: 群ID
"""
try:
server = current_app.dashboard_server
group_members = server.contact_db.get_chatroom_small_member_list(roomid)
context_enabled = bool(server.member_context_service) and server.member_context_service.is_group_enabled(roomid)
if context_enabled:
contexts = server.member_context_db.list_group_member_contexts(roomid)
context_map = {item.get("wxid"): item for item in contexts}
for member in group_members:
context = context_map.get(member.get("wxid"), {})
member["activity_level"] = context.get("activity_level", "")
member["response_style_hint"] = context.get("response_style_hint", "")
member["summary_text"] = context.get("summary_text", "")
member["last_profiled_at"] = context.get("last_profiled_at", "")
else:
for member in group_members:
member["activity_level"] = ""
member["response_style_hint"] = ""
member["summary_text"] = ""
member["last_profiled_at"] = ""
return jsonify({
"success": True,
"data": {
"members": group_members,
"member_context_enabled": context_enabled
}
})
except Exception as e:
logger.error(f"获取群成员列表失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@contacts_bp.route('/api/group_profile/<roomid>', methods=['GET'])
@login_required
def api_group_profile(roomid):
"""获取指定群的资料信息(群公告、群主、管理员、成员数)"""
try:
server = current_app.dashboard_server
# 直接复用联系人库中已有身份字段,按群聚合成页面可展示的资料结构。
profile = server.contact_db.get_chatroom_profile(roomid)
return jsonify({
"success": True,
"data": profile
})
except Exception as e:
logger.error(f"获取群资料失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@contacts_bp.route('/api/group_profile/<roomid>/sync_announcement', methods=['POST'])
@login_required
def api_sync_group_announcement(roomid):
"""手动同步指定群公告(调用 /Group/GetChatRoomInfoDetail"""
try:
server = current_app.dashboard_server
if not roomid:
return jsonify({"success": False, "error": "缺少群ID"}), 400
if not getattr(server, "robot", None) or not getattr(server.robot, "ipad_bot", None):
return jsonify({"success": False, "error": "机器人实例未初始化"}), 503
async def fetch_and_merge():
# 先拉基础群信息,再拉 Detail 信息,最后合并,避免只用 Detail 导致字段不完整。
base_info = await server.robot.ipad_bot.get_chatroom_info(roomid)
detail_info = await server.robot.ipad_bot.get_chatroom_announce(roomid)
merged_info = dict(base_info or {})
detail_contact = None
if isinstance(detail_info, dict):
contact_list = detail_info.get("ContactList")
if isinstance(contact_list, list) and contact_list:
first = contact_list[0]
if isinstance(first, dict):
detail_contact = first
if detail_contact:
merged_info.update(detail_contact)
if isinstance(detail_info, dict):
merged_info.update(detail_info)
# 统一公告字段命名,供 contacts_db.save_chatroom_info 直接提取入库。
announcement = (
merged_info.get("ChatRoomAnnouncement")
or merged_info.get("Announcement")
or merged_info.get("Annoucement")
or merged_info.get("AnnouncementContent")
or merged_info.get("chatRoomAnnouncement")
)
if announcement:
merged_info["ChatRoomAnnouncement"] = announcement
# 保底补上群ID避免少数字段缺失导致无法更新到对应群。
if not merged_info.get("UserName"):
merged_info["UserName"] = roomid
return merged_info
merged_info = asyncio.run(fetch_and_merge())
if not merged_info:
return jsonify({"success": False, "error": "获取群详情失败"}), 500
save_ok = server.contact_db.save_chatroom_info(merged_info)
if not save_ok:
return jsonify({"success": False, "error": "保存群公告失败"}), 500
profile = server.contact_db.get_chatroom_profile(roomid)
return jsonify({
"success": True,
"message": "群公告同步成功",
"data": {
"profile": profile
}
})
except Exception as e:
logger.error(f"手动同步群公告失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@contacts_bp.route('/api/group_member_context/<roomid>/<wxid>', methods=['GET'])
@login_required
def api_group_member_context(roomid, wxid):
"""获取群成员交互摘要"""
try:
server = current_app.dashboard_server
if not server.member_context_service:
return jsonify({"success": False, "error": "成员交互摘要插件未加载"}), 503
if not server.member_context_service.is_group_enabled(roomid):
return jsonify({"success": False, "error": "该群未启用成员交互摘要功能"}), 403
context = server.member_context_db.get_member_context(roomid, wxid)
return jsonify({
"success": True,
"data": {
"context": context,
"ready": bool(context)
}
})
except Exception as e:
logger.error(f"获取群成员交互摘要失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@contacts_bp.route('/api/group_member_context/refresh', methods=['POST'])
@login_required
def api_refresh_group_member_context():
"""刷新群成员交互摘要"""
try:
server = current_app.dashboard_server
if not server.member_context_service:
return jsonify({"success": False, "error": "成员交互摘要插件未加载"}), 503
data = request.json or {}
roomid = data.get("roomid")
wxid = data.get("wxid")
if roomid and wxid:
if not server.member_context_service.is_group_enabled(roomid):
return jsonify({"success": False, "error": "该群未启用成员交互摘要功能"}), 403
run_member_context_refresh_in_thread(server.member_context_service.refresh_member_context, roomid, wxid)
return jsonify({"success": True, "message": "成员交互摘要刷新任务已提交"})
if roomid:
if not server.member_context_service.is_group_enabled(roomid):
return jsonify({"success": False, "error": "该群未启用成员交互摘要功能"}), 403
run_member_context_refresh_in_thread(server.member_context_service.refresh_group_contexts, roomid)
return jsonify({"success": True, "message": "本群成员交互摘要刷新任务已提交"})
run_member_context_refresh_in_thread(server.member_context_service.refresh_all_chatrooms)
return jsonify({"success": True, "message": "全量成员交互摘要刷新任务已提交"})
except Exception as e:
logger.error(f"刷新群成员交互摘要失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@contacts_bp.route('/api/update', methods=['POST'])
@login_required
def api_contacts_update():
"""更新通讯录信息API"""
try:
server = current_app.dashboard_server
# 通讯录刷新改成后台异步任务:
# 1. 远端拉联系人详情 + 群成员详情 + 头像缓存同步都可能较慢;
# 2. 若接口同步等待,会直接卡住后台请求线程,影响整体使用体验;
# 3. 这里提交任务后立刻返回,等后台线程慢慢完成刷新。
submitted = run_contacts_refresh_in_thread(server)
if submitted:
return jsonify({"success": True, "message": "通讯录更新任务已提交,请稍后手动刷新查看结果"})
return jsonify({"success": True, "message": "通讯录更新任务已在执行中,请稍后再刷新"})
except Exception as e:
logger.error(f"更新通讯录失败: {e}")
return jsonify({"success": False, "message": f"更新通讯录失败: {str(e)}"}), 500
@contacts_bp.route('/api/recent_messages', methods=['GET'])
@login_required
def api_recent_messages():
"""获取最近聊天消息"""
try:
server = current_app.dashboard_server
wxid = _safe_text(request.args.get("wxid")).strip()
chat_type = _safe_text(request.args.get("chat_type")).strip() or "personal"
limit = min(max(int(request.args.get("limit", 20)), 1), 50)
if not wxid:
return jsonify({"success": False, "message": "缺少聊天对象"}), 400
if chat_type == "group":
raw_messages = server.message_storage.get_recent_group_chat_messages(wxid, limit=limit)
history_tip = f"最近 {limit} 条群消息"
else:
raw_messages = server.message_storage.get_recent_personal_messages(wxid, limit=limit)
history_tip = f"最近 {limit} 条已归档消息(私聊历史可能不完整)"
messages = [
_normalize_recent_message(server, item, chat_type, wxid)
for item in raw_messages
]
return jsonify({
"success": True,
"data": {
"messages": messages,
"chat_type": chat_type,
"history_tip": history_tip
}
})
except Exception as e:
logger.exception(f"获取最近聊天消息失败: {e}")
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)
message_storage = getattr(server, "message_storage", None)
if not message_storage:
return jsonify({"success": False, "message": "消息存储未初始化"}), 503
if hasattr(message_storage, "get_recent_emoji_assets"):
records = message_storage.get_recent_emoji_assets(limit=limit)
elif hasattr(message_storage, "message_db") and hasattr(message_storage.message_db, "get_recent_emoji_assets"):
records = message_storage.message_db.get_recent_emoji_assets(limit=limit)
else:
logger.error("当前 message_storage 不支持 get_recent_emoji_assets")
return jsonify({"success": False, "message": "当前消息存储版本不支持表情库"}), 500
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():
"""发送消息API
支持的消息类型:
- text: 文本消息
- image: 图片消息
- voice: 语音消息
- video: 视频消息
- link: 链接消息
"""
try:
data = request.form if request.files else request.json
wxid = data.get('wxid')
msg_type = data.get('type')
content = data.get('content')
if not wxid or not msg_type:
return jsonify({'success': False, 'message': '缺少必要参数'})
# 获取机器人实例
server = current_app.dashboard_server
if not server or not server.client:
return jsonify({'success': False, 'message': '机器人未初始化'})
# 根据消息类型发送消息
if msg_type == 'text':
send_message_in_thread(server.client.send_text_message, wxid, content)
return jsonify({
'success': True,
'message': '消息发送中'
})
elif msg_type == 'image':
if 'file' not in request.files:
return jsonify({'success': False, 'message': '未上传文件'})
file = request.files['file']
send_message_in_thread(server.client.send_image_message, wxid, file.read())
return jsonify({
'success': True,
'message': '消息发送中'
})
elif msg_type == 'voice':
if 'file' not in request.files:
return jsonify({'success': False, 'message': '未上传文件'})
file = request.files['file']
if file.filename.endswith('.mp3'):
format_str = "mp3"
elif file.filename.endswith('.wav'):
format_str = "wav"
else:
return jsonify({
'success': False,
'message': '不支持的音频格式'
})
send_message_in_thread(server.client.send_voice_message, wxid, file.read(), format=format_str)
return jsonify({
'success': True,
'message': '消息发送中'
})
elif msg_type == 'video':
if 'file' not in request.files:
return jsonify({'success': False, 'message': '未上传文件'})
file = request.files['file']
send_message_in_thread(server.client.send_video_message, wxid, file.read())
return jsonify({
'success': True,
'message': '消息发送中'
})
elif msg_type == 'link':
url = content.get('url')
title = content.get('title', '')
description = content.get('description', '')
send_message_in_thread(server.client.send_link_message, wxid, url, title, description)
return jsonify({
'success': True,
'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 re.fullmatch(r"[0-9a-f]{16,64}", md5) or total_length <= 0:
return jsonify({'success': False, 'message': '缺少表情 md5 或长度'})
try:
loop = get_or_create_loop()
future = asyncio.run_coroutine_threadsafe(server.client.send_emoji_message(wxid, md5, total_length), loop)
future.result(timeout=20)
except Exception as e:
logger.error(f"发送表情失败 md5={md5} len={total_length} wxid={wxid}: {e}")
return jsonify({'success': False, 'message': f'表情发送失败: {str(e)}'}), 500
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