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")