ButtonBot / app /shift_engine.py
github-actions[bot]
Automated sync from GitHub
c71e4ac
Raw History Blame Contribute Delete
8.8 kB
"""/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