Files
shirobana-bot/bot-utility/migrate_chat_data.py
T
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

594 lines
26 KiB
Python

#!/usr/bin/env python3
"""Offline, non-destructive migration from bot-chat data to unified-UID chat data.
The source chat directory and identity accounts are opened read-only in spirit:
no source file is replaced or removed. All destination text writes are atomic,
and each SQLite migration runs in one IMMEDIATE transaction with FTS validation.
"""
import argparse
import hashlib
import json
import os
import sqlite3
import tempfile
from pathlib import Path
TG_USER_OFFSET = 10 ** 10
SCHEMA = """
CREATE TABLE IF NOT EXISTS facts(
id INTEGER PRIMARY KEY,
uid INTEGER NOT NULL,
content TEXT NOT NULL,
importance INTEGER NOT NULL DEFAULT 3,
keywords TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT ''
);
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
content, keywords, content='facts', content_rowid='id', tokenize='trigram'
);
CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
INSERT INTO facts_fts(rowid, content, keywords) VALUES (new.id, new.content, new.keywords);
END;
CREATE TRIGGER IF NOT EXISTS facts_ad AFTER DELETE ON facts BEGIN
INSERT INTO facts_fts(facts_fts, rowid, content, keywords)
VALUES ('delete', old.id, old.content, old.keywords);
END;
CREATE TRIGGER IF NOT EXISTS facts_au AFTER UPDATE ON facts BEGIN
INSERT INTO facts_fts(facts_fts, rowid, content, keywords)
VALUES ('delete', old.id, old.content, old.keywords);
INSERT INTO facts_fts(rowid, content, keywords) VALUES (new.id, new.content, new.keywords);
END;
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS triples(
id INTEGER PRIMARY KEY,
uid INTEGER NOT NULL,
subject TEXT NOT NULL,
predicate TEXT NOT NULL,
object TEXT NOT NULL,
importance INTEGER NOT NULL DEFAULT 3,
valid_from REAL NOT NULL,
valid_until REAL,
created_at REAL NOT NULL,
source TEXT NOT NULL DEFAULT 'model'
);
CREATE INDEX IF NOT EXISTS triples_uid ON triples(uid);
CREATE INDEX IF NOT EXISTS triples_subj ON triples(uid, subject);
CREATE TABLE IF NOT EXISTS relations(
uid INTEGER PRIMARY KEY,
first_met REAL,
msg_count INTEGER NOT NULL DEFAULT 0,
affinity INTEGER NOT NULL DEFAULT 50,
last_seen REAL,
notes TEXT NOT NULL DEFAULT '[]'
);
"""
def _atomic_bytes(path, data):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".migrate-", dir=str(path.parent))
try:
with os.fdopen(fd, "wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _atomic_text(path, text):
_atomic_bytes(path, text.encode("utf-8"))
def _atomic_json(path, value):
_atomic_text(path, json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
def _read_json(path):
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
def _int(value):
try:
return int(value)
except (TypeError, ValueError):
return None
def _source_name(source, path):
try:
return str(Path(path).relative_to(source))
except ValueError:
return str(path)
class ChatMigrator:
def __init__(self, source, identity_accounts, dest, raw_tg=False, provision_tg=False):
self.source = Path(source).resolve()
self.accounts = Path(identity_accounts).resolve()
self.dest = Path(dest).resolve()
self.raw_tg = bool(raw_tg)
self.provision_tg = bool(provision_tg)
if not self.source.is_dir():
raise ValueError("source chat directory does not exist: %s" % self.source)
if not self.accounts.is_dir():
raise ValueError("identity accounts directory does not exist: %s" % self.accounts)
if self.source == self.dest:
raise ValueError("source and destination must differ")
self.dest.mkdir(parents=True, exist_ok=True)
self.report = {
"version": 1,
"source": str(self.source),
"identity_accounts": str(self.accounts),
"dest": str(self.dest),
"mapping": {"tg_ns_to_uid": {}, "old_key_to_uid": {}},
"files": {
"history_lines": 0, "memory_lines": 0, "group_lines": 0,
"status_files": 0, "setting_merged": False, "stickers_copied": 0,
},
"sqlite": {
"source_found": False, "facts": 0, "triples": 0,
"relations": 0, "meta": 0, "fact_conflicts": 0,
"triple_conflicts": 0, "meta_conflicts": 0,
"unmatched": {"facts": 0, "triples": 0, "relations": 0},
"fts_validated": False,
},
"quarantine": [],
"conflicts": [],
"provisioned_tg": [],
}
self.old_to_uid = {}
self.uid_set = set()
self._unmatched_db = []
def _conflict(self, kind, source, detail):
self.report["conflicts"].append({
"kind": kind, "source": _source_name(self.source, source), "detail": detail,
})
def _quarantine(self, path, category, reason):
base = self.dest / "quarantine" / category / Path(path).name
target = base
data = Path(path).read_bytes()
if target.exists() and target.read_bytes() != data:
# Same basenames may come from different source directories, and a
# later migration must not overwrite evidence from an earlier run.
digest = hashlib.sha256(data).hexdigest()[:12]
target = base.with_name("%s-%s%s" % (base.stem, digest, base.suffix))
serial = 2
while target.exists() and target.read_bytes() != data:
target = base.with_name("%s-%s-%d%s" % (
base.stem, digest, serial, base.suffix))
serial += 1
if not target.exists():
_atomic_bytes(target, data)
self.report["quarantine"].append({
"source": _source_name(self.source, path),
"target": str(target.relative_to(self.dest)),
"reason": reason,
})
def _legacy_private_keys(self):
keys = set()
for category, pattern in (("history", "*.txt"), ("memory", "*.txt"), ("status", "*.json")):
for path in (self.source / category).glob(pattern):
value = _int(path.stem)
if value is not None and value > 0:
keys.add(value)
source_db = self.source / "memory.db"
if source_db.is_file():
con = sqlite3.connect("file:%s?mode=ro" % source_db.as_posix(), uri=True)
try:
for table in ("facts", "triples", "relations"):
try:
for (value,) in con.execute("SELECT DISTINCT uid FROM %s" % table):
value = _int(value)
if value is not None and value > 0:
keys.add(value)
except sqlite3.Error:
pass
finally:
con.close()
return sorted(keys)
def provision_legacy_tg(self):
"""Explicitly create missing identities for the old TG-only raw-ID layout."""
if not self.provision_tg:
return
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import identity
identity.configure(str(self.accounts))
for old_key in self._legacy_private_keys():
ns = old_key if old_key >= TG_USER_OFFSET else TG_USER_OFFSET + old_key
before = identity.resolve("tg", ns)
uid = identity.ensure_tg(ns)
if before is None and uid is not None:
self.report["provisioned_tg"].append({"old_key": old_key, "tg_ns": ns, "uid": uid})
def load_identity(self):
candidates = {}
tg_candidates = {}
users = self.accounts / "users"
for path in sorted(users.glob("*.json")):
try:
rec = _read_json(path)
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
self._conflict("identity_invalid", path, str(exc))
continue
if not isinstance(rec, dict):
self._conflict("identity_invalid", path, "record is not an object")
continue
uid = _int(rec.get("uid"))
if uid is None:
self._conflict("identity_invalid", path, "missing numeric uid")
continue
self.uid_set.add(uid)
candidates.setdefault(str(uid), set()).add(uid)
tg = rec.get("tg") or {}
if isinstance(tg, dict):
ns = _int(tg.get("ns"))
raw = _int(tg.get("raw"))
if ns is None and raw is not None:
ns = TG_USER_OFFSET + raw
if ns is not None:
candidates.setdefault(str(ns), set()).add(uid)
tg_candidates.setdefault(str(ns), set()).add(uid)
qq_items = rec.get("qq") or []
if isinstance(qq_items, list):
for item in qq_items:
qq = _int(item.get("id")) if isinstance(item, dict) else None
if qq is not None:
candidates.setdefault(str(qq), set()).add(uid)
if self.raw_tg:
# The legacy chat process was TG-only. A small integer is a raw TG
# user ID, never a QQ guess; namespace IDs are used as-is.
for key in self._legacy_private_keys():
ns = key if key >= TG_USER_OFFSET else key + TG_USER_OFFSET
owners = tg_candidates.get(str(ns), set())
if len(owners) == 1:
self.old_to_uid[str(key)] = next(iter(owners))
elif len(owners) > 1:
self._conflict("identity_key_ambiguous", self.accounts,
"%s -> %s" % (key, sorted(owners)))
else:
for key, owners in sorted(candidates.items()):
if len(owners) == 1:
self.old_to_uid[key] = next(iter(owners))
else:
self._conflict("identity_key_ambiguous", self.accounts, "%s -> %s" % (key, sorted(owners)))
self.report["mapping"]["old_key_to_uid"] = dict(sorted(self.old_to_uid.items()))
self.report["mapping"]["tg_ns_to_uid"] = {
key: self.old_to_uid[key] for key in sorted(tg_candidates) if key in self.old_to_uid
}
def _merge_text(self, source, target):
with open(source, "r", encoding="utf-8") as fh:
incoming = [line.rstrip("\r\n") for line in fh if line.strip()]
existing = []
if Path(target).exists():
with open(target, "r", encoding="utf-8") as fh:
existing = [line.rstrip("\r\n") for line in fh if line.strip()]
seen = set(existing)
additions = []
for line in incoming:
if line not in seen:
seen.add(line)
additions.append(line)
if additions or not Path(target).exists():
_atomic_text(target, "\n".join(existing + additions) + ("\n" if existing or additions else ""))
return len(additions)
def _merge_json(self, current, incoming, source, prefix=""):
for key, value in sorted(incoming.items()):
path = "%s.%s" % (prefix, key) if prefix else str(key)
if key not in current:
current[key] = value
elif isinstance(current[key], dict) and isinstance(value, dict):
self._merge_json(current[key], value, source, path)
elif current[key] != value:
self._conflict("json_value", source, "%s: destination value retained" % path)
def _merge_json_file(self, source, target):
incoming = _read_json(source)
if not isinstance(incoming, dict):
raise ValueError("JSON root must be an object")
current = _read_json(target) if Path(target).exists() else {}
if not isinstance(current, dict):
raise ValueError("destination JSON root must be an object: %s" % target)
self._merge_json(current, incoming, source)
_atomic_json(target, current)
def _migrate_private_text(self, category, suffix):
src_dir = self.source / category
dst_dir = self.dest / category
if not src_dir.is_dir():
return
for path in sorted(src_dir.glob("*" + suffix)):
if not path.is_file():
continue
key = path.name[:-len(suffix)]
uid = self.old_to_uid.get(key)
if uid is None:
self._quarantine(path, category, "no unambiguous identity mapping for old key " + key)
continue
target = dst_dir / (str(uid) + suffix)
try:
if suffix == ".txt":
added = self._merge_text(path, target)
self.report["files"]["history_lines" if category == "history" else "memory_lines"] += added
else:
self._merge_json_file(path, target)
self.report["files"]["status_files"] += 1
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
self._quarantine(path, category, "invalid source data: %s" % exc)
def migrate_files(self):
self._migrate_private_text("history", ".txt")
self._migrate_private_text("memory", ".txt")
self._migrate_private_text("status", ".json")
group_dir = self.source / "history" / "group"
if group_dir.is_dir():
for path in sorted(group_dir.glob("*.txt")):
if path.is_file():
# Group namespaces deliberately remain unchanged.
self.report["files"]["group_lines"] += self._merge_text(
path, self.dest / "history" / "group" / path.name)
setting = self.source / "setting.json"
if setting.is_file():
try:
self._merge_json_file(setting, self.dest / "setting.json")
self.report["files"]["setting_merged"] = True
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
self._quarantine(setting, "setting", "invalid setting: %s" % exc)
stickers = self.source / "stickers"
if stickers.is_dir():
for path in sorted(p for p in stickers.rglob("*") if p.is_file()):
rel = path.relative_to(stickers)
target = self.dest / "stickers" / rel
data = path.read_bytes()
if target.exists():
if target.read_bytes() == data:
continue
self._quarantine(path, "stickers", "destination sticker differs; destination retained")
continue
_atomic_bytes(target, data)
self.report["files"]["stickers_copied"] += 1
def _rows(self, con, table, columns):
available = {row[1] for row in con.execute("PRAGMA table_info(%s)" % table)}
if not available:
return []
missing = set(columns) - available
if missing:
raise ValueError("source %s lacks columns: %s" % (table, sorted(missing)))
con.row_factory = sqlite3.Row
order = "id" if "id" in available else ("uid" if "uid" in available else "key")
return [dict(row) for row in con.execute("SELECT * FROM %s ORDER BY %s" % (table, order))]
def _new_uid(self, table, row):
old_uid = _int(row.get("uid"))
uid = self.old_to_uid.get(str(old_uid))
if uid is None:
self.report["sqlite"]["unmatched"][table] += 1
self._unmatched_db.append({"table": table, "row": row})
return uid
@staticmethod
def _notes(value):
try:
parsed = json.loads(value or "[]")
return parsed if isinstance(parsed, list) else []
except (TypeError, json.JSONDecodeError):
return []
def _merge_relation(self, con, source_uid, uid, row):
marker = "chat_migration:relation:%s" % source_uid
previous = con.execute("SELECT value FROM meta WHERE key=?", (marker,)).fetchone()
old_count = 0
if previous:
try:
state = json.loads(previous[0])
if int(state["uid"]) != uid:
raise ValueError("identity target changed")
old_count = int(state["msg_count"])
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
raise ValueError("invalid migration relation marker %s: %s" % (marker, exc))
count = int(row.get("msg_count") or 0)
delta = count - old_count
existing = con.execute(
"SELECT first_met,msg_count,affinity,last_seen,notes FROM relations WHERE uid=?", (uid,)
).fetchone()
if existing is None:
con.execute(
"INSERT INTO relations(uid,first_met,msg_count,affinity,last_seen,notes) VALUES(?,?,?,?,?,?)",
(uid, row.get("first_met"), count, int(row.get("affinity") or 50),
row.get("last_seen"), row.get("notes") or "[]"),
)
else:
first_values = [v for v in (existing[0], row.get("first_met")) if v is not None]
first_met = min(first_values) if first_values else None
last_values = [v for v in (existing[3], row.get("last_seen")) if v is not None]
last_seen = max(last_values) if last_values else None
notes = self._notes(existing[4])
serialized = {json.dumps(item, ensure_ascii=False, sort_keys=True) for item in notes}
for item in self._notes(row.get("notes")):
key = json.dumps(item, ensure_ascii=False, sort_keys=True)
if key not in serialized:
notes.append(item)
serialized.add(key)
notes = sorted(notes, key=lambda item: item.get("ts", 0) if isinstance(item, dict) else 0)[-5:]
con.execute(
"UPDATE relations SET first_met=?,msg_count=?,last_seen=?,notes=? WHERE uid=?",
(first_met, max(0, int(existing[1]) + delta), last_seen,
json.dumps(notes, ensure_ascii=False), uid),
)
con.execute("INSERT OR REPLACE INTO meta(key,value) VALUES(?,?)", (
marker, json.dumps({"uid": uid, "msg_count": count}, sort_keys=True),
))
self.report["sqlite"]["relations"] += 1
def migrate_sqlite(self):
source_db = self.source / "memory.db"
dest_db = self.dest / "memory.db"
if source_db == dest_db:
raise ValueError("source and destination memory.db must differ")
src = None
if source_db.is_file():
self.report["sqlite"]["source_found"] = True
src = sqlite3.connect("file:%s?mode=ro" % source_db.as_posix(), uri=True)
out = sqlite3.connect(str(dest_db), timeout=30)
try:
out.execute("PRAGMA journal_mode=WAL")
out.executescript(SCHEMA)
if src is None:
out.close()
out = None
return
fact_rows = self._rows(src, "facts", {
"id", "uid", "content", "importance", "keywords", "created_at", "archived", "source",
})
triple_rows = self._rows(src, "triples", {
"id", "uid", "subject", "predicate", "object", "importance",
"valid_from", "valid_until", "created_at", "source",
})
relation_rows = self._rows(src, "relations", {
"uid", "first_met", "msg_count", "affinity", "last_seen", "notes",
})
meta_rows = self._rows(src, "meta", {"key", "value"})
fact_rows.sort(key=lambda row: (int(row["uid"]), int(row["id"])))
triple_rows.sort(key=lambda row: (int(row["uid"]), int(row["id"])))
relation_rows.sort(key=lambda row: int(row["uid"]))
out.execute("BEGIN IMMEDIATE")
for row in fact_rows:
uid = self._new_uid("facts", row)
if uid is None:
continue
duplicate = out.execute(
"SELECT id FROM facts WHERE uid=? AND content=?", (uid, row["content"])
).fetchone()
if duplicate:
self.report["sqlite"]["fact_conflicts"] += 1
continue
out.execute(
"INSERT INTO facts(uid,content,importance,keywords,created_at,archived,source)"
" VALUES(?,?,?,?,?,?,?)",
(uid, row["content"], int(row["importance"]), row["keywords"] or "",
float(row["created_at"]), int(row["archived"]), row["source"] or ""),
)
self.report["sqlite"]["facts"] += 1
for row in triple_rows:
uid = self._new_uid("triples", row)
if uid is None:
continue
duplicate = out.execute(
"SELECT id FROM triples WHERE uid=? AND subject=? AND predicate=? AND object=?"
" AND valid_from=? AND valid_until IS ?",
(uid, row["subject"], row["predicate"], row["object"],
float(row["valid_from"]), row["valid_until"]),
).fetchone()
if duplicate:
self.report["sqlite"]["triple_conflicts"] += 1
continue
out.execute(
"INSERT INTO triples(uid,subject,predicate,object,importance,valid_from,valid_until,created_at,source)"
" VALUES(?,?,?,?,?,?,?,?,?)",
(uid, row["subject"], row["predicate"], row["object"], int(row["importance"]),
float(row["valid_from"]), row["valid_until"], float(row["created_at"]), row["source"] or "model"),
)
self.report["sqlite"]["triples"] += 1
for row in relation_rows:
source_uid = _int(row.get("uid"))
uid = self._new_uid("relations", row)
if uid is not None:
self._merge_relation(out, source_uid, uid, row)
for row in sorted(meta_rows, key=lambda item: item["key"]):
key, value = row["key"], row["value"]
if key.startswith("chat_migration:"):
continue
if key.startswith("imported:"):
old_key = key[len("imported:"):]
mapped = self.old_to_uid.get(old_key)
if mapped is None:
self._unmatched_db.append({"table": "meta", "row": row})
continue
key = "imported:%s" % mapped
existing = out.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
if existing is not None:
if existing[0] != value:
self.report["sqlite"]["meta_conflicts"] += 1
continue
out.execute("INSERT INTO meta(key,value) VALUES(?,?)", (key, value))
self.report["sqlite"]["meta"] += 1
facts_count = out.execute("SELECT COUNT(*) FROM facts").fetchone()[0]
fts_count = out.execute("SELECT COUNT(*) FROM facts_fts").fetchone()[0]
if facts_count != fts_count:
raise ValueError("FTS row count mismatch: facts=%d fts=%d" % (facts_count, fts_count))
out.execute("INSERT INTO facts_fts(facts_fts) VALUES('integrity-check')")
out.commit()
self.report["sqlite"]["fts_validated"] = True
except Exception:
if out is not None:
out.rollback()
raise
finally:
if src is not None:
src.close()
if out is not None:
out.close()
if self._unmatched_db:
target = self.dest / "quarantine" / "sqlite_unmatched.json"
_atomic_json(target, self._unmatched_db)
self.report["quarantine"].append({
"source": "memory.db", "target": str(target.relative_to(self.dest)),
"reason": "SQLite rows without an unambiguous identity mapping",
})
def run(self):
if self.raw_tg:
self.provision_legacy_tg()
self.load_identity()
self.migrate_files()
self.migrate_sqlite()
return self.report
def migrate(source, identity_accounts, dest, report=None, raw_tg=False, provision_tg=False):
migrator = ChatMigrator(source, identity_accounts, dest, raw_tg=raw_tg, provision_tg=provision_tg)
result = migrator.run()
report_path = Path(report) if report else migrator.dest / "migration-report.json"
_atomic_json(report_path, result)
return result
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, help="old bot-chat directory")
parser.add_argument("--identity-accounts", required=True, help="identity accounts directory")
parser.add_argument("--dest", required=True, help="new bot-utility/chat directory")
parser.add_argument("--report", help="JSON report path (default: DEST/migration-report.json)")
parser.add_argument("--raw-tg", action="store_true",
help="interpret legacy private keys as raw/namespaced TG IDs only")
parser.add_argument("--provision-tg", action="store_true",
help="with --raw-tg, explicitly create missing identity records")
args = parser.parse_args(argv)
result = migrate(args.source, args.identity_accounts, args.dest, args.report,
raw_tg=args.raw_tg, provision_tg=args.provision_tg)
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
if __name__ == "__main__":
main()