2026-07-08 23:25:58 +08:00
|
|
|
|
"""联系人域模型:联系人 / 联系人列表 / 群聊列表 / 群成员。"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-07-16 13:03:58 +08:00
|
|
|
|
import asyncio
|
|
|
|
|
|
import enum
|
2026-07-17 18:10:31 +08:00
|
|
|
|
import logging
|
|
|
|
|
|
from typing import Optional
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger("woc-bridge")
|
2026-07-16 13:03:58 +08:00
|
|
|
|
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 联系人接口
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class Contact(BaseModel):
|
|
|
|
|
|
"""联系人。"""
|
|
|
|
|
|
|
|
|
|
|
|
wxid: str
|
|
|
|
|
|
nickname: str = ""
|
|
|
|
|
|
remark: str = ""
|
|
|
|
|
|
avatar_url: str = ""
|
|
|
|
|
|
type: str = Field(
|
|
|
|
|
|
default="person",
|
|
|
|
|
|
description="person / group / official / system",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 扩展字段:从 contact.db 的 contact 表读取
|
|
|
|
|
|
alias: Optional[str] = Field(default=None, description="微信号/别名")
|
|
|
|
|
|
encrypt_username: Optional[str] = Field(default=None, description="加密用户名")
|
|
|
|
|
|
quan_pin: Optional[str] = Field(default=None, description="全拼")
|
|
|
|
|
|
pin_yin_initial: Optional[str] = Field(default=None, description="拼音首字母")
|
|
|
|
|
|
big_head_url: Optional[str] = Field(default=None, description="高清头像 URL")
|
|
|
|
|
|
small_head_url: Optional[str] = Field(default=None, description="缩略头像 URL")
|
|
|
|
|
|
description: Optional[str] = Field(default=None, description="个性签名/描述")
|
|
|
|
|
|
local_type: Optional[int] = Field(default=None, description="联系人类型标记")
|
|
|
|
|
|
verify_flag: Optional[int] = Field(default=None, description="认证标记")
|
|
|
|
|
|
delete_flag: Optional[int] = Field(default=None, description="删除标记")
|
|
|
|
|
|
chat_room_type: Optional[int] = Field(default=None, description="群类型标记")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ContactsResponse(BaseModel):
|
|
|
|
|
|
"""联系人列表响应。"""
|
|
|
|
|
|
|
|
|
|
|
|
contacts: list[Contact] = Field(default_factory=list)
|
|
|
|
|
|
total: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GroupsResponse(BaseModel):
|
|
|
|
|
|
"""群聊列表响应。"""
|
|
|
|
|
|
|
|
|
|
|
|
groups: list[Contact] = Field(default_factory=list)
|
|
|
|
|
|
total: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 群成员接口
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class GroupMember(BaseModel):
|
|
|
|
|
|
"""群成员。"""
|
|
|
|
|
|
|
|
|
|
|
|
wxid: str
|
|
|
|
|
|
nickname: str = ""
|
|
|
|
|
|
display_name: str = ""
|
|
|
|
|
|
is_admin: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GroupMembersResponse(BaseModel):
|
|
|
|
|
|
"""群成员列表响应。"""
|
|
|
|
|
|
|
|
|
|
|
|
group_wxid: str
|
|
|
|
|
|
members: list[GroupMember] = Field(default_factory=list)
|
|
|
|
|
|
total: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 修改好友备注(experimental)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class SetRemarkRequest(BaseModel):
|
|
|
|
|
|
"""修改好友备注请求(experimental)。"""
|
|
|
|
|
|
|
|
|
|
|
|
remark: str = Field(description="新备注名")
|
|
|
|
|
|
display_name: Optional[str] = Field(
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
description="会话显示名(备注/昵称)用于搜索框定位;"
|
|
|
|
|
|
"不传则从 contact.db 解析(修改前的 remark 优先,其次 nickname,回退 wxid)",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SetRemarkResponse(BaseModel):
|
|
|
|
|
|
"""修改好友备注响应。"""
|
|
|
|
|
|
|
|
|
|
|
|
success: bool = Field(default=False)
|
|
|
|
|
|
error: Optional[str] = Field(default=None, description="失败时的错误描述")
|
2026-07-16 16:53:42 +08:00
|
|
|
|
verified: Optional[bool] = Field(
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
description="post_verify 校验结果:True=DB 中 remark 已更新为目标值,"
|
|
|
|
|
|
"False=超时仍为旧值,None=未执行校验",
|
|
|
|
|
|
)
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 添加好友(experimental)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class AddFriendRequest(BaseModel):
|
|
|
|
|
|
"""添加好友请求(experimental)。"""
|
|
|
|
|
|
|
|
|
|
|
|
keyword: str = Field(description="搜索关键词(wxid/手机号/微信号)")
|
|
|
|
|
|
message: str = Field(default="", description="可选好友验证消息")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AddFriendResponse(BaseModel):
|
|
|
|
|
|
"""添加好友响应。"""
|
|
|
|
|
|
|
|
|
|
|
|
success: bool = Field(default=False)
|
|
|
|
|
|
error: Optional[str] = Field(default=None, description="失败时的错误描述")
|
2026-07-16 16:53:42 +08:00
|
|
|
|
verified: Optional[bool] = Field(
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
description="post_verify 校验结果:True=检测到好友列表变化或输入框清空,"
|
|
|
|
|
|
"False=未检测到变化,None=未执行校验",
|
|
|
|
|
|
)
|
2026-07-16 13:03:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 好友申请自动通过
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class FriendRequestItem(BaseModel):
|
|
|
|
|
|
"""好友申请条目(API 响应用)。"""
|
|
|
|
|
|
stranger_wxid: str
|
|
|
|
|
|
nickname: str = ""
|
|
|
|
|
|
verify_message: str = ""
|
|
|
|
|
|
scene: str = ""
|
|
|
|
|
|
create_time: int
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
# 运行时数据结构:避免 friend_parser.py 与 friend_watcher.py 循环导入
|
|
|
|
|
|
class FriendRequestInfo:
|
|
|
|
|
|
"""好友申请信息(从 fmessage sysmsg XML 解析)。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
stranger_wxid: str,
|
|
|
|
|
|
nickname: str,
|
|
|
|
|
|
verify_message: str,
|
|
|
|
|
|
scene: str,
|
|
|
|
|
|
raw_xml: str,
|
|
|
|
|
|
create_time: int,
|
|
|
|
|
|
msg_local_id: int,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
self.stranger_wxid = stranger_wxid
|
|
|
|
|
|
self.nickname = nickname
|
|
|
|
|
|
self.verify_message = verify_message
|
|
|
|
|
|
self.scene = scene
|
|
|
|
|
|
self.raw_xml = raw_xml
|
|
|
|
|
|
self.create_time = create_time
|
|
|
|
|
|
self.msg_local_id = msg_local_id
|
|
|
|
|
|
|
|
|
|
|
|
def __repr__(self) -> str:
|
|
|
|
|
|
return (
|
|
|
|
|
|
f"FriendRequestInfo(stranger_wxid={self.stranger_wxid!r}, "
|
|
|
|
|
|
f"nickname={self.nickname!r}, scene={self.scene!r}, "
|
|
|
|
|
|
f"create_time={self.create_time}, msg_local_id={self.msg_local_id})"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 13:03:58 +08:00
|
|
|
|
class FriendRequestsResponse(BaseModel):
|
|
|
|
|
|
"""好友申请列表响应。"""
|
|
|
|
|
|
requests: list[FriendRequestItem] = Field(default_factory=list)
|
|
|
|
|
|
total: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AcceptFriendRequest(BaseModel):
|
|
|
|
|
|
"""手动通过好友申请请求。"""
|
|
|
|
|
|
stranger_wxid: str = Field(description="申请人 wxid(含 @stranger 后缀)")
|
|
|
|
|
|
nickname: Optional[str] = Field(default=None, description="申请人昵称(UI 定位用)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AcceptFriendResponse(BaseModel):
|
|
|
|
|
|
"""通过好友申请响应。"""
|
|
|
|
|
|
success: bool = Field(default=False)
|
|
|
|
|
|
error: Optional[str] = Field(default=None)
|
2026-07-16 16:53:42 +08:00
|
|
|
|
verified: Optional[bool] = Field(
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
description="post_verify 校验结果:True=DB 中 @stranger 后缀消失已变为好友,"
|
|
|
|
|
|
"False=超时仍未通过,None=未执行校验",
|
|
|
|
|
|
)
|
2026-07-16 13:03:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AutoAcceptStatus(BaseModel):
|
|
|
|
|
|
"""自动通过运行状态。"""
|
|
|
|
|
|
running: bool
|
|
|
|
|
|
enabled: bool
|
|
|
|
|
|
processed_count: int = 0
|
|
|
|
|
|
accepted_count: int = 0
|
|
|
|
|
|
rejected_count: int = 0
|
|
|
|
|
|
last_processed_time: Optional[int] = None
|
2026-07-17 18:10:31 +08:00
|
|
|
|
cursor_create_time: Optional[int] = Field(
|
|
|
|
|
|
default=None, description="当前轮询 create_time 游标(调试用)"
|
|
|
|
|
|
)
|
|
|
|
|
|
cursor_local_id: Optional[int] = Field(
|
|
|
|
|
|
default=None, description="当前轮询 local_id 游标(调试用)"
|
|
|
|
|
|
)
|
|
|
|
|
|
db_readable: Optional[bool] = Field(
|
|
|
|
|
|
default=None, description="DB 是否可读(调试用)"
|
|
|
|
|
|
)
|
|
|
|
|
|
breaker_state: Optional[str] = Field(
|
|
|
|
|
|
default=None, description="熔断器当前状态(调试用)"
|
|
|
|
|
|
)
|
2026-07-16 13:03:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AcceptRuleConfig(BaseModel):
|
|
|
|
|
|
"""自动通过规则配置。"""
|
|
|
|
|
|
enabled: bool = Field(default=False, description="全局开关")
|
|
|
|
|
|
accept_all: bool = Field(default=False, description="通过所有申请(忽略以下规则)")
|
|
|
|
|
|
whitelist_wxids: list[str] = Field(default_factory=list, description="白名单 wxid 列表")
|
|
|
|
|
|
whitelist_nicknames: list[str] = Field(default_factory=list, description="白名单昵称列表(精确匹配)")
|
|
|
|
|
|
keywords: list[str] = Field(default_factory=list, description="验证消息关键词列表(子串匹配)")
|
|
|
|
|
|
blacklist_wxids: list[str] = Field(default_factory=list, description="黑名单 wxid 列表")
|
|
|
|
|
|
blacklist_nicknames: list[str] = Field(default_factory=list, description="黑名单昵称列表")
|
|
|
|
|
|
allow_scenes: list[str] = Field(default_factory=list, description="允许的场景(空=不限制)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AcceptDecision(enum.Enum):
|
|
|
|
|
|
"""规则匹配决策结果。"""
|
|
|
|
|
|
ACCEPT = "accept"
|
|
|
|
|
|
REJECT = "reject"
|
|
|
|
|
|
SKIP = "skip"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AcceptRuleEngine:
|
|
|
|
|
|
"""好友申请自动通过规则引擎。
|
|
|
|
|
|
|
|
|
|
|
|
按"黑名单 → 场景 → 白名单 → 关键词 → SKIP"优先级决策,
|
|
|
|
|
|
配置热更新受 asyncio.Lock 保护。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, config: AcceptRuleConfig) -> None:
|
|
|
|
|
|
self._config = config
|
|
|
|
|
|
self._lock = asyncio.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
async def get_config(self) -> AcceptRuleConfig:
|
|
|
|
|
|
"""获取当前规则配置(供 API 查询)。"""
|
|
|
|
|
|
async with self._lock:
|
|
|
|
|
|
return self._config
|
|
|
|
|
|
|
|
|
|
|
|
async def evaluate(self, req: "FriendRequestInfo") -> AcceptDecision:
|
|
|
|
|
|
async with self._lock:
|
|
|
|
|
|
cfg = self._config
|
|
|
|
|
|
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"[规则引擎] 开始评估 wxid=%s nickname=%s scene=%s verify=%s",
|
|
|
|
|
|
req.stranger_wxid, req.nickname, req.scene,
|
|
|
|
|
|
repr(req.verify_message[:80]) if req.verify_message else "",
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"[规则引擎] 当前配置 enabled=%s accept_all=%s allow_scenes=%s keywords=%s "
|
|
|
|
|
|
"whitelist_wxids=%s whitelist_nicknames=%s blacklist_wxids=%s blacklist_nicknames=%s",
|
|
|
|
|
|
cfg.enabled, cfg.accept_all, cfg.allow_scenes, cfg.keywords,
|
|
|
|
|
|
cfg.whitelist_wxids, cfg.whitelist_nicknames,
|
|
|
|
|
|
cfg.blacklist_wxids, cfg.blacklist_nicknames,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-16 13:03:58 +08:00
|
|
|
|
if not cfg.enabled:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info("[规则引擎] 全局开关 enabled=false → SKIP")
|
2026-07-16 13:03:58 +08:00
|
|
|
|
return AcceptDecision.SKIP
|
|
|
|
|
|
|
|
|
|
|
|
if cfg.accept_all:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info("[规则引擎] accept_all=true → ACCEPT")
|
2026-07-16 13:03:58 +08:00
|
|
|
|
return AcceptDecision.ACCEPT
|
|
|
|
|
|
|
|
|
|
|
|
# 黑名单优先
|
|
|
|
|
|
if req.stranger_wxid in cfg.blacklist_wxids:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info("[规则引擎] wxid 命中黑名单 → REJECT")
|
2026-07-16 13:03:58 +08:00
|
|
|
|
return AcceptDecision.REJECT
|
2026-07-17 18:10:31 +08:00
|
|
|
|
if req.nickname and req.nickname in cfg.blacklist_nicknames:
|
|
|
|
|
|
logger.info("[规则引擎] nickname 命中黑名单 → REJECT")
|
2026-07-16 13:03:58 +08:00
|
|
|
|
return AcceptDecision.REJECT
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info("[规则引擎] 黑名单未命中")
|
2026-07-16 13:03:58 +08:00
|
|
|
|
|
|
|
|
|
|
# 场景过滤
|
|
|
|
|
|
if cfg.allow_scenes and req.scene not in cfg.allow_scenes:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"[规则引擎] scene=%s 不在允许列表 %s → SKIP",
|
|
|
|
|
|
req.scene, cfg.allow_scenes,
|
|
|
|
|
|
)
|
2026-07-16 13:03:58 +08:00
|
|
|
|
return AcceptDecision.SKIP
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info("[规则引擎] 场景检查通过 allow_scenes=%s", cfg.allow_scenes)
|
2026-07-16 13:03:58 +08:00
|
|
|
|
|
|
|
|
|
|
# 白名单
|
|
|
|
|
|
if req.stranger_wxid in cfg.whitelist_wxids:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info("[规则引擎] wxid 命中白名单 → ACCEPT")
|
2026-07-16 13:03:58 +08:00
|
|
|
|
return AcceptDecision.ACCEPT
|
2026-07-17 18:10:31 +08:00
|
|
|
|
if req.nickname and req.nickname in cfg.whitelist_nicknames:
|
|
|
|
|
|
logger.info("[规则引擎] nickname 命中白名单 → ACCEPT")
|
2026-07-16 13:03:58 +08:00
|
|
|
|
return AcceptDecision.ACCEPT
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info("[规则引擎] 白名单未命中")
|
2026-07-16 13:03:58 +08:00
|
|
|
|
|
|
|
|
|
|
# 关键词
|
|
|
|
|
|
if cfg.keywords:
|
|
|
|
|
|
for kw in cfg.keywords:
|
|
|
|
|
|
if kw in req.verify_message:
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info("[规则引擎] 验证消息命中关键词 '%s' → ACCEPT", kw)
|
2026-07-16 13:03:58 +08:00
|
|
|
|
return AcceptDecision.ACCEPT
|
2026-07-17 18:10:31 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"[规则引擎] 关键词未命中 keywords=%s verify=%s",
|
|
|
|
|
|
cfg.keywords,
|
|
|
|
|
|
repr(req.verify_message[:80]) if req.verify_message else "",
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.info("[规则引擎] 未配置关键词")
|
|
|
|
|
|
|
|
|
|
|
|
logger.info("[规则引擎] 无匹配规则 → SKIP")
|
2026-07-16 13:03:58 +08:00
|
|
|
|
return AcceptDecision.SKIP
|
|
|
|
|
|
|
|
|
|
|
|
async def update_config(self, config: AcceptRuleConfig) -> None:
|
|
|
|
|
|
async with self._lock:
|
|
|
|
|
|
self._config = config
|