Files
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

562 lines
18 KiB
Python
Raw Permalink 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.
# identity.py — 统一身份层(平台层,与 channel.py / tg_channel.py / qq_channel.py 同级)
#
# 目标:每个使用者只有一个与平台无关的身份 id(uid)。TG 账号首次对话自动开卡;
# QQ 账号通过「QQ 侧发起 + TG 侧确认」绑定到同一个 uid。业务状态一律以 uid 为键,
# QQ 侧不再存在任何独立配置;未绑定的 QQ 用户只能看到绑定指引。
#
# 存储(目录由 configure() 注入,本模块不感知调用方的目录结构):
# users/<uid>.json 权威记录,每用户一个文件
# index.json 生成的映射表(tg / qq → uid),供人查看与快速加载
# pending.json QQ 绑定待确认请求
#
# id 段位:QQ 号历史上限约 4.29e9(uint32);TG 用户 ns = 1e10 + raw;TG 群 ns = 1e12 + |chat_id|。
# 故 [4.3e9, 1e10) 无任何业务 id 占用:uid 取 UID_BASE 起递增,GID_BASE 预留给第二步的
# 群身份(本模块暂不使用),二者错开,日志里也不会与 QQ/TG id 混淆。
import json
import os
import random
import threading
import time
from datetime import datetime, timedelta, timezone
UID_BASE = 9000000001
GID_BASE = 8000000001
PENDING_TTL = 600 # 绑定确认码有效期(秒)
MIN_QQ_LEN = 5
MAX_QQ_LEN = 11
TOKEN_LEN = 6
TZ8 = timezone(timedelta(hours=8))
_lock = threading.RLock()
_base_dir = ""
_admin_tg_raw = set()
_users = {}
_tg_index = {}
_qq_index = {}
_pending = {}
_next_seq = 1
# ==================== 基础工具 ====================
def _stamp():
return datetime.now(TZ8).strftime("%Y-%m-%d %H:%M:%S")
def _tg_offset():
"""TG 用户命名空间偏移(与 tg_channel.TG_USER_OFFSET 同一来源)"""
from tg_channel import TG_USER_OFFSET
return TG_USER_OFFSET
def _users_dir():
return os.path.join(_base_dir, "users")
def _user_path(uid):
return os.path.join(_users_dir(), "%d.json" % int(uid))
def _index_path():
return os.path.join(_base_dir, "index.json")
def _pending_path():
return os.path.join(_base_dir, "pending.json")
def _read_json(path, default=None):
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return default
return data
def _write_json(path, data):
"""原子写:先写临时文件再 replace,避免半截文件"""
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
def _valid_qq(value):
text = str(value).strip()
return text.isdigit() and MIN_QQ_LEN <= len(text) <= MAX_QQ_LEN
# ==================== 加载 / 落盘 ====================
def _qq_id(item):
"""QQ 绑定项 -> int,取不到返回 None。
防御性加固(既有代码):users/*.json 里 qq 列表若混入非 dict 项或非法 id,
原写法 `int(item.get("id"))` 会抛 AttributeError/KeyError 而**不在** except 覆盖
范围内,导致整份身份库加载失败。统一走本取值器后脏数据只被忽略。
"""
if not isinstance(item, dict):
return None
try:
return int(item.get("id"))
except (TypeError, ValueError):
return None
def _index_record(rec):
uid = int(rec["uid"])
tg = rec.get("tg") or {}
if tg.get("raw") is not None:
try:
_tg_index[int(tg["raw"])] = uid
except (TypeError, ValueError):
pass
for item in (rec.get("qq") or []):
qq = _qq_id(item)
if qq is None:
continue
_qq_index[qq] = uid
def _reload():
"""以 users/*.json 为权威重建内存索引;index.json 只用于延续 seq,防止 uid 复用"""
global _users, _tg_index, _qq_index, _pending, _next_seq
_users, _tg_index, _qq_index = {}, {}, {}
try:
names = sorted(os.listdir(_users_dir()))
except OSError:
names = []
for name in names:
if not name.endswith(".json"):
continue
rec = _read_json(os.path.join(_users_dir(), name))
if not isinstance(rec, dict):
continue
try:
uid = int(rec.get("uid"))
except (TypeError, ValueError):
continue
rec["uid"] = uid
_users[uid] = rec
for rec in _users.values():
_index_record(rec)
_next_seq = len(_users) + 1
idx = _read_json(_index_path())
if isinstance(idx, dict):
try:
_next_seq = max(_next_seq, int(idx.get("seq", 0)) + 1)
except (TypeError, ValueError):
pass
_pending = _read_json(_pending_path(), {})
if not isinstance(_pending, dict):
_pending = {}
_prune_pending()
_save_index()
_save_pending()
def _save_index():
if not _base_dir:
return
_write_json(_index_path(), {
"seq": _next_seq - 1,
"uid_base": UID_BASE,
"gid_base": GID_BASE,
"updated": _stamp(),
"tg": {str(k): v for k, v in sorted(_tg_index.items())},
"qq": {str(k): v for k, v in sorted(_qq_index.items())},
})
def _save_user(rec):
rec["updated_at"] = _stamp()
_write_json(_user_path(rec["uid"]), rec)
def _save_pending():
if not _base_dir:
return
_write_json(_pending_path(), _pending)
def _prune_pending():
now = time.time()
for token in [t for t, item in _pending.items()
if now - float(item.get("created", 0)) > PENDING_TTL]:
_pending.pop(token, None)
def _new_token():
for _ in range(50):
token = "%0*d" % (TOKEN_LEN, random.randint(10 ** (TOKEN_LEN - 1), 10 ** TOKEN_LEN - 1))
if token not in _pending:
return token
raise RuntimeError("无法分配绑定确认码")
def _alloc_uid():
global _next_seq
uid = UID_BASE + _next_seq - 1
_next_seq += 1
return uid
# ==================== 对外接口 ====================
def configure(accounts_dir, admin_tg_ids=None):
"""由入口调用:注入账号映射目录并加载身份库。admin_tg_ids 为 settings.json 的 admin.tg(裸 id)"""
global _base_dir, _admin_tg_raw
with _lock:
_base_dir = os.path.abspath(accounts_dir)
os.makedirs(_users_dir(), exist_ok=True)
_admin_tg_raw = set()
for value in (admin_tg_ids or []):
try:
_admin_tg_raw.add(int(value))
except (TypeError, ValueError):
pass
_reload()
print("[identity] 身份库已加载:%s(用户 %d,管理员名单 %d)"
% (_base_dir, len(_users), len(_admin_tg_raw)))
return _base_dir
def accounts_dir():
return _base_dir
def resolve(channel_name, user_id):
"""把某通道的用户 id 解析为 uid;未绑定返回 None"""
try:
value = int(user_id)
except (TypeError, ValueError):
return None
with _lock:
if channel_name == "tg":
offset = _tg_offset()
raw = value - offset if value >= offset else value
return _tg_index.get(raw)
if channel_name == "qq":
return _qq_index.get(value)
return None
def ensure_tg(tg_ns_id, username=""):
"""TG 账号首次对话自动开卡(幂等)"""
with _lock:
ns = int(tg_ns_id)
offset = _tg_offset()
raw = ns - offset if ns >= offset else ns
uid = _tg_index.get(raw)
if uid is not None:
rec = _users.get(uid)
tg = (rec or {}).get("tg") or {}
if rec and username and tg.get("username") != username:
tg["username"] = username
rec["tg"] = tg
_save_user(rec)
return uid
uid = _alloc_uid()
rec = {
"uid": uid,
"created_at": _stamp(),
"admin": raw in _admin_tg_raw,
"tg": {"raw": raw, "ns": ns, "username": username,
"source": "auto", "bound_at": _stamp()},
"qq": [],
"note": "",
}
_users[uid] = rec
_index_record(rec)
_save_user(rec)
_save_index()
return uid
def force_bind_qq(qq, uid, source="manual"):
"""不做确认直接绑定(迁移/管理员操作用)"""
with _lock:
rec = _users.get(int(uid))
if rec is None:
return False, "身份 id 不存在"
qq = int(qq)
owner = _qq_index.get(qq)
if owner is not None and owner != int(uid):
return False, "该 QQ 已绑定到其它身份 %d" % owner
rec.setdefault("qq", [])
if not any(_qq_id(x) == qq for x in rec["qq"]):
rec["qq"].append({"id": qq, "source": source, "bound_at": _stamp()})
_users[int(uid)] = rec
_index_record(rec)
_save_user(rec)
_save_index()
return True, "已绑定"
def request_qq_bind(qq, uid):
"""QQ 侧发起绑定 → 返回 (ok, 提示, 确认码)"""
with _lock:
if not _valid_qq(qq):
return False, "QQ 号格式不正确", None
qq = int(qq)
rec = _users.get(int(uid))
if rec is None:
return False, "身份 id 不存在,请核对 TG 侧 .me 显示的 id", None
owner = _qq_index.get(qq)
if owner == int(uid):
return False, "该 QQ 已经绑定在这个身份上了", None
if owner is not None:
return False, "该 QQ 已绑定到其它身份,请先在原身份侧解绑", None
for token, item in _pending.items():
if int(item.get("qq", 0)) == qq and int(item.get("uid", 0)) == int(uid):
# 重发即刷新有效期:QQ 侧每次发起都会重新通知 TG,
# created 不刷新会让「10 分钟内有效」的文案撒谎
item["created"] = time.time()
_save_pending()
return True, "已有待确认请求", token
token = _new_token()
_pending[token] = {"qq": qq, "uid": int(uid), "created": time.time()}
_save_pending()
return True, "已发送确认请求", token
def confirm_qq_bind(uid, token):
"""TG 侧确认 → 返回 (ok, 提示, qq)"""
with _lock:
token = str(token).strip()
item = _pending.get(token)
if not item:
return False, "确认码无效或已过期", None
if int(item.get("uid", 0)) != int(uid):
return False, "该确认码不属于你的身份", None
if time.time() - float(item.get("created", 0)) > PENDING_TTL:
_pending.pop(token, None)
_save_pending()
return False, "确认码已过期,请在 QQ 侧重新发起绑定", None
qq = int(item["qq"])
owner = _qq_index.get(qq)
if owner is not None and owner != int(uid):
_pending.pop(token, None)
_save_pending()
return False, "该 QQ 已被其它身份绑定", None
rec = _users[int(uid)]
rec.setdefault("qq", [])
if not any(_qq_id(x) == qq for x in rec["qq"]):
rec["qq"].append({"id": qq, "source": "confirm", "bound_at": _stamp()})
rec["admin"] = is_admin(uid)
_users[int(uid)] = rec
_index_record(rec)
_pending.pop(token, None)
_save_user(rec)
_save_index()
_save_pending()
return True, "绑定成功", qq
def unbind_qq(uid, qq):
with _lock:
if not _valid_qq(qq):
return False, "QQ 号格式不正确"
qq = int(qq)
rec = _users.get(int(uid))
if rec is None:
return False, "身份 id 不存在"
items = rec.get("qq") or []
if not any(_qq_id(x) == qq for x in items):
return False, "该 QQ 未绑定在你的身份上"
rec["qq"] = [x for x in items if _qq_id(x) != qq]
_users[int(uid)] = rec
_qq_index.pop(qq, None)
_save_user(rec)
_save_index()
return True, "已解绑 QQ %d" % qq
def pending_for(uid):
"""该身份下待确认的 QQ 绑定请求"""
with _lock:
_prune_pending()
now = time.time()
result = []
for token, item in _pending.items():
if int(item.get("uid", 0)) != int(uid):
continue
remain = max(0, int(PENDING_TTL - (now - float(item.get("created", 0)))))
result.append({"token": token, "qq": int(item.get("qq", 0)),
"remain_seconds": remain})
return sorted(result, key=lambda x: x["qq"])
def get(uid):
with _lock:
rec = _users.get(int(uid))
return json.loads(json.dumps(rec)) if rec else None
def find_qq(qq):
try:
value = int(qq)
except (TypeError, ValueError):
return None
with _lock:
return _qq_index.get(value)
def list_users():
with _lock:
return [get(uid) for uid in sorted(_users)]
def is_admin(uid):
try:
value = int(uid)
except (TypeError, ValueError):
return False
with _lock:
rec = _users.get(value)
if not rec:
return False
if rec.get("admin") is True:
return True
tg = rec.get("tg") or {}
try:
return int(tg.get("raw")) in _admin_tg_raw
except (TypeError, ValueError):
return False
def _push_flags(rec):
"""个人推送开关(默认开):{"qq": bool, "tg": bool}"""
push = (rec or {}).get("push") or {}
return {
"qq": push.get("qq") is not False,
"tg": push.get("tg") is not False,
}
def set_push(uid, platform, enabled):
"""设置某身份的单侧推送开关(.my qq-push / .my tg-push)。返回 (ok, message)。"""
try:
value = int(uid)
except (TypeError, ValueError):
return False, "身份 id 无效"
platform = str(platform or "").lower()
if platform not in ("qq", "tg"):
return False, "平台只能是 qq 或 tg"
with _lock:
rec = _users.get(value)
if not rec:
return False, "身份不存在"
push = rec.get("push") or {}
push[platform] = bool(enabled)
rec["push"] = push
_save_user(rec)
return True, ("开启" if enabled else "关闭")
def push_targets(uid):
"""统一异步推送的**全部**私聊目标:TG 私聊(若该身份有 TG)+ 每个已绑定 QQ 私聊。
返回 [("tg", ns), ("qq", raw_qq), ...],TG 在前、QQ 按绑定顺序、同 uid 内去重。
身份不存在 / 无任何合法目标时返回 [](不抛异常,由调用方记日志)。
TG 目标取 rec["tg"]["ns"];极少数历史记录缺 ns 时按 _tg_offset() 用 raw 回退。
"""
try:
value = int(uid)
except (TypeError, ValueError):
return []
with _lock:
rec = _users.get(value)
if not rec:
return []
targets = []
tg = rec.get("tg") or {}
ns = tg.get("ns")
if ns is None and tg.get("raw") is not None:
try:
ns = int(tg["raw"]) + _tg_offset()
except (TypeError, ValueError):
ns = None
if ns is not None:
try:
targets.append(("tg", int(ns)))
except (TypeError, ValueError):
pass
seen = set()
for item in (rec.get("qq") or []):
if not isinstance(item, dict):
continue
raw = item.get("id")
if raw is None or not _valid_qq(raw):
continue
try:
qq = int(raw)
except (TypeError, ValueError):
continue
if qq in seen:
continue
seen.add(qq)
targets.append(("qq", qq))
# 个人推送开关(.my qq-push / .my tg-push,默认开):关闭的一侧从推送目标剔除
push = rec.get("push") or {}
if push.get("tg") is False:
targets = [t for t in targets if t[0] != "tg"]
if push.get("qq") is False:
targets = [t for t in targets if t[0] != "qq"]
return targets
def push_target(uid):
"""兼容接口:统一异步推送的**主目标**(TG 私聊)。返回 (channel_name, target_id) 或 None。
需要 TG+QQ 镜像推送的调用方请改用 push_targets() / channel.push_to_user()。
"""
for name, target_id in push_targets(uid):
if name == "tg":
return (name, target_id)
return None
def summary(uid):
with _lock:
rec = _users.get(int(uid))
if not rec:
return None
tg = rec.get("tg") or {}
return {
"uid": int(rec["uid"]),
"created_at": rec.get("created_at") or "",
"admin": is_admin(uid),
"tg_raw": tg.get("raw"),
"tg_ns": tg.get("ns"),
"tg_username": tg.get("username") or "",
"qq": [q for q in (_qq_id(x) for x in (rec.get("qq") or [])) if q is not None],
"pending": pending_for(uid),
"push": _push_flags(rec),
}
def admin_uids():
"""所有管理员的统一身份 id(以身份库为准:TG/QQ 两侧管理员已合并到同一 uid)。
供异步告警按 uid 扇出(push_to_user)使用;不再依赖 settings.json 的
admin.<channel> 原始 id(QQ 侧那份在统一身份改造后恒为空)。
返回排序后的 uid 列表;身份库为空/无管理员时返回 []。
"""
result = []
with _lock:
for uid in sorted(_users):
try:
if is_admin(uid):
result.append(int(uid))
except (TypeError, ValueError):
continue
return result
def stats():
with _lock:
return {"users": len(_users), "tg": len(_tg_index), "qq": len(_qq_index),
"pending": len(_pending), "next_seq": _next_seq}