Spaces:
Paused
Paused
File size: 13,599 Bytes
65651b1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 | """Putting a backup made by /export back into the bot (/import).
The backup is a JSON file: {"exported_at", "channels": [...], "posts": [...], "ignored": [...]}. Importing only ADDS:
* a channel the bot already has is left as it is (its title, username and access come from Telegram, which is newer
than the file); a channel it does not have is added;
* a post at a message id the bot already has is left as it is; every other published post is added, and so is every
draft that is not already here;
* a post the owner has made the bot forget (/posts -> Only forget it in the bot) is not brought back;
* nothing is ever deleted or overwritten.
The file is read as data only: every value is checked before it goes near the database.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from .db import Channel, Database, IgnoredPost, Post, as_utc, utcnow
log = logging.getLogger(__name__)
MAX_BYTES = 40 * 1024 * 1024 # a backup of tens of thousands of posts is a few megabytes
CHUNK = 500 # rows saved in one go
POST_KINDS = ("draft", "sent")
SOURCES = ("bot", "adopted")
class BackupError(ValueError):
"""The file can't be used; the message says why (and is safe to show)."""
# ------------------------------------------------------------------------------------------------ reading
def _int(value, what: str) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{what} is not a whole number")
return value
def _text(value, what: str, limit: Optional[int] = None) -> str:
if value is None:
return ""
if not isinstance(value, str):
raise ValueError(f"{what} is not text")
return value[:limit] if limit else value
def _when(value, what: str, default: Optional[datetime] = None) -> Optional[datetime]:
if value in (None, ""):
return default
if not isinstance(value, str):
raise ValueError(f"{what} is not a date")
try:
d = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
raise ValueError(f"{what} is not a date")
return as_utc(d)
def _rows_of(value, what: str) -> list:
if value is None:
return []
if not isinstance(value, list):
raise BackupError(f"“{what}” should be a list.")
return value
def channel_row(row) -> dict:
"""One channel of the file as the values for a Channel row (ValueError if it is not usable)."""
if not isinstance(row, dict):
raise ValueError("not an object")
cid = _int(row.get("id"), "id")
if cid == 0:
raise ValueError("id is 0")
username = _text(row.get("username"), "username", 64).strip().lstrip("@") or None
created = _when(row.get("created_at"), "created_at", utcnow())
return dict(
id=cid,
access_hash=_int(row.get("access_hash", 0) or 0, "access_hash"),
title=_text(row.get("title"), "title", 256),
username=username,
active=bool(row.get("active", True)),
added_by=_int(row.get("added_by", 0) or 0, "added_by"),
created_at=created,
)
def _buttons(value) -> list:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("buttons are not a list")
for line in value:
if not isinstance(line, list) or not all(isinstance(b, dict) for b in line):
raise ValueError("buttons are not rows of buttons")
return value
def _entities(value) -> list:
if value is None:
return []
if not isinstance(value, list) or not all(isinstance(e, dict) for e in value):
raise ValueError("formatting is not a list")
return value
def post_row(row) -> dict:
"""One post of the file as the values for a Post row (ValueError if it is not usable)."""
if not isinstance(row, dict):
raise ValueError("not an object")
cid = _int(row.get("channel_id"), "channel_id")
mid = row.get("message_id")
mid = None if mid is None else _int(mid, "message_id")
status = row.get("status") or ("sent" if mid else "draft")
if status not in POST_KINDS:
raise ValueError(f"status “{str(status)[:20]}” is unknown")
if status == "sent" and (mid is None or mid < 1):
raise ValueError("a published post without a message id")
if status == "draft":
mid = None
source = row.get("source") if row.get("source") in SOURCES else "bot"
kind = row.get("media_kind")
if kind is not None and (not isinstance(kind, str) or len(kind) > 16):
raise ValueError("media_kind is not usable")
ref = row.get("media_file_id")
if ref is not None and not isinstance(ref, str):
raise ValueError("media_file_id is not text")
created = _when(row.get("created_at"), "created_at", utcnow())
return dict(
channel_id=cid,
message_id=mid,
status=status,
source=source,
text=_text(row.get("text"), "text"),
entities=_entities(row.get("entities")),
media_kind=kind or None,
media_file_id=ref or None,
buttons=_buttons(row.get("buttons")),
link_preview=bool(row.get("link_preview")),
created_by=_int(row.get("created_by", 0) or 0, "created_by"),
created_at=created,
updated_at=_when(row.get("updated_at"), "updated_at", created),
sent_at=_when(row.get("sent_at"), "sent_at"),
)
@dataclass
class Backup:
exported_at: Optional[str] = None
channels: list = field(default_factory=list) # dicts for Channel(**d)
posts: list = field(default_factory=list) # dicts for Post(**d)
ignored: list = field(default_factory=list) # (channel id, message id)
bad_rows: int = 0 # entries of the file that were not usable and are left out
def parse_backup(raw: bytes) -> Backup:
"""The file's bytes -> a Backup. BackupError (with a message for the person) when it is not an /export file."""
if len(raw) > MAX_BYTES:
raise BackupError(f"it is larger than {MAX_BYTES // (1024 * 1024)} MB.")
try:
data = json.loads(raw.decode("utf-8-sig"))
except (UnicodeDecodeError, ValueError):
raise BackupError("it is not a JSON file. Send the file that /export made, unchanged.")
if not isinstance(data, dict) or ("channels" not in data and "posts" not in data):
raise BackupError("it does not look like a backup from /export (no channels and posts in it).")
bk = Backup(exported_at=data.get("exported_at") if isinstance(data.get("exported_at"), str) else None)
for row in _rows_of(data.get("channels"), "channels"):
try:
bk.channels.append(channel_row(row))
except (ValueError, TypeError):
bk.bad_rows += 1
for row in _rows_of(data.get("posts"), "posts"):
try:
bk.posts.append(post_row(row))
except (ValueError, TypeError):
bk.bad_rows += 1
for row in _rows_of(data.get("ignored"), "ignored"):
try:
if not isinstance(row, dict):
raise ValueError("not an object")
bk.ignored.append((_int(row.get("channel_id"), "channel_id"), _int(row.get("message_id"), "message_id")))
except (ValueError, TypeError):
bk.bad_rows += 1
if not (bk.channels or bk.posts):
raise BackupError("there is nothing in it that can be imported." + (" (All entries were unreadable.)" if bk.bad_rows else ""))
return bk
# ------------------------------------------------------------------------------------------------ the plan
@dataclass
class ImportPlan:
backup: Backup
new_channels: list = field(default_factory=list) # channel dicts that are not in the bot yet
channels_present: int = 0 # channels of the file the bot has already (left as they are)
new_posts: list = field(default_factory=list) # published posts and drafts to add
posts_present: int = 0 # published posts at a message id the bot already has
drafts_present: int = 0 # drafts that are here already
posts_forgotten: int = 0 # posts the owner made the bot forget: not brought back
posts_orphaned: int = 0 # posts of a channel that is neither in the bot nor in the file
new_ignored: list = field(default_factory=list) # "forgotten" marks to add
@property
def sent_to_add(self) -> int:
return sum(1 for p in self.new_posts if p["status"] == "sent")
@property
def drafts_to_add(self) -> int:
return sum(1 for p in self.new_posts if p["status"] == "draft")
@property
def nothing_new(self) -> bool:
return not (self.new_channels or self.new_posts or self.new_ignored)
async def plan_import(db: Database, backup: Backup) -> ImportPlan:
"""What importing would do - nothing is written."""
plan = ImportPlan(backup=backup)
async with db.Session() as s:
have_channels = {int(c) for c in (await s.execute(select(Channel.id))).scalars()}
known_marks = {(int(c), int(m)) for c, m in (await s.execute(select(IgnoredPost.channel_id, IgnoredPost.message_id))).all()}
wanted = {p["channel_id"] for p in backup.posts}
have_posts: set = set()
have_drafts: set = set()
if wanted:
q = select(Post.channel_id, Post.message_id).where(Post.message_id.is_not(None))
have_posts = {(int(c), int(m)) for c, m in (await s.execute(q)).all() if int(c) in wanted}
q = select(Post.channel_id, Post.text, Post.created_at).where(Post.status == "draft")
have_drafts = {(int(c), t, as_utc(at)) for c, t, at in (await s.execute(q)).all() if int(c) in wanted}
in_file: dict = {}
for ch in backup.channels:
if ch["id"] in have_channels:
plan.channels_present += 1
elif ch["id"] not in in_file:
in_file[ch["id"]] = ch
plan.new_channels.append(ch)
marks = set(known_marks)
for mark in backup.ignored:
if mark[0] in have_channels or mark[0] in in_file:
if mark not in marks:
marks.add(mark)
plan.new_ignored.append(mark)
seen_posts: set = set()
seen_drafts: set = set()
for p in backup.posts:
cid = p["channel_id"]
if cid not in have_channels and cid not in in_file:
plan.posts_orphaned += 1
elif p["status"] == "sent":
key = (cid, p["message_id"])
if key in marks:
plan.posts_forgotten += 1
elif key in have_posts or key in seen_posts:
plan.posts_present += 1
else:
seen_posts.add(key)
plan.new_posts.append(p)
else:
key = (cid, p["text"], p["created_at"])
if key in have_drafts or key in seen_drafts:
plan.drafts_present += 1
else:
seen_drafts.add(key)
plan.new_posts.append(p)
return plan
# ----------------------------------------------------------------------------------------------- doing it
@dataclass
class ImportResult:
channels_added: int = 0
posts_added: int = 0
drafts_added: int = 0
marks_added: int = 0
skipped_meanwhile: int = 0 # posts that turned up in the bot while the import was running
async def _save_posts(db: Database, rows: list) -> list:
"""Save these posts; returns the ones that were saved (one that is there already is skipped, never an error)."""
async with db.Session() as s:
s.add_all([Post(**r) for r in rows])
try:
await s.commit()
return list(rows)
except IntegrityError:
await s.rollback()
saved = []
for r in rows: # one is there already: one by one
async with db.Session() as s:
s.add(Post(**r))
try:
await s.commit()
saved.append(r)
except IntegrityError:
await s.rollback()
return saved
async def apply_import(db: Database, plan: ImportPlan, progress=None) -> ImportResult:
"""Write the plan: channels first (posts point at them), then the posts in pieces, then the "forgotten" marks."""
res = ImportResult()
if plan.new_channels:
async with db.Session() as s:
for d in plan.new_channels:
if await s.get(Channel, d["id"]) is None:
s.add(Channel(**d))
res.channels_added += 1
await s.commit()
rows = plan.new_posts
for i in range(0, len(rows), CHUNK):
part = rows[i : i + CHUNK]
saved = await _save_posts(db, part)
res.posts_added += sum(1 for r in saved if r["status"] == "sent")
res.drafts_added += sum(1 for r in saved if r["status"] == "draft")
res.skipped_meanwhile += len(part) - len(saved)
if progress is not None:
await progress(min(i + CHUNK, len(rows)), len(rows))
for cid, mid in plan.new_ignored:
await db.ignore_post(cid, mid)
res.marks_added += 1
return res
def stamp(iso: Optional[str]) -> str:
"""'2026-10-07T10:00:00+00:00' -> '07 Oct 2026, 10:00 UTC' (the text as it is when it can't be read)."""
if not iso:
return ""
try:
d = as_utc(datetime.fromisoformat(iso.replace("Z", "+00:00")))
except ValueError:
return iso[:40]
return d.astimezone(timezone.utc).strftime("%d %b %Y, %H:%M UTC")
|