WechatOnCloud/bridge/woc_bridge/routes/contacts.py
Kris 31953eedf4 feat: 新增好友申请自动通过功能
- 新增好友申请XML解析器,支持解析微信4.x Linux的fmessage系统消息
- 新增好友申请自动通过规则引擎,支持黑白名单、关键词、场景过滤
- 新增xdotool驱动的好友申请UI自动化流程
- 新增后台好友申请监听器,支持增量轮询与状态统计
- 新增配套API接口,支持查询申请列表、手动/自动通过、配置管理
- 新增多分辨率UI定位配置,适配1280x720和1920x1080分辨率
- 新增配置项支持自动通过功能的开关与规则配置
2026-07-16 13:03:58 +08:00

459 lines
16 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,
_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
from woc_bridge.messaging.friend_parser import parse_friend_request
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,
)
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)
# ---------------------------------------------------------------------------
# 路由:好友申请自动通过
# ---------------------------------------------------------------------------
@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()
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,
)
return AcceptFriendResponse(success=True, error=None)
@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,
)