WechatOnCloud/bridge/woc_bridge/routes/contacts.py

501 lines
17 KiB
Python
Raw Normal View History

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,
_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): 未找到微信消息 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,
)
# 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): 未找到微信消息 DBHTTP 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)
"""
if not req.stranger_wxid:
raise BridgeError(code="INVALID_PARAMS", message="stranger_wxid 不能为空")
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:
# lambda 用默认参数捕获 reqSendQueue.enqueue 延迟执行,避免变量被覆盖)
await send_queue.enqueue(
lambda req=req: xdotool.accept_friend_request(
stranger_wxid=req.stranger_wxid,
nickname=req.nickname or "",
)
)
except BridgeError:
# 透传 BridgeErrorRATE_LIMITED / SEND_FAILED / WINDOW_NOT_FOUND 等)
raise
except Exception as e:
raise BridgeError(code="SEND_FAILED", message=f"通过好友申请失败: {e}")
logger.info(
"friends/accept: wxid=%s nickname=%s → UI 操作完成",
req.stranger_wxid, req.nickname,
)
# Task 10: 复用 _verify_accept_with_retry 校验好友是否真正通过
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",
req.stranger_wxid, exc,
)
verified = False
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:
"""更新自动通过规则配置(热生效)。"""
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()
return config
@router.get("/api/friends/auto_accept/status", response_model=AutoAcceptStatus)
async def get_auto_accept_status() -> AutoAcceptStatus:
"""查询自动通过运行状态。"""
watcher = _require_friend_watcher()
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,
)