from __future__ import annotations import asyncio import logging from fastapi import APIRouter from woc_bridge.config import _require_db_reader, _require_xdotool, _require_send_queue from woc_bridge.db.coordinator import _check_db_readable, with_db_retry from woc_bridge.routes.send import _resolve_display_name from woc_bridge.models import ( BridgeError, Contact, ContactsResponse, GroupsResponse, GroupMembersResponse, SetRemarkRequest, SetRemarkResponse, AddFriendRequest, AddFriendResponse, ) 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, ) return SetRemarkResponse(success=True, error=None) # --------------------------------------------------------------------------- # 路由: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() 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, ) return AddFriendResponse(success=True, error=None)