File size: 8,800 Bytes
2e79f82
 
 
 
 
65651b1
c71e4ac
 
 
 
 
 
 
65651b1
c71e4ac
 
 
 
 
 
 
 
2e79f82
 
 
 
 
 
 
65651b1
 
2e79f82
c71e4ac
2e79f82
 
 
65651b1
2e79f82
 
 
65651b1
2e79f82
65651b1
 
2e79f82
c71e4ac
2e79f82
 
 
 
 
 
 
 
 
 
 
 
c71e4ac
 
 
65651b1
 
 
 
c71e4ac
65651b1
 
 
 
2e79f82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65651b1
c71e4ac
 
 
 
 
 
 
 
2e79f82
 
 
 
 
 
 
 
 
 
65651b1
 
 
 
c71e4ac
 
 
2e79f82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65651b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e79f82
 
 
65651b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e79f82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""/shift: copy posts of one channel into another channel - text, formatting, media, albums, buttons and replies
- and register the copies in My posts. The source is only read, never changed or deleted.

It is the /repost copy loop pointed at two channels. Links from a shifted post to another post of the source channel
follow the content: when that post was shifted too, the link points at its copy in the destination.

Three optional extras are remembered with the shift (job marks), so that continuing a stopped shift keeps them:
  -c          afterwards every link to a post of the source, in ANY connected channel, is pointed at the copy
              (crosslinks.relink_after_shift; the source itself is only read);
  -all        afterwards the destination gets the source's name, description and profile photo
              (profile_copy.copy_profile);
  --no-media  the copies get no files: every post is copied as its text, with its formatting and buttons (mark "nomedia";
              repost_engine.without_files). A post that is only a file has nothing to copy and is left out.

When Telegram hides the first new copies (read back right after posting) the shift stops by itself - more copies would
only be more posts nobody can see - and goes on only when the owner presses Continue (mark "hiddenok").

A source that Telegram restricts (a copyright strike: "This channel is unavailable due to copyright infringement", its
posts marked or shown as a notice) is copied from what My posts saved of its posts - every post that has a saved copy,
however intact Telegram's version looks (repost_engine.copy_source). A post without a saved copy is copied as Telegram
hands it out if that is the post itself, and skipped if it is only the notice. A source that can't be read at all (a
banned or private channel) is copied from My posts alone: mark "fromdb", set when the plan was made that way.
"""
from __future__ import annotations

import logging
from types import SimpleNamespace
from typing import Any, Awaitable, Callable, Optional

from .crosslinks import CrossResult, relink_after_shift, shift_links
from .profile_copy import copy_profile
from .repost_engine import (
    STOP_IF_HIDDEN,
    CopyJob,
    DeleteResult,
    RepostOptions,
    RepostPlan,
    RepostResult,
    copy_loop,
    delete_pending,
    plan_from_saved,
    relink_copies,
    saved_lookup,
    saved_units,
)
from .restrictions import probe_channel
from .tgutil import peer_of

log = logging.getLogger(__name__)


def source_of(shift) -> SimpleNamespace:
    """The source channel as stored with the shift (it need not be registered in the bot)."""
    return SimpleNamespace(
        id=shift.src_channel_id, access_hash=shift.src_access_hash, username=shift.src_username, title=shift.src_title
    )


async def plan_from_my_posts(
    db, src, first_id=None, last_id=None, unreadable=None, no_media: bool = False
) -> Optional[RepostPlan]:
    """The plan of a shift made from My posts alone, for a source that can't be read; None when My posts has nothing
    of that channel (in that range)."""
    plan = plan_from_saved(
        src, await db.sent_posts_from(src.id, first_id or 1), RepostOptions(), first_id=first_id, last_id=last_id,
        unreadable=unreadable, no_media=no_media,
    )
    return None if plan.error else plan


async def run_shift(
    client,
    db,
    shift,
    dst,
    user_id: int,
    *,
    delay: float = 1.2,
    settle_pause: float = 0.7,
    progress: Optional[Callable[[RepostResult], Awaitable[None]]] = None,
    should_stop: Optional[Callable[[], bool]] = None,
) -> RepostResult:
    """Copy the posts shift.first_id..shift.last_id of the source into `dst` (a registered channel), in order.
    Safe to run again to continue: posts that already have a copy are skipped."""
    src = source_of(shift)
    idmap = dict(await db.shift_pairs(shift.id, alive_only=False))
    links = shift_links(src, dst, idmap)
    marks = await db.marks_of(shift.id)
    from_db = "fromdb" in marks  # the source can't be read: its posts come from My posts alone
    restricted = None
    if not from_db:  # does Telegram restrict the source channel itself (a copyright strike)? Then My posts is used
        try:
            _top, restricted, _err = await probe_channel(client, peer_of(src))
        except Exception:  # unknown is treated as "not restricted": marks on single posts and the read-back still guard
            log.warning("could not ask Telegram whether the source channel is restricted", exc_info=True)
    job = CopyJob(
        src_peer=peer_of(src),
        dst_peer=peer_of(dst),
        opts=RepostOptions(),
        links=links,
        first_id=shift.first_id,
        last_id=shift.last_id,
        done=await db.shift_done_ids(shift.id),
        idmap=idmap,
        record=lambda rows: db.record_shift(shift.id, dst.id, rows, user_id),
        saved=saved_lookup(db, src.id),  # posts Telegram holds back (a copyright strike) are copied from My posts
        use_saved=True,
        units=(lambda: saved_units(db, src.id, shift.first_id, shift.last_id)) if from_db else None,
        from_db=from_db,
        restricted=restricted,
        no_media="nomedia" in marks,
        stop_if_hidden=0 if "hiddenok" in marks else STOP_IF_HIDDEN,  # the owner said to go on although Telegram hides them
    )
    res = await copy_loop(client, job, delay=delay, progress=progress, should_stop=should_stop, settle_pause=settle_pause)
    if not res.stopped and not res.aborted:
        res.phase = "finish"
        if progress:
            await progress(res)
        try:
            pairs = await db.shift_pairs(shift.id)
            res.final = await relink_copies(
                client, db, dst, dict(pairs), [n for _, n in pairs], links=links, delay=delay,
                settle_pause=settle_pause, should_stop=should_stop,
            )
        except Exception as e:  # the copies are fine; only the links between them were not updated
            log.exception("pointing the links of a shift at the copies failed")
            res.final_error = type(e).__name__
        marks = await db.marks_of(shift.id)
        if "profile" in marks and "profile_done" not in marks:
            await shift_profile(client, db, shift, dst, user_id, res, progress)
        if "relink" in marks and not (res.final is not None and res.final.stopped):
            res.phase = "links"
            res.cross = CrossResult()

            async def watch(_cross) -> None:
                if progress:
                    await progress(res)

            if progress:
                await progress(res)
            try:
                await relink_after_shift(
                    client, db, shift, src, dst, delay=delay, settle_pause=settle_pause, progress=watch,
                    should_stop=should_stop, result=res.cross,
                )
            except Exception as e:  # the copies are fine; only the links in the other channels were not updated
                log.exception("pointing the links of the other channels at the shifted posts failed")
                res.cross_error = type(e).__name__
    return res


async def shift_profile(client, db, shift, dst, user_id: int, res: RepostResult, progress=None) -> None:
    """-all: the destination takes over the source's name, description and photo (the outcome goes into `res`)."""
    res.phase = "profile"
    if progress:
        await progress(res)
    try:
        res.profile = await copy_profile(client, source_of(shift), dst)
        if res.profile.title:  # the destination has a new name: keep the bot's own list right
            await db.save_channel(dst.id, dst.access_hash, res.profile.title, dst.username, user_id)
        if res.profile.ok:
            await db.set_mark(shift.id, "profile_done")
    except Exception as e:  # the copies are fine; only the channel's name / description / photo were not copied
        log.exception("copying the channel profile failed")
        res.profile_error = type(e).__name__


async def delete_shift_copies(
    client,
    db,
    dst,
    shift,
    *,
    delay: float = 0.5,
    progress: Optional[Callable[[DeleteResult], Awaitable[None]]] = None,
    fallback: Optional[Callable[[list], Awaitable[Any]]] = None,
) -> DeleteResult:
    """Undo: remove the copies of this shift from the destination channel (the source is not touched)."""
    todo = await db.shift_pending_new(shift.id, 1_000_000)

    async def mark(ids: list) -> None:
        await db.mark_shift_deleted(shift.id, dst.id, ids)

    res = await delete_pending(client, peer_of(dst), todo, mark, delay=delay, progress=progress, fallback=fallback)
    res.remaining = (await db.shift_counts(shift.id))["new_left"]
    res.blocked = res.remaining > 0 and not res.error
    return res