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:
+167
@@ -0,0 +1,167 @@
|
||||
# channel.py — 统一消息通道抽象
|
||||
# 各业务模块不再直接构造 OneBot action,而是通过 Channel.send() 发送。
|
||||
# 通道命名: "qq" = NapCat/OneBot11 反向WS, "tg" = Telegram Bot
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
|
||||
import identity
|
||||
|
||||
# 每个入口进程在自己的目录运行(utility/ 或 chat/),
|
||||
# settings.json 放在各自入口目录下,按 CWD 读取。
|
||||
def load_settings() -> dict:
|
||||
path = os.path.join(os.getcwd(), "settings.json")
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
data = {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def admin_list(channel_name: str) -> list:
|
||||
"""取某通道的管理员 id 列表(settings.json -> admin -> <channel>)"""
|
||||
s = load_settings().get("admin", {})
|
||||
lst = s.get(channel_name, [])
|
||||
return [int(x) for x in lst] if isinstance(lst, list) else []
|
||||
|
||||
|
||||
class Channel:
|
||||
"""消息通道基类。
|
||||
content 约定: str 纯文本, 或 list[dict] 段数组(OneBot 段格式:
|
||||
{"type":"text","data":{"text":..}} / {"type":"image","data":{"file":..}})
|
||||
"""
|
||||
name = "base"
|
||||
|
||||
def is_admin(self, user_id) -> bool:
|
||||
try:
|
||||
if int(user_id) in admin_list(self.name):
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
# 统一身份:管理员判定以身份库为准(QQ/TG 两侧管理员合并到同一 uid)
|
||||
return identity.is_admin(user_id)
|
||||
|
||||
async def send(self, msg_type, target_id, content):
|
||||
raise NotImplementedError
|
||||
# msg_type: "group" | "private"
|
||||
|
||||
def typing(self, target_id):
|
||||
"""处理消息期间的「正在输入」提示(异步上下文管理器,默认 no-op)。
|
||||
用法: async with channel.typing(user_id): await 耗时操作()
|
||||
"""
|
||||
@contextlib.asynccontextmanager
|
||||
async def _noop():
|
||||
yield
|
||||
return _noop()
|
||||
|
||||
|
||||
CHANNELS = {} # channel name -> Channel 实例
|
||||
|
||||
|
||||
def register_channel(ch: Channel):
|
||||
CHANNELS[ch.name] = ch
|
||||
|
||||
|
||||
def unregister_channel(name: str):
|
||||
CHANNELS.pop(name, None)
|
||||
|
||||
|
||||
def get_channel(name: str):
|
||||
return CHANNELS.get(name)
|
||||
|
||||
|
||||
def all_channels():
|
||||
return list(CHANNELS.values())
|
||||
|
||||
|
||||
async def _mirror_private(channel, target_id, content):
|
||||
"""私聊回复镜像:按身份推送开关,把消息复制到该身份的其它平台私聊。
|
||||
|
||||
- 仅私聊生效;群聊、含 file 段的内容不镜像;
|
||||
- 镜像发送直接走目标通道的 send()(不经过 send_message),不会递归;
|
||||
- 开关关闭的一侧由 identity.push_targets() 直接剔除。
|
||||
"""
|
||||
try:
|
||||
if isinstance(content, list) and any(
|
||||
isinstance(seg, dict) and seg.get("type") == "file" for seg in content):
|
||||
print(f"[channel] 镜像跳过(含 file 段): {getattr(channel, 'name', '?')}/{target_id}")
|
||||
return
|
||||
uid = identity.resolve(channel.name, target_id)
|
||||
if not uid:
|
||||
return
|
||||
targets = [t for t in identity.push_targets(uid)
|
||||
if not (t[0] == channel.name and str(t[1]) == str(target_id))]
|
||||
for name, tid in targets:
|
||||
ch = CHANNELS.get(name)
|
||||
if ch is None:
|
||||
continue
|
||||
try:
|
||||
ok = await ch.send("private", tid, content)
|
||||
print(f"[channel] 镜像 uid={uid} -> {name}/{tid} "
|
||||
f"{'成功' if ok is not False else '失败'}")
|
||||
except Exception as e:
|
||||
print(f"[channel] 镜像 uid={uid} -> {name}/{tid} 失败: {e}")
|
||||
except Exception as e:
|
||||
print(f"[channel] 镜像异常: {e}")
|
||||
|
||||
|
||||
async def send_message(channel, msg_type, target_id, content, mirror=True):
|
||||
"""统一发送入口(与旧 main.send_message 签名兼容: 第一个参数是通道对象)
|
||||
|
||||
返回 bool: 是否确实投递。
|
||||
- 通道缺失 / send() 抛异常 -> False
|
||||
- send() 显式返回 False -> False(通道自我报告投递失败)
|
||||
- send() 返回 None / True -> True(兼容旧通道实现,语义不变)
|
||||
mirror: 私聊回复是否按身份推送开关镜像到其它平台(主动扇出 push_to_user 传 False 防重复)
|
||||
"""
|
||||
if not channel:
|
||||
print("[channel] 通道未连接,跳过发送")
|
||||
return False
|
||||
try:
|
||||
result = await channel.send(msg_type, target_id, content)
|
||||
except Exception as e:
|
||||
print(f"[channel:{getattr(channel, 'name', '?')}] 发送失败: {e}")
|
||||
return False
|
||||
if result is False:
|
||||
print(f"[channel:{getattr(channel, 'name', '?')}] 通道报告投递失败")
|
||||
return False
|
||||
if mirror and msg_type == "private":
|
||||
await _mirror_private(channel, target_id, content)
|
||||
return True
|
||||
|
||||
|
||||
async def push_to_user(uid, content, tag="推送", targets=None):
|
||||
"""按统一身份扇出「个人私聊推送」:TG 私聊 + 每个已绑定 QQ 私聊(QQ 镜像)。
|
||||
|
||||
参数:
|
||||
uid 统一身份 id
|
||||
content 消息内容(str 或 OneBot 段数组)
|
||||
tag 日志/审计标签(仅用于日志)
|
||||
targets 可选:限定本次扇出的目标集合 [(channel, target_id), ...]。
|
||||
持久化队列重试时只补投未成功的目标,避免已成功目标被重复推送。
|
||||
返回 {(channel_name, target_id): bool};通道未注册/发送失败一律记 False,不抛异常。
|
||||
"""
|
||||
if targets is None:
|
||||
targets = identity.push_targets(uid)
|
||||
else:
|
||||
targets = list(targets)
|
||||
results = {}
|
||||
if not targets:
|
||||
print(f"[channel] 身份 {uid} 无可用的推送目标,跳过{tag}")
|
||||
return results
|
||||
for name, target_id in targets:
|
||||
ch = CHANNELS.get(name)
|
||||
if ch is None:
|
||||
print(f"[channel] {tag} uid={uid} 目标 {name}/{target_id} 失败:通道未注册")
|
||||
results[(name, target_id)] = False
|
||||
continue
|
||||
try:
|
||||
ok = await send_message(ch, "private", target_id, content, mirror=False)
|
||||
except Exception as e: # send_message 已兜底;此处仅防御
|
||||
print(f"[channel] {tag} uid={uid} 目标 {name}/{target_id} 失败: {e}")
|
||||
ok = False
|
||||
ok = True if ok is None else bool(ok)
|
||||
results[(name, target_id)] = ok
|
||||
print(f"[channel] {tag} uid={uid} -> {name}/{target_id} {'成功' if ok else '失败'}")
|
||||
return results
|
||||
Reference in New Issue
Block a user