新增完整的woc-bridge业务服务,实现微信API代理、密钥自动提取、消息队列限流等功能: 1. 新增bridge核心代码与s6服务配置 2. 新增ptrace权限初始化脚本与docker配置 3. 新增M2M鉴权API代理与环境变量配置 4. 新增SQLCipher解密、密钥缓存、二维码截图等工具模块 5. 完善docker构建与实例部署配置
409 lines
16 KiB
Python
409 lines
16 KiB
Python
"""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_<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 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="失败原因")
|