import asyncio import os import re import threading import xml.etree.ElementTree as ET from concurrent.futures import ThreadPoolExecutor from flask import Blueprint, render_template, jsonify, request, current_app 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() _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 _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(" 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 "", } # 联系人管理页面 @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 head_images = server.contact_manager.get_all_head_images() 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/group_members/', 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/', 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//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//', 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 # 假设 contact_manager 有 update_contacts 方法用于同步通讯录 result = asyncio.run(server.robot.refresh_contacts_db()) if result: return jsonify({"success": True, "message": "通讯录更新成功"}) else: return jsonify({"success": False, "message": "通讯录更新失败"}), 500 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