WechatOnCloud/bridge/woc_bridge/routes/messages.py
Kris c45282f094 feat: 新增好友自动通过、UI自动化能力与多分辨率适配
- 新增登录状态守卫后台任务
- 新增好友申请自动通过规则引擎
- 新增多分辨率UI配置与模板资源
- 新增消息拉取复合游标支持
- 优化发送队列与UI自动化逻辑
- 新增批量发送日志与错误处理
- 优化Docker镜像构建与ptrace初始化
- 新增联系人名称缓存预热
2026-07-17 18:10:31 +08:00

339 lines
14 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.

from __future__ import annotations
import asyncio
import logging
from typing import Optional
from fastapi import APIRouter, Query, Request
from fastapi.responses import StreamingResponse
from woc_bridge.config import _require_db_reader, _require_message_streamer
from woc_bridge.db.coordinator import with_db_retry, _check_db_readable
from woc_bridge.models import (
MessagesResponse,
MessagesBySessionResponse,
MessageSearchResponse,
BridgeError,
)
from woc_bridge.messaging import StreamEvent, format_sse
logger = logging.getLogger("woc-bridge")
router = APIRouter()
@router.get("/api/messages/since", response_model=MessagesResponse)
@with_db_retry
async def get_messages_since(
cursor: int = 0,
cursor_local_id: int = Query(
0,
description="上次拉取的最大 local_id同 create_time 时的 tie-breaker默认 0",
),
limit: int = 50,
is_sender: Optional[bool] = Query(
None,
description="按发送方向过滤True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)",
),
) -> MessagesResponse:
"""增量消息拉取(按 create_time + local_id 复合游标)。
channels PullerAdapter 的核心接口:调用方维护本地 cursor首次为 0
每次拉取 create_time > cursor 的消息,把响应中 next_cursor 存下来作为
下次的 cursor。has_more=true 时应立即继续拉取,否则按 poll_interval_ms 轮询。
Args:
cursor: 上次拉取的最大 create_time首次传 0拉全量
cursor_local_id: 上次拉取的最大 local_id用于同秒消息去重默认 0
limit: 最多返回条数1~200默认 50。建议用 /api/status 返回的
max_batch_size 作为上限参考
is_sender: 按发送方向过滤True=仅本人发送 / False=仅对方发送 /
None=不过滤默认。SQL 层过滤,无法定位本人时返回空
Returns:
MessagesResponse含 messages 列表 / next_cursor / next_cursor_local_id / has_more
Raises:
BridgeError(INVALID_PARAMS): cursor<0 或 limit 越界HTTP 400
BridgeError(DB_NOT_FOUND): 未找到微信消息 DBHTTP 500
BridgeError(DB_ENCRYPTED): DB 已加密,需 SQLCipher 密钥HTTP 503
Notes:
- DB 读取通过 cp 快照避免锁定原文件sqlite3 同步阻塞,故用
asyncio.to_thread 包装,避免阻塞 event loop
- 群消息 content 形如 "wxid:\\n正文"DbReader 会拆出 sender 字段
- has_more=true 当且仅当返回条数 >= limit可能还有更多未拉取
"""
# 参数校验
if cursor < 0:
raise BridgeError(
code="INVALID_PARAMS",
message=f"cursor 必须 >= 0收到 {cursor}",
)
if cursor_local_id < 0:
raise BridgeError(
code="INVALID_PARAMS",
message=f"cursor_local_id 必须 >= 0收到 {cursor_local_id}",
)
if limit < 1 or limit > 200:
raise BridgeError(
code="INVALID_PARAMS",
message=f"limit 必须在 1~200 之间,收到 {limit}",
)
db_reader = _require_db_reader()
# DB 加密时返回 DB_ENCRYPTED而非空数据
await _check_db_readable()
# DB 读取是同步阻塞操作,用 to_thread 包装
result = await asyncio.to_thread(
db_reader.get_messages_since, cursor, limit, is_sender,
cursor_local_id=cursor_local_id,
)
msg_count = len(result.get("messages", []))
logger.info(
"messages/since: cursor=%d cursor_local_id=%d limit=%d is_sender=%s"
"返回 %d 条, next_cursor=%s next_cursor_local_id=%s has_more=%s",
cursor, cursor_local_id, limit, is_sender, msg_count,
result.get("next_cursor"), result.get("next_cursor_local_id"), result.get("has_more"),
)
return MessagesResponse(**result)
# ---------------------------------------------------------------------------
# 路由GET /api/messages/streamSSE 实时消息推送)
# ---------------------------------------------------------------------------
@router.get("/api/messages/stream")
async def stream_messages(request: Request) -> StreamingResponse:
"""SSE 实时消息推送长连接。
建立 text/event-stream 连接后,服务端主动推送新消息事件,替代客户端
轮询 /api/messages/since。内部由 MessageStreamer 周期性轮询 message DB
(有订阅者 1s、无订阅者 5sDB mtime 变化时读增量消息并广播。
事件类型:
- sync连接建立时立即发送data.cursor 为当前全局游标。客户端据此
判断是否需要用 /api/messages/since 补全断线期间消息。
- messages一批新消息data 含 messages / next_cursor / has_more
结构与 /api/messages/since 响应体一致。事件 id 为 next_cursor。
- statusDB 状态变化时发送data 含 db_accessible / db_error_code。
DB 加密/退出/恢复可读时触发,客户端据此决定是否降级到轮询。
- heartbeat每 30 秒心跳,保活 NAT/代理连接data 为空对象。
- kicked订阅者被服务端剔除超过 MAX_SUBSCRIBERS 上限data.reason
给出原因。客户端收到后应关闭连接并按需重连。
断线补偿:
SSE 不保证 100% 投递。客户端断线重连后应:
1. 连接本接口,拿 sync 事件中的 cursor
2. 用 max(本地cursor, sync.cursor) 调 /api/messages/since 补全
3. 补全后继续消费本接口事件流
Returns:
StreamingResponsemedia_type=text/event-stream持续推送 SSE 帧
Notes:
- DB 加密时不关闭连接,改发 status 事件通知客户端key 提取成功后
自动恢复推送
- 响应头设置 X-Accel-Buffering: no 关闭 nginx 缓冲,避免事件被攒批
- 客户端断开时自动取消订阅,释放队列
- 不抛业务错误:即使 DB 不可读也建立连接,通过 status 事件告知
- 订阅者上限由 MessageStreamer.MAX_SUBSCRIBERS 控制,超限时最早
订阅者会被剔除并收到 kicked 事件
"""
streamer = _require_message_streamer()
queue = streamer.subscribe()
async def event_generator():
try:
while True:
if await request.is_disconnected():
break
try:
event = await asyncio.wait_for(queue.get(), timeout=30.0)
except asyncio.TimeoutError:
# 队列 30s 无事件,发心跳保活
event = StreamEvent(event="heartbeat", data={})
# kicked 事件:被服务端剔除(订阅者超上限),发送后立即退出
if event.event == "kicked":
yield format_sse(event)
break
yield format_sse(event)
finally:
streamer.unsubscribe(queue)
headers = {
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # nginx 关闭缓冲,确保实时推送
"Connection": "keep-alive",
}
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers=headers,
)
# ---------------------------------------------------------------------------
# 路由GET /api/messages/by_session
# ---------------------------------------------------------------------------
@router.get("/api/messages/by_session", response_model=MessagesBySessionResponse)
@with_db_retry
async def get_messages_by_session(
talker: str,
cursor: int = 0,
cursor_local_id: int = Query(
0,
description="上次拉取的最大 local_id同 create_time 时的 tie-breaker默认 0",
),
limit: int = 50,
direction: str = "before",
is_sender: Optional[bool] = Query(
None,
description="按发送方向过滤True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)",
),
) -> MessagesBySessionResponse:
"""按会话拉取历史消息单会话O(1) 定位分片表)。
通过 talker 计算 Msg_<MD5(talker)> 表名直接查单表,避免遍历所有分片。
用于客户端聊天窗口的历史消息展示与上滑加载。
Args:
talker: 会话对方 wxid群消息为 chatroom id
cursor: 游标,首次传 0before 模式取该时间之前的旧消息,
after 模式取该时间之后的新消息
cursor_local_id: 上次拉取的最大 local_id用于同秒消息去重默认 0
limit: 最多返回条数1~200默认 50
direction: before默认往前翻历史/ after往后拉新消息
is_sender: 按发送方向过滤True=仅本人发送 / False=仅对方发送 /
None=不过滤默认。SQL 层过滤,无法定位本人时返回空
Returns:
MessagesBySessionResponse含 messages / next_cursor / next_cursor_local_id / has_more / talker
Raises:
BridgeError(INVALID_PARAMS): talker 为空、limit 越界、direction 非法HTTP 400
BridgeError(DB_NOT_FOUND): 未找到微信消息 DBHTTP 500
BridgeError(DB_ENCRYPTED): DB 已加密HTTP 503
Notes:
- before 模式返回升序消息(旧在前、新在后),便于客户端追加到列表头部
- cursor=0 + before 取最新 limit 条cursor=某消息时间 + before 取更旧的消息
- has_more=true 时用 next_cursor + next_cursor_local_id 继续翻页
- 表不存在时返回空列表而非报错(会话可能从未有过消息)
"""
if not talker:
raise BridgeError(code="INVALID_PARAMS", message="talker 不能为空")
if limit < 1 or limit > 200:
raise BridgeError(
code="INVALID_PARAMS",
message=f"limit 必须在 1~200 之间,收到 {limit}",
)
if direction not in ("before", "after"):
raise BridgeError(
code="INVALID_PARAMS",
message=f"direction 必须为 before 或 after收到 {direction}",
)
if cursor < 0:
raise BridgeError(
code="INVALID_PARAMS",
message=f"cursor 必须 >= 0收到 {cursor}",
)
if cursor_local_id < 0:
raise BridgeError(
code="INVALID_PARAMS",
message=f"cursor_local_id 必须 >= 0收到 {cursor_local_id}",
)
db_reader = _require_db_reader()
await _check_db_readable()
result = await asyncio.to_thread(
db_reader.get_messages_by_session,
talker, cursor, limit, direction, cursor_local_id, is_sender,
)
msg_count = len(result.get("messages", []))
logger.info(
"messages/by_session: talker=%s cursor=%d cursor_local_id=%d direction=%s "
"limit=%d is_sender=%s → 返回 %d 条, next_cursor=%s next_cursor_local_id=%s has_more=%s",
talker, cursor, cursor_local_id, direction, limit, is_sender, msg_count,
result.get("next_cursor"), result.get("next_cursor_local_id"), result.get("has_more"),
)
return MessagesBySessionResponse(**result)
# ---------------------------------------------------------------------------
# 路由GET /api/messages/search
# ---------------------------------------------------------------------------
@router.get("/api/messages/search", response_model=MessageSearchResponse)
@with_db_retry
async def search_messages(
keyword: str,
talker: str = "",
start_time: int = 0,
end_time: int = 0,
limit: int = 50,
is_sender: Optional[bool] = Query(
None,
description="按发送方向过滤True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)",
),
) -> MessageSearchResponse:
"""按关键词搜索历史消息。
遍历所有 Msg_* 分片表(或指定 talker 的单表),对 message_content
做 LIKE 模糊匹配,合并后按 create_time 降序返回(最新匹配在前)。
Args:
keyword: 搜索关键词(非空)
talker: 可选,限定在指定会话内搜索
start_time: 可选起始时间戳0 表示不限制
end_time: 可选结束时间戳0 表示不限制
limit: 最多返回条数1~200默认 50
is_sender: 按发送方向过滤True=仅本人发送 / False=仅对方发送 /
None=不过滤默认。SQL 层过滤,无法定位本人时返回空
Returns:
MessageSearchResponse含 messages / total
Raises:
BridgeError(INVALID_PARAMS): keyword 为空或 limit 越界HTTP 400
BridgeError(DB_NOT_FOUND): 未找到微信消息 DBHTTP 500
BridgeError(DB_ENCRYPTED): DB 已加密HTTP 503
Notes:
- 搜索基于 SQL LIKE性能取决于 DB 大小与索引;大库可能较慢
- 限制:无法搜索 zstd 压缩消息WCDB_CT_message_content==4 时存储
压缩 BLOBLIKE 不匹配二进制);压缩消息会被静默漏掉
- keyword 中的 % 和 _ 已转义为字面量,按子串匹配
- total 为合并后返回的条数(受每表 LIMIT 截断,可能小于实际命中数)
- 群消息的 sender 前缀("wxid:\\n")会先被剥离再匹配
"""
if not keyword:
raise BridgeError(code="INVALID_PARAMS", message="keyword 不能为空")
if limit < 1 or limit > 200:
raise BridgeError(
code="INVALID_PARAMS",
message=f"limit 必须在 1~200 之间,收到 {limit}",
)
db_reader = _require_db_reader()
await _check_db_readable()
result = await asyncio.to_thread(
db_reader.search_messages,
keyword,
talker if talker else None,
start_time if start_time > 0 else None,
end_time if end_time > 0 else None,
limit,
is_sender,
)
msg_count = len(result.get("messages", []))
logger.info(
"messages/search: keyword=%r talker=%s is_sender=%s → 命中 %d 条(返回 %d 条)",
keyword, talker or "(全部)", is_sender, result.get("total", 0), msg_count,
)
return MessageSearchResponse(**result)