WechatOnCloud/bridge/models.py
Kris a2201d8be3 feat: 新增woc-bridge服务及配套能力
新增完整的woc-bridge业务服务,实现微信API代理、密钥自动提取、消息队列限流等功能:
1. 新增bridge核心代码与s6服务配置
2. 新增ptrace权限初始化脚本与docker配置
3. 新增M2M鉴权API代理与环境变量配置
4. 新增SQLCipher解密、密钥缓存、二维码截图等工具模块
5. 完善docker构建与实例部署配置
2026-07-07 19:04:13 +08:00

409 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.

"""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_codeDB 不可达时的具体原因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_invaliddb_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_idbridge 本地生成的发送 ID格式 local_<unix秒>_<随机>。
仅用于客户端幂等去重,**不对应微信原生 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 salt32 位十六进制)。未指定时 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环境变量/ apiPOST 注入)/ 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="失败原因")