Initial import of Shirobana bot backend

Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
This commit is contained in:
RRRosmontis
2026-11-11 00:25:30 +08:00
commit 34089d0042
106 changed files with 28028 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
# qq_channel.py — QQ 通道适配(NapCat / OneBot 11)
# 与 tg_channel.py 同级:本模块只负责「统一 content ↔ OneBot 消息段」的翻译,
# 不含任何业务逻辑;收事件的入站传输层在 utility/bridges/napcat.py(反向 WS :9089)。
# 通道命名: "qq" = NapCat/OneBot11 反向WS
import asyncio
import json
import re
import uuid
from channel import Channel
BOT_QQ = 1295798840
# 发送回执等待上限(秒):NapCat 需读 OSS 挂载文件 + 上传 QQ,大图可能数十秒
RECEIPT_TIMEOUT = 60
# 发送失败后的重试前等待(秒):OSS 挂载瞬断(ENOTCONN)等通常数秒内自愈
RETRY_DELAY = 3
class NapcatChannel(Channel):
name = "qq"
def __init__(self, admin_qq=None):
self.ws = None
self._admin_qq = set(int(x) for x in (admin_qq or []))
self._pending = {} # echo -> asyncio.Future(send_msg 的 API 回执)
@property
def connected(self):
return self.ws is not None
def attach(self, ws):
# 旧连接残留的回执 Future 不能静默丢弃:置失败让等待方立即返回,
# 否则会悬挂到 RECEIPT_TIMEOUT 才按"结果未知"放弃(消息静默丢失)
for fut in self._pending.values():
if not fut.done():
fut.set_result({"status": "failed", "message": "NapCat 连接已重建"})
self._pending.clear() # 重连后旧回执全部作废
self.ws = ws
def detach(self, ws=None):
# 传入 ws 时只清理"自己这条连接"的状态:重连重叠(新连接已 attach、
# 旧连接 handler 才退出)时不得误清新连接的 ws 与回执;
# 无参调用保持旧行为(无条件清理,向后兼容)
if ws is not None and self.ws is not ws:
return
# 断开时把所有等待中的回执置为失败,避免 send() 悬挂到超时
for fut in self._pending.values():
if not fut.done():
fut.set_result({"status": "failed", "message": "NapCat 连接已断开"})
self._pending.clear()
self.ws = None
def resolve_receipt(self, data):
"""供 WS 接收循环调用:若是指令回执(带 echo)则核销并返回 True。"""
echo = data.get("echo")
if not echo:
return False
fut = self._pending.pop(echo, None)
if fut is None:
return False
if not fut.done():
fut.set_result(data)
return True
def is_admin(self, user_id) -> bool:
try:
if int(user_id) in self._admin_qq:
return True
except (TypeError, ValueError):
pass
return super().is_admin(user_id)
async def send(self, msg_type, target_id, content):
"""返回 bool: True=NapCat 回执发送成功;False=失败(未连接/重试后仍失败/结果未知)。
回执语义(OneBot 11 echo 机制):send_msg 携带 echo,等待 NapCat API 响应,
status=='failed' 视为失败并自动重试一次;回执超时按"结果未知"处理——
此时消息可能已送达,为免重复发送不再重试,仅记日志。
不抛异常(reply 路径依赖现有行为)。
"""
if not self.ws:
print("[qq] NapCat 未连接,跳过发送")
return False
outcome = await self._attempt_send(msg_type, target_id, content)
if outcome == "ok":
return True
if outcome == "unknown":
return False
# 明确失败(NapCat 报告 failed / 发送异常):等几秒让挂载/网络自愈后重试一次
await asyncio.sleep(RETRY_DELAY)
if not self.ws:
print("[qq] 重试前 NapCat 已断开,放弃")
return False
print(f"[qq] {RETRY_DELAY} 秒后重试发送 ({msg_type}/{target_id})...")
return await self._attempt_send(msg_type, target_id, content) == "ok"
async def _attempt_send(self, msg_type, target_id, content):
"""单次发送并等待回执。返回 "ok" / "failed" / "unknown"(超时,结果不明)。"""
echo = uuid.uuid4().hex
fut = asyncio.get_running_loop().create_future()
self._pending[echo] = fut
action = {
"action": "send_msg",
"params": {
"message_type": msg_type,
"message": content,
},
"echo": echo,
}
if msg_type == "group":
action["params"]["group_id"] = target_id
else:
action["params"]["user_id"] = target_id
try:
await self.ws.send(json.dumps(action))
resp = await asyncio.wait_for(fut, timeout=RECEIPT_TIMEOUT)
except asyncio.TimeoutError:
self._pending.pop(echo, None)
print(f"[qq] 发送回执超时(>{RECEIPT_TIMEOUT}s, 结果未知不重发): {msg_type}/{target_id}")
return "unknown"
except Exception as e:
self._pending.pop(echo, None)
print(f"[qq] 发送异常: {type(e).__name__}: {str(e)[:100]}")
return "failed"
if resp.get("status") == "failed":
detail = resp.get("message") or resp.get("wording") or resp
print(f"[qq] NapCat 报告发送失败: {str(detail)[:120]}")
return "failed"
return "ok"
# ---------------- OneBot 11 事件解析(QQ 侧协议) ----------------
def get_plain_text(segments):
text_parts = []
for seg in segments:
if seg.get("type") == "text":
text_parts.append(seg.get("data", {}).get("text", ""))
return "".join(text_parts).strip()
def get_image_infos(segments):
infos = []
for seg in segments:
if seg.get("type") == "image" and str(seg.get("data", {}).get("sub_type", "0")) != "1":
url = seg.get("data", {}).get("url", "")
if url:
infos.append({
"url": url,
"file": seg.get("data", {}).get("file", ""),
"file_size": seg.get("data", {}).get("file_size", "0"),
})
return infos
def is_at_me(segments, self_id):
for seg in segments:
if seg.get("type") == "at" and str(seg.get("data", {}).get("qq", "")) == str(self_id):
return True
return False
def is_reply_to_me(segments, self_id):
for seg in segments:
if seg.get("type") == "reply" and str(seg.get("data", {}).get("qq", "")) == str(self_id):
return True
return False
def re_sub_at(raw_text):
return re.sub(r'@[0-9]+\s*', '', raw_text).strip()