mirror of
https://github.com/snowy-yang/xiaonuan.git
synced 2026-10-21 08:04:48 +00:00
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:
1 parent
8bbab7e92b
commit
ea83b5df4b
6 files changed
+412
-80
No files matched your search
+185
@@ -0,0 +1,185 @@
|
||||
"""OneBot 11 适配器:反向 WebSocket(NapCat / Lagrange 等实现连进来)。
|
||||
|
||||
接入方式(以 NapCat 为例):在 OneBot 实现里配置反向 WebSocket 地址
|
||||
ws://<本服务>:3000/onebot/v11/ws (配了 OB_ACCESS_TOKEN 时带 ?access_token=)
|
||||
|
||||
- 私聊:所有消息都回
|
||||
- 群聊:只回应 @她 的消息,回复自动 @ 回去
|
||||
- 会话 id:ob-<self_id>-p<user_id> / ob-<self_id>-g<group_id>,
|
||||
与 TUI 会话隔离,各自有独立的记忆、作息和时间表
|
||||
- 主动消息(叫早 / 提醒 / 回访 / 慢信 / 连续天数…)经适配器发到对应 QQ
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from . import db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 可选访问令牌:OneBot 实现侧配置同样的 token 即可
|
||||
ACCESS_TOKEN = os.getenv("OB_ACCESS_TOKEN", "")
|
||||
|
||||
|
||||
class OneBotConn:
|
||||
def __init__(self, ws: WebSocket, self_id):
|
||||
self.ws = ws
|
||||
self.self_id = self_id
|
||||
self.pending = {} # echo -> Future
|
||||
self.targets = {} # session -> {"type", "group_id", "user_id"}
|
||||
|
||||
|
||||
connections: dict = {} # self_id -> OneBotConn
|
||||
|
||||
|
||||
def _extract_text(message, self_id) -> tuple[str, bool]:
|
||||
"""返回 (纯文本, 是否@了她)。兼容 CQ 码字符串与消息段数组两种格式"""
|
||||
if isinstance(message, str):
|
||||
at_me = f"[CQ:at,qq={self_id}]" in message
|
||||
return re.sub(r"\[CQ:[^\]]*\]", "", message).strip(), at_me
|
||||
parts, at_me = [], False
|
||||
for seg in message or []:
|
||||
t, d = seg.get("type"), seg.get("data") or {}
|
||||
if t == "text":
|
||||
parts.append(d.get("text", ""))
|
||||
elif t == "at" and str(d.get("qq")) == str(self_id):
|
||||
at_me = True
|
||||
return "".join(parts).strip(), at_me
|
||||
|
||||
|
||||
async def call_action(conn: OneBotConn, action: str, params: dict,
|
||||
timeout: float = 30) -> dict:
|
||||
"""向 OneBot 实现发一个 action 并等它的 echo 响应"""
|
||||
echo = uuid.uuid4().hex
|
||||
fut = asyncio.get_running_loop().create_future()
|
||||
conn.pending[echo] = fut
|
||||
await conn.ws.send_text(json.dumps(
|
||||
{"action": action, "params": params, "echo": echo}))
|
||||
try:
|
||||
return await asyncio.wait_for(fut, timeout)
|
||||
finally:
|
||||
conn.pending.pop(echo, None)
|
||||
|
||||
|
||||
async def reply_message(conn: OneBotConn, session: str, text: str) -> bool:
|
||||
"""往会话对应的 QQ 对象发消息;发送成功才记进对话历史"""
|
||||
tgt = conn.targets.get(session)
|
||||
if not tgt:
|
||||
return False
|
||||
if tgt["type"] == "group":
|
||||
action = "send_group_msg"
|
||||
params = {"group_id": tgt["group_id"],
|
||||
"message": f"[CQ:at,qq={tgt['user_id']}] {text}"}
|
||||
else:
|
||||
action = "send_private_msg"
|
||||
params = {"user_id": tgt["user_id"], "message": text}
|
||||
try:
|
||||
res = await call_action(conn, action, params)
|
||||
except Exception:
|
||||
return False
|
||||
if res.get("retcode") != 0:
|
||||
return False
|
||||
await db.add_message(session, "assistant", text)
|
||||
return True
|
||||
|
||||
|
||||
async def send_to_session(session: str, text: str) -> bool:
|
||||
"""主动消息入口(push_proactive 调用):找挂着这个会话的连接发出去"""
|
||||
for conn in list(connections.values()):
|
||||
if session in conn.targets:
|
||||
return await reply_message(conn, session, text)
|
||||
return False
|
||||
|
||||
|
||||
def _notify_for(conn: OneBotConn, session: str):
|
||||
"""把 remember_later 的推送转成 QQ 消息(只挑用户真正关心的)"""
|
||||
async def notify(payload: dict) -> None:
|
||||
t = payload.get("type")
|
||||
try:
|
||||
if t == "event":
|
||||
kind = "叫早" if payload.get("kind") == "alarm" else "提醒"
|
||||
extra = ",每天都算" if payload.get("repeat") == "daily" else ""
|
||||
await reply_message(
|
||||
conn, session,
|
||||
f"好呀,我记下了:{payload['fire_at'].replace('T', ' ')} "
|
||||
f"{kind}({payload['text']}){extra}")
|
||||
elif t == "care":
|
||||
await reply_message(
|
||||
conn, session,
|
||||
"🫂 嗯,我记在心里了。明天这个时候我来问问你好不好些。"
|
||||
if payload.get("kind") == "emotion"
|
||||
else f"好,「{payload['note']}」这事儿我惦记着!"
|
||||
"到时候我来问你结果~")
|
||||
elif t == "streak":
|
||||
await reply_message(
|
||||
conn, session,
|
||||
f"🔥 对了,今天是我们连续陪伴的第 {payload['days']} 天")
|
||||
elif t == "lore_saved":
|
||||
await reply_message(
|
||||
conn, session,
|
||||
"🧬 这段身世我刻进脑海里了("
|
||||
+ "、".join(payload["topics"]) + "),永远记得,绝不瞎说。")
|
||||
# memories / routines 等后台数据推送不发 QQ,静默
|
||||
except Exception:
|
||||
pass # 连接断了就算了,数据已入库
|
||||
return notify
|
||||
|
||||
|
||||
async def _process_message(conn: OneBotConn, data: dict) -> None:
|
||||
mtype = data.get("message_type")
|
||||
user_id = data.get("user_id")
|
||||
text, at_me = _extract_text(data.get("message"), conn.self_id)
|
||||
if mtype == "private":
|
||||
session = f"ob-{conn.self_id}-p{user_id}"
|
||||
conn.targets[session] = {"type": "private", "user_id": user_id}
|
||||
else:
|
||||
session = f"ob-{conn.self_id}-g{data.get('group_id')}"
|
||||
conn.targets[session] = {"type": "group",
|
||||
"group_id": data.get("group_id"),
|
||||
"user_id": user_id}
|
||||
if not text or (mtype == "group" and not at_me):
|
||||
return # 空消息 / 群里没 @ 她
|
||||
from .main import chat_turn # 延迟导入避免循环
|
||||
reply = await chat_turn(session, text, _notify_for(conn, session))
|
||||
if reply:
|
||||
await reply_message(conn, session, reply)
|
||||
|
||||
|
||||
@router.websocket("/onebot/v11/ws")
|
||||
async def onebot_ws(ws: WebSocket):
|
||||
if ACCESS_TOKEN:
|
||||
token = ws.query_params.get("access_token") or ""
|
||||
auth = ws.headers.get("authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
token = auth[7:]
|
||||
if token != ACCESS_TOKEN:
|
||||
await ws.close(code=4401)
|
||||
return
|
||||
await ws.accept()
|
||||
conn = None
|
||||
try:
|
||||
while True:
|
||||
data = json.loads(await ws.receive_text())
|
||||
if "post_type" in data: # 事件上报
|
||||
self_id = data.get("self_id")
|
||||
if conn is None or conn.self_id != self_id:
|
||||
conn = OneBotConn(ws, self_id)
|
||||
connections[self_id] = conn
|
||||
if data.get("post_type") == "message":
|
||||
# 后台处理,读循环继续收(事件里可能夹着 action 的 echo 响应)
|
||||
asyncio.create_task(_process_message(conn, data))
|
||||
elif conn is not None and "echo" in data: # action 的响应
|
||||
fut = conn.pending.pop(data.get("echo"), None)
|
||||
if fut and not fut.done():
|
||||
fut.set_result(data)
|
||||
# 其余(对端主动调 action 等)忽略
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
if conn is not None:
|
||||
connections.pop(conn.self_id, None)
|
||||
Reference in new issue
Block a user