Spaces:
Paused
Paused
Download app/shift_engine.py from Mega-Evolutions/ButtonBot: direct link, hf CLI and curl.
- Browser
- Download file 8.8 kB
-
https://huggingface.co/spaces/Mega-Evolutions/ButtonBot/resolve/main/app/shift_engine.py
- Command line
-
hf download hf://spaces/Mega-Evolutions/ButtonBot/app/shift_engine.py
-
curl -L -o shift_engine.py https://huggingface.co/spaces/Mega-Evolutions/ButtonBot/resolve/main/app/shift_engine.py
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 | |