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:
+561
@@ -0,0 +1,561 @@
|
||||
# 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}
|
||||
Reference in New Issue
Block a user