Files
shirobana-bot/qq_channel.py
T
RRRosmontis 34089d0042 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.
2026-11-11 00:25:30 +08:00

173 lines
6.6 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.
# 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()