"""woc-bridge 数据模型与错误码定义。 本模块定义规格中所有接口的请求/响应 Pydantic v2 schema、预定义错误码常量, 以及统一的 BridgeError 异常类。路由层直接 raise BridgeError,由 server.py 中的 全局异常处理器统一捕获并转换为 ErrorResponse。 """ from __future__ import annotations from enum import Enum from typing import Any, Optional from pydantic import BaseModel, Field # --------------------------------------------------------------------------- # 登录态枚举 # --------------------------------------------------------------------------- class LoginState(str, Enum): """微信登录状态枚举。""" NOT_RUNNING = "not_running" NOT_LOGGED_IN = "not_logged_in" LOGGING_IN = "logging_in" LOGGED_IN = "logged_in" LOGGED_OUT = "logged_out" # --------------------------------------------------------------------------- # 错误码定义(与 spec 一致) # --------------------------------------------------------------------------- # 每个错误码对应一个 HTTP 状态码 ERROR_CODES: dict[str, int] = { "WECHAT_NOT_RUNNING": 503, "WECHAT_NOT_LOGGED_IN": 401, "WINDOW_NOT_FOUND": 503, "CONTACT_NOT_FOUND": 404, "SEND_FAILED": 500, "DB_LOCKED": 503, "DB_NOT_FOUND": 500, "INVALID_PARAMS": 400, "BRIDGE_INTERNAL_ERROR": 500, "LOGIN_TIMEOUT": 408, "MEDIA_NOT_FOUND": 404, "RATE_LIMITED": 429, "DB_ENCRYPTED": 503, "DB_NEED_INIT": 503, "DB_INIT_IN_PROGRESS": 503, "DB_KEY_INVALID": 503, "LOGOUT_FAILED": 500, "RESTART_TIMEOUT": 408, } class BridgeError(Exception): """bridge 统一业务异常。 路由层直接 raise BridgeError(code="...", message="...", details=...), 由全局异常处理器捕获后转换为统一 ErrorResponse 并设置对应 HTTP 状态码。 """ def __init__( self, code: str = "BRIDGE_INTERNAL_ERROR", message: str = "bridge internal error", http_status: Optional[int] = None, details: Optional[Any] = None, ) -> None: self.code = code self.message = message # 若未显式指定 http_status,则查表取默认值;查不到则回落到 500 self.http_status = http_status if http_status is not None else ERROR_CODES.get(code, 500) self.details = details super().__init__(f"[{code}] {message}") # --------------------------------------------------------------------------- # 通用错误响应 # --------------------------------------------------------------------------- class ErrorResponse(BaseModel): """统一错误响应结构。""" success: bool = Field(default=False, description="固定为 false") error: dict[str, Any] = Field( description="错误详情,含 code/message/details 三个字段" ) # --------------------------------------------------------------------------- # 状态接口(GET /api/status) # --------------------------------------------------------------------------- class StatusResponse(BaseModel): """bridge 状态响应。 新增字段(1.1.0): - db_error_code:DB 不可达时的具体原因(encrypted / not_found / unreadable), db_accessible=true 时为 None - send_queue_pending:发送队列当前积压任务数,客户端据此决定是否退避 - media_supported:图片/文件发送是否已实现真实传输(与 BRIDGE_CAPABILITIES 中 image_send/file_send 同步) - bridge_capabilities:当前 bridge 支持的能力集合,客户端据此协商 """ bridge_version: str = Field(description="bridge 版本号") wechat_running: bool = Field(description="微信进程是否运行") wechat_window_found: bool = Field(description="是否找到微信窗口") login_state: LoginState = Field(description="登录态") db_accessible: bool = Field(description="微信本地 DB 是否可读") db_error_code: Optional[str] = Field( default=None, description="DB 不可达原因:not_found / unreadable / encrypted_no_key / encrypted_key_ok / key_extract_failed / need_init / init_in_progress / key_invalid;db_accessible=true 时为 None", ) init_in_progress: bool = Field(default=False, description="是否正在进行后台 DB 初始化(密钥提取/预解密)") init_progress_pct: Optional[float] = Field(default=None, description="初始化进度百分比 0-100") init_message: Optional[str] = Field(default=None, description="初始化阶段描述") current_wxid: str = Field(default="", description="当前登录 wxid,未登录时为空") current_nickname: str = Field(default="", description="当前登录昵称,未登录时为空") uptime_seconds: float = Field(description="bridge 已运行秒数") display: str = Field(description="当前 DISPLAY 环境变量值") max_batch_size: int = Field(default=50, description="客户端单次拉取建议上限") poll_interval_ms: int = Field(default=2000, description="客户端轮询建议间隔(毫秒)") send_queue_pending: int = Field(default=0, description="发送队列积压任务数") media_supported: bool = Field(default=False, description="图片/文件真实传输是否已实现") bridge_capabilities: list[str] = Field( default_factory=list, description="bridge 支持的能力集合,如 ['text_send','image_send','long_poll']", ) # --------------------------------------------------------------------------- # 消息接口(GET /api/messages/since) # --------------------------------------------------------------------------- class Message(BaseModel): """单条微信消息。""" msg_id: str = Field(description="消息 ID") talker: str = Field(description="会话对方 wxid(群消息为 chatroom id)") sender: str = Field(default="", description="实际发送者 wxid(群消息中为成员 wxid)") is_sender: bool = Field(default=False, description="是否为本机发送") type: int = Field(description="微信原始消息类型") render_type: str = Field(description="渲染类型:text/image/voice/video/file/system") content: str = Field(default="", description="消息内容文本") create_time: int = Field(description="消息时间戳(Unix 秒)") session_type: str = Field(description="会话类型:p2p / group") class MessagesResponse(BaseModel): """消息拉取响应。""" messages: list[Message] = Field(default_factory=list) next_cursor: int = Field(description="下次拉取应使用的 cursor") has_more: bool = Field(default=False, description="是否可能还有更多消息") # --------------------------------------------------------------------------- # 发送接口 # --------------------------------------------------------------------------- class SendTextRequest(BaseModel): """发送文本消息请求。""" to_wxid: str = Field(description="目标 wxid") content: str = Field(description="文本内容") display_name: Optional[str] = Field( default=None, description="可选:用于微信搜索框定位会话的显示名。不传时 bridge 自动按备注->昵称->wxid 查找", ) class SendFileRequest(BaseModel): """发送图片/文件请求。""" to_wxid: str = Field(description="目标 wxid") file_path: str = Field(description="容器内文件绝对路径") display_name: Optional[str] = Field( default=None, description="可选:用于微信搜索框定位会话的显示名。不传时 bridge 自动按备注->昵称->wxid 查找", ) class SendResponse(BaseModel): """发送消息响应。 字段说明: - local_send_id:bridge 本地生成的发送 ID,格式 local__<随机>。 仅用于客户端幂等去重,**不对应微信原生 msg_id**,禁止用于 /api/media/{msg_id} 查媒体。 - placeholder:是否为占位实现(true 表示真实文件/图片内容未传输, 仅发了提示文本;客户端应走降级处理)。 """ success: bool = Field(default=False) local_send_id: str = Field(default="", description="本地生成的发送 ID,非微信原生 msg_id") placeholder: bool = Field(default=False, description="是否为占位实现(真实内容未传输)") error: Optional[str] = Field(default=None, description="失败时的错误描述") # --------------------------------------------------------------------------- # 联系人接口 # --------------------------------------------------------------------------- class Contact(BaseModel): """联系人。""" wxid: str nickname: str = "" remark: str = "" avatar_url: str = "" type: str = Field(default="person", description="person / group / official") # 扩展字段:从 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 # --------------------------------------------------------------------------- # 登录二维码接口 # --------------------------------------------------------------------------- class QrLoginStartResult(BaseModel): """启动扫码登录结果。""" qr_data_url: str = Field(default="", description="data:image/png;base64,... 形式的二维码图片") message: str = "" connected: bool = False class QrLoginWaitResult(BaseModel): """扫码等待结果。""" connected: bool = False message: str = "" qr_data_url: str = "" credentials: Optional[dict[str, str]] = Field( default=None, description="登录成功时含 wxid/nickname", ) # --------------------------------------------------------------------------- # 退出登录接口(POST /api/login/logout) # --------------------------------------------------------------------------- class LogoutResponse(BaseModel): """退出登录响应。""" success: bool = Field(description="是否成功") message: str = Field(description="结果描述") # --------------------------------------------------------------------------- # 重启微信接口(POST /api/wechat/restart) # --------------------------------------------------------------------------- class RestartResponse(BaseModel): """重启微信响应。""" success: bool message: str pid: Optional[int] = None # --------------------------------------------------------------------------- # 诊断接口 # --------------------------------------------------------------------------- class ConnectivityResponse(BaseModel): """连通性检查响应。""" reachable: bool = False latency_ms: int = 0 error: Optional[str] = None class DiagnosticItem(BaseModel): """诊断项定义。""" check_id: str name: str severity: str = Field(default="info", description="info / warning / critical") description: str = "" auto_repairable: bool = False class DiagnosticRunResult(BaseModel): """诊断执行结果。""" check_id: str passed: bool = False severity: str = "info" message: str = "" auto_repairable: bool = False repair_plan: Optional[str] = None # --------------------------------------------------------------------------- # DB 解密接口(POST /api/db/decrypt, GET /api/db/key/status) # --------------------------------------------------------------------------- class DbDecryptRequest(BaseModel): """手动 key 注入请求。 外部系统(如 ForcePilot)或管理员通过 POST /api/db/decrypt 传入 64 位 十六进制密钥,bridge 验证并缓存,后续所有 DB 查询接口立即恢复可用。 """ key: str = Field(description="64 位十六进制 SQLCipher 密钥") salt: Optional[str] = Field( default=None, description="可选:该 key 对应的 DB salt(32 位十六进制)。未指定时 bridge 自动读取当前 DB 的 salt", ) class DbDecryptResponse(BaseModel): """key 注入结果。 - verified=true 时 key 已缓存,后续 DB 查询接口立即可用 - verified=false 时 key 不匹配,未缓存 """ success: bool = Field(description="请求是否处理成功(不代表 key 正确)") verified: bool = Field(default=False, description="key 是否通过验证并缓存") key_mode: Optional[str] = Field( default=None, description="key 形态:raw_enc_key / sqlcipher_passphrase;验证失败时为 None", ) error: Optional[str] = Field(default=None, description="验证失败或特殊状态:key_mismatch / file_too_small / db_not_encrypted") class DbKeyStatusResponse(BaseModel): """key 缓存状态查询响应。 供调用方判断是否需要注入 key 或等待自动提取。 """ cached: bool = Field(default=False, description="是否有缓存的 key") source: Optional[str] = Field( default=None, description="key 来源:env(环境变量)/ api(POST 注入)/ auto_extract(内存扫描)/ file(持久化文件)", ) verified: bool = Field(default=False, description="缓存 key 是否已通过验证") key_prefix: Optional[str] = Field( default=None, description="key 前 4 + ... + 后 4 字符,用于确认是哪个 key(不泄露完整 key)", ) class DbInitRequest(BaseModel): """DB 初始化请求(显式触发密钥提取)。""" pid: Optional[int] = Field(default=None, description="可选:指定微信进程 PID") db_dir: Optional[str] = Field(default=None, description="可选:指定微信数据目录,默认自动检测") force: bool = Field(default=False, description="是否强制重新提取,忽略已有 keys 文件") class DbInitResponse(BaseModel): """DB 初始化响应。""" success: bool = Field(description="请求是否受理") state: str = Field(description="状态:started / already_done / in_progress / failed") message: str = Field(description="阶段描述") key_count: Optional[int] = Field(default=None, description="已提取到的 salt→key 映射数量") class DbInitStatusResponse(BaseModel): """DB 初始化后台任务状态。""" state: str = Field(description="状态:idle / running / success / failed") progress_pct: Optional[float] = Field(default=None, description="进度百分比 0-100") message: Optional[str] = Field(default=None, description="当前阶段描述") key_count: Optional[int] = Field(default=None, description="成功提取的密钥数") error: Optional[str] = Field(default=None, description="失败原因")