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
+361
View File
@@ -0,0 +1,361 @@
# identity_cmd.py — 统一身份指令:.my(兼容旧 .me / .bind / .unbind 别名)
# 由 dispatch.py 前置路由调用。
# .my / .my help 帮助菜单
# .my account 查看身份信息
# .my account bind ... 绑定(状态查看 / QQ 侧发起 / TG 侧确认码)
# .my account unbind [QQ号] 解绑(TG 侧任意 QQ;QQ 侧只能解绑自己)
# .my qq-push <on|off> QQ 侧私聊推送开关(默认开,作用于全部消息)
# .my tg-push <on|off> TG 侧私聊推送开关(默认开,作用于全部消息)
import identity
from channel import get_channel, send_message
UNBOUND_TEXT = (
"你还没有绑定统一身份,暂时不能使用本 bot 的功能。\n"
"绑定步骤:\n"
"1. 在 Telegram 私聊我,发送 .my account 获取你的身份 id;\n"
"2. 回到这里(QQ 私聊)发送 .my account bind <身份 id>;\n"
"3. 在 Telegram 侧回复 .my account bind <确认码> 完成确认。"
)
GROUP_BIND_TEXT = (
"该功能会保存你的个人配置,需要先绑定统一身份(请勿在群里操作):\n"
"1. Telegram 私聊我发送 .my account 获取身份 id;\n"
"2. QQ 私聊我发送 .my account bind <身份 id>;\n"
"3. 回 Telegram 侧用 .my account bind <确认码> 确认。\n"
"群内其它功能(.setu / .pixiv / .jm / .live 等)无需绑定即可使用。"
)
QQ_BIND_USAGE = (
"绑定步骤:\n"
"1. 在 Telegram 私聊我,发送 .my account 获取你的身份 id;\n"
"2. 在这里发送 .my account bind <身份 id>;\n"
"3. 在 Telegram 侧回复 .my account bind <确认码> 完成确认。\n"
"也可以先用 .my account bind status 查看绑定状态。"
)
TG_BIND_USAGE = (
"用法:\n"
"• .my account bind 查看绑定状态\n"
"• .my account bind status 查看绑定状态\n"
"• .my account bind <确认码> 确认一条 QQ 绑定请求(6 位数字)\n"
"• .my account bind unbind [QQ号] 解绑(不写 QQ 号则列出已绑定的 QQ)\n"
"绑定 QQ:让该 QQ 私聊我发送 .my account bind <你的身份 id>,再回到这里确认。"
)
MY_HELP_TEXT = (
".my 统一身份与个人设置\n"
"━━━━━━━━━━━━\n"
".my 本菜单\n"
".my account 查看身份信息\n"
".my account bind 绑定 QQ(发起/确认/状态)\n"
".my account unbind 解绑 QQ\n"
".my qq-push <on|off> QQ 侧私聊推送开关(默认开)\n"
".my tg-push <on|off> TG 侧私聊推送开关(默认开)\n"
"━━━━━━━━━━━━\n"
"推送开关作用于你的全部消息(指令回复与主动通知),\n"
"关闭某侧后该侧不再收到任何私聊消息。"
)
STATUS_SUBCOMMANDS = ("status", "状态")
UNBIND_SUBCOMMANDS = ("unbind", "解绑", "remove")
async def _reply(channel, msg_type, target_id, content):
# 身份/设置类指令的回复不跨平台镜像:设置指令在哪个平台发就回在哪个平台,
# 避免 "关推送的确认语又弹到另一平台" 这类自我矛盾
await send_message(channel, msg_type, target_id, content, mirror=False)
async def _notify(channel_name, target_id, content):
channel = get_channel(channel_name)
if channel is None or not target_id:
return False
# send_message returns False for a known delivery failure; never turn that
# into a successful notification (notably the QQ binding request flow).
return await send_message(channel, "private", target_id, content) is True
def _pending_lines(info):
lines = []
for item in info.get("pending") or []:
lines.append(" QQ %s → 确认码 %s(剩余 %d 分钟)"
% (item["qq"], item["token"], max(1, item["remain_seconds"] // 60)))
return lines
def _status_text(uid, qq=None):
"""绑定状态文案;qq 不为 None 时额外标注该 QQ 是否已绑定"""
info = identity.summary(uid)
if not info:
return "身份信息读取失败,请联系管理员"
lines = [
"统一身份绑定状态",
"━━━━━━━━━━━━",
"身份 id:%d(%s)" % (info["uid"], "管理员" if info["admin"] else "普通用户"),
"TG 账号:%s" % (info["tg_raw"] or "未绑定"),
"QQ 账号:%s" % (", ".join(str(x) for x in info["qq"]) if info["qq"] else "未绑定"),
]
if qq is not None:
bound = int(qq) in (info["qq"] or [])
lines.append("本 QQ:%s" % ("已绑定" if bound else "未绑定"))
pending = _pending_lines(info)
if pending:
lines.append("待确认:")
lines.extend(pending)
lines.append("━━━━━━━━━━━━")
lines.append("绑定:QQ 私聊 .my account bind <身份 id> → TG 侧 .my account bind <确认码>")
lines.append("解绑:.my account unbind [QQ号]")
lines.append("推送:.my qq-push on|off / .my tg-push on|off")
return "\n".join(lines)
def _own_qq(channel, msg_type, target_id):
"""QQ 私聊里用户自己的 QQ 号(私聊时 target_id 即 QQ 号);其它情况返回 None"""
if channel.name == "qq" and msg_type == "private":
try:
return int(target_id)
except (TypeError, ValueError):
return None
return None
async def _do_unbind(channel, msg_type, target_id, uid, arg):
"""解绑:TG 侧可指定 QQ;QQ 侧只能解绑自己(必须私聊)"""
info = identity.summary(uid) or {}
qq_list = info.get("qq") or []
if channel.name == "qq":
if msg_type != "private":
await _reply(channel, msg_type, target_id, "解绑请在私聊进行:私聊我发送 .my account unbind")
return
own = _own_qq(channel, msg_type, target_id)
if own is None:
await _reply(channel, msg_type, target_id, "无法识别你的 QQ 号,请稍后重试")
return
if arg and str(arg) != str(own):
await _reply(channel, msg_type, target_id,
"QQ 侧只能解绑自己(本 QQ 为 %d):如需解绑其它 QQ 请在 TG 侧操作" % own)
return
ok, message = identity.unbind_qq(uid, own)
await _reply(channel, msg_type, target_id, message if ok else "解绑失败:%s" % message)
return
if not arg:
if qq_list:
await _reply(channel, msg_type, target_id,
"已绑定的 QQ:%s\n用法:.my account unbind <QQ号>(不写则只列出)"
% ", ".join(str(x) for x in qq_list))
else:
await _reply(channel, msg_type, target_id, "你的身份还没有绑定任何 QQ")
return
ok, message = identity.unbind_qq(uid, arg)
await _reply(channel, msg_type, target_id, message if ok else "解绑失败:%s" % message)
if ok:
await _notify("qq", arg, "你的 QQ 已与统一身份 %d 解绑,如需继续使用请重新绑定。" % uid)
async def handle_me(channel, msg_type, target_id, uid):
info = identity.summary(uid)
if not info:
await _reply(channel, msg_type, target_id, "身份信息读取失败,请联系管理员")
return
lines = [
"统一身份",
"━━━━━━━━━━━━",
"身份 id:%d" % info["uid"],
"身份类型:%s" % ("管理员" if info["admin"] else "普通用户"),
"TG 账号:%s" % (info["tg_raw"] or "未绑定"),
"QQ 账号:%s" % (", ".join(str(x) for x in info["qq"]) if info["qq"] else "未绑定"),
]
pending = _pending_lines(info)
if pending:
lines.append("待确认绑定:")
lines.extend(pending)
lines.append("━━━━━━━━━━━━")
lines.append("绑定 QQ:QQ 私聊发 .my account bind %d,再回到这里发 .my account bind <确认码>" % info["uid"])
lines.append("推送:.my qq-push on|off / .my tg-push on|off")
await _reply(channel, msg_type, target_id, "\n".join(lines))
async def handle_bind(channel, msg_type, target_id, uid, arg):
"""已绑定用户的 .bind:status / unbind /(TG 侧)确认码"""
arg = (arg or "").strip()
low = arg.lower()
if low in STATUS_SUBCOMMANDS:
await _reply(channel, msg_type, target_id,
_status_text(uid, _own_qq(channel, msg_type, target_id)))
return
if low in UNBIND_SUBCOMMANDS:
await _do_unbind(channel, msg_type, target_id, uid, "")
return
if channel.name != "tg":
# QQ 侧已绑定:给出状态与解绑入口
info = identity.summary(uid) or {}
pending = _pending_lines(info)
text = "你已绑定身份 %d。\n" % uid
if pending:
text += "待确认的绑定请求:\n" + "\n".join(pending) + "\n请在 TG 侧回复 .bind <确认码>。\n"
text += "查看状态:.my account bind status\n解绑本 QQ:.my account unbind\n为其它 QQ 绑定:让该 QQ 私聊我发送 .my account bind %d" % uid
await _reply(channel, msg_type, target_id, text)
return
if not arg:
info = identity.summary(uid) or {}
pending = _pending_lines(info)
if pending:
await _reply(channel, msg_type, target_id,
"待确认的 QQ 绑定请求:\n" + "\n".join(pending) + "\n回复 .my account bind <确认码> 确认。")
else:
await _reply(channel, msg_type, target_id, TG_BIND_USAGE)
return
token = low
if token.isdigit() and len(token) == 6:
ok, message, qq = identity.confirm_qq_bind(uid, token)
if not ok:
await _reply(channel, msg_type, target_id, "确认失败:%s" % message)
return
await _reply(channel, msg_type, target_id,
"确认成功:QQ %d 已绑定到身份 %d,现在可以用该 QQ 使用全部功能。" % (qq, uid))
await _notify("qq", qq, "绑定成功:你的 QQ 已与统一身份 %d 关联。" % uid)
return
if token.isdigit():
await _reply(channel, msg_type, target_id,
"这是身份 id 而不是确认码。绑定 QQ 请在 QQ 私聊发送 .my account bind %s,"
"然后回到这里用 .my account bind <6 位确认码> 确认。" % token)
return
await _reply(channel, msg_type, target_id, TG_BIND_USAGE)
async def handle_qq_bind(channel, msg_type, target_id, qq, arg):
"""QQ 侧未绑定用户的 .bind:status / unbind / <身份 id> 发起绑定"""
arg = (arg or "").strip()
low = arg.lower()
if low in STATUS_SUBCOMMANDS:
await _reply(channel, msg_type, target_id,
"绑定状态\n━━━━━━━━━━━━\n本 QQ:%s\n尚未绑定任何统一身份。\n━━━━━━━━━━━━\n%s"
% (qq, QQ_BIND_USAGE))
return
if low in UNBIND_SUBCOMMANDS:
await _reply(channel, msg_type, target_id,
"本 QQ 尚未绑定任何统一身份,无需解绑。\n\n" + QQ_BIND_USAGE)
return
if msg_type != "private":
await _reply(channel, msg_type, target_id, "绑定请在私聊进行(避免身份 id 泄露到群里)")
return
if not arg:
await _reply(channel, msg_type, target_id, QQ_BIND_USAGE)
return
if not (arg.isdigit() and len(arg) >= 10):
await _reply(channel, msg_type, target_id,
"身份 id 应为 10 位数字(在 TG 私聊用 .me 查看),例如 .my account bind 9000000001\n\n"
+ QQ_BIND_USAGE)
return
uid = int(arg)
# 先验身份与 TG 可达性,再创建 pending:避免告知失败但待确认请求已残留
info = identity.summary(uid)
if not info:
await _reply(channel, msg_type, target_id,
"绑定失败:身份 id 不存在,请核对 TG 侧 .me 显示的 id")
return
if not info.get("tg_ns"):
await _reply(channel, msg_type, target_id, "该身份没有可用的 TG 账号,无法发送确认请求")
return
ok, message, token = identity.request_qq_bind(qq, uid)
if not ok:
await _reply(channel, msg_type, target_id, "绑定失败:%s" % message)
return
text = ("【身份绑定请求】\n"
"QQ %d 请求绑定到你的身份 %d。\n"
"确认码:%s(10 分钟内有效)\n"
"确认方式:在本对话回复 .my account bind %s\n"
"如果这不是你本人的操作,请忽略本条消息。" % (qq, uid, token, token))
if not await _notify("tg", info["tg_ns"], text):
# TG 通道不可用:pending 已创建(TG 恢复后仍可在有效期内确认),
# 文案如实告知,不谎称"已发送"
await _reply(channel, msg_type, target_id,
"TG 通道暂时不可用,确认请求未能送达。\n"
"请稍后重发本指令(确认码 10 分钟内有效,重发会刷新有效期)。")
return
await _reply(channel, msg_type, target_id,
"已向身份 %d 的 TG 账号发送确认请求(确认码 %s)。\n"
"请在 Telegram 私聊中回复 .my account bind %s 完成绑定。" % (uid, token, token))
async def handle_unbind(channel, msg_type, target_id, uid, arg):
await _do_unbind(channel, msg_type, target_id, uid, (arg or "").strip())
async def handle_my(channel, msg_type, target_id, user_id, uid, arg):
""".my 统一入口。user_id=原始平台 id(未绑定 QQ 时为 QQ 号),uid=统一身份(未绑定为 None)。"""
arg = (arg or "").strip()
low = arg.lower()
if not low or low in ("help", "-h", "帮助"):
await _reply(channel, msg_type, target_id, MY_HELP_TEXT)
return
parts = arg.split(maxsplit=1)
head = parts[0].lower()
rest = parts[1].strip() if len(parts) > 1 else ""
if head == "account":
if not rest:
if uid is None:
await _reply(channel, msg_type, target_id,
"尚未绑定统一身份。\n" + QQ_BIND_USAGE)
return
await handle_me(channel, msg_type, target_id, uid)
return
sub = rest.split(maxsplit=1)
sub_head = sub[0].lower()
sub_rest = sub[1].strip() if len(sub) > 1 else ""
if sub_head == "bind":
if uid is None:
await handle_qq_bind(channel, msg_type, target_id, user_id, sub_rest)
else:
await handle_bind(channel, msg_type, target_id, uid, sub_rest)
return
if sub_head in UNBIND_SUBCOMMANDS:
if uid is None:
await _reply(channel, msg_type, target_id,
"本 QQ 尚未绑定任何统一身份,无需解绑。\n\n" + QQ_BIND_USAGE)
return
await _do_unbind(channel, msg_type, target_id, uid, sub_rest)
return
await _reply(channel, msg_type, target_id,
"未知子命令:%s\n\n" % sub_head + MY_HELP_TEXT)
return
if head in ("qq-push", "tg-push"):
platform = head.split("-")[0]
if uid is None:
await _reply(channel, msg_type, target_id,
"需要先绑定统一身份才能设置推送开关:\n" + QQ_BIND_USAGE)
return
flag = rest.lower()
if flag not in ("on", "off"):
cur = (identity.summary(uid) or {}).get("push") or {}
state = "开" if cur.get(platform, True) else "关"
await _reply(channel, msg_type, target_id,
"用法:.my %s on|off(当前:%s)" % (head, state))
return
ok, message = identity.set_push(uid, platform, flag == "on")
if not ok:
await _reply(channel, msg_type, target_id, "设置失败:%s" % message)
return
effect = "该侧将不再收到任何私聊消息" if flag == "off" else "该侧将收到你的全部私聊消息"
await _reply(channel, msg_type, target_id,
"%s 侧私聊推送已%s。%s。" % (platform.upper(), message, effect))
return
await _reply(channel, msg_type, target_id, MY_HELP_TEXT)