WechatOnCloud/bridge/woc_bridge/routes/contacts.py
Kris 102b98adea refactor: 完成项目包结构重构与基础模块搭建
本次提交将woc-bridge项目重构为模块化包结构,按职责拆分多个子域:
1. 新增models层定义所有Pydantic数据模型与统一错误体系
2. 拆分db/ui/messaging/routes等业务域模块
3. 实现基础API路由:状态查询、截图、登录、媒体获取等
4. 重构tools脚本的模块导入路径
5. 补充版本号与能力清单定义
6. 完善全局配置与依赖管理

整体完成项目从单文件脚本到可维护的包结构迁移,为后续功能开发打下基础。
2026-07-08 23:25:58 +08:00

309 lines
10 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.

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): 未找到微信消息 DBHTTP 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): 未找到微信消息 DBHTTP 500
BridgeError(DB_ENCRYPTED): DB 已加密HTTP 503
BridgeError(CONTACT_NOT_FOUND): 指定 wxid 不存在HTTP 404
Notes:
- 不存在的 wxid 返回 CONTACT_NOT_FOUND404与 DB 不可达错误区分
- 群聊 usernamexxx@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): 未找到微信消息 DBHTTP 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): 未找到微信消息 DBHTTP 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: SetRemarkRequestwxid 应与路径参数一致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)