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
+167
View File
@@ -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