Spaces:
Paused
Paused
Download app/handlers/shift.py from Mega-Evolutions/ButtonBot: direct link, hf CLI and curl.
- Browser
- Download file 32.2 kB
-
https://huggingface.co/spaces/Mega-Evolutions/ButtonBot/resolve/main/app/handlers/shift.py
- Command line
-
hf download hf://spaces/Mega-Evolutions/ButtonBot/app/handlers/shift.py
-
curl -L -o shift.py https://huggingface.co/spaces/Mega-Evolutions/ButtonBot/resolve/main/app/handlers/shift.py
32.2 kB
| """/shift <source> <destination> [from_id] [to_id]: copy posts from one channel into another. Owner only.""" | |
| from __future__ import annotations | |
| import logging | |
| import re | |
| import secrets | |
| import time | |
| from dataclasses import dataclass, field | |
| from typing import Optional | |
| from telethon import Button, errors | |
| from ..channelref import ChannelRef, resolve_channel | |
| from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, purge_pending, require_owner, say | |
| from ..crosslinks import CrossResult, progress_text, relink_after_shift, report_lines, restore_after_shift_undo | |
| from ..profile_copy import profile_lines | |
| from ..repost_engine import RepostOptions, RepostPlan, RepostResult, plan_repost, saved_lookup | |
| from ..shift_engine import delete_shift_copies, plan_from_my_posts, run_shift, shift_profile, source_of | |
| from ..tgutil import esc, explain_rpc, get_rights, post_link | |
| from ..userbot import delete_problem_text | |
| from .repost import finishing_notes, held_plan_lines, hidden_progress | |
| log = logging.getLogger(__name__) | |
| USAGE = ( | |
| "Usage: <code>/shift <source> <destination> [from_id] [to_id]</code>\n" | |
| "Copies posts from one channel into another - text, formatting, media, albums, buttons, replies - and registers " | |
| "the copies in My posts. The source is only read: nothing in it is changed or deleted.\n\n" | |
| "A channel can be given as <code>@username</code>, a t.me link, its id (<code>-100…</code>) or an invite link " | |
| "(the bot must be a member). The bot must be an admin of the destination (Post messages).\n\n" | |
| "<code>/shift @source @destination</code> - every post\n" | |
| "<code>/shift @source @destination 15 20</code> - only the posts with message ids 15 to 20\n" | |
| "<code>/shift @source @destination 15</code> - only post 15\n\n" | |
| "A channel that Telegram restricts (a copyright strike: “This channel is unavailable due to copyright " | |
| "infringement”, its posts shown as “This message couldn't be displayed on your device due to copyright " | |
| "infringement”) is copied from what My posts saved of it - every post that has a saved copy, however intact " | |
| "Telegram's version looks. Telegram's notice is never copied. A post without a saved copy is copied as Telegram " | |
| "hands it out if it is still the post itself, and skipped if only the notice is left.\n\n" | |
| "Three extras, each optional, in any order, with or without a range:\n" | |
| "<code>-c</code> - afterwards every link to a post of the source - in ANY of your connected channels - is pointed " | |
| "at the copy in the destination\n" | |
| "<code>-all</code> - afterwards the destination gets the source's name, description and profile photo\n" | |
| "<code>--no-media</code> - no file is copied: every post goes out as its text with its formatting and buttons " | |
| "(use it when Telegram hides the copies because of their files)\n" | |
| "<code>/shift @source @destination -c -all</code> does the first two." | |
| ) | |
| class ShiftFlags: | |
| """The optional extras of /shift.""" | |
| links: bool = False # -c: links to the source's posts, in all connected channels, follow the copies | |
| profile: bool = False # -all: the destination gets the source's name, description and profile photo | |
| no_media: bool = False # --no-media: the copies get no files (text, formatting and buttons only) | |
| class ShiftJob: | |
| """A shift that was planned and is waiting for the owner's OK (or is running).""" | |
| user: int | |
| src: ChannelRef | |
| dst: ChannelRef | |
| plan: RepostPlan | |
| already: int = 0 # posts of the range that an earlier shift copied into this destination | |
| flags: ShiftFlags = field(default_factory=ShiftFlags) | |
| running: bool = False | |
| stop: bool = False | |
| shift_id: str = "" | |
| created: float = field(default_factory=time.monotonic) | |
| _OPTION = re.compile(r"^[-\u2013\u2014]{1,2}([A-Za-z][A-Za-z0-9_-]*)$") # phone keyboards turn "--" into a long dash | |
| def parse_shift_args(raw: str) -> tuple: | |
| """-> (source text, destination text, first id or None, last id or None, ShiftFlags). | |
| -c, -all and --no-media can stand anywhere, in any order ("- all" with a space is understood too, and so are | |
| -nomedia and a long dash in place of --); a channel id such as -1001234567890 is not an option.""" | |
| text = re.sub(r"(?<!\S)([-\u2013\u2014]{1,2})\s+(all|c|no[-_]?media)(?!\S)", r"\1\2", raw or "", flags=re.I) | |
| flags, toks = ShiftFlags(), [] | |
| for t in text.split(): | |
| m = _OPTION.match(t) | |
| if m is None: | |
| toks.append(t) | |
| continue | |
| name = re.sub(r"[-_]", "", m.group(1)).lower() | |
| if name == "c": | |
| flags.links = True | |
| elif name == "all": | |
| flags.profile = True | |
| elif name == "nomedia": | |
| flags.no_media = True | |
| else: | |
| raise ValueError(f"Unknown option {t}. The options are -c, -all and --no-media.") | |
| if len(toks) < 2 or len(toks) > 4: | |
| raise ValueError("Give the source and the destination channel, and optionally the first and the last message id.") | |
| nums: list = [] | |
| for t in toks[2:]: | |
| if not t.isdigit() or int(t) < 1: | |
| raise ValueError(f"“{t}” is not a message id. Message ids are positive numbers, for example 15 20.") | |
| nums.append(int(t)) | |
| first = last = None | |
| if len(nums) == 1: | |
| first = last = nums[0] | |
| elif len(nums) == 2: | |
| first, last = min(nums), max(nums) | |
| return toks[0], toks[1], first, last, flags | |
| def plan_text( | |
| p: RepostPlan, src: ChannelRef, dst: ChannelRef, already: int, delay: float, flags: Optional[ShiftFlags] = None, | |
| others: int = 0, info_right: bool = True, | |
| ) -> str: | |
| kinds = f"{p.text} text, {p.media} media" | |
| if p.polls: | |
| kinds += f", {p.polls} poll(s)" | |
| if p.other: | |
| kinds += f", {p.other} other" | |
| lines = [ | |
| "📦 <b>Shift plan</b>", | |
| f"From <b>{esc(src.title)}</b> → <b>{esc(dst.title)}</b>", | |
| f"Post ids {p.first}-{p.last}: <b>{p.units} posts</b> to copy ({p.messages} messages, {p.albums} album(s)) - {kinds}", | |
| ] | |
| if p.service: | |
| lines.append(f"• {p.service} service message(s) (like “pinned a message”) can't be copied and are skipped") | |
| if p.polls: | |
| lines.append("• polls are copied without their votes") | |
| if p.dropped_posts: | |
| lines.append(f"• {p.dropped_posts} post(s) carry other bots' buttons (reactions...) - those are not copied") | |
| lines += held_plan_lines(p, src.title, shift=True) | |
| if p.no_media: | |
| note = "• 🚫 <b>--no-media</b>: no file is copied - every post goes out as its text with its formatting and buttons" | |
| if p.files_left_out: | |
| note += f". {p.files_left_out} post(s) carry a file that stays out" | |
| if p.file_only: | |
| note += f"; {p.file_only} post(s) are only a file, so there is nothing to copy of them and they are skipped" | |
| lines.append(note + ".") | |
| if p.post_links: | |
| lines.append( | |
| f"• {p.post_links} link(s) in {p.post_link_posts} post(s) point at other posts of {esc(src.title)}: those that " | |
| "are shifted too will point at their copies" | |
| ) | |
| if p.replies: | |
| lines.append(f"• {p.replies} post(s) answer another post: the copy answers the copy (if that post is shifted too)") | |
| if already: | |
| lines.append( | |
| f"• ⚠️ {already} of these posts were already shifted into {esc(dst.title)} before: they would be copied again" | |
| ) | |
| flags = flags or ShiftFlags() | |
| if p.from_db: | |
| lines.insert( | |
| 2, | |
| f"⚠️ <b>I can't read {esc(src.title)}</b>: {esc(p.unreadable or 'Telegram refuses')}. My posts has the saved " | |
| "posts of it, so they are copied from there.", | |
| ) | |
| if flags.links: | |
| where = f"your {others} other connected channel(s)" if others else "your connected channels" | |
| lines.append( | |
| f"• 🔗 <b>-c</b>: afterwards every link to a post of {esc(src.title)} - a hyperlink, a typed link or a " | |
| f"button - in {where} is pointed at the copy in {esc(dst.title)} (links to posts that are not shifted stay " | |
| f"as they are). {esc(src.title)} itself is not changed." | |
| ) | |
| if flags.profile: | |
| lines.append( | |
| f"• 🖼 <b>-all</b>: afterwards {esc(dst.title)} gets the name, description and profile photo of " | |
| f"{esc(src.title)}. Its own are replaced - undo does not bring them back. A part the source does not have " | |
| "is left as it is." | |
| ) | |
| if not info_right: | |
| lines.append( | |
| f"• ⚠️ The bot does not have the “Change channel info” admin right in {esc(dst.title)} yet - give it " | |
| "before the copying is finished, or use the “copy again” button afterwards." | |
| ) | |
| minutes = max(1, round(p.eta_seconds(delay) / 60)) | |
| if p.from_db: | |
| what = "its text, formatting and buttons" if p.no_media else ( | |
| "its text, formatting, buttons and media (a file that Telegram refuses goes out without it)" | |
| ) | |
| how = ( | |
| f"<b>How:</b> every post is posted again from what My posts saved of it - {what} - and every button is read " | |
| "back to make sure it is there. Copies go to the end of the destination silently, in the original order, and " | |
| "are registered in My posts." | |
| ) | |
| kept = "<b>Not kept:</b> original dates, view counts, reactions, comments, poll votes, pins, replies and albums (every picture of an album becomes a post of its own)." | |
| elif p.no_media: | |
| how = ( | |
| "<b>How:</b> every post is posted again as its text with its formatting and buttons - no file is copied - and " | |
| "every button is read back to make sure it is there. Copies go to the end of the destination silently, in the " | |
| "original order, and are registered in My posts." | |
| ) | |
| kept = "<b>Not kept:</b> the files, albums (a caption becomes a post of its own), original dates, view counts, reactions, comments, poll votes and pins." | |
| elif p.channel_restricted: | |
| how = ( | |
| "<b>How:</b> Telegram copies nothing out of a restricted channel, so every post is posted again - from what " | |
| "My posts saved of it, or as Telegram hands it out when there is no saved copy - with its text, formatting, " | |
| "media and buttons in one go, and every button is read back to make sure it is there. Copies go to the end of " | |
| "the destination silently, in the original order, and are registered in My posts." | |
| ) | |
| kept = "<b>Not kept:</b> original dates, view counts, reactions, comments, poll votes and pins." | |
| else: | |
| how = ( | |
| "<b>How:</b> posts are copied by Telegram itself where possible (exactly as they are); posts with buttons or " | |
| "replies are posted again with their text, formatting, media and buttons in one go, and every button is read " | |
| "back to make sure it is there. Copies go to the end of the destination silently, in the original order, and " | |
| "are registered in My posts." | |
| ) | |
| if p.held_saved or p.live_restricted: | |
| how += " Posts that Telegram restricts are never copied by Telegram itself: they are posted again." | |
| kept = "<b>Not kept:</b> original dates, view counts, reactions, comments, poll votes and pins." | |
| lines += [ | |
| "", | |
| how, | |
| "", | |
| kept, | |
| f"Time: about {minutes} min. <b>Nothing is changed in {esc(src.title)}.</b>", | |
| ] | |
| return "\n".join(lines) | |
| def register(ctx: Ctx) -> None: | |
| client, db, cfg = ctx.client, ctx.db, ctx.cfg | |
| # ---------------------------------------------------------------- views | |
| async def view_for(sh): | |
| """Text and buttons describing where a shift stands.""" | |
| dst = await db.get_channel(sh.dst_channel_id) | |
| c = await db.shift_counts(sh.id) | |
| src_name = esc(sh.src_title or str(sh.src_channel_id)) | |
| dst_name = esc(dst.title) if dst else str(sh.dst_channel_id) | |
| route = f"<b>{src_name}</b> → <b>{dst_name}</b>" | |
| first_copy = "" | |
| if dst and c["first_new"]: | |
| first_copy = f'\n<a href="{post_link(dst, c["first_new"])}">First copy</a>' | |
| undo = [Button.inline("↩️ Undo - remove the copies", f"sfu:{sh.id}")] | |
| marks = await db.marks_of(sh.id) | |
| if "nomedia" in marks: | |
| route += " (without files)" | |
| extras = [] | |
| if "relink" in marks: | |
| extras.append([Button.inline("🔗 Update links again", f"sfl:{sh.id}")]) | |
| if "profile" in marks and "profile_done" not in marks: | |
| extras.append([Button.inline("🖼 Copy name, description, photo again", f"sfp:{sh.id}")]) | |
| if sh.status == "copied": | |
| text = ( | |
| f"✅ {route}: <b>{c['copied']} message(s) copied</b> (source ids {sh.first_id}-{sh.last_id}) and " | |
| f"registered in My posts.{first_copy}\nThe source was not changed. Look at the end of the destination; " | |
| "if something is wrong, undo removes just these copies." | |
| ) | |
| kb = extras + [undo] | |
| elif sh.status == "copies_deleted": | |
| text = f"↩️ {route}: the copies were removed again." | |
| kb = [] | |
| elif sh.status == "stopped": | |
| text = f"⏹ {route}: <b>stopped</b>. {c['copied']} message(s) copied so far.{first_copy}" | |
| go = "▶️ Continue anyway" if "hiddenok" in marks else "▶️ Continue" | |
| kb = [[Button.inline(go, f"sfc:{sh.id}")], undo] | |
| elif sh.status == "copying": | |
| text = f"⏳ {route}: a shift was running when the bot stopped. {c['copied']} message(s) copied.{first_copy}" | |
| kb = [[Button.inline("▶️ Continue", f"sfc:{sh.id}")], undo] | |
| else: # incomplete | |
| text = ( | |
| f"⚠️ {route}: <b>not everything was copied</b>. {c['copied']} message(s) copied.{first_copy}\n" | |
| "Nothing was changed in the source." | |
| ) | |
| kb = [[Button.inline("🔁 Try the rest again", f"sfc:{sh.id}")], undo] | |
| return text, kb | |
| # --------------------------------------------------------------- /shift | |
| async def h_shift(event): | |
| purge_pending(ctx) | |
| raw = (event.pattern_match.group(1) or "").strip() | |
| if not raw: | |
| sh = await db.open_shift() | |
| if sh is not None: | |
| text, kb = await view_for(sh) | |
| await say(event, "Unfinished shift:\n\n" + text, kb) | |
| return | |
| await say(event, USAGE) | |
| # a finished shift keeps its Undo: offer it again, so it can be found without scrolling up to its old message | |
| last = await db.last_finished_shift() | |
| if last is not None: | |
| text, kb = await view_for(last) | |
| await say(event, "Latest finished shift - you can still undo it:\n\n" + text, kb) | |
| return | |
| try: | |
| src_text, dst_text, first, last, flags = parse_shift_args(raw) | |
| except ValueError as e: | |
| raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE).replace("<", "<").replace(">", ">")) | |
| src = await resolve_channel(ctx, src_text) | |
| dst = await resolve_channel(ctx, dst_text) | |
| if src.id == dst.id: | |
| raise UserError("The source and the destination are the same channel. To rebuild one channel in order, use /repost.") | |
| sh = await db.open_shift(src.id, dst.id) | |
| if sh is not None: | |
| text, kb = await view_for(sh) | |
| await say(event, "There is an unfinished shift between these two channels. Finish or undo it first.\n\n" + text, kb) | |
| return | |
| rights = await get_rights(client, dst) | |
| if rights is not None and not (rights.admin and rights.post): | |
| raise UserError(f"The bot needs the “Post messages” admin right in {dst.title}.") | |
| if ctx.lock.locked(): | |
| raise UserError("Another long job (replace / repost / shift) is still running.") | |
| note = await say(event, f"🔍 Reading <b>{esc(src.title)}</b>…") | |
| last_edit = [0.0] | |
| async def prog(p): | |
| if time.monotonic() - last_edit[0] < 3: | |
| return | |
| last_edit[0] = time.monotonic() | |
| try: | |
| await note.edit(f"🔍 Reading <b>{esc(src.title)}</b>: post id {p.scanned}…") | |
| except Exception: | |
| pass | |
| try: | |
| async with ctx.lock: | |
| plan = await plan_repost( | |
| client, src, RepostOptions(), progress=prog, first_id=first, last_id=last, | |
| saved=saved_lookup(db, src.id), use_saved=True, no_media=flags.no_media, | |
| ) | |
| except errors.RPCError as e: | |
| why = explain_rpc(e) | |
| # a channel that can't be read any more (banned, private ...) may still be in My posts: copy it from there | |
| plan = await plan_from_my_posts(db, src, first, last, unreadable=why, no_media=flags.no_media) | |
| if plan is None: | |
| raise UserError( | |
| f"I can't read {src.title}: {why}\nThe bot has to be a member of the source channel " | |
| "(an admin is best; it only reads). My posts has no saved posts of it to copy from instead." | |
| ) | |
| if plan.error: | |
| await note.edit(f"⚠️ {esc(plan.error)}") | |
| return | |
| already = len(await db.shifted_ids(src.id, dst.id, plan.first, plan.last)) | |
| others = 0 | |
| if flags.links: # the channels the links are looked for in: all connected ones, the source itself excluded | |
| known = {c.id for c in await db.list_channels()} | {dst.id} | |
| others = len(known - {src.id}) | |
| info_right = rights is None or bool(getattr(rights, "change_info", True)) | |
| token = secrets.token_hex(4) | |
| ctx.pending[token] = ShiftJob(user=event.sender_id, src=src, dst=dst, plan=plan, already=already, flags=flags) | |
| kb = [[Button.inline("▶️ Start copying", f"sfa:{token}"), Button.inline("✖️ Cancel", f"sfn:{token}")]] | |
| await note.edit(plan_text(plan, src, dst, already, cfg.edit_delay, flags, others, info_right), buttons=kb) | |
| async def cb_cancel(event, parts): | |
| require_owner(ctx, event) | |
| ctx.pending.pop(parts[0], None) | |
| await edit_callback_message(event, "Cancelled - nothing was copied.") | |
| # ------------------------------------------------------------------ copy | |
| async def run_copy(event, sid: str, job: Optional[ShiftJob] = None) -> None: | |
| sh = await db.get_shift(sid) | |
| dst = await db.get_channel(sh.dst_channel_id) | |
| if dst is None: | |
| raise UserError("The destination channel is not registered any more.") | |
| job = job or ShiftJob(user=event.sender_id, src=None, dst=None, plan=None) # type: ignore[arg-type] | |
| job.running, job.stop, job.shift_id = True, False, sid | |
| ctx.pending[f"job:{sid}"] = job | |
| total = job.plan.units if job.plan else None | |
| last = [0.0] | |
| names = f"<b>{esc(sh.src_title)}</b> → <b>{esc(dst.title)}</b>" | |
| async def prog(res): | |
| if res.phase in ("copy", "links") and time.monotonic() - last[0] < 4: | |
| return | |
| last[0] = time.monotonic() | |
| done = res.copied_units + res.skipped_done | |
| of = f"/{total}" if total else "" | |
| if res.phase == "links" and res.cross is not None: | |
| body = f"✅ Copied {done}{of} posts.\n" + progress_text(res.cross, title="Pointing links of your channels at the copies") | |
| elif res.phase == "profile": | |
| body = f"🖼 Copied {done}{of} posts. Now copying the name, description and photo of {esc(sh.src_title)}…" | |
| elif res.phase == "finish": | |
| body = f"🔗 Copied {done}{of} posts. Now pointing links at the new copies…" | |
| else: | |
| body = f"⏳ Shifting {names}: {done}{of} posts…\nThe source stays untouched." | |
| body += hidden_progress(res) | |
| try: | |
| await edit_callback_message(event, body, [[Button.inline("⏹ Stop", f"sfs:{sid}")]]) | |
| except errors.MessageNotModifiedError: | |
| pass | |
| except Exception: | |
| log.debug("progress update failed", exc_info=True) | |
| try: | |
| async with ctx.lock: | |
| await db.set_shift_status(sid, "copying") | |
| res = await run_shift( | |
| client, db, sh, dst, event.sender_id, delay=cfg.edit_delay, progress=prog, should_stop=lambda: job.stop | |
| ) | |
| finally: | |
| job.running = False | |
| status = "stopped" if res.stopped else ("incomplete" if (res.failed or res.aborted or res.held_back) else "copied") | |
| await db.set_shift_status(sid, status) | |
| if res.hidden_stop: # stopped because Telegram hides the copies: pressing Continue means "go on anyway" | |
| await db.set_mark(sid, "hiddenok") | |
| sh = await db.get_shift(sid) | |
| text, kb = await view_for(sh) | |
| notes = [] | |
| if res.copied_units: | |
| notes.append(f"{res.forwarded} post(s) were copied by Telegram, {res.rebuilt} were posted again with their buttons.") | |
| if res.repaired: | |
| notes.append(f"{res.repaired} post(s) needed their buttons set again after posting - they show them now.") | |
| if res.failed: | |
| sample = ", ".join(f"#{i} {esc(n)}" for i, n in res.failed[:6]) | |
| notes.append(f"<b>{len(res.failed)} post(s) failed</b> ({sample}{'…' if len(res.failed) > 6 else ''}).") | |
| if any(n == "ButtonsNotShown" for _, n in res.failed): | |
| notes.append( | |
| "ButtonsNotShown: Telegram did not show the buttons on the new post, so that copy was removed again. " | |
| "The original is untouched - use “Try the rest again”." | |
| ) | |
| if any(n == "ReplyParentMissing" for _, n in res.failed): | |
| notes.append("ReplyParentMissing: that post answers a post whose copy failed; it follows when you try again.") | |
| if res.aborted: | |
| notes.append(f"Stopped early because of {esc(res.aborted)}.") | |
| notes += finishing_notes(res, shift=True) | |
| await edit_callback_message(event, text + ("\n\n" + "\n".join(notes) if notes else ""), kb) | |
| async def cb_start(event, parts): | |
| require_owner(ctx, event) | |
| job = ctx.pending.get(parts[0]) | |
| if not isinstance(job, ShiftJob) or job.user != event.sender_id: | |
| raise UserError("That plan has expired. Run /shift again.") | |
| if job.running or ctx.lock.locked(): | |
| raise UserError("A long job is already running.") | |
| if await db.open_shift(job.src.id, job.dst.id) is not None: | |
| raise UserError("There is already an unfinished shift between these two channels.") | |
| rights = await get_rights(client, job.dst) | |
| if rights is not None and not (rights.admin and rights.post): | |
| raise UserError(f"The bot needs the “Post messages” admin right in {job.dst.title}.") | |
| # the copies are registered in My posts, so the destination has to be one of the bot's channels | |
| await db.save_channel(job.dst.id, job.dst.access_hash, job.dst.title, job.dst.username, event.sender_id) | |
| sid = secrets.token_hex(6) | |
| p = job.plan | |
| await db.create_shift(sid, src=job.src, dst_channel_id=job.dst.id, first_id=p.first, last_id=p.last, user_id=event.sender_id) | |
| if job.flags.links: | |
| await db.set_mark(sid, "relink") | |
| if job.flags.profile: | |
| await db.set_mark(sid, "profile") | |
| if job.flags.no_media: # continuing the shift later goes on without files as well | |
| await db.set_mark(sid, "nomedia") | |
| if p.from_db: # the source can't be read: continuing the shift goes on from My posts as well | |
| await db.set_mark(sid, "fromdb") | |
| ctx.pending.pop(parts[0], None) | |
| await run_copy(event, sid, job) | |
| async def cb_continue(event, parts): | |
| require_owner(ctx, event) | |
| sh = await db.get_shift(parts[0]) | |
| if sh is None or sh.status not in ("stopped", "incomplete", "copying"): | |
| raise UserError("There is nothing to continue.") | |
| if ctx.lock.locked(): | |
| raise UserError("A long job is already running.") | |
| await run_copy(event, sh.id) | |
| async def cb_stop(event, parts): | |
| require_owner(ctx, event) | |
| job = ctx.pending.get(f"job:{parts[0]}") | |
| if isinstance(job, ShiftJob) and job.running: | |
| job.stop = True | |
| await event.respond("⏹ Stopping after the post that is being worked on…") | |
| else: | |
| await event.respond("Nothing is running.") | |
| # ------------------------------------------------------------ the extras again | |
| async def cb_links(event, parts): | |
| """-c once more: look through every connected channel for links to the source's posts.""" | |
| require_owner(ctx, event) | |
| sh, dst = await load(parts) | |
| if sh.status != "copied": | |
| raise UserError("Links can be updated once everything is copied.") | |
| if ctx.lock.locked(): | |
| raise UserError("A long job is already running.") | |
| job = ShiftJob(user=event.sender_id, src=None, dst=None, plan=None, running=True, shift_id=sh.id) # type: ignore[arg-type] | |
| ctx.pending[f"job:{sh.id}"] = job | |
| cross, last = CrossResult(), [0.0] | |
| async def prog(c): | |
| if time.monotonic() - last[0] < 4: | |
| return | |
| last[0] = time.monotonic() | |
| try: | |
| await edit_callback_message( | |
| event, progress_text(c, title="Pointing links at the copies"), [[Button.inline("⏹ Stop", f"sfs:{sh.id}")]] | |
| ) | |
| except errors.MessageNotModifiedError: | |
| pass | |
| except Exception: | |
| log.debug("progress update failed", exc_info=True) | |
| error = None | |
| try: | |
| async with ctx.lock: | |
| await edit_callback_message(event, "🔗 Looking for links to the source's posts in your channels…") | |
| await relink_after_shift( | |
| client, db, sh, source_of(sh), dst, delay=cfg.edit_delay, progress=prog, | |
| should_stop=lambda: job.stop, result=cross, | |
| ) | |
| except Exception as e: # the copies are fine; only the links were not (all) updated | |
| log.exception("pointing the links at the shifted posts failed") | |
| error = type(e).__name__ | |
| finally: | |
| job.running = False | |
| ctx.pending.pop(f"job:{sh.id}", None) | |
| notes = report_lines(cross, what=f"the copies in {esc(dst.title)}") | |
| if error: | |
| notes.append(f"Links could not be updated ({esc(error)}).") | |
| if not notes: | |
| notes.append("🔗 There is nothing to change.") | |
| text, kb = await view_for(sh) | |
| await edit_callback_message(event, text + "\n\n" + "\n".join(notes), kb) | |
| async def cb_profile(event, parts): | |
| """-all once more: the name, description and photo of the source.""" | |
| require_owner(ctx, event) | |
| sh, dst = await load(parts) | |
| if "profile" not in await db.marks_of(sh.id): | |
| raise UserError("This shift was not started with -all.") | |
| if sh.status not in ("copied", "stopped", "incomplete"): | |
| raise UserError("There is nothing to copy it to any more.") | |
| if ctx.lock.locked(): | |
| raise UserError("A long job is already running.") | |
| holder = RepostResult() | |
| async with ctx.lock: | |
| await edit_callback_message(event, f"🖼 Copying the name, description and photo of {esc(sh.src_title)}…") | |
| await shift_profile(client, db, sh, dst, event.sender_id, holder) | |
| dst = await db.get_channel(sh.dst_channel_id) or dst # it may have a new name now | |
| notes = profile_lines(holder.profile, dst.title) if holder.profile is not None else [] | |
| if holder.profile_error: | |
| notes.append(f"The name, description and photo could not be copied ({esc(holder.profile_error)}).") | |
| text, kb = await view_for(sh) | |
| await edit_callback_message(event, text + "\n\n" + "\n".join(notes), kb) | |
| # ------------------------------------------------------------------ undo | |
| async def load(parts): | |
| sh = await db.get_shift(parts[0]) | |
| if sh is None: | |
| raise UserError("I don't know that shift any more.") | |
| dst = await db.get_channel(sh.dst_channel_id) | |
| if dst is None: | |
| raise UserError("The destination channel is not registered any more.") | |
| return sh, dst | |
| async def cb_back(event, parts): | |
| sh, _ = await load(parts) | |
| text, kb = await view_for(sh) | |
| await edit_callback_message(event, text, kb) | |
| async def cb_undo_ask(event, parts): | |
| require_owner(ctx, event) | |
| sh, dst = await load(parts) | |
| c = await db.shift_counts(sh.id) | |
| if not c["new_left"]: | |
| raise UserError("There is nothing to remove.") | |
| await edit_callback_message( | |
| event, | |
| f"↩️ <b>Remove the {c['new_left']} copied message(s) from {esc(dst.title)}?</b>\n" | |
| f"The posts in {esc(sh.src_title)} are not touched.", | |
| [[Button.inline("↩️ Yes, remove the copies", f"sfuy:{sh.id}"), Button.inline("Back", f"sfb:{sh.id}")]], | |
| ) | |
| async def cb_undo(event, parts): | |
| require_owner(ctx, event) | |
| sh, dst = await load(parts) | |
| if ctx.lock.locked(): | |
| raise UserError("A long job is already running.") | |
| fallback, deleter = await ctx.userbot.fallback_for(dst) if ctx.userbot is not None else (None, None) | |
| back, last = CrossResult(), [0.0] | |
| async def prog(c): | |
| if time.monotonic() - last[0] < 4: | |
| return | |
| last[0] = time.monotonic() | |
| try: | |
| await edit_callback_message(event, progress_text(c, title="Pointing links back at the source's posts")) | |
| except Exception: | |
| log.debug("progress update failed", exc_info=True) | |
| try: | |
| async with ctx.lock: | |
| try: # links that were pointed at the copies would be left without a post: point them back first | |
| await restore_after_shift_undo(client, db, sh, source_of(sh), dst, delay=cfg.edit_delay, progress=prog, result=back) | |
| except Exception: | |
| log.exception("pointing the links back at the source's posts failed") | |
| back.stopped = True | |
| res = await delete_shift_copies(client, db, dst, sh, delay=0.5, fallback=fallback) | |
| finally: | |
| if deleter is not None: | |
| await deleter.close() | |
| link_notes = report_lines(back, back=True) | |
| link_text = ("\n" + "\n".join(link_notes)) if link_notes else "" | |
| if res.remaining == 0 and not res.error: | |
| await db.set_shift_status(sh.id, "copies_deleted") | |
| await edit_callback_message( | |
| event, | |
| f"↩️ Done. {res.deleted} copied message(s) removed from {esc(dst.title)}; the source was not touched.{link_text}", | |
| ) | |
| else: | |
| why = esc(delete_problem_text(ctx.userbot, bot_error=res.error, fallback_error=res.fallback_error, tried=res.tried_userbot)) | |
| await edit_callback_message( | |
| event, | |
| f"⚠️ Removed {res.deleted}, {res.remaining} copy/copies are still there. {why}{link_text}", | |
| [[Button.inline("🔁 Try again", f"sfuy:{sh.id}")]], | |
| ) | |