Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
173 lines
6.6 KiB
Python
173 lines
6.6 KiB
Python
# 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()
|