本次提交包含多项核心更新: 1. 新增微信4.0浅色主题UI模板资源与说明文档,补充了联系人图标、新的朋友入口等四个UI元素的图像匹配模板 2. 重构UI驱动层,将原2375行的单文件拆分为5个职责清晰的子驱动类,提升代码可维护性 3. 新增批量群发消息与聊天记录导出的完整数据模型、路由与后台协程,支持幂等校验与风控配置 4. 为所有业务接口新增post_verify校验逻辑,补充了verified字段返回操作结果 5. 优化媒体文件解密逻辑,修复了导出功能中的数据库错误处理与分辨率硬编码问题 6. 更新版本配置,将媒体发送功能标记为已落地,调整了能力列表与配置项
501 lines
17 KiB
Python
501 lines
17 KiB
Python
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): 未找到微信消息 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)
|
||
"""
|
||
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 用默认参数捕获 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 等)
|
||
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,
|
||
)
|