from __future__ import annotations import asyncio import logging import time from fastapi import APIRouter from woc_bridge.config import ( _state, _require_db_reader, _require_xdotool, _require_send_queue, _require_rule_engine, _require_friend_watcher, ) from woc_bridge.db.coordinator import _check_db_readable, with_db_retry from woc_bridge.routes.send import _resolve_display_name, _display_name_cache from woc_bridge.messaging.friend_parser import parse_friend_request from woc_bridge.ui.drivers.contact import _verify_accept_with_retry from woc_bridge.models import ( BridgeError, Contact, ContactsResponse, GroupsResponse, GroupMembersResponse, SetRemarkRequest, SetRemarkResponse, AddFriendRequest, AddFriendResponse, AcceptRuleConfig, FriendRequestItem, FriendRequestsResponse, AcceptFriendRequest, AcceptFriendResponse, AutoAcceptStatus, ) logger = logging.getLogger("woc-bridge") router = APIRouter() # --------------------------------------------------------------------------- # 路由:GET /api/contacts # --------------------------------------------------------------------------- @router.get("/api/contacts", response_model=ContactsResponse) @with_db_retry async def get_contacts(keyword: str = "", limit: int = 50) -> ContactsResponse: """联系人列表查询(channels SessionAdapter / WizardAdapter 使用)。 Args: keyword: 模糊匹配 wxid / nickname / remark,空串返回全部 limit: 1~200,默认 50 Returns: ContactsResponse:含 contacts 列表 / total Raises: BridgeError(INVALID_PARAMS): limit 越界(HTTP 400) BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503) Notes: - 查询走 cp 快照 + sqlite3 同步阻塞,asyncio.to_thread 包装 - keyword 为空时仍走索引,不会全表扫描 """ if limit < 1 or limit > 200: raise BridgeError( code="INVALID_PARAMS", message=f"limit 必须在 1~200 之间,收到 {limit}", ) db_reader = _require_db_reader() # DB 加密时返回 DB_ENCRYPTED,而非空数据 await _check_db_readable() result = await asyncio.to_thread(db_reader.get_contacts, keyword, limit) contacts_list = result.get("contacts", []) logger.info( "contacts: keyword='%s' limit=%d → 返回 %d 条, total=%d", keyword, limit, len(contacts_list), result.get("total", 0), ) return ContactsResponse( contacts=[Contact(**c) for c in contacts_list], total=result.get("total", 0), ) # --------------------------------------------------------------------------- # 路由:GET /api/contacts/{wxid} # --------------------------------------------------------------------------- @router.get("/api/contacts/{wxid}", response_model=Contact) @with_db_retry async def get_contact_detail(wxid: str) -> Contact: """单条联系人详情查询。 Args: wxid: 路径参数,联系人 wxid(如 wxid_xxx 或 gh_xxx 公众号) Returns: Contact:单条联系人记录 Raises: BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503) BridgeError(CONTACT_NOT_FOUND): 指定 wxid 不存在(HTTP 404) Notes: - 不存在的 wxid 返回 CONTACT_NOT_FOUND(404),与 DB 不可达错误区分 - 群聊 username(xxx@chatroom)也可用本接口查(DbReader 视为联系人) """ db_reader = _require_db_reader() # DB 加密时返回 DB_ENCRYPTED,而非空数据 await _check_db_readable() result = await asyncio.to_thread(db_reader.get_contact_detail, wxid) if result is None: raise BridgeError( code="CONTACT_NOT_FOUND", message=f"未找到联系人: {wxid}", ) return Contact(**result) # --------------------------------------------------------------------------- # 路由:GET /api/groups # --------------------------------------------------------------------------- @router.get("/api/groups", response_model=GroupsResponse) @with_db_retry async def get_groups(limit: int = 50) -> GroupsResponse: """群聊列表查询。 Args: limit: 1~200,默认 50 Returns: GroupsResponse:含 groups 列表(每项为 Contact 结构)/ total Raises: BridgeError(INVALID_PARAMS): limit 越界(HTTP 400) BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503) Notes: - 群聊判定条件:username LIKE '%@chatroom' - 返回结构复用 Contact(群也存于 contact 表,字段相同) """ if limit < 1 or limit > 200: raise BridgeError( code="INVALID_PARAMS", message=f"limit 必须在 1~200 之间,收到 {limit}", ) db_reader = _require_db_reader() # DB 加密时返回 DB_ENCRYPTED,而非空数据 await _check_db_readable() result = await asyncio.to_thread(db_reader.get_groups, limit) groups_list = result.get("groups", []) logger.info( "groups: limit=%d → 返回 %d 个群, total=%d", limit, len(groups_list), result.get("total", 0), ) return GroupsResponse( groups=[Contact(**g) for g in groups_list], total=result.get("total", 0), ) # --------------------------------------------------------------------------- # 路由:GET /api/groups/{wxid}/members # --------------------------------------------------------------------------- @router.get("/api/groups/{wxid}/members", response_model=GroupMembersResponse) @with_db_retry async def get_group_members(wxid: str) -> GroupMembersResponse: """群成员列表查询。 Args: wxid: 路径参数,群聊 wxid(形如 xxxxx@chatroom) Returns: GroupMembersResponse:含 members 列表 / total / group_wxid Raises: BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503) Notes: - 群成员信息存于 group_members 表,DbReader 联表查询 - 若群 wxid 不存在或无成员记录,返回空列表而非错误 """ db_reader = _require_db_reader() # DB 加密时返回 DB_ENCRYPTED,而非空数据 await _check_db_readable() result = await asyncio.to_thread(db_reader.get_group_members, wxid) return GroupMembersResponse(**result) # --------------------------------------------------------------------------- # 路由:POST /api/contacts/{wxid}/remark (experimental) # --------------------------------------------------------------------------- @router.post("/api/contacts/{wxid}/remark", response_model=SetRemarkResponse) async def set_contact_remark(wxid: str, req: SetRemarkRequest) -> SetRemarkResponse: """修改好友备注名(experimental)。 UI 自动化路径:定位会话 → 点右上角"..."→ 备注 → 输入 → 完成。 Args: wxid: 路径参数,好友 wxid req: SetRemarkRequest(wxid 应与路径参数一致,req.wxid 优先) Returns: SetRemarkResponse Raises: BridgeError(INVALID_PARAMS): wxid/remark 为空(HTTP 400) BridgeError(WECHAT_NOT_LOGGED_IN): 未登录(HTTP 401) BridgeError(WINDOW_NOT_FOUND / SEND_FAILED) Notes: - experimental:菜单项与按钮坐标均为估算值 - 修改是否真正生效无法校验(无 UI 元素检测) """ # 路径参数 wxid 为权威标识(body 中不再含 wxid 字段) if not wxid: raise BridgeError(code="INVALID_PARAMS", message="wxid 不能为空") if not req.remark: raise BridgeError(code="INVALID_PARAMS", message="remark 不能为空") xdotool = _require_xdotool() send_queue = _require_send_queue() db_reader = _require_db_reader() login_state = await xdotool.detect_login_state() if login_state != "logged_in": raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法修改备注", ) # 解析 display_name(优先 req.display_name,其次 DB 查旧 remark/nickname,回退 wxid) display_name = await _resolve_display_name( db_reader, wxid, req.display_name ) try: await send_queue.enqueue( lambda: xdotool.set_contact_remark(wxid, req.remark, display_name) ) except BridgeError: raise except Exception as e: raise BridgeError( code="SEND_FAILED", message=f"修改备注失败: {e}", ) logger.info( "contacts/remark: wxid=%s remark=%s → UI 操作完成", wxid, req.remark, ) # Task 11: post_verify 校验备注是否真正写入 DB # 先失效 display_name 缓存(remark 已变,旧缓存值不再有效) _display_name_cache.pop(wxid, None) verified: bool | None = None try: verified = await xdotool._contact.post_verify_set_contact_remark( db_reader, wxid, req.remark ) except Exception as exc: logger.warning( "contacts/remark: post_verify 异常 (wxid=%s): %s", wxid, exc, ) verified = False return SetRemarkResponse(success=True, error=None, verified=verified) # --------------------------------------------------------------------------- # 路由:POST /api/friends/add (experimental) # --------------------------------------------------------------------------- @router.post("/api/friends/add", response_model=AddFriendResponse) async def add_friend(req: AddFriendRequest) -> AddFriendResponse: """搜索并添加好友(experimental)。 UI 自动化路径:激活窗口 → Ctrl+F 搜索关键词 → 点"添加联系人" → 可选填验证消息 → 点"发送"提交好友请求。 Args: req: AddFriendRequest Returns: AddFriendResponse Raises: BridgeError(INVALID_PARAMS): keyword 为空(HTTP 400) BridgeError(WECHAT_NOT_LOGGED_IN): 未登录(HTTP 401) BridgeError(WINDOW_NOT_FOUND / SEND_FAILED) Notes: - experimental:搜索结果与按钮坐标均为估算值 - 无法校验对方是否已收到请求 """ if not req.keyword: raise BridgeError(code="INVALID_PARAMS", message="keyword 不能为空") xdotool = _require_xdotool() send_queue = _require_send_queue() db_reader = _require_db_reader() login_state = await xdotool.detect_login_state() if login_state != "logged_in": raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法添加好友", ) try: await send_queue.enqueue( lambda: xdotool.add_friend(req.keyword, req.message) ) except BridgeError: raise except Exception as e: raise BridgeError( code="SEND_FAILED", message=f"添加好友失败: {e}", ) logger.info( "friends/add: keyword=%s message_len=%d → UI 操作完成", req.keyword, len(req.message) if req.message else 0, ) # Task 11: post_verify 校验好友申请是否已发送 verified: bool | None = None try: verified = await xdotool._contact.post_verify_add_friend( db_reader, req.keyword ) except Exception as exc: logger.warning( "friends/add: post_verify 异常 (keyword=%s): %s", req.keyword, exc, ) verified = False return AddFriendResponse(success=True, error=None, verified=verified) # --------------------------------------------------------------------------- # 路由:好友申请自动通过 # --------------------------------------------------------------------------- @router.get("/api/friends/requests", response_model=FriendRequestsResponse) @with_db_retry async def list_friend_requests(limit: int = 50) -> FriendRequestsResponse: """查询待处理的好友申请列表(从 fmessage 系统消息解析)。 Args: limit: 1~200,默认 50 Returns: FriendRequestsResponse:含 requests 列表 / total Raises: BridgeError(INVALID_PARAMS): limit 越界(HTTP 400) BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503) """ if limit < 1 or limit > 200: raise BridgeError( code="INVALID_PARAMS", message=f"limit 必须在 1~200 之间,收到 {limit}", ) db_reader = _require_db_reader() await _check_db_readable() # get_friend_requests_since(cursor_create_time, cursor_local_id, limit) # 查询全部待处理申请:cursor_create_time=0, cursor_local_id=0 result = await asyncio.to_thread( db_reader.get_friend_requests_since, 0, 0, limit ) if result is None: # DB 不可读(_ensure_decrypted 抛 BridgeError 已被 _check_db_readable 拦截, # 此处兜底防御) return FriendRequestsResponse(requests=[], total=0) # 解析 XML 提取结构化信息 requests = [] for item in result.get("requests", []): info = parse_friend_request( item["content"], item["create_time"], item["local_id"] ) if info is not None: requests.append(FriendRequestItem( stranger_wxid=info.stranger_wxid, nickname=info.nickname, verify_message=info.verify_message, scene=info.scene, create_time=info.create_time, )) return FriendRequestsResponse(requests=requests, total=len(requests)) @router.post("/api/friends/accept", response_model=AcceptFriendResponse) async def accept_friend(req: AcceptFriendRequest) -> AcceptFriendResponse: """手动通过好友申请(experimental)。 Args: req: AcceptFriendRequest Returns: AcceptFriendResponse Raises: BridgeError(INVALID_PARAMS): stranger_wxid 为空(HTTP 400) BridgeError(WECHAT_NOT_LOGGED_IN): 未登录(HTTP 401) BridgeError(WINDOW_NOT_FOUND / SEND_FAILED / RATE_LIMITED) """ logger.info( "friends/accept: 收到手动通过好友申请 wxid=%s nickname=%s", req.stranger_wxid, req.nickname, ) if not req.stranger_wxid: logger.warning("friends/accept: 参数校验失败 — stranger_wxid 为空") raise BridgeError(code="INVALID_PARAMS", message="stranger_wxid 不能为空") xdotool = _require_xdotool() send_queue = _require_send_queue() db_reader = _require_db_reader() t0 = time.perf_counter() login_state = await xdotool.detect_login_state() logger.info( "friends/accept: 登录态检测 → %s (%.0fms)", login_state, (time.perf_counter() - t0) * 1000, ) if login_state != "logged_in": logger.warning("friends/accept: 拒绝通过,登录态=%s", login_state) raise BridgeError( code="WECHAT_NOT_LOGGED_IN", message=f"当前登录态为 {login_state},无法通过好友申请", ) logger.info( "friends/accept: 入队 send_queue (pending=%d)", send_queue.pending_count(), ) t0 = time.perf_counter() try: # lambda 用默认参数捕获 req(SendQueue.enqueue 延迟执行,避免变量被覆盖) await send_queue.enqueue( lambda req=req: xdotool.accept_friend_request( stranger_wxid=req.stranger_wxid, nickname=req.nickname or "", ) ) except BridgeError: # 透传 BridgeError(RATE_LIMITED / SEND_FAILED / WINDOW_NOT_FOUND 等) logger.warning( "friends/accept: 入队/执行失败 BridgeError (%.0fms)", (time.perf_counter() - t0) * 1000, ) raise except Exception as e: logger.warning( "friends/accept: 入队/执行失败 %s: %s (%.0fms)", type(e).__name__, e, (time.perf_counter() - t0) * 1000, ) raise BridgeError(code="SEND_FAILED", message=f"通过好友申请失败: {e}") logger.info( "friends/accept: wxid=%s nickname=%s → UI 操作完成 (%.0fms)", req.stranger_wxid, req.nickname, (time.perf_counter() - t0) * 1000, ) # Task 10: 复用 _verify_accept_with_retry 校验好友是否真正通过 t0 = time.perf_counter() verified: bool | None = None try: verified = await _verify_accept_with_retry( db_reader, req.stranger_wxid ) except Exception as exc: logger.warning( "friends/accept: post_verify 异常 (wxid=%s): %s (%.0fms)", req.stranger_wxid, exc, (time.perf_counter() - t0) * 1000, ) verified = False logger.info( "friends/accept: ✓ wxid=%s verified=%s post_verify=%.0fms", req.stranger_wxid, verified, (time.perf_counter() - t0) * 1000, ) return AcceptFriendResponse(success=True, error=None, verified=verified) @router.get("/api/friends/auto_accept/config", response_model=AcceptRuleConfig) async def get_auto_accept_config() -> AcceptRuleConfig: """查询自动通过规则配置。""" rule_engine = _require_rule_engine() return await rule_engine.get_config() @router.put("/api/friends/auto_accept/config", response_model=AcceptRuleConfig) async def update_auto_accept_config(config: AcceptRuleConfig) -> AcceptRuleConfig: """更新自动通过规则配置(热生效)。""" logger.info( "friends/auto_accept/config: 更新配置 enabled=%s accept_all=%s", config.enabled, config.accept_all, ) rule_engine = _require_rule_engine() await rule_engine.update_config(config) # 同步更新 watcher 的 enabled 状态 watcher = _require_friend_watcher() watcher.set_enabled(config.enabled) # 启用 auto_accept 时确保 watcher 协程已启动 # (lifespan 仅在初始 auto_accept_enabled=True 时启动 watcher, # 用户通过 API 从 False 切到 True 时需补启动;start() 幂等,已运行则跳过) if config.enabled and not watcher.is_running: await watcher.start() # 关闭 auto_accept 时停止 watcher 协程,避免继续轮询 if not config.enabled and watcher.is_running: await watcher.stop() return config @router.get("/api/friends/auto_accept/status", response_model=AutoAcceptStatus) async def get_auto_accept_status() -> AutoAcceptStatus: """查询自动通过运行状态。""" watcher = _require_friend_watcher() db_reader = _require_db_reader() breaker = _state.accept_breaker db_readable = await asyncio.to_thread(db_reader.is_db_accessible) breaker_state = None if breaker is not None: try: breaker_state = breaker.state.value except Exception: pass logger.info( "friends/auto_accept/status: running=%s enabled=%s db_readable=%s breaker_state=%s " "processed=%d accepted=%d rejected=%d", watcher.is_running, watcher.is_enabled, db_readable, breaker_state, watcher.processed_count, watcher.accepted_count, watcher.rejected_count, ) return AutoAcceptStatus( running=watcher.is_running, enabled=watcher.is_enabled, processed_count=watcher.processed_count, accepted_count=watcher.accepted_count, rejected_count=watcher.rejected_count, last_processed_time=watcher.last_processed_time, cursor_create_time=watcher.cursor_create_time, cursor_local_id=watcher.cursor_local_id, db_readable=db_readable, breaker_state=breaker_state, )