WechatOnCloud/bridge/models.py

409 lines
16 KiB
Python
Raw Normal View History

"""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="失败原因")