OneBot 11 适配器:反向 WS 接入 QQ(NapCat/Lagrange)

- app/onebot.py: /onebot/v11/ws 反向 WS;私聊全回、群聊仅回应 @她 并自动 @ 回去
- CQ 码字符串与消息段数组双格式解析;会话 id ob-<self_id>-p/g-<目标> 与 TUI 隔离
- action echo 配对调用(send_private_msg / send_group_msg),retcode==0 才落历史
- remember_later 的推送(事件确认/关怀/连续天数/身世)自动转成 QQ 消息
- 主动消息路由:push_proactive 优先 TUI、ob- 会话走 QQ,均不在线保持 pending
- 主干重构:chat_turn() 供 TUI 与 OneBot 共用;助手消息由传输层确认送达后落库
- 可选 OB_ACCESS_TOKEN 令牌校验;8 个新测试(回复/CQ剥离/群@/通知转换/主动推送/令牌)
This commit is contained in:
snowy-yang committed 2026-09-29 20:27:13 +08:00
1 parent 8bbab7e92b
commit ea83b5df4b
6 files changed
+412 -80

No files matched your search

+3
View File
@@ -297,8 +297,11 @@ def offline(monkeypatch):
@pytest.fixture
def client(offline, fdb):
"""不带 lifespan 的测试应用(绕过 PG 连接)"""
from app import onebot
app = FastAPI()
app.websocket("/ws")(main.chat)
app.include_router(onebot.router)
return TestClient(app)
+2 -2
View File
@@ -76,7 +76,7 @@ def test_streak_increments(fdb, offline):
async def send_json(self, payload):
pass
asyncio.run(main.remember_later(FakeWS(), "s1", "我来了", sig))
asyncio.run(main.remember_later(FakeWS().send_json, "s1", "我来了", sig))
assert asyncio.run(fdb.get_meta("s1", "streak")) == "4"
@@ -89,5 +89,5 @@ def test_streak_resets_after_gap(fdb, offline):
async def send_json(self, payload):
pass
asyncio.run(main.remember_later(FakeWS(), "s1", "好久不见", sig))
asyncio.run(main.remember_later(FakeWS().send_json, "s1", "好久不见", sig))
assert asyncio.run(fdb.get_meta("s1", "streak")) == "1"
+140
View File
@@ -0,0 +1,140 @@
"""OneBot 11 适配器测试(TestClient 模拟 OneBot 实现的反向 WS)"""
import json
import time
import pytest
from starlette.websockets import WebSocketDisconnect
from app import onebot
@pytest.fixture(autouse=True)
def clean_conn():
onebot.connections.clear()
yield
onebot.connections.clear()
def _msg_event(user_id=123, message="你好呀", message_type="private",
group_id=None, self_id=10000):
ev = {"time": 0, "self_id": self_id, "post_type": "message",
"message_type": message_type,
"sub_type": "friend" if message_type == "private" else "normal",
"message_id": 1, "user_id": user_id, "message": message,
"raw_message": str(message),
"sender": {"user_id": user_id, "nickname": "tester"}}
if group_id:
ev["group_id"] = group_id
return ev
def _drain_action(ws):
"""收一个 action 并替 OneBot 实现回一个成功 echo,返回该 action"""
action = json.loads(ws.receive_text())
ws.send_text(json.dumps({"status": "ok", "retcode": 0, "data": {},
"echo": action["echo"]}))
return action
def test_private_reply(client, route_sig, fdb):
route_sig({})
with client.websocket_connect("/onebot/v11/ws") as ws:
ws.send_text(json.dumps(_msg_event(message="你好呀")))
action = _drain_action(ws)
assert action["action"] == "send_private_msg"
assert action["params"]["user_id"] == 123
assert "回声模式" in action["params"]["message"]
assert "你好呀" in action["params"]["message"]
# 会话映射注册成功
assert "ob-10000-p123" in onebot.connections[10000].targets
time.sleep(0.3)
# 对话进了历史(user + assistant)
assert len(fdb.history) == 2
def test_cq_code_stripped(client, route_sig, fdb):
route_sig({})
with client.websocket_connect("/onebot/v11/ws") as ws:
ws.send_text(json.dumps(
_msg_event(message="[CQ:face,id=1]你叫什么名字")))
action = _drain_action(ws)
assert "你叫什么名字" in action["params"]["message"]
def test_group_requires_at(client, route_sig, fdb):
route_sig({})
with client.websocket_connect("/onebot/v11/ws") as ws:
# 群里没 @ 她 -> 不回应
ws.send_text(json.dumps(_msg_event(
message_type="group", group_id=777, message="大家好啊")))
# 紧跟一条私聊 -> 第一条收到的 action 应该是私聊回复
ws.send_text(json.dumps(_msg_event(message="私聊一下")))
action = _drain_action(ws)
assert action["action"] == "send_private_msg"
assert fdb.history[0]["session"] == "ob-10000-p123"
def test_group_at_replies_with_at(client, route_sig, fdb):
route_sig({})
with client.websocket_connect("/onebot/v11/ws") as ws:
ws.send_text(json.dumps(_msg_event(
message_type="group", group_id=777,
message=[{"type": "at", "data": {"qq": 10000}},
{"type": "text", "data": {"text": " 在吗"}}])))
action = _drain_action(ws)
assert action["action"] == "send_group_msg"
assert action["params"]["group_id"] == 777
assert action["params"]["message"].startswith("[CQ:at,qq=123]")
assert "回声模式" in action["params"]["message"]
# 群会话独立于私聊
sessions = {h["session"] for h in fdb.history}
assert "ob-10000-g777" in sessions
def test_notify_event_becomes_qq_message(client, route_sig, fdb):
from datetime import datetime, timedelta
route_sig({"events": [{"kind": "alarm",
"fire_at": datetime.now() + timedelta(hours=14),
"text": "起床", "repeat": "daily"}]})
with client.websocket_connect("/onebot/v11/ws") as ws:
ws.send_text(json.dumps(_msg_event(message="每天早上7点叫我起床")))
a1 = _drain_action(ws) # 回复
assert "回声模式" in a1["params"]["message"]
a2 = _drain_action(ws) # 事件确认 -> QQ 消息
assert a2["action"] == "send_private_msg"
assert "我记下了" in a2["params"]["message"]
assert "每天都算" in a2["params"]["message"]
assert len(fdb.events) == 1
def test_streak_pushed_proactively_via_onebot(client, route_sig, fdb):
route_sig({})
from datetime import datetime, timedelta
import asyncio
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
# 用 FakeDB 直接种下"昨天来过、已连续 4 天" -> 这次该庆祝第 5 天
asyncio.run(fdb.set_meta("ob-10000-p123", "last_active", yesterday))
asyncio.run(fdb.set_meta("ob-10000-p123", "streak", "4"))
with client.websocket_connect("/onebot/v11/ws") as ws:
ws.send_text(json.dumps(_msg_event(message="我来了")))
actions = [_drain_action(ws), _drain_action(ws)]
texts = [a["params"]["message"] for a in actions]
assert any("连续陪伴的第 5 天" in t for t in texts), texts
assert any("回声模式" in t for t in texts), texts
def test_access_token_enforced(client, route_sig, monkeypatch):
monkeypatch.setattr(onebot, "ACCESS_TOKEN", "secret")
with pytest.raises(WebSocketDisconnect):
with client.websocket_connect("/onebot/v11/ws") as ws:
ws.receive_text()
with client.websocket_connect("/onebot/v11/ws?access_token=secret") as ws:
ws.send_text(json.dumps(_msg_event(message="在吗")))
action = _drain_action(ws)
assert "回声模式" in action["params"]["message"]
def test_disconnected_target_returns_false(client, route_sig):
import asyncio
route_sig({})
assert asyncio.run(onebot.send_to_session("ob-x-p1", "hi")) is False