Spaces:
Paused
Paused
github-actions[bot] commited on
Commit ·
c71e4ac
1
Parent(s): 65651b1
Automated sync from GitHub
Browse files- .gitattributes +3 -3
- app/db.py +7 -0
- app/handlers/basic.py +4 -2
- app/handlers/repost.py +115 -18
- app/handlers/shift.py +72 -24
- app/repost_engine.py +218 -54
- app/shift_engine.py +33 -11
- tests/fakes.py +5 -0
- tests/test_held_back.py +67 -16
- tests/test_no_media.py +279 -0
- tests/test_restricted.py +1 -0
- tests/test_restricted_source.py +418 -0
- tests/test_shift.py +56 -0
- tests/test_shift_extras.py +1 -1
.gitattributes
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.ttf filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
app/db.py
CHANGED
|
@@ -924,6 +924,13 @@ class Database:
|
|
| 924 |
q = q.where(Shift.dst_channel_id == dst_id)
|
| 925 |
return (await s.execute(q.order_by(desc(Shift.created_at)).limit(1))).scalar_one_or_none()
|
| 926 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 927 |
async def set_shift_status(self, sid: str, status: str) -> None:
|
| 928 |
async with self.Session() as s:
|
| 929 |
sh = await s.get(Shift, sid)
|
|
|
|
| 924 |
q = q.where(Shift.dst_channel_id == dst_id)
|
| 925 |
return (await s.execute(q.order_by(desc(Shift.created_at)).limit(1))).scalar_one_or_none()
|
| 926 |
|
| 927 |
+
async def last_finished_shift(self) -> Optional[Shift]:
|
| 928 |
+
"""The newest finished shift that still has copies in its destination - so its Undo can be offered again."""
|
| 929 |
+
async with self.Session() as s:
|
| 930 |
+
alive = select(ShiftItem.id).where(ShiftItem.shift_id == Shift.id, ShiftItem.new_deleted.is_(False)).exists()
|
| 931 |
+
q = select(Shift).where(Shift.status == "copied", alive).order_by(desc(Shift.created_at)).limit(1)
|
| 932 |
+
return (await s.execute(q)).scalar_one_or_none()
|
| 933 |
+
|
| 934 |
async def set_shift_status(self, sid: str, status: str) -> None:
|
| 935 |
async with self.Session() as s:
|
| 936 |
sh = await s.get(Shift, sid)
|
app/handlers/basic.py
CHANGED
|
@@ -29,8 +29,10 @@ HELP = (
|
|
| 29 |
"/handleswap @old @new - change the username @old written in posts (text, captions, button names) to @new in all channels; links stay\n"
|
| 30 |
"/undo - undo the last /replace or /handleswap\n"
|
| 31 |
"/repost @old @new - copy every post of a channel in order (links swapped, replies, pins and links between posts kept), then delete the old posts\n"
|
| 32 |
-
"/shift @source @destination [from_id] [to_id] [-c] [-all] - copy posts from one channel into another (buttons kept, listed in My posts); "
|
| 33 |
-
"
|
|
|
|
|
|
|
| 34 |
"/sync - compare every channel with My posts right now (posts deleted, edited or added by others, the older posts from before the bot was added; service messages are deleted)\n"
|
| 35 |
"/userbot - status of the helper account that deletes posts the bot is not allowed to delete\n"
|
| 36 |
"/export - download a JSON backup of your channels and posts\n"
|
|
|
|
| 29 |
"/handleswap @old @new - change the username @old written in posts (text, captions, button names) to @new in all channels; links stay\n"
|
| 30 |
"/undo - undo the last /replace or /handleswap\n"
|
| 31 |
"/repost @old @new - copy every post of a channel in order (links swapped, replies, pins and links between posts kept), then delete the old posts\n"
|
| 32 |
+
"/shift @source @destination [from_id] [to_id] [-c] [-all] [--no-media] - copy posts from one channel into another (buttons kept, listed in My posts); "
|
| 33 |
+
"a channel Telegram restricts (copyright strike) is copied from My posts; "
|
| 34 |
+
"-c: links to the source's posts in all your channels are changed to the copies; -all: the destination also gets the source's name, description and photo; "
|
| 35 |
+
"--no-media: no files, only the text and buttons\n"
|
| 36 |
"/sync - compare every channel with My posts right now (posts deleted, edited or added by others, the older posts from before the bot was added; service messages are deleted)\n"
|
| 37 |
"/userbot - status of the helper account that deletes posts the bot is not allowed to delete\n"
|
| 38 |
"/export - download a JSON backup of your channels and posts\n"
|
app/handlers/repost.py
CHANGED
|
@@ -147,23 +147,45 @@ def plan_text(p: RepostPlan, o: RepostOptions, title: str, delay: float) -> str:
|
|
| 147 |
|
| 148 |
|
| 149 |
def held_plan_lines(p: RepostPlan, title: str, *, shift: bool = False) -> list:
|
| 150 |
-
"""Plan lines about posts Telegram
|
|
|
|
| 151 |
lines = []
|
| 152 |
name = esc(title)
|
| 153 |
-
if p.channel_restricted:
|
| 154 |
-
lines.append(f"• ⚠️ Telegram restricts {name} itself: “{esc(p.channel_restricted)}”")
|
| 155 |
said = f" (“{esc(p.held_why)}”)" if p.held_why else ""
|
| 156 |
-
if p.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
lines.append(
|
| 158 |
-
f"• 📋
|
| 159 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
"Telegram refuses goes out without it)."
|
| 161 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
if p.held_skipped and shift:
|
| 163 |
lines.append(
|
| 164 |
-
f"• ⚠️ Telegram
|
| 165 |
-
"them, so they are skipped and the shift will show as not complete. If you have a backup made
|
| 166 |
-
"put it back with /import and run /shift again."
|
| 167 |
)
|
| 168 |
elif p.held_skipped:
|
| 169 |
lines.append(
|
|
@@ -171,36 +193,49 @@ def held_plan_lines(p: RepostPlan, title: str, *, shift: bool = False) -> list:
|
|
| 171 |
"Copying the notice would be wrong, so they are skipped, and the old posts can't be deleted afterwards "
|
| 172 |
"(the repost will show as not complete)."
|
| 173 |
)
|
|
|
|
|
|
|
| 174 |
return lines
|
| 175 |
|
| 176 |
|
| 177 |
def held_notes(res) -> list:
|
| 178 |
-
"""Lines about the posts Telegram
|
| 179 |
notes = []
|
| 180 |
|
| 181 |
def sample(ids, n=8):
|
| 182 |
return ", ".join(f"#{i}" for i in ids[:n]) + ("…" if len(ids) > n else "")
|
| 183 |
|
|
|
|
| 184 |
if res.from_db:
|
| 185 |
if res.from_saved:
|
| 186 |
notes.append(
|
| 187 |
f"📋 The source can't be read, so {res.from_saved} post(s) were copied from what My posts saved of them: "
|
| 188 |
-
"
|
| 189 |
)
|
| 190 |
elif res.from_saved:
|
| 191 |
notes.append(
|
| 192 |
-
f"📋 {res.from_saved} post(s) are
|
| 193 |
-
"
|
| 194 |
)
|
| 195 |
if res.media_lost:
|
| 196 |
notes.append(
|
| 197 |
f"⚠️ {len(res.media_lost)} of them went out without their media, because Telegram refuses the saved file "
|
| 198 |
f"({sample(res.media_lost)})."
|
| 199 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
if res.held_back and res.used_saved:
|
| 201 |
notes.append(
|
| 202 |
-
f"⚠️ {len(res.held_back)} post(s)
|
| 203 |
-
f"were not copied ({sample(res.held_back)}). Put a backup made before that back with /import, then use "
|
| 204 |
"“Try the rest again”."
|
| 205 |
)
|
| 206 |
elif res.held_back:
|
|
@@ -212,9 +247,70 @@ def held_notes(res) -> list:
|
|
| 212 |
return notes
|
| 213 |
|
| 214 |
|
| 215 |
-
def
|
| 216 |
-
"""Lines about
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
if res.replies:
|
| 219 |
notes.append(f"↩️ {res.replies} post(s) answer the copy of the post they answered.")
|
| 220 |
if res.reply_lost:
|
|
@@ -430,6 +526,7 @@ def register(ctx: Ctx) -> None:
|
|
| 430 |
body = f"🔗 Copied {done}{of} posts. Now pointing links at the new copies and pinning…"
|
| 431 |
else:
|
| 432 |
body = f"⏳ Copying <b>{esc(ch.title)}</b>: {done}{of} posts…\nThe old posts stay untouched."
|
|
|
|
| 433 |
try:
|
| 434 |
await edit_callback_message(event, body, [[Button.inline("⏹ Stop", f"rps:{mig.id}")]])
|
| 435 |
except errors.MessageNotModifiedError:
|
|
|
|
| 147 |
|
| 148 |
|
| 149 |
def held_plan_lines(p: RepostPlan, title: str, *, shift: bool = False) -> list:
|
| 150 |
+
"""Plan lines about posts Telegram restricts (a copyright strike: the channel is "unavailable", or its posts are
|
| 151 |
+
marked or shown as a notice)."""
|
| 152 |
lines = []
|
| 153 |
name = esc(title)
|
|
|
|
|
|
|
| 154 |
said = f" (“{esc(p.held_why)}”)" if p.held_why else ""
|
| 155 |
+
if p.channel_restricted and shift:
|
| 156 |
+
lines.append(f"• ⚠️ Telegram restricts {name} itself: “{esc(p.channel_restricted)}”")
|
| 157 |
+
elif p.channel_restricted:
|
| 158 |
+
lines.append(
|
| 159 |
+
f"• ⚠️ Telegram restricts {name} itself: “{esc(p.channel_restricted)}”. Telegram may hide what is posted into "
|
| 160 |
+
"such a channel as well, so a repost here may not bring the posts back. To save a struck channel use "
|
| 161 |
+
"/shift: it copies the posts from My posts into another channel."
|
| 162 |
+
)
|
| 163 |
+
if p.held_saved and p.no_media:
|
| 164 |
lines.append(
|
| 165 |
+
f"• 📋 {p.held_saved} post(s) of {name} are restricted by Telegram{said}. They are copied from what My posts "
|
| 166 |
+
"saved of them - text, formatting and buttons - not from what Telegram shows now."
|
| 167 |
+
)
|
| 168 |
+
elif p.held_saved:
|
| 169 |
+
lines.append(
|
| 170 |
+
f"• 📋 {p.held_saved} post(s) of {name} are restricted by Telegram{said}. They are copied from what My posts "
|
| 171 |
+
"saved of them - text, formatting, buttons and media - not from what Telegram shows now (a file that "
|
| 172 |
"Telegram refuses goes out without it)."
|
| 173 |
)
|
| 174 |
+
if p.live_restricted and shift:
|
| 175 |
+
lines.append(
|
| 176 |
+
f"• ⚠️ {p.live_restricted} restricted post(s) of {name} have no saved copy in My posts, but Telegram still "
|
| 177 |
+
"hands out their content, so they are copied as they are now."
|
| 178 |
+
)
|
| 179 |
+
elif p.live_restricted:
|
| 180 |
+
lines.append(
|
| 181 |
+
f"• ⚠️ Telegram marks {p.live_restricted} post(s) of {name} as restricted but still hands out their content, "
|
| 182 |
+
"so they are copied as they are now."
|
| 183 |
+
)
|
| 184 |
if p.held_skipped and shift:
|
| 185 |
lines.append(
|
| 186 |
+
f"• ⚠️ Telegram shows only its notice for {p.held_skipped} more post(s) of {name}{said} and My posts has no "
|
| 187 |
+
"saved copy of them, so they are skipped and the shift will show as not complete. If you have a backup made "
|
| 188 |
+
"before that, put it back with /import and run /shift again."
|
| 189 |
)
|
| 190 |
elif p.held_skipped:
|
| 191 |
lines.append(
|
|
|
|
| 193 |
"Copying the notice would be wrong, so they are skipped, and the old posts can't be deleted afterwards "
|
| 194 |
"(the repost will show as not complete)."
|
| 195 |
)
|
| 196 |
+
if p.channel_restricted or p.held_saved or p.live_restricted:
|
| 197 |
+
lines.append("• The new posts are read back: if Telegram itself hides them, the report says so.")
|
| 198 |
return lines
|
| 199 |
|
| 200 |
|
| 201 |
def held_notes(res) -> list:
|
| 202 |
+
"""Lines about the posts Telegram restricts, for the end of a repost or shift."""
|
| 203 |
notes = []
|
| 204 |
|
| 205 |
def sample(ids, n=8):
|
| 206 |
return ", ".join(f"#{i}" for i in ids[:n]) + ("…" if len(ids) > n else "")
|
| 207 |
|
| 208 |
+
what = "text, formatting and buttons" if getattr(res, "no_media", False) else "text, formatting, buttons and media"
|
| 209 |
if res.from_db:
|
| 210 |
if res.from_saved:
|
| 211 |
notes.append(
|
| 212 |
f"📋 The source can't be read, so {res.from_saved} post(s) were copied from what My posts saved of them: "
|
| 213 |
+
f"{what} - without replies, pins and albums."
|
| 214 |
)
|
| 215 |
elif res.from_saved:
|
| 216 |
notes.append(
|
| 217 |
+
f"📋 {res.from_saved} post(s) are restricted by Telegram. They were copied from what My posts saved of "
|
| 218 |
+
f"them: {what}."
|
| 219 |
)
|
| 220 |
if res.media_lost:
|
| 221 |
notes.append(
|
| 222 |
f"⚠️ {len(res.media_lost)} of them went out without their media, because Telegram refuses the saved file "
|
| 223 |
f"({sample(res.media_lost)})."
|
| 224 |
)
|
| 225 |
+
if res.live_restricted and res.used_saved:
|
| 226 |
+
notes.append(
|
| 227 |
+
f"⚠️ {res.live_restricted} restricted post(s) had no saved copy in My posts, so they were copied as Telegram "
|
| 228 |
+
"hands them out (their content was intact)."
|
| 229 |
+
)
|
| 230 |
+
elif res.live_restricted:
|
| 231 |
+
notes.append(
|
| 232 |
+
f"⚠️ Telegram marks {res.live_restricted} post(s) as restricted but still handed out their content, so they "
|
| 233 |
+
"were copied as they are."
|
| 234 |
+
)
|
| 235 |
if res.held_back and res.used_saved:
|
| 236 |
notes.append(
|
| 237 |
+
f"⚠️ {len(res.held_back)} post(s): Telegram shows only its notice and My posts has no saved copy of them, so "
|
| 238 |
+
f"they were not copied ({sample(res.held_back)}). Put a backup made before that back with /import, then use "
|
| 239 |
"“Try the rest again”."
|
| 240 |
)
|
| 241 |
elif res.held_back:
|
|
|
|
| 247 |
return notes
|
| 248 |
|
| 249 |
|
| 250 |
+
def no_media_notes(res) -> list:
|
| 251 |
+
"""Lines about the files that /shift --no-media left out."""
|
| 252 |
+
if not getattr(res, "no_media", False):
|
| 253 |
+
return []
|
| 254 |
+
notes = []
|
| 255 |
+
if res.files_left_out:
|
| 256 |
+
notes.append(f"🚫 {res.files_left_out} post(s) were copied as text only - their file was left out (--no-media).")
|
| 257 |
+
if res.file_only:
|
| 258 |
+
ids = ", ".join(f"#{i}" for i in res.file_only[:8]) + ("…" if len(res.file_only) > 8 else "")
|
| 259 |
+
notes.append(f"🚫 {len(res.file_only)} post(s) are only a file, so there was nothing to copy of them ({ids}).")
|
| 260 |
+
return notes
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def hidden_notes(res, *, shift: bool = False) -> list:
|
| 264 |
+
"""Lines about new posts that TELEGRAM itself hides (they were read back after posting)."""
|
| 265 |
+
if not getattr(res, "hidden", None):
|
| 266 |
+
return []
|
| 267 |
+
k, n = len(res.hidden), res.checked
|
| 268 |
+
with_file = sum(1 for _, has in res.hidden if has)
|
| 269 |
+
plain = k - with_file
|
| 270 |
+
why = f" (“{esc(res.hidden_why)}”)" if res.hidden_why else ""
|
| 271 |
+
lines = [
|
| 272 |
+
f"⚠️ <b>Telegram itself hides {k} of the {n} new post(s) I read back</b>{why}: right after they were created they "
|
| 273 |
+
"carry its restriction mark, so its apps show the notice instead of them. The bot did not copy a notice - it "
|
| 274 |
+
"never posts one: Telegram marks these posts itself, because of something in them or because of the destination."
|
| 275 |
+
]
|
| 276 |
+
if with_file:
|
| 277 |
+
hint = (
|
| 278 |
+
" <code>/shift … --no-media</code> copies the text and buttons without the files."
|
| 279 |
+
if shift
|
| 280 |
+
else ""
|
| 281 |
+
)
|
| 282 |
+
lines.append(
|
| 283 |
+
f"• {with_file} post(s) of these carry a file. Telegram may hide a message that carries a file it has "
|
| 284 |
+
f"blocked for copyright, wherever it is posted.{hint}"
|
| 285 |
+
)
|
| 286 |
+
if plain:
|
| 287 |
+
lines.append(
|
| 288 |
+
f"• {plain} post(s) of these have no file: Telegram hides them for their text or links, or because it "
|
| 289 |
+
"restricts the new channel too."
|
| 290 |
+
)
|
| 291 |
+
if getattr(res, "dst_restricted", None):
|
| 292 |
+
lines.append(f"• Telegram restricts the destination channel itself: “{esc(res.dst_restricted)}”.")
|
| 293 |
+
if getattr(res, "hidden_stop", False):
|
| 294 |
+
lines.append(
|
| 295 |
+
f"⏹ <b>I stopped after {res.copied_units} post(s)</b>: Telegram hides every new copy I read back, so going on "
|
| 296 |
+
"would only add more posts nobody can see. Nothing else was posted. ▶️ <b>Continue anyway</b> copies the rest; "
|
| 297 |
+
"↩️ <b>Undo</b> removes the copies."
|
| 298 |
+
)
|
| 299 |
+
lines.append("The saved posts in My posts are intact. Undo removes the new copies again.")
|
| 300 |
+
return lines
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def hidden_progress(res) -> str:
|
| 304 |
+
"""One line for the running progress message when Telegram hides what was just posted."""
|
| 305 |
+
if not getattr(res, "hidden", None):
|
| 306 |
+
return ""
|
| 307 |
+
why = f" (“{esc(res.hidden_why)}”)" if res.hidden_why else ""
|
| 308 |
+
return f"\n⚠️ Telegram itself hides {len(res.hidden)} of the {res.checked} new posts checked so far{why}. ⏹ Stop is below."
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def finishing_notes(res, *, shift: bool = False) -> list:
|
| 312 |
+
"""Lines about answers, links and pins for the end of a repost or shift."""
|
| 313 |
+
notes = held_notes(res) + no_media_notes(res) + hidden_notes(res, shift=shift)
|
| 314 |
if res.replies:
|
| 315 |
notes.append(f"↩️ {res.replies} post(s) answer the copy of the post they answered.")
|
| 316 |
if res.reply_lost:
|
|
|
|
| 526 |
body = f"🔗 Copied {done}{of} posts. Now pointing links at the new copies and pinning…"
|
| 527 |
else:
|
| 528 |
body = f"⏳ Copying <b>{esc(ch.title)}</b>: {done}{of} posts…\nThe old posts stay untouched."
|
| 529 |
+
body += hidden_progress(res)
|
| 530 |
try:
|
| 531 |
await edit_callback_message(event, body, [[Button.inline("⏹ Stop", f"rps:{mig.id}")]])
|
| 532 |
except errors.MessageNotModifiedError:
|
app/handlers/shift.py
CHANGED
|
@@ -18,7 +18,7 @@ from ..repost_engine import RepostOptions, RepostPlan, RepostResult, plan_repost
|
|
| 18 |
from ..shift_engine import delete_shift_copies, plan_from_my_posts, run_shift, shift_profile, source_of
|
| 19 |
from ..tgutil import esc, explain_rpc, get_rights, post_link
|
| 20 |
from ..userbot import delete_problem_text
|
| 21 |
-
from .repost import finishing_notes, held_plan_lines
|
| 22 |
|
| 23 |
log = logging.getLogger(__name__)
|
| 24 |
|
|
@@ -31,23 +31,28 @@ USAGE = (
|
|
| 31 |
"<code>/shift @source @destination</code> - every post\n"
|
| 32 |
"<code>/shift @source @destination 15 20</code> - only the posts with message ids 15 to 20\n"
|
| 33 |
"<code>/shift @source @destination 15</code> - only post 15\n\n"
|
| 34 |
-
"A
|
| 35 |
-
"
|
| 36 |
-
"
|
| 37 |
-
"
|
|
|
|
|
|
|
| 38 |
"<code>-c</code> - afterwards every link to a post of the source - in ANY of your connected channels - is pointed "
|
| 39 |
"at the copy in the destination\n"
|
| 40 |
"<code>-all</code> - afterwards the destination gets the source's name, description and profile photo\n"
|
| 41 |
-
"<code>
|
|
|
|
|
|
|
| 42 |
)
|
| 43 |
|
| 44 |
|
| 45 |
@dataclass
|
| 46 |
class ShiftFlags:
|
| 47 |
-
"""The
|
| 48 |
|
| 49 |
links: bool = False # -c: links to the source's posts, in all connected channels, follow the copies
|
| 50 |
profile: bool = False # -all: the destination gets the source's name, description and profile photo
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
@dataclass
|
|
@@ -66,26 +71,30 @@ class ShiftJob:
|
|
| 66 |
created: float = field(default_factory=time.monotonic)
|
| 67 |
|
| 68 |
|
| 69 |
-
_OPTION = re.compile(r"^-{1,2}([A-Za-z][A-Za-z0-9_]*)$")
|
| 70 |
|
| 71 |
|
| 72 |
def parse_shift_args(raw: str) -> tuple:
|
| 73 |
"""-> (source text, destination text, first id or None, last id or None, ShiftFlags).
|
| 74 |
|
| 75 |
-
-c and -
|
| 76 |
-
-1001234567890 is not an option."""
|
| 77 |
-
text = re.sub(r"(?<!\S)(-{1,2})\s+(all|c)(?!\S)", r"\1\2", raw or "", flags=re.I)
|
| 78 |
flags, toks = ShiftFlags(), []
|
| 79 |
for t in text.split():
|
| 80 |
m = _OPTION.match(t)
|
| 81 |
if m is None:
|
| 82 |
toks.append(t)
|
| 83 |
-
|
|
|
|
|
|
|
| 84 |
flags.links = True
|
| 85 |
-
elif
|
| 86 |
flags.profile = True
|
|
|
|
|
|
|
| 87 |
else:
|
| 88 |
-
raise ValueError(f"Unknown option {t}. The options are -c and -
|
| 89 |
if len(toks) < 2 or len(toks) > 4:
|
| 90 |
raise ValueError("Give the source and the destination channel, and optionally the first and the last message id.")
|
| 91 |
nums: list = []
|
|
@@ -122,6 +131,13 @@ def plan_text(
|
|
| 122 |
if p.dropped_posts:
|
| 123 |
lines.append(f"• {p.dropped_posts} post(s) carry other bots' buttons (reactions...) - those are not copied")
|
| 124 |
lines += held_plan_lines(p, src.title, shift=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
if p.post_links:
|
| 126 |
lines.append(
|
| 127 |
f"• {p.post_links} link(s) in {p.post_link_posts} post(s) point at other posts of {esc(src.title)}: those that "
|
|
@@ -160,13 +176,30 @@ def plan_text(
|
|
| 160 |
)
|
| 161 |
minutes = max(1, round(p.eta_seconds(delay) / 60))
|
| 162 |
if p.from_db:
|
|
|
|
|
|
|
|
|
|
| 163 |
how = (
|
| 164 |
-
"<b>How:</b> every post is posted again from what My posts saved of it -
|
| 165 |
-
"
|
| 166 |
-
"
|
| 167 |
-
"My posts."
|
| 168 |
)
|
| 169 |
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)."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
else:
|
| 171 |
how = (
|
| 172 |
"<b>How:</b> posts are copied by Telegram itself where possible (exactly as they are); posts with buttons or "
|
|
@@ -174,6 +207,8 @@ def plan_text(
|
|
| 174 |
"back to make sure it is there. Copies go to the end of the destination silently, in the original order, and "
|
| 175 |
"are registered in My posts."
|
| 176 |
)
|
|
|
|
|
|
|
| 177 |
kept = "<b>Not kept:</b> original dates, view counts, reactions, comments, poll votes and pins."
|
| 178 |
lines += [
|
| 179 |
"",
|
|
@@ -201,6 +236,8 @@ def register(ctx: Ctx) -> None:
|
|
| 201 |
first_copy = f'\n<a href="{post_link(dst, c["first_new"])}">First copy</a>'
|
| 202 |
undo = [Button.inline("↩️ Undo - remove the copies", f"sfu:{sh.id}")]
|
| 203 |
marks = await db.marks_of(sh.id)
|
|
|
|
|
|
|
| 204 |
extras = []
|
| 205 |
if "relink" in marks:
|
| 206 |
extras.append([Button.inline("🔗 Update links again", f"sfl:{sh.id}")])
|
|
@@ -218,7 +255,8 @@ def register(ctx: Ctx) -> None:
|
|
| 218 |
kb = []
|
| 219 |
elif sh.status == "stopped":
|
| 220 |
text = f"⏹ {route}: <b>stopped</b>. {c['copied']} message(s) copied so far.{first_copy}"
|
| 221 |
-
|
|
|
|
| 222 |
elif sh.status == "copying":
|
| 223 |
text = f"⏳ {route}: a shift was running when the bot stopped. {c['copied']} message(s) copied.{first_copy}"
|
| 224 |
kb = [[Button.inline("▶️ Continue", f"sfc:{sh.id}")], undo]
|
|
@@ -241,8 +279,13 @@ def register(ctx: Ctx) -> None:
|
|
| 241 |
if sh is not None:
|
| 242 |
text, kb = await view_for(sh)
|
| 243 |
await say(event, "Unfinished shift:\n\n" + text, kb)
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
return
|
| 247 |
try:
|
| 248 |
src_text, dst_text, first, last, flags = parse_shift_args(raw)
|
|
@@ -278,12 +321,12 @@ def register(ctx: Ctx) -> None:
|
|
| 278 |
async with ctx.lock:
|
| 279 |
plan = await plan_repost(
|
| 280 |
client, src, RepostOptions(), progress=prog, first_id=first, last_id=last,
|
| 281 |
-
saved=saved_lookup(db, src.id), use_saved=True,
|
| 282 |
)
|
| 283 |
except errors.RPCError as e:
|
| 284 |
why = explain_rpc(e)
|
| 285 |
# a channel that can't be read any more (banned, private ...) may still be in My posts: copy it from there
|
| 286 |
-
plan = await plan_from_my_posts(db, src, first, last, unreadable=why)
|
| 287 |
if plan is None:
|
| 288 |
raise UserError(
|
| 289 |
f"I can't read {src.title}: {why}\nThe bot has to be a member of the source channel "
|
|
@@ -336,6 +379,7 @@ def register(ctx: Ctx) -> None:
|
|
| 336 |
body = f"🔗 Copied {done}{of} posts. Now pointing links at the new copies…"
|
| 337 |
else:
|
| 338 |
body = f"⏳ Shifting {names}: {done}{of} posts…\nThe source stays untouched."
|
|
|
|
| 339 |
try:
|
| 340 |
await edit_callback_message(event, body, [[Button.inline("⏹ Stop", f"sfs:{sid}")]])
|
| 341 |
except errors.MessageNotModifiedError:
|
|
@@ -353,6 +397,8 @@ def register(ctx: Ctx) -> None:
|
|
| 353 |
job.running = False
|
| 354 |
status = "stopped" if res.stopped else ("incomplete" if (res.failed or res.aborted or res.held_back) else "copied")
|
| 355 |
await db.set_shift_status(sid, status)
|
|
|
|
|
|
|
| 356 |
sh = await db.get_shift(sid)
|
| 357 |
text, kb = await view_for(sh)
|
| 358 |
notes = []
|
|
@@ -372,7 +418,7 @@ def register(ctx: Ctx) -> None:
|
|
| 372 |
notes.append("ReplyParentMissing: that post answers a post whose copy failed; it follows when you try again.")
|
| 373 |
if res.aborted:
|
| 374 |
notes.append(f"Stopped early because of {esc(res.aborted)}.")
|
| 375 |
-
notes += finishing_notes(res)
|
| 376 |
await edit_callback_message(event, text + ("\n\n" + "\n".join(notes) if notes else ""), kb)
|
| 377 |
|
| 378 |
@on_cb(ctx, "sfa")
|
|
@@ -397,6 +443,8 @@ def register(ctx: Ctx) -> None:
|
|
| 397 |
await db.set_mark(sid, "relink")
|
| 398 |
if job.flags.profile:
|
| 399 |
await db.set_mark(sid, "profile")
|
|
|
|
|
|
|
| 400 |
if p.from_db: # the source can't be read: continuing the shift goes on from My posts as well
|
| 401 |
await db.set_mark(sid, "fromdb")
|
| 402 |
ctx.pending.pop(parts[0], None)
|
|
|
|
| 18 |
from ..shift_engine import delete_shift_copies, plan_from_my_posts, run_shift, shift_profile, source_of
|
| 19 |
from ..tgutil import esc, explain_rpc, get_rights, post_link
|
| 20 |
from ..userbot import delete_problem_text
|
| 21 |
+
from .repost import finishing_notes, held_plan_lines, hidden_progress
|
| 22 |
|
| 23 |
log = logging.getLogger(__name__)
|
| 24 |
|
|
|
|
| 31 |
"<code>/shift @source @destination</code> - every post\n"
|
| 32 |
"<code>/shift @source @destination 15 20</code> - only the posts with message ids 15 to 20\n"
|
| 33 |
"<code>/shift @source @destination 15</code> - only post 15\n\n"
|
| 34 |
+
"A channel that Telegram restricts (a copyright strike: “This channel is unavailable due to copyright "
|
| 35 |
+
"infringement”, its posts shown as “This message couldn't be displayed on your device due to copyright "
|
| 36 |
+
"infringement”) is copied from what My posts saved of it - every post that has a saved copy, however intact "
|
| 37 |
+
"Telegram's version looks. Telegram's notice is never copied. A post without a saved copy is copied as Telegram "
|
| 38 |
+
"hands it out if it is still the post itself, and skipped if only the notice is left.\n\n"
|
| 39 |
+
"Three extras, each optional, in any order, with or without a range:\n"
|
| 40 |
"<code>-c</code> - afterwards every link to a post of the source - in ANY of your connected channels - is pointed "
|
| 41 |
"at the copy in the destination\n"
|
| 42 |
"<code>-all</code> - afterwards the destination gets the source's name, description and profile photo\n"
|
| 43 |
+
"<code>--no-media</code> - no file is copied: every post goes out as its text with its formatting and buttons "
|
| 44 |
+
"(use it when Telegram hides the copies because of their files)\n"
|
| 45 |
+
"<code>/shift @source @destination -c -all</code> does the first two."
|
| 46 |
)
|
| 47 |
|
| 48 |
|
| 49 |
@dataclass
|
| 50 |
class ShiftFlags:
|
| 51 |
+
"""The optional extras of /shift."""
|
| 52 |
|
| 53 |
links: bool = False # -c: links to the source's posts, in all connected channels, follow the copies
|
| 54 |
profile: bool = False # -all: the destination gets the source's name, description and profile photo
|
| 55 |
+
no_media: bool = False # --no-media: the copies get no files (text, formatting and buttons only)
|
| 56 |
|
| 57 |
|
| 58 |
@dataclass
|
|
|
|
| 71 |
created: float = field(default_factory=time.monotonic)
|
| 72 |
|
| 73 |
|
| 74 |
+
_OPTION = re.compile(r"^[-\u2013\u2014]{1,2}([A-Za-z][A-Za-z0-9_-]*)$") # phone keyboards turn "--" into a long dash
|
| 75 |
|
| 76 |
|
| 77 |
def parse_shift_args(raw: str) -> tuple:
|
| 78 |
"""-> (source text, destination text, first id or None, last id or None, ShiftFlags).
|
| 79 |
|
| 80 |
+
-c, -all and --no-media can stand anywhere, in any order ("- all" with a space is understood too, and so are
|
| 81 |
+
-nomedia and a long dash in place of --); a channel id such as -1001234567890 is not an option."""
|
| 82 |
+
text = re.sub(r"(?<!\S)([-\u2013\u2014]{1,2})\s+(all|c|no[-_]?media)(?!\S)", r"\1\2", raw or "", flags=re.I)
|
| 83 |
flags, toks = ShiftFlags(), []
|
| 84 |
for t in text.split():
|
| 85 |
m = _OPTION.match(t)
|
| 86 |
if m is None:
|
| 87 |
toks.append(t)
|
| 88 |
+
continue
|
| 89 |
+
name = re.sub(r"[-_]", "", m.group(1)).lower()
|
| 90 |
+
if name == "c":
|
| 91 |
flags.links = True
|
| 92 |
+
elif name == "all":
|
| 93 |
flags.profile = True
|
| 94 |
+
elif name == "nomedia":
|
| 95 |
+
flags.no_media = True
|
| 96 |
else:
|
| 97 |
+
raise ValueError(f"Unknown option {t}. The options are -c, -all and --no-media.")
|
| 98 |
if len(toks) < 2 or len(toks) > 4:
|
| 99 |
raise ValueError("Give the source and the destination channel, and optionally the first and the last message id.")
|
| 100 |
nums: list = []
|
|
|
|
| 131 |
if p.dropped_posts:
|
| 132 |
lines.append(f"• {p.dropped_posts} post(s) carry other bots' buttons (reactions...) - those are not copied")
|
| 133 |
lines += held_plan_lines(p, src.title, shift=True)
|
| 134 |
+
if p.no_media:
|
| 135 |
+
note = "• 🚫 <b>--no-media</b>: no file is copied - every post goes out as its text with its formatting and buttons"
|
| 136 |
+
if p.files_left_out:
|
| 137 |
+
note += f". {p.files_left_out} post(s) carry a file that stays out"
|
| 138 |
+
if p.file_only:
|
| 139 |
+
note += f"; {p.file_only} post(s) are only a file, so there is nothing to copy of them and they are skipped"
|
| 140 |
+
lines.append(note + ".")
|
| 141 |
if p.post_links:
|
| 142 |
lines.append(
|
| 143 |
f"• {p.post_links} link(s) in {p.post_link_posts} post(s) point at other posts of {esc(src.title)}: those that "
|
|
|
|
| 176 |
)
|
| 177 |
minutes = max(1, round(p.eta_seconds(delay) / 60))
|
| 178 |
if p.from_db:
|
| 179 |
+
what = "its text, formatting and buttons" if p.no_media else (
|
| 180 |
+
"its text, formatting, buttons and media (a file that Telegram refuses goes out without it)"
|
| 181 |
+
)
|
| 182 |
how = (
|
| 183 |
+
f"<b>How:</b> every post is posted again from what My posts saved of it - {what} - and every button is read "
|
| 184 |
+
"back to make sure it is there. Copies go to the end of the destination silently, in the original order, and "
|
| 185 |
+
"are registered in My posts."
|
|
|
|
| 186 |
)
|
| 187 |
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)."
|
| 188 |
+
elif p.no_media:
|
| 189 |
+
how = (
|
| 190 |
+
"<b>How:</b> every post is posted again as its text with its formatting and buttons - no file is copied - and "
|
| 191 |
+
"every button is read back to make sure it is there. Copies go to the end of the destination silently, in the "
|
| 192 |
+
"original order, and are registered in My posts."
|
| 193 |
+
)
|
| 194 |
+
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."
|
| 195 |
+
elif p.channel_restricted:
|
| 196 |
+
how = (
|
| 197 |
+
"<b>How:</b> Telegram copies nothing out of a restricted channel, so every post is posted again - from what "
|
| 198 |
+
"My posts saved of it, or as Telegram hands it out when there is no saved copy - with its text, formatting, "
|
| 199 |
+
"media and buttons in one go, and every button is read back to make sure it is there. Copies go to the end of "
|
| 200 |
+
"the destination silently, in the original order, and are registered in My posts."
|
| 201 |
+
)
|
| 202 |
+
kept = "<b>Not kept:</b> original dates, view counts, reactions, comments, poll votes and pins."
|
| 203 |
else:
|
| 204 |
how = (
|
| 205 |
"<b>How:</b> posts are copied by Telegram itself where possible (exactly as they are); posts with buttons or "
|
|
|
|
| 207 |
"back to make sure it is there. Copies go to the end of the destination silently, in the original order, and "
|
| 208 |
"are registered in My posts."
|
| 209 |
)
|
| 210 |
+
if p.held_saved or p.live_restricted:
|
| 211 |
+
how += " Posts that Telegram restricts are never copied by Telegram itself: they are posted again."
|
| 212 |
kept = "<b>Not kept:</b> original dates, view counts, reactions, comments, poll votes and pins."
|
| 213 |
lines += [
|
| 214 |
"",
|
|
|
|
| 236 |
first_copy = f'\n<a href="{post_link(dst, c["first_new"])}">First copy</a>'
|
| 237 |
undo = [Button.inline("↩️ Undo - remove the copies", f"sfu:{sh.id}")]
|
| 238 |
marks = await db.marks_of(sh.id)
|
| 239 |
+
if "nomedia" in marks:
|
| 240 |
+
route += " (without files)"
|
| 241 |
extras = []
|
| 242 |
if "relink" in marks:
|
| 243 |
extras.append([Button.inline("🔗 Update links again", f"sfl:{sh.id}")])
|
|
|
|
| 255 |
kb = []
|
| 256 |
elif sh.status == "stopped":
|
| 257 |
text = f"⏹ {route}: <b>stopped</b>. {c['copied']} message(s) copied so far.{first_copy}"
|
| 258 |
+
go = "▶️ Continue anyway" if "hiddenok" in marks else "▶️ Continue"
|
| 259 |
+
kb = [[Button.inline(go, f"sfc:{sh.id}")], undo]
|
| 260 |
elif sh.status == "copying":
|
| 261 |
text = f"⏳ {route}: a shift was running when the bot stopped. {c['copied']} message(s) copied.{first_copy}"
|
| 262 |
kb = [[Button.inline("▶️ Continue", f"sfc:{sh.id}")], undo]
|
|
|
|
| 279 |
if sh is not None:
|
| 280 |
text, kb = await view_for(sh)
|
| 281 |
await say(event, "Unfinished shift:\n\n" + text, kb)
|
| 282 |
+
return
|
| 283 |
+
await say(event, USAGE)
|
| 284 |
+
# a finished shift keeps its Undo: offer it again, so it can be found without scrolling up to its old message
|
| 285 |
+
last = await db.last_finished_shift()
|
| 286 |
+
if last is not None:
|
| 287 |
+
text, kb = await view_for(last)
|
| 288 |
+
await say(event, "Latest finished shift - you can still undo it:\n\n" + text, kb)
|
| 289 |
return
|
| 290 |
try:
|
| 291 |
src_text, dst_text, first, last, flags = parse_shift_args(raw)
|
|
|
|
| 321 |
async with ctx.lock:
|
| 322 |
plan = await plan_repost(
|
| 323 |
client, src, RepostOptions(), progress=prog, first_id=first, last_id=last,
|
| 324 |
+
saved=saved_lookup(db, src.id), use_saved=True, no_media=flags.no_media,
|
| 325 |
)
|
| 326 |
except errors.RPCError as e:
|
| 327 |
why = explain_rpc(e)
|
| 328 |
# a channel that can't be read any more (banned, private ...) may still be in My posts: copy it from there
|
| 329 |
+
plan = await plan_from_my_posts(db, src, first, last, unreadable=why, no_media=flags.no_media)
|
| 330 |
if plan is None:
|
| 331 |
raise UserError(
|
| 332 |
f"I can't read {src.title}: {why}\nThe bot has to be a member of the source channel "
|
|
|
|
| 379 |
body = f"🔗 Copied {done}{of} posts. Now pointing links at the new copies…"
|
| 380 |
else:
|
| 381 |
body = f"⏳ Shifting {names}: {done}{of} posts…\nThe source stays untouched."
|
| 382 |
+
body += hidden_progress(res)
|
| 383 |
try:
|
| 384 |
await edit_callback_message(event, body, [[Button.inline("⏹ Stop", f"sfs:{sid}")]])
|
| 385 |
except errors.MessageNotModifiedError:
|
|
|
|
| 397 |
job.running = False
|
| 398 |
status = "stopped" if res.stopped else ("incomplete" if (res.failed or res.aborted or res.held_back) else "copied")
|
| 399 |
await db.set_shift_status(sid, status)
|
| 400 |
+
if res.hidden_stop: # stopped because Telegram hides the copies: pressing Continue means "go on anyway"
|
| 401 |
+
await db.set_mark(sid, "hiddenok")
|
| 402 |
sh = await db.get_shift(sid)
|
| 403 |
text, kb = await view_for(sh)
|
| 404 |
notes = []
|
|
|
|
| 418 |
notes.append("ReplyParentMissing: that post answers a post whose copy failed; it follows when you try again.")
|
| 419 |
if res.aborted:
|
| 420 |
notes.append(f"Stopped early because of {esc(res.aborted)}.")
|
| 421 |
+
notes += finishing_notes(res, shift=True)
|
| 422 |
await edit_callback_message(event, text + ("\n\n" + "\n".join(notes) if notes else ""), kb)
|
| 423 |
|
| 424 |
@on_cb(ctx, "sfa")
|
|
|
|
| 443 |
await db.set_mark(sid, "relink")
|
| 444 |
if job.flags.profile:
|
| 445 |
await db.set_mark(sid, "profile")
|
| 446 |
+
if job.flags.no_media: # continuing the shift later goes on without files as well
|
| 447 |
+
await db.set_mark(sid, "nomedia")
|
| 448 |
if p.from_db: # the source can't be read: continuing the shift goes on from My posts as well
|
| 449 |
await db.set_mark(sid, "fromdb")
|
| 450 |
ctx.pending.pop(parts[0], None)
|
app/repost_engine.py
CHANGED
|
@@ -17,10 +17,14 @@ What a plain copy would lose is put back as well:
|
|
| 17 |
copy already exists, afterwards (finalize_repost) for posts that are copied later;
|
| 18 |
* a post that was pinned has its copy pinned at the end.
|
| 19 |
|
| 20 |
-
A post that TELEGRAM
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
"""
|
| 25 |
from __future__ import annotations
|
| 26 |
|
|
@@ -37,7 +41,7 @@ from .crosslinks import CrossResult, relink_after_repost
|
|
| 37 |
from .linkswap import compute_message_changes, url_only_markup
|
| 38 |
from .postlinks import PostLinks, relink_message
|
| 39 |
from .replace_engine import CHUNK, FALLBACK_EMPTY_BATCHES, fetch_messages, is_skippable, preview_flag
|
| 40 |
-
from .restrictions import is_placeholder_text, message_restriction,
|
| 41 |
from .tgutil import (
|
| 42 |
FATAL,
|
| 43 |
build_markup,
|
|
@@ -178,24 +182,39 @@ def saved_lookup(db, channel_id: int) -> SavedLookup:
|
|
| 178 |
return lookup
|
| 179 |
|
| 180 |
|
| 181 |
-
def
|
| 182 |
-
"""
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
for m in unit:
|
| 188 |
-
row = rows.get(m.id)
|
| 189 |
-
saved_text = row.text if row is not None else None
|
| 190 |
-
why = message_restriction(m, saved_text)
|
| 191 |
-
if not why:
|
| 192 |
-
continue
|
| 193 |
live = m.message or ""
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
return why
|
| 198 |
-
return None
|
| 199 |
|
| 200 |
|
| 201 |
def stand_in(m, row):
|
|
@@ -219,17 +238,47 @@ def stand_in(m, row):
|
|
| 219 |
|
| 220 |
|
| 221 |
def stand_ins(unit: list, rows: dict) -> Optional[list]:
|
| 222 |
-
"""The saved copies that stand in for a
|
| 223 |
-
|
| 224 |
out = []
|
| 225 |
for m in unit:
|
| 226 |
row = rows.get(m.id)
|
| 227 |
-
if
|
| 228 |
return None
|
| 229 |
out.append(stand_in(m, row))
|
| 230 |
return out
|
| 231 |
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
def saved_message(row, channel_id: int):
|
| 234 |
"""A post built from its saved copy alone, for a channel that can't be read at all. Only the saved parts exist: no
|
| 235 |
album, no reply, no pin."""
|
|
@@ -240,13 +289,14 @@ def saved_message(row, channel_id: int):
|
|
| 240 |
|
| 241 |
|
| 242 |
def saved_rows_between(rows: list, first: Optional[int], last: Optional[int]) -> list:
|
| 243 |
-
"""The saved posts (oldest first, one per message id) that have something to post
|
|
|
|
| 244 |
out, seen = [], set()
|
| 245 |
for row in sorted(rows, key=lambda r: (r.message_id or 0, r.id)):
|
| 246 |
mid = row.message_id
|
| 247 |
if mid is None or mid in seen or (first and mid < first) or (last and mid > last):
|
| 248 |
continue
|
| 249 |
-
if (row
|
| 250 |
seen.add(mid)
|
| 251 |
out.append(row)
|
| 252 |
return out
|
|
@@ -282,8 +332,12 @@ class RepostPlan:
|
|
| 282 |
post_links: int = 0
|
| 283 |
pinned: int = 0 # pinned posts (their copies get pinned)
|
| 284 |
replies: int = 0 # posts that answer another post
|
| 285 |
-
held_saved: int = 0 # posts
|
| 286 |
-
held_skipped: int = 0 # posts
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
held_why: Optional[str] = None # what Telegram says about them
|
| 288 |
channel_restricted: Optional[str] = None # what Telegram says when it restricts the whole channel
|
| 289 |
from_db: bool = False # the channel can't be read: the plan (and the copy) is made from My posts alone
|
|
@@ -324,20 +378,34 @@ def tally(plan: RepostPlan, m, opts: RepostOptions, links: PostLinks) -> None:
|
|
| 324 |
plan.replies += 1
|
| 325 |
|
| 326 |
|
| 327 |
-
def plan_from_saved(
|
|
|
|
|
|
|
| 328 |
"""The plan of a copy made from My posts alone (the channel can't be read): one post per saved message."""
|
| 329 |
-
plan = RepostPlan(
|
|
|
|
|
|
|
| 330 |
links = PostLinks.for_channel(ch)
|
| 331 |
chosen = saved_rows_between(rows, first_id, last_id)
|
| 332 |
for row in chosen:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
plan.units += 1
|
| 334 |
-
tally(plan,
|
| 335 |
if chosen:
|
| 336 |
plan.first, plan.last = chosen[0].message_id, chosen[-1].message_id
|
| 337 |
plan.scanned = plan.last
|
| 338 |
else:
|
| 339 |
plan.first, plan.last = first_id or 1, last_id or 0
|
|
|
|
| 340 |
plan.error = "There is nothing to copy."
|
|
|
|
|
|
|
| 341 |
return plan
|
| 342 |
|
| 343 |
|
|
@@ -366,12 +434,14 @@ async def plan_repost(
|
|
| 366 |
last_id: Optional[int] = None,
|
| 367 |
saved: Optional[SavedLookup] = None,
|
| 368 |
use_saved: bool = False,
|
|
|
|
| 369 |
) -> RepostPlan:
|
| 370 |
"""Read the channel and count what a copy would do. first_id / last_id limit the range (used by /shift).
|
| 371 |
`saved` looks up My posts' copies of the channel's posts; with `use_saved` a post Telegram holds back is counted as
|
| 372 |
-
the saved post it will be copied from (/shift), without it such a post is left out (/repost).
|
|
|
|
| 373 |
peer = peer_of(ch)
|
| 374 |
-
plan = RepostPlan(channel=ch, partial=bool(opts.last))
|
| 375 |
try:
|
| 376 |
top, plan.channel_restricted, _ = await probe_channel(client, peer)
|
| 377 |
except Exception as e: # the counter is only a help: without it the ids are read until nothing more turns up
|
|
@@ -411,20 +481,34 @@ async def plan_repost(
|
|
| 411 |
plan.service += 1
|
| 412 |
continue
|
| 413 |
gid = getattr(m, "grouped_id", None)
|
| 414 |
-
new_unit = gid is None or gid != prev_gid
|
| 415 |
row = rows.get(m.id)
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
| 417 |
if why:
|
| 418 |
plan.held_why = plan.held_why or why
|
| 419 |
-
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
prev_gid = gid
|
| 422 |
continue
|
|
|
|
|
|
|
| 423 |
plan.held_saved += 1 if new_unit else 0
|
| 424 |
-
|
|
|
|
| 425 |
if new_unit:
|
| 426 |
plan.units += 1
|
| 427 |
-
if gid is not None:
|
| 428 |
plan.albums += 1
|
| 429 |
prev_gid = gid
|
| 430 |
tally(plan, m, opts, links)
|
|
@@ -444,6 +528,8 @@ async def plan_repost(
|
|
| 444 |
"Telegram holds back every post in this range (it shows a notice instead of the post) and My posts has "
|
| 445 |
"no saved copy of them, so there is nothing to copy."
|
| 446 |
)
|
|
|
|
|
|
|
| 447 |
return plan
|
| 448 |
|
| 449 |
|
|
@@ -626,9 +712,11 @@ async def copy_unit(
|
|
| 626 |
dst_peer=None,
|
| 627 |
*,
|
| 628 |
from_saved: bool = False,
|
|
|
|
| 629 |
) -> list:
|
| 630 |
"""Create the copy of one post (or album) at the end of `dst_peer` (default: the same channel `peer`).
|
| 631 |
-
`from_saved`: the posts in `unit` are saved copies standing in for posts Telegram
|
|
|
|
| 632 |
dst = dst_peer if dst_peer is not None else peer
|
| 633 |
if from_saved:
|
| 634 |
news = await resend_saved(client, dst, unit, finals, st, reply_to)
|
|
@@ -638,7 +726,7 @@ async def copy_unit(
|
|
| 638 |
st.replies += 1
|
| 639 |
return news
|
| 640 |
pure = all(is_pure(m, f, reply_to) for m, f in zip(unit, finals))
|
| 641 |
-
if st.mode == "forward" and pure:
|
| 642 |
try:
|
| 643 |
news = await forward_unit(client, peer, unit, dst)
|
| 644 |
if len(news) == len(unit):
|
|
@@ -659,7 +747,7 @@ async def copy_unit(
|
|
| 659 |
# rebuild necessary, a plain copy by Telegram still works - without the reply.
|
| 660 |
if isinstance(e, errors.RPCError) and type(e).__name__ in FATAL:
|
| 661 |
raise
|
| 662 |
-
if reply_to and st.mode == "forward" and all(is_pure(m, f) for m, f in zip(unit, finals)):
|
| 663 |
news = await forward_unit(client, peer, unit, dst)
|
| 664 |
if len(news) != len(unit):
|
| 665 |
await delete_ids(client, dst, [n.id for n in news])
|
|
@@ -721,12 +809,21 @@ class RepostResult:
|
|
| 721 |
replies: int = 0 # copies created as an answer to the copy of the post the original answered
|
| 722 |
reply_lost: int = 0 # answers that could only be copied without the reply
|
| 723 |
reply_dropped: int = 0 # answers to a post that no longer exists (nothing to answer)
|
| 724 |
-
from_saved: int = 0 # posts
|
| 725 |
media_lost: list = field(default_factory=list) # ... of which these (old ids) went out without their media
|
| 726 |
-
held_back: list = field(default_factory=list) # posts
|
| 727 |
-
held_why: Optional[str] = None # what Telegram says about
|
| 728 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
from_db: bool = False # the channel can't be read: every post was copied from My posts alone
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 730 |
aborted: Optional[str] = None
|
| 731 |
stopped: bool = False
|
| 732 |
first_new: Optional[int] = None
|
|
@@ -762,6 +859,30 @@ def _rows(unit: list, finals: list, news: list) -> list:
|
|
| 762 |
return rows
|
| 763 |
|
| 764 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 765 |
@dataclass
|
| 766 |
class CopyJob:
|
| 767 |
"""Everything the copy loop needs to know; /repost and /shift each fill it in their own way."""
|
|
@@ -779,6 +900,9 @@ class CopyJob:
|
|
| 779 |
use_saved: bool = False # a post Telegram holds back is rebuilt from its saved copy (otherwise it is left out)
|
| 780 |
units: Optional[Callable[[], AsyncIterator[list]]] = None # where the posts come from (default: read the channel)
|
| 781 |
from_db: bool = False # `units` are saved posts: they are all rebuilt, none is read from Telegram
|
|
|
|
|
|
|
|
|
|
| 782 |
|
| 783 |
|
| 784 |
async def copy_loop(
|
|
@@ -795,7 +919,10 @@ async def copy_loop(
|
|
| 795 |
src, dst = job.src_peer, job.dst_peer
|
| 796 |
seen: set = set() # old ids read in this run (a reply to one of them needs its copy)
|
| 797 |
res, st, streak = RepostResult(used_saved=job.use_saved or job.from_db, from_db=job.from_db), _State(), 0
|
|
|
|
| 798 |
units = job.units() if job.units is not None else iter_units(client, src, job.first_id, job.last_id)
|
|
|
|
|
|
|
| 799 |
async for unit in units:
|
| 800 |
if should_stop and should_stop():
|
| 801 |
res.stopped = True
|
|
@@ -806,20 +933,43 @@ async def copy_loop(
|
|
| 806 |
res.skipped_done += 1
|
| 807 |
continue
|
| 808 |
rows = await job.saved(ids) if (job.saved and not job.from_db) else {}
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
|
|
|
|
|
|
| 812 |
res.held_why = res.held_why or why
|
| 813 |
-
|
|
|
|
|
|
|
|
|
|
| 814 |
if standing is None:
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
|
|
|
|
|
|
|
|
|
| 818 |
unit, from_saved = standing, True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 819 |
finals = [final_state(m, job.opts, job.links) for m in unit]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 820 |
try:
|
| 821 |
reply_to = new_parent(unit[0], job.idmap, seen)
|
| 822 |
-
news = await copy_unit(
|
|
|
|
|
|
|
| 823 |
await settle_buttons(client, dst, finals, news, st, pause=settle_pause)
|
| 824 |
except Exception as e: # one bad post must not stop the others
|
| 825 |
name = type(e).__name__
|
|
@@ -845,13 +995,27 @@ async def copy_loop(
|
|
| 845 |
job.idmap[old.id] = new.id
|
| 846 |
if reply_parent(unit[0]) is not None and reply_to is None:
|
| 847 |
res.reply_dropped += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 848 |
res.copied_units += 1
|
| 849 |
res.copied_messages += len(unit)
|
| 850 |
res.first_new = res.first_new or news[0].id
|
| 851 |
res.last_new = news[-1].id
|
| 852 |
if progress:
|
| 853 |
await progress(res)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 854 |
await asyncio.sleep(delay)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
res.forwarded, res.rebuilt, res.repaired = st.forwarded, st.rebuilt, st.repaired
|
| 856 |
res.replies, res.reply_lost = st.replies, st.reply_lost
|
| 857 |
res.from_saved, res.media_lost = st.from_saved, st.media_lost
|
|
|
|
| 17 |
copy already exists, afterwards (finalize_repost) for posts that are copied later;
|
| 18 |
* a post that was pinned has its copy pinned at the end.
|
| 19 |
|
| 20 |
+
A post that TELEGRAM restricts (a copyright strike: the whole channel is "unavailable due to copyright infringement",
|
| 21 |
+
or the post carries a restriction mark, or Telegram's apps show "This message couldn't be displayed on your device due
|
| 22 |
+
to copyright infringement" instead of the post) is not copied as Telegram hands it out now. /shift rebuilds such a post
|
| 23 |
+
from what My posts saved of it (text, formatting, buttons, media) - for a restricted CHANNEL every post that has a saved
|
| 24 |
+
copy, however intact Telegram's version looks. A post without a saved copy is copied as Telegram hands it out when that
|
| 25 |
+
is the post itself, and skipped (and reported) when it is only Telegram's notice. Telegram's notice is never posted, from
|
| 26 |
+
whichever source. /repost leaves a post whose content Telegram holds back alone.
|
| 27 |
+
Every copy of a restricted source is read back: if Telegram itself hides the new post, that is reported (copy_loop).
|
| 28 |
"""
|
| 29 |
from __future__ import annotations
|
| 30 |
|
|
|
|
| 41 |
from .linkswap import compute_message_changes, url_only_markup
|
| 42 |
from .postlinks import PostLinks, relink_message
|
| 43 |
from .replace_engine import CHUNK, FALLBACK_EMPTY_BATCHES, fetch_messages, is_skippable, preview_flag
|
| 44 |
+
from .restrictions import PLACEHOLDER_NOTE, is_placeholder_text, message_restriction, probe_channel
|
| 45 |
from .tgutil import (
|
| 46 |
FATAL,
|
| 47 |
build_markup,
|
|
|
|
| 182 |
return lookup
|
| 183 |
|
| 184 |
|
| 185 |
+
def usable_row(row) -> bool:
|
| 186 |
+
"""A saved post that can take the place of a restricted post: it has something to post, and what it says is not
|
| 187 |
+
Telegram's notice (a saved copy that an older version made from a restricted post can hold the notice)."""
|
| 188 |
+
if row is None:
|
| 189 |
+
return False
|
| 190 |
+
text = row.text or ""
|
| 191 |
+
if is_placeholder_text(text):
|
| 192 |
+
return False
|
| 193 |
+
return bool(text.strip() or row.media_file_id)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def copy_source(unit: list, rows: dict, *, restricted: Optional[str] = None, use_saved: bool = False) -> tuple:
|
| 197 |
+
"""Where the copy of this post (or album) comes from -> (source, why).
|
| 198 |
+
|
| 199 |
+
"live" - as Telegram hands the post out: nothing restricts it, or Telegram restricts it but still hands out the
|
| 200 |
+
post itself and there is no saved copy to use instead;
|
| 201 |
+
"saved" - from what My posts saved of it: Telegram restricts the channel (`restricted`) or the post (a copyright
|
| 202 |
+
strike) and My posts has every part of it. Only when `use_saved` (/shift) - the saved copy wins even when
|
| 203 |
+
Telegram's version still looks intact, because that is the owner's rule for a struck channel;
|
| 204 |
+
"skip" - not copied: what Telegram hands out is its notice - or nothing - and My posts can't take over.
|
| 205 |
+
|
| 206 |
+
`why` is Telegram's own wording about the restriction, None when nothing restricts the post."""
|
| 207 |
+
marked = next((w for w in (message_restriction(m) for m in unit) if w), None)
|
| 208 |
+
why = marked or restricted
|
| 209 |
+
if why is None:
|
| 210 |
+
return "live", None
|
| 211 |
+
if use_saved and all(usable_row(rows.get(m.id)) for m in unit):
|
| 212 |
+
return "saved", why
|
| 213 |
for m in unit:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
live = m.message or ""
|
| 215 |
+
if is_placeholder_text(live) or (not live.strip() and getattr(m, "media", None) is None):
|
| 216 |
+
return "skip", why
|
| 217 |
+
return "live", why
|
|
|
|
|
|
|
| 218 |
|
| 219 |
|
| 220 |
def stand_in(m, row):
|
|
|
|
| 238 |
|
| 239 |
|
| 240 |
def stand_ins(unit: list, rows: dict) -> Optional[list]:
|
| 241 |
+
"""The saved copies that stand in for a restricted post, or None when My posts does not have all of them (or one of
|
| 242 |
+
them has nothing to post, or holds only Telegram's notice)."""
|
| 243 |
out = []
|
| 244 |
for m in unit:
|
| 245 |
row = rows.get(m.id)
|
| 246 |
+
if not usable_row(row):
|
| 247 |
return None
|
| 248 |
out.append(stand_in(m, row))
|
| 249 |
return out
|
| 250 |
|
| 251 |
|
| 252 |
+
FILE_MEDIA = (types.MessageMediaPhoto, types.MessageMediaDocument) # a photo, video, document, voice, sticker ...
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def has_file(m) -> bool:
|
| 256 |
+
"""The post carries a file. A link preview, a poll, a location or a contact is not a file."""
|
| 257 |
+
return isinstance(getattr(m, "media", None), FILE_MEDIA)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def without_files(unit: list) -> tuple:
|
| 261 |
+
"""/shift --no-media: the posts of `unit` as they look without their files -> (those that still have a text, the
|
| 262 |
+
number of files left out). A post that was only a file has nothing left to post and is dropped."""
|
| 263 |
+
out, lost = [], 0
|
| 264 |
+
for m in unit:
|
| 265 |
+
c = m
|
| 266 |
+
if has_file(m):
|
| 267 |
+
c = copy.copy(m)
|
| 268 |
+
c.media, c.saved_ref, c.invert_media = None, None, None
|
| 269 |
+
lost += 1
|
| 270 |
+
if (c.message or "").strip():
|
| 271 |
+
out.append(c)
|
| 272 |
+
return out, lost
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
async def single_posts(units: AsyncIterator[list]) -> AsyncIterator[list]:
|
| 276 |
+
"""/shift --no-media: an album can't be rebuilt without its pictures, so every message of it is a post of its own."""
|
| 277 |
+
async for unit in units:
|
| 278 |
+
for m in unit:
|
| 279 |
+
yield [m]
|
| 280 |
+
|
| 281 |
+
|
| 282 |
def saved_message(row, channel_id: int):
|
| 283 |
"""A post built from its saved copy alone, for a channel that can't be read at all. Only the saved parts exist: no
|
| 284 |
album, no reply, no pin."""
|
|
|
|
| 289 |
|
| 290 |
|
| 291 |
def saved_rows_between(rows: list, first: Optional[int], last: Optional[int]) -> list:
|
| 292 |
+
"""The saved posts (oldest first, one per message id) that can be posted - they have something to post that is not
|
| 293 |
+
Telegram's notice - between two message ids."""
|
| 294 |
out, seen = [], set()
|
| 295 |
for row in sorted(rows, key=lambda r: (r.message_id or 0, r.id)):
|
| 296 |
mid = row.message_id
|
| 297 |
if mid is None or mid in seen or (first and mid < first) or (last and mid > last):
|
| 298 |
continue
|
| 299 |
+
if usable_row(row):
|
| 300 |
seen.add(mid)
|
| 301 |
out.append(row)
|
| 302 |
return out
|
|
|
|
| 332 |
post_links: int = 0
|
| 333 |
pinned: int = 0 # pinned posts (their copies get pinned)
|
| 334 |
replies: int = 0 # posts that answer another post
|
| 335 |
+
held_saved: int = 0 # restricted posts that are copied from what My posts saved of them (counted in units)
|
| 336 |
+
held_skipped: int = 0 # restricted posts that can't be copied: only Telegram's notice is left (not counted in units)
|
| 337 |
+
live_restricted: int = 0 # restricted posts without a saved copy that are copied as Telegram hands them out
|
| 338 |
+
no_media: bool = False # /shift --no-media: the files are left out
|
| 339 |
+
files_left_out: int = 0 # ... posts that are copied as text without their file (counted in units)
|
| 340 |
+
file_only: int = 0 # ... posts that are only a file: nothing to copy (not counted in units)
|
| 341 |
held_why: Optional[str] = None # what Telegram says about them
|
| 342 |
channel_restricted: Optional[str] = None # what Telegram says when it restricts the whole channel
|
| 343 |
from_db: bool = False # the channel can't be read: the plan (and the copy) is made from My posts alone
|
|
|
|
| 378 |
plan.replies += 1
|
| 379 |
|
| 380 |
|
| 381 |
+
def plan_from_saved(
|
| 382 |
+
ch, rows: list, opts: RepostOptions, *, first_id=None, last_id=None, unreadable=None, no_media: bool = False
|
| 383 |
+
) -> RepostPlan:
|
| 384 |
"""The plan of a copy made from My posts alone (the channel can't be read): one post per saved message."""
|
| 385 |
+
plan = RepostPlan(
|
| 386 |
+
channel=ch, partial=bool(first_id or last_id), from_db=True, unreadable=unreadable, no_media=no_media
|
| 387 |
+
)
|
| 388 |
links = PostLinks.for_channel(ch)
|
| 389 |
chosen = saved_rows_between(rows, first_id, last_id)
|
| 390 |
for row in chosen:
|
| 391 |
+
m = saved_message(row, ch.id)
|
| 392 |
+
if no_media:
|
| 393 |
+
kept, lost = without_files([m])
|
| 394 |
+
if not kept:
|
| 395 |
+
plan.file_only += 1
|
| 396 |
+
continue
|
| 397 |
+
m, plan.files_left_out = kept[0], plan.files_left_out + lost
|
| 398 |
plan.units += 1
|
| 399 |
+
tally(plan, m, opts, links)
|
| 400 |
if chosen:
|
| 401 |
plan.first, plan.last = chosen[0].message_id, chosen[-1].message_id
|
| 402 |
plan.scanned = plan.last
|
| 403 |
else:
|
| 404 |
plan.first, plan.last = first_id or 1, last_id or 0
|
| 405 |
+
if not plan.units:
|
| 406 |
plan.error = "There is nothing to copy."
|
| 407 |
+
if plan.file_only:
|
| 408 |
+
plan.error = "Every saved post in this range is only a file, so without files there is nothing to copy."
|
| 409 |
return plan
|
| 410 |
|
| 411 |
|
|
|
|
| 434 |
last_id: Optional[int] = None,
|
| 435 |
saved: Optional[SavedLookup] = None,
|
| 436 |
use_saved: bool = False,
|
| 437 |
+
no_media: bool = False,
|
| 438 |
) -> RepostPlan:
|
| 439 |
"""Read the channel and count what a copy would do. first_id / last_id limit the range (used by /shift).
|
| 440 |
`saved` looks up My posts' copies of the channel's posts; with `use_saved` a post Telegram holds back is counted as
|
| 441 |
+
the saved post it will be copied from (/shift), without it such a post is left out (/repost). With `no_media` the
|
| 442 |
+
files are left out of the copies (/shift --no-media): a post that is only a file has nothing left to copy."""
|
| 443 |
peer = peer_of(ch)
|
| 444 |
+
plan = RepostPlan(channel=ch, partial=bool(opts.last), no_media=no_media)
|
| 445 |
try:
|
| 446 |
top, plan.channel_restricted, _ = await probe_channel(client, peer)
|
| 447 |
except Exception as e: # the counter is only a help: without it the ids are read until nothing more turns up
|
|
|
|
| 481 |
plan.service += 1
|
| 482 |
continue
|
| 483 |
gid = getattr(m, "grouped_id", None)
|
| 484 |
+
new_unit = no_media or gid is None or gid != prev_gid # without files an album is a post per message
|
| 485 |
row = rows.get(m.id)
|
| 486 |
+
# a struck channel changes how /shift copies (My posts first); /repost copies as before, post by post
|
| 487 |
+
source, why = copy_source(
|
| 488 |
+
[m], rows, restricted=plan.channel_restricted if use_saved else None, use_saved=use_saved
|
| 489 |
+
)
|
| 490 |
if why:
|
| 491 |
plan.held_why = plan.held_why or why
|
| 492 |
+
if source == "skip":
|
| 493 |
+
plan.held_skipped += 1 if new_unit else 0 # nothing to copy it from
|
| 494 |
+
prev_gid = gid
|
| 495 |
+
continue
|
| 496 |
+
if source == "saved":
|
| 497 |
+
m = stand_in(m, row) # counted as the saved post it will be copied from
|
| 498 |
+
if no_media:
|
| 499 |
+
kept, lost = without_files([m])
|
| 500 |
+
if not kept: # nothing but a file: nothing to copy without it
|
| 501 |
+
plan.file_only += 1
|
| 502 |
prev_gid = gid
|
| 503 |
continue
|
| 504 |
+
m, plan.files_left_out = kept[0], plan.files_left_out + lost
|
| 505 |
+
if source == "saved":
|
| 506 |
plan.held_saved += 1 if new_unit else 0
|
| 507 |
+
elif why:
|
| 508 |
+
plan.live_restricted += 1 if new_unit else 0
|
| 509 |
if new_unit:
|
| 510 |
plan.units += 1
|
| 511 |
+
if gid is not None and not no_media:
|
| 512 |
plan.albums += 1
|
| 513 |
prev_gid = gid
|
| 514 |
tally(plan, m, opts, links)
|
|
|
|
| 528 |
"Telegram holds back every post in this range (it shows a notice instead of the post) and My posts has "
|
| 529 |
"no saved copy of them, so there is nothing to copy."
|
| 530 |
)
|
| 531 |
+
elif plan.file_only:
|
| 532 |
+
plan.error = "Every post in this range is only a file, so without files there is nothing to copy."
|
| 533 |
return plan
|
| 534 |
|
| 535 |
|
|
|
|
| 712 |
dst_peer=None,
|
| 713 |
*,
|
| 714 |
from_saved: bool = False,
|
| 715 |
+
no_forward: bool = False,
|
| 716 |
) -> list:
|
| 717 |
"""Create the copy of one post (or album) at the end of `dst_peer` (default: the same channel `peer`).
|
| 718 |
+
`from_saved`: the posts in `unit` are saved copies standing in for posts Telegram restricts - never forwarded.
|
| 719 |
+
`no_forward`: Telegram restricts these posts and refuses to copy them itself: they are rebuilt from their parts."""
|
| 720 |
dst = dst_peer if dst_peer is not None else peer
|
| 721 |
if from_saved:
|
| 722 |
news = await resend_saved(client, dst, unit, finals, st, reply_to)
|
|
|
|
| 726 |
st.replies += 1
|
| 727 |
return news
|
| 728 |
pure = all(is_pure(m, f, reply_to) for m, f in zip(unit, finals))
|
| 729 |
+
if st.mode == "forward" and pure and not no_forward:
|
| 730 |
try:
|
| 731 |
news = await forward_unit(client, peer, unit, dst)
|
| 732 |
if len(news) == len(unit):
|
|
|
|
| 747 |
# rebuild necessary, a plain copy by Telegram still works - without the reply.
|
| 748 |
if isinstance(e, errors.RPCError) and type(e).__name__ in FATAL:
|
| 749 |
raise
|
| 750 |
+
if reply_to and st.mode == "forward" and not no_forward and all(is_pure(m, f) for m, f in zip(unit, finals)):
|
| 751 |
news = await forward_unit(client, peer, unit, dst)
|
| 752 |
if len(news) != len(unit):
|
| 753 |
await delete_ids(client, dst, [n.id for n in news])
|
|
|
|
| 809 |
replies: int = 0 # copies created as an answer to the copy of the post the original answered
|
| 810 |
reply_lost: int = 0 # answers that could only be copied without the reply
|
| 811 |
reply_dropped: int = 0 # answers to a post that no longer exists (nothing to answer)
|
| 812 |
+
from_saved: int = 0 # restricted posts that were copied from their saved copy in My posts
|
| 813 |
media_lost: list = field(default_factory=list) # ... of which these (old ids) went out without their media
|
| 814 |
+
held_back: list = field(default_factory=list) # posts that were not copied: only Telegram's notice was left (old id)
|
| 815 |
+
held_why: Optional[str] = None # what Telegram says about the restricted posts
|
| 816 |
+
live_restricted: int = 0 # restricted posts without a saved copy, copied as Telegram hands them out
|
| 817 |
+
no_media: bool = False # /shift --no-media: no file was copied
|
| 818 |
+
files_left_out: int = 0 # ... posts that were copied as text, without their file
|
| 819 |
+
file_only: list = field(default_factory=list) # ... posts that are only a file: nothing to copy (old id)
|
| 820 |
+
used_saved: bool = False # the run could take restricted posts from My posts (/shift)
|
| 821 |
from_db: bool = False # the channel can't be read: every post was copied from My posts alone
|
| 822 |
+
checked: int = 0 # new posts that were read back to see whether Telegram itself hides them
|
| 823 |
+
hidden: list = field(default_factory=list) # [(old id, had media)] new posts Telegram itself hides (restriction mark)
|
| 824 |
+
hidden_why: Optional[str] = None # what Telegram says about them
|
| 825 |
+
dst_restricted: Optional[str] = None # what Telegram says about the destination channel (when copies were hidden)
|
| 826 |
+
hidden_stop: bool = False # the run stopped by itself: Telegram hides every copy it read back
|
| 827 |
aborted: Optional[str] = None
|
| 828 |
stopped: bool = False
|
| 829 |
first_new: Optional[int] = None
|
|
|
|
| 859 |
return rows
|
| 860 |
|
| 861 |
|
| 862 |
+
CHECK_FIRST = 3 # the first copies of any run are read back ...
|
| 863 |
+
CHECK_EVERY = 10 # ... then every tenth (all of them when Telegram restricts the source)
|
| 864 |
+
STOP_IF_HIDDEN = 3 # /shift stops when Telegram hides this many new copies and not one of those read back is shown
|
| 865 |
+
|
| 866 |
+
|
| 867 |
+
async def check_copies(client, dst_peer, unit: list, news: list, res: RepostResult) -> None:
|
| 868 |
+
"""Read the new posts back: does TELEGRAM itself hide them (a restriction mark, or its notice as the text)? That
|
| 869 |
+
is not the bot copying a notice - Telegram restricts the new post as well, usually because of a file or a link in
|
| 870 |
+
it. What is found goes into `res` (checked, hidden, hidden_why). Never fails the copy: it is only a look."""
|
| 871 |
+
try:
|
| 872 |
+
got = await fetch_messages(client, dst_peer, [n.id for n in news])
|
| 873 |
+
except Exception as e: # a look that fails is not a failed copy (the post exists and is registered already)
|
| 874 |
+
log.info("could not read the new posts back: %s", type(e).__name__)
|
| 875 |
+
return
|
| 876 |
+
for old, new in zip(unit, got):
|
| 877 |
+
if is_skippable(new):
|
| 878 |
+
continue
|
| 879 |
+
res.checked += 1
|
| 880 |
+
why = message_restriction(new)
|
| 881 |
+
if why:
|
| 882 |
+
res.hidden.append((old.id, _has_media([old])))
|
| 883 |
+
res.hidden_why = res.hidden_why or why
|
| 884 |
+
|
| 885 |
+
|
| 886 |
@dataclass
|
| 887 |
class CopyJob:
|
| 888 |
"""Everything the copy loop needs to know; /repost and /shift each fill it in their own way."""
|
|
|
|
| 900 |
use_saved: bool = False # a post Telegram holds back is rebuilt from its saved copy (otherwise it is left out)
|
| 901 |
units: Optional[Callable[[], AsyncIterator[list]]] = None # where the posts come from (default: read the channel)
|
| 902 |
from_db: bool = False # `units` are saved posts: they are all rebuilt, none is read from Telegram
|
| 903 |
+
restricted: Optional[str] = None # what Telegram says about the source channel itself (a copyright strike), if anything
|
| 904 |
+
no_media: bool = False # the copies get no files: text, formatting and buttons only (/shift --no-media)
|
| 905 |
+
stop_if_hidden: int = 0 # stop when this many copies were read back and Telegram hides every one (0: never)
|
| 906 |
|
| 907 |
|
| 908 |
async def copy_loop(
|
|
|
|
| 919 |
src, dst = job.src_peer, job.dst_peer
|
| 920 |
seen: set = set() # old ids read in this run (a reply to one of them needs its copy)
|
| 921 |
res, st, streak = RepostResult(used_saved=job.use_saved or job.from_db, from_db=job.from_db), _State(), 0
|
| 922 |
+
res.no_media = job.no_media
|
| 923 |
units = job.units() if job.units is not None else iter_units(client, src, job.first_id, job.last_id)
|
| 924 |
+
if job.no_media:
|
| 925 |
+
units = single_posts(units)
|
| 926 |
async for unit in units:
|
| 927 |
if should_stop and should_stop():
|
| 928 |
res.stopped = True
|
|
|
|
| 933 |
res.skipped_done += 1
|
| 934 |
continue
|
| 935 |
rows = await job.saved(ids) if (job.saved and not job.from_db) else {}
|
| 936 |
+
if job.from_db:
|
| 937 |
+
source, why = "saved", None
|
| 938 |
+
else:
|
| 939 |
+
source, why = copy_source(unit, rows, restricted=job.restricted, use_saved=job.use_saved)
|
| 940 |
+
if why: # Telegram restricts this post (or the whole channel): what it hands out now is not trusted
|
| 941 |
res.held_why = res.held_why or why
|
| 942 |
+
from_saved = job.from_db # saved posts already
|
| 943 |
+
standing = None
|
| 944 |
+
if source == "saved" and not job.from_db:
|
| 945 |
+
standing = stand_ins(unit, rows)
|
| 946 |
if standing is None:
|
| 947 |
+
source = "skip"
|
| 948 |
+
if source == "skip":
|
| 949 |
+
log.info("post %s: Telegram shows only its notice (%s) and My posts has no saved copy to use", ids[0], why)
|
| 950 |
+
res.held_back.append(ids[0])
|
| 951 |
+
continue
|
| 952 |
+
if standing is not None:
|
| 953 |
unit, from_saved = standing, True
|
| 954 |
+
no_forward, left_out = bool(why), 0
|
| 955 |
+
if job.no_media: # the copy is the text with its formatting and buttons; its file stays where it is
|
| 956 |
+
unit, left_out = without_files(unit)
|
| 957 |
+
if not unit: # nothing but a file: there is nothing to post without it, and nothing waits for it
|
| 958 |
+
res.file_only.append(ids[0])
|
| 959 |
+
seen.difference_update(ids)
|
| 960 |
+
continue
|
| 961 |
+
no_forward = no_forward or bool(left_out) # a copy by Telegram would carry the file
|
| 962 |
finals = [final_state(m, job.opts, job.links) for m in unit]
|
| 963 |
+
if any(is_placeholder_text(f.text) for f in finals): # Telegram's notice is never posted, whatever it came from
|
| 964 |
+
log.info("post %s: its text is Telegram's notice - not copied", ids[0])
|
| 965 |
+
res.held_back.append(ids[0])
|
| 966 |
+
res.held_why = res.held_why or PLACEHOLDER_NOTE
|
| 967 |
+
continue
|
| 968 |
try:
|
| 969 |
reply_to = new_parent(unit[0], job.idmap, seen)
|
| 970 |
+
news = await copy_unit(
|
| 971 |
+
client, src, unit, finals, st, reply_to, dst, from_saved=from_saved, no_forward=no_forward
|
| 972 |
+
)
|
| 973 |
await settle_buttons(client, dst, finals, news, st, pause=settle_pause)
|
| 974 |
except Exception as e: # one bad post must not stop the others
|
| 975 |
name = type(e).__name__
|
|
|
|
| 995 |
job.idmap[old.id] = new.id
|
| 996 |
if reply_parent(unit[0]) is not None and reply_to is None:
|
| 997 |
res.reply_dropped += 1
|
| 998 |
+
if why and not from_saved:
|
| 999 |
+
res.live_restricted += 1
|
| 1000 |
+
res.files_left_out += left_out
|
| 1001 |
+
if why or res.copied_units < CHECK_FIRST or res.copied_units % CHECK_EVERY == 0:
|
| 1002 |
+
await check_copies(client, dst, unit, news, res)
|
| 1003 |
res.copied_units += 1
|
| 1004 |
res.copied_messages += len(unit)
|
| 1005 |
res.first_new = res.first_new or news[0].id
|
| 1006 |
res.last_new = news[-1].id
|
| 1007 |
if progress:
|
| 1008 |
await progress(res)
|
| 1009 |
+
if job.stop_if_hidden and res.checked >= job.stop_if_hidden and len(res.hidden) == res.checked:
|
| 1010 |
+
log.info("Telegram hides every copy read back (%s): stopping", res.checked)
|
| 1011 |
+
res.stopped = res.hidden_stop = True # more copies would only be more posts that nobody can see
|
| 1012 |
+
break
|
| 1013 |
await asyncio.sleep(delay)
|
| 1014 |
+
if res.hidden: # Telegram hides copies: is the destination itself restricted too?
|
| 1015 |
+
try:
|
| 1016 |
+
_top, res.dst_restricted, _err = await probe_channel(client, dst)
|
| 1017 |
+
except Exception: # only an extra for the report
|
| 1018 |
+
log.debug("could not ask Telegram about the destination channel", exc_info=True)
|
| 1019 |
res.forwarded, res.rebuilt, res.repaired = st.forwarded, st.rebuilt, st.repaired
|
| 1020 |
res.replies, res.reply_lost = st.replies, st.reply_lost
|
| 1021 |
res.from_saved, res.media_lost = st.from_saved, st.media_lost
|
app/shift_engine.py
CHANGED
|
@@ -4,14 +4,22 @@
|
|
| 4 |
It is the /repost copy loop pointed at two channels. Links from a shifted post to another post of the source channel
|
| 5 |
follow the content: when that post was shifted too, the link points at its copy in the destination.
|
| 6 |
|
| 7 |
-
|
| 8 |
-
-c
|
| 9 |
-
|
| 10 |
-
-all
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
from __future__ import annotations
|
| 17 |
|
|
@@ -22,6 +30,7 @@ from typing import Any, Awaitable, Callable, Optional
|
|
| 22 |
from .crosslinks import CrossResult, relink_after_shift, shift_links
|
| 23 |
from .profile_copy import copy_profile
|
| 24 |
from .repost_engine import (
|
|
|
|
| 25 |
CopyJob,
|
| 26 |
DeleteResult,
|
| 27 |
RepostOptions,
|
|
@@ -34,6 +43,7 @@ from .repost_engine import (
|
|
| 34 |
saved_lookup,
|
| 35 |
saved_units,
|
| 36 |
)
|
|
|
|
| 37 |
from .tgutil import peer_of
|
| 38 |
|
| 39 |
log = logging.getLogger(__name__)
|
|
@@ -46,12 +56,14 @@ def source_of(shift) -> SimpleNamespace:
|
|
| 46 |
)
|
| 47 |
|
| 48 |
|
| 49 |
-
async def plan_from_my_posts(
|
|
|
|
|
|
|
| 50 |
"""The plan of a shift made from My posts alone, for a source that can't be read; None when My posts has nothing
|
| 51 |
of that channel (in that range)."""
|
| 52 |
plan = plan_from_saved(
|
| 53 |
src, await db.sent_posts_from(src.id, first_id or 1), RepostOptions(), first_id=first_id, last_id=last_id,
|
| 54 |
-
unreadable=unreadable,
|
| 55 |
)
|
| 56 |
return None if plan.error else plan
|
| 57 |
|
|
@@ -73,7 +85,14 @@ async def run_shift(
|
|
| 73 |
src = source_of(shift)
|
| 74 |
idmap = dict(await db.shift_pairs(shift.id, alive_only=False))
|
| 75 |
links = shift_links(src, dst, idmap)
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
job = CopyJob(
|
| 78 |
src_peer=peer_of(src),
|
| 79 |
dst_peer=peer_of(dst),
|
|
@@ -88,6 +107,9 @@ async def run_shift(
|
|
| 88 |
use_saved=True,
|
| 89 |
units=(lambda: saved_units(db, src.id, shift.first_id, shift.last_id)) if from_db else None,
|
| 90 |
from_db=from_db,
|
|
|
|
|
|
|
|
|
|
| 91 |
)
|
| 92 |
res = await copy_loop(client, job, delay=delay, progress=progress, should_stop=should_stop, settle_pause=settle_pause)
|
| 93 |
if not res.stopped and not res.aborted:
|
|
|
|
| 4 |
It is the /repost copy loop pointed at two channels. Links from a shifted post to another post of the source channel
|
| 5 |
follow the content: when that post was shifted too, the link points at its copy in the destination.
|
| 6 |
|
| 7 |
+
Three optional extras are remembered with the shift (job marks), so that continuing a stopped shift keeps them:
|
| 8 |
+
-c afterwards every link to a post of the source, in ANY connected channel, is pointed at the copy
|
| 9 |
+
(crosslinks.relink_after_shift; the source itself is only read);
|
| 10 |
+
-all afterwards the destination gets the source's name, description and profile photo
|
| 11 |
+
(profile_copy.copy_profile);
|
| 12 |
+
--no-media the copies get no files: every post is copied as its text, with its formatting and buttons (mark "nomedia";
|
| 13 |
+
repost_engine.without_files). A post that is only a file has nothing to copy and is left out.
|
| 14 |
+
|
| 15 |
+
When Telegram hides the first new copies (read back right after posting) the shift stops by itself - more copies would
|
| 16 |
+
only be more posts nobody can see - and goes on only when the owner presses Continue (mark "hiddenok").
|
| 17 |
+
|
| 18 |
+
A source that Telegram restricts (a copyright strike: "This channel is unavailable due to copyright infringement", its
|
| 19 |
+
posts marked or shown as a notice) is copied from what My posts saved of its posts - every post that has a saved copy,
|
| 20 |
+
however intact Telegram's version looks (repost_engine.copy_source). A post without a saved copy is copied as Telegram
|
| 21 |
+
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
|
| 22 |
+
banned or private channel) is copied from My posts alone: mark "fromdb", set when the plan was made that way.
|
| 23 |
"""
|
| 24 |
from __future__ import annotations
|
| 25 |
|
|
|
|
| 30 |
from .crosslinks import CrossResult, relink_after_shift, shift_links
|
| 31 |
from .profile_copy import copy_profile
|
| 32 |
from .repost_engine import (
|
| 33 |
+
STOP_IF_HIDDEN,
|
| 34 |
CopyJob,
|
| 35 |
DeleteResult,
|
| 36 |
RepostOptions,
|
|
|
|
| 43 |
saved_lookup,
|
| 44 |
saved_units,
|
| 45 |
)
|
| 46 |
+
from .restrictions import probe_channel
|
| 47 |
from .tgutil import peer_of
|
| 48 |
|
| 49 |
log = logging.getLogger(__name__)
|
|
|
|
| 56 |
)
|
| 57 |
|
| 58 |
|
| 59 |
+
async def plan_from_my_posts(
|
| 60 |
+
db, src, first_id=None, last_id=None, unreadable=None, no_media: bool = False
|
| 61 |
+
) -> Optional[RepostPlan]:
|
| 62 |
"""The plan of a shift made from My posts alone, for a source that can't be read; None when My posts has nothing
|
| 63 |
of that channel (in that range)."""
|
| 64 |
plan = plan_from_saved(
|
| 65 |
src, await db.sent_posts_from(src.id, first_id or 1), RepostOptions(), first_id=first_id, last_id=last_id,
|
| 66 |
+
unreadable=unreadable, no_media=no_media,
|
| 67 |
)
|
| 68 |
return None if plan.error else plan
|
| 69 |
|
|
|
|
| 85 |
src = source_of(shift)
|
| 86 |
idmap = dict(await db.shift_pairs(shift.id, alive_only=False))
|
| 87 |
links = shift_links(src, dst, idmap)
|
| 88 |
+
marks = await db.marks_of(shift.id)
|
| 89 |
+
from_db = "fromdb" in marks # the source can't be read: its posts come from My posts alone
|
| 90 |
+
restricted = None
|
| 91 |
+
if not from_db: # does Telegram restrict the source channel itself (a copyright strike)? Then My posts is used
|
| 92 |
+
try:
|
| 93 |
+
_top, restricted, _err = await probe_channel(client, peer_of(src))
|
| 94 |
+
except Exception: # unknown is treated as "not restricted": marks on single posts and the read-back still guard
|
| 95 |
+
log.warning("could not ask Telegram whether the source channel is restricted", exc_info=True)
|
| 96 |
job = CopyJob(
|
| 97 |
src_peer=peer_of(src),
|
| 98 |
dst_peer=peer_of(dst),
|
|
|
|
| 107 |
use_saved=True,
|
| 108 |
units=(lambda: saved_units(db, src.id, shift.first_id, shift.last_id)) if from_db else None,
|
| 109 |
from_db=from_db,
|
| 110 |
+
restricted=restricted,
|
| 111 |
+
no_media="nomedia" in marks,
|
| 112 |
+
stop_if_hidden=0 if "hiddenok" in marks else STOP_IF_HIDDEN, # the owner said to go on although Telegram hides them
|
| 113 |
)
|
| 114 |
res = await copy_loop(client, job, delay=delay, progress=progress, should_stop=should_stop, settle_pause=settle_pause)
|
| 115 |
if not res.stopped and not res.aborted:
|
tests/fakes.py
CHANGED
|
@@ -139,6 +139,7 @@ class FakeChannelClient:
|
|
| 139 |
self.users = {} # user id -> types.User, for participant lists
|
| 140 |
self.cant_delete_at_all = set() # ids nobody can delete (userbot included)
|
| 141 |
self.restriction = None # text: Telegram restricts the whole channel (a copyright strike)
|
|
|
|
| 142 |
self.about = "" # the channel's description
|
| 143 |
self.photo_id = None # the channel's profile photo (its bytes are in _photo_data)
|
| 144 |
self._photo_data = {} # photo id -> bytes; shared by all channels next to each other
|
|
@@ -236,6 +237,10 @@ class FakeChannelClient:
|
|
| 236 |
hide = True
|
| 237 |
self.hide_keyboard_on_create -= 1
|
| 238 |
self._set_markup(m, markup, hide=hide)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
self.msgs[m.id] = m
|
| 240 |
self._remember_media(m)
|
| 241 |
return m
|
|
|
|
| 139 |
self.users = {} # user id -> types.User, for participant lists
|
| 140 |
self.cant_delete_at_all = set() # ids nobody can delete (userbot included)
|
| 141 |
self.restriction = None # text: Telegram restricts the whole channel (a copyright strike)
|
| 142 |
+
self.restrict_new = None # f(new message) -> text or None: Telegram itself restricts a NEW post (a blocked file ...)
|
| 143 |
self.about = "" # the channel's description
|
| 144 |
self.photo_id = None # the channel's profile photo (its bytes are in _photo_data)
|
| 145 |
self._photo_data = {} # photo id -> bytes; shared by all channels next to each other
|
|
|
|
| 237 |
hide = True
|
| 238 |
self.hide_keyboard_on_create -= 1
|
| 239 |
self._set_markup(m, markup, hide=hide)
|
| 240 |
+
if self.restrict_new is not None:
|
| 241 |
+
why = self.restrict_new(m)
|
| 242 |
+
if why:
|
| 243 |
+
m.restriction_reason = [types.RestrictionReason(platform="all", reason="copyright", text=why)]
|
| 244 |
self.msgs[m.id] = m
|
| 245 |
self._remember_media(m)
|
| 246 |
return m
|
tests/test_held_back.py
CHANGED
|
@@ -6,6 +6,7 @@ import asyncio
|
|
| 6 |
from telethon import functions, types
|
| 7 |
|
| 8 |
from app.repost_engine import RepostOptions, plan_repost, saved_lookup
|
|
|
|
| 9 |
from app.shift_engine import run_shift
|
| 10 |
from app.tgutil import button_url
|
| 11 |
|
|
@@ -13,7 +14,7 @@ from .dbutil import db_url_for, fresh_db
|
|
| 13 |
from .fakes import FakeChannelClient, make_msg, markup, photo_media, rpc, service_msg, url_btn
|
| 14 |
from .harness import norm
|
| 15 |
from .test_repost_flow import fill, make_app
|
| 16 |
-
from .test_restricted import NOTICE, strike
|
| 17 |
from .test_shift import SRC, old_posts, shift_app
|
| 18 |
from .test_sync import saved
|
| 19 |
|
|
@@ -135,24 +136,30 @@ def test_a_post_that_telegram_shows_as_it_is_still_comes_from_the_channel(tmp_pa
|
|
| 135 |
run(go())
|
| 136 |
|
| 137 |
|
| 138 |
-
def
|
|
|
|
|
|
|
|
|
|
| 139 |
async def go():
|
| 140 |
orig = originals()
|
| 141 |
live = dict(orig)
|
| 142 |
live[2] = strike(make_msg(2, "Episode 2 is out", [types.MessageEntityBold(0, 9)], markup=orig[2].reply_markup))
|
| 143 |
-
live[3] = strike(make_msg(3, "Caption
|
| 144 |
src = FakeChannelClient(live, username="srcch", title="Anime Source")
|
| 145 |
dst = src.add_channel(FakeChannelClient(old_posts(), username="dstch", cid=2, title="Anime Backup"))
|
|
|
|
|
|
|
| 146 |
db = await fresh_db(db_url_for(tmp_path))
|
| 147 |
await db.save_channel(1, 5, "Anime Source", "srcch", 1)
|
| 148 |
await db.save_channel(2, 6, "Anime Backup", "dstch", 1)
|
| 149 |
await save_rows(db, orig, skip=(2,)) # no saved copy of post 2; post 3 has one
|
| 150 |
await db.create_shift("s1", src=SRC, dst_channel_id=2, first_id=1, last_id=6, user_id=1)
|
| 151 |
res = await run_shift(src, db, await db.get_shift("s1"), await db.get_channel(2), 99, delay=0, settle_pause=0)
|
| 152 |
-
|
| 153 |
-
assert res.from_saved ==
|
|
|
|
| 154 |
assert shown(dst) == {11: "Welcome", 12: "Episode 2 is out", 13: "Caption with link", 14: "Fifth", 15: "Sixth"}
|
| 155 |
-
assert
|
| 156 |
await db.close()
|
| 157 |
|
| 158 |
run(go())
|
|
@@ -178,14 +185,54 @@ def test_a_post_that_telegram_empties_out_is_held_back_too(tmp_path):
|
|
| 178 |
run(go())
|
| 179 |
|
| 180 |
|
| 181 |
-
def
|
|
|
|
|
|
|
|
|
|
| 182 |
async def go():
|
| 183 |
orig = originals()
|
| 184 |
-
orig[6] = make_msg(6, NOTICE)
|
| 185 |
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(2, 3, 5), orig=orig, flagged=False)
|
| 186 |
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 187 |
-
assert res.from_saved == 3 and res.held_back == [] and res.failed == []
|
| 188 |
-
assert dst
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
await db.close()
|
| 190 |
|
| 191 |
run(go())
|
|
@@ -376,22 +423,26 @@ def buttons(app):
|
|
| 376 |
def test_the_shift_plan_and_result_explain_what_comes_from_my_posts(tmp_path):
|
| 377 |
async def go():
|
| 378 |
app, dst = await held_app(tmp_path, no_row=(5,))
|
| 379 |
-
app.tg.restriction =
|
| 380 |
await app.text("/shift @srcch @dstch")
|
| 381 |
plan = norm(app.out.last_text)
|
| 382 |
-
assert "Telegram restricts Anime Source itself" in plan
|
| 383 |
-
assert "
|
| 384 |
-
assert "
|
|
|
|
| 385 |
assert "4 posts" in plan # 1, 2, 3 and 6 are copied
|
|
|
|
| 386 |
assert not any(isinstance(r, functions.messages.SendMessageRequest) for r in dst.requests) # a plan posts nothing
|
| 387 |
|
| 388 |
await app.press(app.out.callback_data("Start copying"))
|
| 389 |
done = norm(app.out.last_text)
|
| 390 |
-
assert "
|
| 391 |
-
assert "1 post(s)
|
| 392 |
assert "not everything was copied" in done # so the rest can be tried again later
|
| 393 |
assert any("Try the rest again" in t for t in buttons(app))
|
|
|
|
| 394 |
assert not any(NOTICE in (m.message or "") for m in dst.msgs.values())
|
|
|
|
| 395 |
|
| 396 |
# the missing copy arrives (a backup put back with /import): the rest can be copied
|
| 397 |
await save_rows(app.db, {5: originals()[5]})
|
|
|
|
| 6 |
from telethon import functions, types
|
| 7 |
|
| 8 |
from app.repost_engine import RepostOptions, plan_repost, saved_lookup
|
| 9 |
+
from app.restrictions import PLACEHOLDER_NOTE
|
| 10 |
from app.shift_engine import run_shift
|
| 11 |
from app.tgutil import button_url
|
| 12 |
|
|
|
|
| 14 |
from .fakes import FakeChannelClient, make_msg, markup, photo_media, rpc, service_msg, url_btn
|
| 15 |
from .harness import norm
|
| 16 |
from .test_repost_flow import fill, make_app
|
| 17 |
+
from .test_restricted import CHANNEL_NOTICE, NOTICE, strike
|
| 18 |
from .test_shift import SRC, old_posts, shift_app
|
| 19 |
from .test_sync import saved
|
| 20 |
|
|
|
|
| 136 |
run(go())
|
| 137 |
|
| 138 |
|
| 139 |
+
def test_a_post_with_telegrams_mark_comes_from_my_posts_even_when_telegram_still_hands_it_out(tmp_path):
|
| 140 |
+
"""The rule of a struck channel: what My posts saved wins over what Telegram hands out now - however intact that
|
| 141 |
+
looks. Only a post without a saved copy is copied as Telegram hands it out (it is the post itself, not a notice)."""
|
| 142 |
+
|
| 143 |
async def go():
|
| 144 |
orig = originals()
|
| 145 |
live = dict(orig)
|
| 146 |
live[2] = strike(make_msg(2, "Episode 2 is out", [types.MessageEntityBold(0, 9)], markup=orig[2].reply_markup))
|
| 147 |
+
live[3] = strike(make_msg(3, "Caption as Telegram hands it out", media=photo_media(31))) # differs from My posts
|
| 148 |
src = FakeChannelClient(live, username="srcch", title="Anime Source")
|
| 149 |
dst = src.add_channel(FakeChannelClient(old_posts(), username="dstch", cid=2, title="Anime Backup"))
|
| 150 |
+
for m in orig.values():
|
| 151 |
+
src._remember_media(m)
|
| 152 |
db = await fresh_db(db_url_for(tmp_path))
|
| 153 |
await db.save_channel(1, 5, "Anime Source", "srcch", 1)
|
| 154 |
await db.save_channel(2, 6, "Anime Backup", "dstch", 1)
|
| 155 |
await save_rows(db, orig, skip=(2,)) # no saved copy of post 2; post 3 has one
|
| 156 |
await db.create_shift("s1", src=SRC, dst_channel_id=2, first_id=1, last_id=6, user_id=1)
|
| 157 |
res = await run_shift(src, db, await db.get_shift("s1"), await db.get_channel(2), 99, delay=0, settle_pause=0)
|
| 158 |
+
assert res.held_back == [] and res.failed == [] and res.copied_units == 5
|
| 159 |
+
assert res.from_saved == 1 # post 3
|
| 160 |
+
assert res.live_restricted == 1 # post 2: marked, but nothing saved to use instead and its content is intact
|
| 161 |
assert shown(dst) == {11: "Welcome", 12: "Episode 2 is out", 13: "Caption with link", 14: "Fifth", 15: "Sixth"}
|
| 162 |
+
assert forwarded_ids(dst) == [1, 6] # a restricted post is never copied by Telegram itself
|
| 163 |
await db.close()
|
| 164 |
|
| 165 |
run(go())
|
|
|
|
| 185 |
run(go())
|
| 186 |
|
| 187 |
|
| 188 |
+
def test_telegrams_notice_is_never_posted_even_when_my_posts_holds_it_as_the_text(tmp_path):
|
| 189 |
+
"""A saved copy made from a restricted post can hold Telegram's notice as its text (an older version did that), and a
|
| 190 |
+
post may really say it: either way it is indistinguishable from the notice, so it is not copied - and reported."""
|
| 191 |
+
|
| 192 |
async def go():
|
| 193 |
orig = originals()
|
| 194 |
+
orig[6] = make_msg(6, NOTICE)
|
| 195 |
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(2, 3, 5), orig=orig, flagged=False)
|
| 196 |
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 197 |
+
assert res.from_saved == 3 and res.held_back == [6] and res.failed == []
|
| 198 |
+
assert shown(dst) == {11: "Welcome", 12: "Episode 2 is out", 13: "Caption with link", 14: "Fifth"}
|
| 199 |
+
assert not any(NOTICE in (m.message or "") for m in dst.msgs.values())
|
| 200 |
+
assert 6 not in forwarded_ids(dst) and res.held_why == PLACEHOLDER_NOTE
|
| 201 |
+
await db.close()
|
| 202 |
+
|
| 203 |
+
run(go())
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def test_a_saved_copy_that_holds_the_notice_is_not_used_for_a_restricted_post(tmp_path):
|
| 207 |
+
async def go():
|
| 208 |
+
orig = originals()
|
| 209 |
+
orig[5] = make_msg(5, "Fifth") # answers nothing
|
| 210 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(2, 3), no_row=(2,), orig=orig)
|
| 211 |
+
await db.create_post( # what an older version saved when it synced the struck post
|
| 212 |
+
channel_id=1, message_id=2, status="sent", source="adopted", created_by=1, link_preview=False,
|
| 213 |
+
text=NOTICE, entities=[], buttons=[], media_kind=None, media_file_id=None,
|
| 214 |
+
)
|
| 215 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 216 |
+
# post 2: Telegram shows the notice and the saved copy is the notice too: nothing to copy; post 3 is fine
|
| 217 |
+
assert res.held_back == [2] and res.from_saved == 1 and res.failed == []
|
| 218 |
+
assert shown(dst) == {11: "Welcome", 12: "Caption with link", 13: "Fifth", 14: "Sixth"}
|
| 219 |
+
assert not any(NOTICE in (m.message or "") for m in dst.msgs.values())
|
| 220 |
+
await db.close()
|
| 221 |
+
|
| 222 |
+
run(go())
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def test_an_answer_to_a_post_that_could_not_be_copied_follows_when_that_post_has_its_copy(tmp_path):
|
| 226 |
+
async def go():
|
| 227 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(2,), no_row=(2,)) # post 5 answers post 2
|
| 228 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 229 |
+
assert res.held_back == [2] and res.failed == [(5, "ReplyParentMissing")] # it would answer nothing
|
| 230 |
+
assert shown(dst) == {11: "Welcome", 12: "Caption with link", 13: "Sixth"}
|
| 231 |
+
await save_rows(db, {2: originals()[2]}) # the saved copy arrives
|
| 232 |
+
again = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 233 |
+
assert again.held_back == [] and again.failed == [] and again.copied_units == 2
|
| 234 |
+
assert dst.msgs[14].message == "Episode 2 is out" and dst.msgs[15].message == "Fifth"
|
| 235 |
+
assert dst.msgs[15].reply_to.reply_to_msg_id == 14 # the answer answers the copy
|
| 236 |
await db.close()
|
| 237 |
|
| 238 |
run(go())
|
|
|
|
| 423 |
def test_the_shift_plan_and_result_explain_what_comes_from_my_posts(tmp_path):
|
| 424 |
async def go():
|
| 425 |
app, dst = await held_app(tmp_path, no_row=(5,))
|
| 426 |
+
app.tg.restriction = CHANNEL_NOTICE # the whole channel is struck: My posts is used for every post it has
|
| 427 |
await app.text("/shift @srcch @dstch")
|
| 428 |
plan = norm(app.out.last_text)
|
| 429 |
+
assert "Telegram restricts Anime Source itself" in plan and "unavailable due to copyright infringement" in plan
|
| 430 |
+
assert "4 post(s) of Anime Source are restricted by Telegram" in plan
|
| 431 |
+
assert "copied from what My posts saved of them" in plan and "not from what Telegram shows now" in plan
|
| 432 |
+
assert "Telegram shows only its notice for 1 more post(s)" in plan and "no saved copy" in plan and "/import" in plan
|
| 433 |
assert "4 posts" in plan # 1, 2, 3 and 6 are copied
|
| 434 |
+
assert "The new posts are read back" in plan
|
| 435 |
assert not any(isinstance(r, functions.messages.SendMessageRequest) for r in dst.requests) # a plan posts nothing
|
| 436 |
|
| 437 |
await app.press(app.out.callback_data("Start copying"))
|
| 438 |
done = norm(app.out.last_text)
|
| 439 |
+
assert "4 post(s) are restricted by Telegram" in done and "copied from what My posts saved of them" in done
|
| 440 |
+
assert "1 post(s): Telegram shows only its notice and My posts has no saved copy of them" in done and "#5" in done
|
| 441 |
assert "not everything was copied" in done # so the rest can be tried again later
|
| 442 |
assert any("Try the rest again" in t for t in buttons(app))
|
| 443 |
+
assert "hides" not in done # nothing the new posts show is restricted
|
| 444 |
assert not any(NOTICE in (m.message or "") for m in dst.msgs.values())
|
| 445 |
+
assert forwarded_ids(dst) == [] # a struck channel: nothing is copied by Telegram itself
|
| 446 |
|
| 447 |
# the missing copy arrives (a backup put back with /import): the rest can be copied
|
| 448 |
await save_rows(app.db, {5: originals()[5]})
|
tests/test_no_media.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/shift --no-media: no file is copied - every post goes out as its text with its formatting and buttons. For a source
|
| 2 |
+
whose files Telegram blocks (a copyright strike), where a post that carries the file is hidden wherever it is posted."""
|
| 3 |
+
import asyncio
|
| 4 |
+
|
| 5 |
+
from telethon import functions, types
|
| 6 |
+
|
| 7 |
+
from app.handlers.shift import USAGE, parse_shift_args
|
| 8 |
+
from app.repost_engine import RepostOptions, plan_repost, saved_lookup, without_files
|
| 9 |
+
from app.shift_engine import plan_from_my_posts, run_shift
|
| 10 |
+
from app.tgutil import button_url
|
| 11 |
+
|
| 12 |
+
from .fakes import make_msg, photo_media
|
| 13 |
+
from .harness import norm
|
| 14 |
+
from .test_held_back import buttons, held_app, held_setup, forwarded_ids, originals, save_rows, shown, unreadable
|
| 15 |
+
from .test_restricted import CHANNEL_NOTICE
|
| 16 |
+
from .test_shift import SRC
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def run(coro):
|
| 20 |
+
return asyncio.run(coro)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def with_files():
|
| 24 |
+
"""The source: text posts, a photo with a caption, a post that is only a photo, an album and an answer to the photo."""
|
| 25 |
+
orig = originals() # 1 text, 2 text + button, 3 photo + caption, 4 service, 5 answers 2, 6 text
|
| 26 |
+
orig[7] = make_msg(7, "", media=photo_media(71)) # only a photo
|
| 27 |
+
orig[8] = make_msg(8, "", media=photo_media(81), grouped_id=80) # an album: no caption on the first picture ...
|
| 28 |
+
orig[9] = make_msg(9, "album caption", [types.MessageEntityBold(0, 5)], media=photo_media(82), grouped_id=80)
|
| 29 |
+
orig[10] = make_msg(10, "Reply to the photo", reply_to=7)
|
| 30 |
+
return orig
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ============================================================================================== the option
|
| 34 |
+
def test_the_option_is_understood_in_every_spelling():
|
| 35 |
+
for raw in (
|
| 36 |
+
"@a @b --no-media",
|
| 37 |
+
"@a @b -no-media",
|
| 38 |
+
"@a @b --nomedia",
|
| 39 |
+
"@a @b -nomedia",
|
| 40 |
+
"@a @b --NO-MEDIA",
|
| 41 |
+
"@a @b --no_media",
|
| 42 |
+
"@a @b —no-media", # a phone keyboard turns -- into a long dash
|
| 43 |
+
"@a @b –no-media",
|
| 44 |
+
"@a @b -- no-media",
|
| 45 |
+
"--no-media @a @b",
|
| 46 |
+
):
|
| 47 |
+
a, b, first, last, flags = parse_shift_args(raw)
|
| 48 |
+
assert (a, b, first, last) == ("@a", "@b", None, None) and flags.no_media and not flags.links and not flags.profile, raw
|
| 49 |
+
a, b, first, last, flags = parse_shift_args("@a @b 5 9 -c --no-media -all")
|
| 50 |
+
assert (a, b, first, last) == ("@a", "@b", 5, 9) and flags.links and flags.profile and flags.no_media
|
| 51 |
+
# a channel id is not an option, and nothing is switched on by accident
|
| 52 |
+
a, b, first, last, flags = parse_shift_args("-1001234567890 @b")
|
| 53 |
+
assert (a, b) == ("-1001234567890", "@b") and not flags.no_media
|
| 54 |
+
try:
|
| 55 |
+
parse_shift_args("@a @b --nomediaa")
|
| 56 |
+
except ValueError as e:
|
| 57 |
+
assert "--no-media" in str(e) and "-all" in str(e)
|
| 58 |
+
else:
|
| 59 |
+
raise AssertionError("an unknown option must be refused")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_the_usage_describes_the_option():
|
| 63 |
+
assert "--no-media" in USAGE and "no file is copied" in USAGE
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_without_files_leaves_a_text_post_and_drops_a_post_that_was_only_a_file():
|
| 67 |
+
photo_caption = make_msg(3, "Caption", media=photo_media(31))
|
| 68 |
+
only_photo = make_msg(7, "", media=photo_media(71))
|
| 69 |
+
plain = make_msg(1, "Welcome")
|
| 70 |
+
kept, lost = without_files([plain, photo_caption, only_photo])
|
| 71 |
+
assert [m.id for m in kept] == [1, 3] and lost == 2
|
| 72 |
+
assert kept[1].media is None and photo_caption.media is not None # the original message is not changed
|
| 73 |
+
assert without_files([only_photo]) == ([], 1)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ======================================================================================== the copy loop
|
| 77 |
+
def test_the_files_stay_out_and_everything_else_is_copied(tmp_path):
|
| 78 |
+
async def go():
|
| 79 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(), orig=with_files(), last=10)
|
| 80 |
+
await db.set_mark("s1", "nomedia")
|
| 81 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 82 |
+
assert (res.failed, res.aborted, res.held_back) == ([], None, []) and res.no_media
|
| 83 |
+
assert res.copied_units == 7 and res.files_left_out == 2 and res.file_only == [7, 8]
|
| 84 |
+
# copies: 1->11 2->12 3->13 5->14 6->15 9->16 10->17 (4 is a service message, 7 and 8 are only files)
|
| 85 |
+
assert shown(dst) == {
|
| 86 |
+
11: "Welcome", 12: "Episode 2 is out", 13: "Caption with link", 14: "Fifth", 15: "Sixth",
|
| 87 |
+
16: "album caption", 17: "Reply to the photo",
|
| 88 |
+
}
|
| 89 |
+
assert all(m.media is None for i, m in dst.msgs.items() if i > 10) # no file anywhere
|
| 90 |
+
assert not sent_media(dst)
|
| 91 |
+
# a post without a file is still copied by Telegram itself - the answer to a post that is only a file too: it has
|
| 92 |
+
# no reply to keep any more
|
| 93 |
+
assert forwarded_ids(dst) == [1, 6, 10]
|
| 94 |
+
# text, formatting, buttons and the links between the copies are kept
|
| 95 |
+
assert [(e.offset, e.length) for e in dst.msgs[16].entities] == [(0, 5)]
|
| 96 |
+
btn = dst.msgs[12].reply_markup.rows[0].buttons[0]
|
| 97 |
+
assert (btn.text, button_url(btn)) == ("Download", "https://t.me/goku?start=AAA")
|
| 98 |
+
assert dst.msgs[13].entities[0].url == "https://t.me/dstch/14"
|
| 99 |
+
assert dst.msgs[14].reply_to.reply_to_msg_id == 12
|
| 100 |
+
# the answer to a post that is only a file answers nothing - and does not wait for a copy that never comes
|
| 101 |
+
assert dst.msgs[17].reply_to is None and res.reply_dropped == 1
|
| 102 |
+
# My posts knows the copies as they are: text, no file
|
| 103 |
+
row = await db.find_by_message(2, 13)
|
| 104 |
+
assert (row.text, row.media_kind, row.media_file_id) == ("Caption with link", None, None)
|
| 105 |
+
# the source was only read
|
| 106 |
+
assert not [r for r in src.requests if type(r).__name__.startswith(("Edit", "Delete", "Send", "Forward"))]
|
| 107 |
+
await db.close()
|
| 108 |
+
|
| 109 |
+
run(go())
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def sent_media(chan):
|
| 113 |
+
return [r for r in chan.requests if isinstance(r, (functions.messages.SendMediaRequest, functions.messages.SendMultiMediaRequest))]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_without_the_option_the_files_are_copied_as_before(tmp_path):
|
| 117 |
+
async def go():
|
| 118 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(), orig=with_files(), last=10)
|
| 119 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 120 |
+
assert not res.no_media and res.files_left_out == 0 and res.file_only == []
|
| 121 |
+
assert dst.msgs[13].media.photo.id == 31
|
| 122 |
+
await db.close()
|
| 123 |
+
|
| 124 |
+
run(go())
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_a_struck_channel_is_copied_from_my_posts_without_files(tmp_path):
|
| 128 |
+
async def go():
|
| 129 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(1, 2, 3, 5, 6, 7), orig=with_files(), last=7)
|
| 130 |
+
src.restriction = CHANNEL_NOTICE
|
| 131 |
+
await db.set_mark("s1", "nomedia")
|
| 132 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 133 |
+
assert (res.failed, res.aborted, res.held_back) == ([], None, [])
|
| 134 |
+
assert res.from_saved == 5 and res.files_left_out == 1 and res.file_only == [7]
|
| 135 |
+
assert shown(dst) == {11: "Welcome", 12: "Episode 2 is out", 13: "Caption with link", 14: "Fifth", 15: "Sixth"}
|
| 136 |
+
assert all(m.media is None for i, m in dst.msgs.items() if i > 10) and not sent_media(dst) and not forwarded_ids(dst)
|
| 137 |
+
await db.close()
|
| 138 |
+
|
| 139 |
+
run(go())
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_a_source_that_cannot_be_read_is_copied_from_my_posts_without_files(tmp_path):
|
| 143 |
+
async def go():
|
| 144 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(), orig=with_files(), last=10)
|
| 145 |
+
unreadable(src)
|
| 146 |
+
await db.set_mark("s1", "fromdb")
|
| 147 |
+
await db.set_mark("s1", "nomedia")
|
| 148 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 149 |
+
assert res.from_db and res.failed == [] and res.aborted is None
|
| 150 |
+
# My posts has no album: its two pictures are two saved posts - the one with the caption goes out as text
|
| 151 |
+
assert res.files_left_out == 2 and res.file_only == [7, 8] # 3 and 9 lost their file; 7 and 8 are only files
|
| 152 |
+
assert shown(dst) == {
|
| 153 |
+
11: "Welcome", 12: "Episode 2 is out", 13: "Caption with link", 14: "Fifth", 15: "Sixth",
|
| 154 |
+
16: "album caption", 17: "Reply to the photo",
|
| 155 |
+
}
|
| 156 |
+
assert not sent_media(dst)
|
| 157 |
+
await db.close()
|
| 158 |
+
|
| 159 |
+
run(go())
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def test_a_media_only_post_is_skipped_but_never_makes_the_shift_incomplete(tmp_path):
|
| 163 |
+
async def go():
|
| 164 |
+
orig = {1: make_msg(1, "Welcome"), 2: make_msg(2, "", media=photo_media(21)), 3: make_msg(3, "Third")}
|
| 165 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(), orig=orig, last=3)
|
| 166 |
+
await db.set_mark("s1", "nomedia")
|
| 167 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 168 |
+
assert res.file_only == [2] and res.held_back == [] and res.failed == [] and res.copied_units == 2
|
| 169 |
+
await db.close()
|
| 170 |
+
|
| 171 |
+
run(go())
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# ================================================================================================ the plan
|
| 175 |
+
def test_the_plan_counts_posts_without_their_files(tmp_path):
|
| 176 |
+
async def go():
|
| 177 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(), orig=with_files(), last=10)
|
| 178 |
+
lookup = saved_lookup(db, 1)
|
| 179 |
+
plan = await plan_repost(src, SRC, RepostOptions(), saved=lookup, use_saved=True, no_media=True)
|
| 180 |
+
# 1 2 3 5 6 9 10 are copied (3 and 9 without their file); 7 and 8 are only files; 4 is a service message
|
| 181 |
+
assert (plan.units, plan.files_left_out, plan.file_only, plan.service) == (7, 2, 2, 1)
|
| 182 |
+
assert (plan.media, plan.albums, plan.messages, plan.no_media) == (0, 0, 7, True)
|
| 183 |
+
full = await plan_repost(src, SRC, RepostOptions(), saved=lookup, use_saved=True)
|
| 184 |
+
assert (full.units, full.media, full.albums, full.files_left_out, full.file_only) == (8, 4, 1, 0, 0)
|
| 185 |
+
await db.close()
|
| 186 |
+
|
| 187 |
+
run(go())
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_the_plan_from_my_posts_counts_posts_without_their_files(tmp_path):
|
| 191 |
+
async def go():
|
| 192 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(), orig=with_files(), last=10)
|
| 193 |
+
ch = await db.get_channel(1)
|
| 194 |
+
plan = await plan_from_my_posts(db, ch, no_media=True)
|
| 195 |
+
assert (plan.units, plan.files_left_out, plan.file_only, plan.media) == (7, 2, 2, 0)
|
| 196 |
+
await db.close()
|
| 197 |
+
|
| 198 |
+
run(go())
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def test_a_range_of_nothing_but_files_is_an_error_not_an_empty_plan(tmp_path):
|
| 202 |
+
async def go():
|
| 203 |
+
orig = {1: make_msg(1, "Welcome"), 2: make_msg(2, "", media=photo_media(21)), 3: make_msg(3, "", media=photo_media(31))}
|
| 204 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(), orig=orig, last=3)
|
| 205 |
+
plan = await plan_repost(src, SRC, RepostOptions(), first_id=2, last_id=3, saved=saved_lookup(db, 1), use_saved=True, no_media=True)
|
| 206 |
+
assert plan.units == 0 and plan.file_only == 2 and "only a file" in plan.error
|
| 207 |
+
await db.close()
|
| 208 |
+
|
| 209 |
+
run(go())
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
# ========================================================================================= the owner's view
|
| 213 |
+
def test_the_command_plans_runs_and_reports_a_shift_without_files(tmp_path):
|
| 214 |
+
async def go():
|
| 215 |
+
app, dst = await held_app(tmp_path, held=())
|
| 216 |
+
await app.text("/shift @srcch @dstch --no-media")
|
| 217 |
+
plan = norm(app.out.last_text)
|
| 218 |
+
assert "--no-media" in plan and "no file is copied" in plan
|
| 219 |
+
assert "1 post(s) carry a file that stays out" in plan and "5 posts" in plan
|
| 220 |
+
assert not any(isinstance(r, functions.messages.SendMessageRequest) for r in dst.requests) # a plan posts nothing
|
| 221 |
+
|
| 222 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 223 |
+
done = norm(app.out.last_text)
|
| 224 |
+
assert "5 message(s) copied" in done and "(without files)" in done
|
| 225 |
+
assert "1 post(s) were copied as text only" in done and "their file was left out" in done
|
| 226 |
+
assert all(m.media is None for i, m in dst.msgs.items() if i > 10)
|
| 227 |
+
sid = app.out.callback_data("Undo").split(":")[1]
|
| 228 |
+
assert await app.db.marks_of(sid) == {"nomedia"}
|
| 229 |
+
await app.db.close()
|
| 230 |
+
|
| 231 |
+
run(go())
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def test_a_shift_that_is_continued_goes_on_without_files(tmp_path):
|
| 235 |
+
async def go():
|
| 236 |
+
app, dst = await held_app(tmp_path, no_row=(5,))
|
| 237 |
+
app.tg.restriction = CHANNEL_NOTICE
|
| 238 |
+
await app.text("/shift @srcch @dstch --no-media")
|
| 239 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 240 |
+
assert any("Try the rest again" in t for t in buttons(app)) # post 5: only the notice, nothing saved
|
| 241 |
+
await save_rows(app.db, {5: make_msg(5, "Fifth", reply_to=2, media=photo_media(51))}) # the saved copy has a photo
|
| 242 |
+
await app.press(app.out.callback_data("Try the rest again"))
|
| 243 |
+
assert dst.msgs[15].message == "Fifth" and dst.msgs[15].media is None # the mark kept the option
|
| 244 |
+
assert not sent_media(dst)
|
| 245 |
+
await app.db.close()
|
| 246 |
+
|
| 247 |
+
run(go())
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def test_the_whole_story_telegram_hides_posts_with_files_and_without_them_it_does_not(tmp_path):
|
| 251 |
+
"""The case behind the option: the new copies are hidden by Telegram because of their files."""
|
| 252 |
+
|
| 253 |
+
async def go():
|
| 254 |
+
for no_media in (False, True):
|
| 255 |
+
app, dst = await held_app(tmp_path / str(no_media), held=())
|
| 256 |
+
app.tg.restriction = CHANNEL_NOTICE
|
| 257 |
+
dst.restrict_new = lambda m: CHANNEL_NOTICE if m.media is not None else None # the photo is blocked
|
| 258 |
+
await app.text("/shift @srcch @dstch" + (" --no-media" if no_media else ""))
|
| 259 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 260 |
+
done = norm(app.out.last_text)
|
| 261 |
+
if no_media:
|
| 262 |
+
assert "hides" not in done and "(without files)" in done
|
| 263 |
+
else:
|
| 264 |
+
assert "Telegram itself hides 1 of the 5 new post(s) I read back" in done and "--no-media" in done
|
| 265 |
+
await app.db.close()
|
| 266 |
+
|
| 267 |
+
run(go())
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def test_the_repost_command_does_not_take_the_option(tmp_path):
|
| 271 |
+
async def go():
|
| 272 |
+
from .test_repost_flow import make_app
|
| 273 |
+
|
| 274 |
+
app = await make_app(tmp_path, msgs={1: make_msg(1, "First post")})
|
| 275 |
+
await app.text("/repost --no-media")
|
| 276 |
+
assert "Unknown" in norm(app.out.last_text) or "unknown" in norm(app.out.last_text).lower()
|
| 277 |
+
await app.db.close()
|
| 278 |
+
|
| 279 |
+
run(go())
|
tests/test_restricted.py
CHANGED
|
@@ -10,6 +10,7 @@ from .test_sync import foreign, looked, old_msg, opts, run, world
|
|
| 10 |
from .test_syncer import dms, make_app, three as three_posts
|
| 11 |
|
| 12 |
NOTICE = "This message couldn't be displayed on your device due to copyright infringement."
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
def strike(m, text=NOTICE):
|
|
|
|
| 10 |
from .test_syncer import dms, make_app, three as three_posts
|
| 11 |
|
| 12 |
NOTICE = "This message couldn't be displayed on your device due to copyright infringement."
|
| 13 |
+
CHANNEL_NOTICE = "This channel is unavailable due to copyright infringement." # what Telegram says of a struck channel
|
| 14 |
|
| 15 |
|
| 16 |
def strike(m, text=NOTICE):
|
tests/test_restricted_source.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A source channel that TELEGRAM restricts (a copyright strike: "This channel is unavailable due to copyright
|
| 2 |
+
infringement"): /shift copies it from what My posts saved - every post that has a saved copy, whatever Telegram hands out
|
| 3 |
+
of it now - and never copies Telegram's notice. Every copy is read back, so the report can say when Telegram itself hides
|
| 4 |
+
the NEW posts (a blocked file, a restricted destination) instead of the bot being blamed for copying a notice."""
|
| 5 |
+
import asyncio
|
| 6 |
+
|
| 7 |
+
from telethon import functions
|
| 8 |
+
|
| 9 |
+
from app.repost_engine import CHECK_FIRST, RepostOptions, plan_repost, saved_lookup
|
| 10 |
+
from app.restrictions import message_restriction
|
| 11 |
+
from app.shift_engine import run_shift
|
| 12 |
+
from app.tgutil import button_url
|
| 13 |
+
|
| 14 |
+
from .fakes import make_msg, rpc
|
| 15 |
+
from .harness import norm
|
| 16 |
+
from .test_held_back import (
|
| 17 |
+
buttons,
|
| 18 |
+
held_app,
|
| 19 |
+
held_setup,
|
| 20 |
+
held_version,
|
| 21 |
+
forwarded_ids,
|
| 22 |
+
originals,
|
| 23 |
+
shown,
|
| 24 |
+
unreadable,
|
| 25 |
+
)
|
| 26 |
+
from .test_restricted import CHANNEL_NOTICE, NOTICE
|
| 27 |
+
from .test_shift import SRC
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def run(coro):
|
| 31 |
+
return asyncio.run(coro)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
COPIES = {11: "Welcome", 12: "Episode 2 is out", 13: "Caption with link", 14: "Fifth", 15: "Sixth"} # as My posts has them
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def differ(src, ids):
|
| 38 |
+
"""What Telegram hands out of these posts now is not what My posts saved (so the source of a copy can be told)."""
|
| 39 |
+
for i in ids:
|
| 40 |
+
old = src.msgs[i]
|
| 41 |
+
src.msgs[i] = make_msg(i, f"LIVE {i}", reply_to=old.reply_to.reply_to_msg_id if old.reply_to else None)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def struck(tmp_path, *, no_row=(), live_differs=True, **kw):
|
| 45 |
+
"""The source is restricted as a whole; its posts are handed out intact (and, by default, differently from what My
|
| 46 |
+
posts saved) and carry no mark of their own - the case that went wrong."""
|
| 47 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=kw.pop("held", ()), no_row=no_row, **kw)
|
| 48 |
+
src.restriction = CHANNEL_NOTICE
|
| 49 |
+
if live_differs:
|
| 50 |
+
differ(src, [i for i in (1, 2, 3, 5, 6) if i in src.msgs and not message_restriction(src.msgs[i])])
|
| 51 |
+
return src, dst, db, dst_row, shift
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ======================================================================================== the copy loop
|
| 55 |
+
def test_a_struck_channel_is_copied_from_my_posts_even_when_telegram_still_hands_the_posts_out(tmp_path):
|
| 56 |
+
async def go():
|
| 57 |
+
src, dst, db, dst_row, shift = await struck(tmp_path)
|
| 58 |
+
dst.fail["ForwardMessagesRequest"] = rpc("MessageIdInvalidError") # what the real run met: nothing can be forwarded
|
| 59 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 60 |
+
assert (res.failed, res.aborted, res.held_back) == ([], None, [])
|
| 61 |
+
assert res.from_saved == 5 and res.copied_units == 5 and res.live_restricted == 0 and res.held_why == CHANNEL_NOTICE
|
| 62 |
+
assert shown(dst) == COPIES # My posts' texts - not "LIVE n"
|
| 63 |
+
assert forwarded_ids(dst) == [] # Telegram is not even asked to copy anything out of the struck channel
|
| 64 |
+
# text, formatting, buttons, media and links come from My posts
|
| 65 |
+
assert [(e.offset, e.length) for e in dst.msgs[12].entities] == [(0, 9)]
|
| 66 |
+
btn = dst.msgs[12].reply_markup.rows[0].buttons[0]
|
| 67 |
+
assert (btn.text, button_url(btn)) == ("Download", "https://t.me/goku?start=AAA")
|
| 68 |
+
assert dst.msgs[13].media.photo.id == 31
|
| 69 |
+
assert dst.msgs[13].entities[0].url == "https://t.me/dstch/14" # the link to post 5 follows its copy
|
| 70 |
+
assert dst.msgs[14].reply_to.reply_to_msg_id == 12 # an answer answers the copy
|
| 71 |
+
# My posts knows the copies as they are, and the source was only read
|
| 72 |
+
assert (await db.find_by_message(2, 12)).text == "Episode 2 is out"
|
| 73 |
+
assert not [r for r in src.requests if type(r).__name__.startswith(("Edit", "Delete", "Send", "Forward"))]
|
| 74 |
+
await db.close()
|
| 75 |
+
|
| 76 |
+
run(go())
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_a_struck_channel_is_copied_from_my_posts_when_telegram_hands_out_only_the_notice(tmp_path):
|
| 80 |
+
"""The same channel when the apps show the notice: the posts carry Telegram's mark, or only its words as the text."""
|
| 81 |
+
|
| 82 |
+
async def go():
|
| 83 |
+
for flagged in (True, False):
|
| 84 |
+
src, dst, db, dst_row, shift = await struck(tmp_path / str(flagged), held=(1, 2, 3, 5, 6), flagged=flagged)
|
| 85 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 86 |
+
assert (res.failed, res.aborted, res.held_back) == ([], None, [])
|
| 87 |
+
assert res.from_saved == 5 and shown(dst) == COPIES
|
| 88 |
+
assert not any(NOTICE in (m.message or "") for m in dst.msgs.values())
|
| 89 |
+
await db.close()
|
| 90 |
+
|
| 91 |
+
run(go())
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_a_post_of_a_struck_channel_without_a_saved_copy_is_copied_as_telegram_hands_it_out(tmp_path):
|
| 95 |
+
async def go():
|
| 96 |
+
src, dst, db, dst_row, shift = await struck(tmp_path, no_row=(3, 5))
|
| 97 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 98 |
+
assert (res.failed, res.aborted, res.held_back) == ([], None, [])
|
| 99 |
+
assert res.from_saved == 3 and res.live_restricted == 2 # 1 2 6 from My posts; 3 and 5 as they are now
|
| 100 |
+
assert shown(dst) == {11: "Welcome", 12: "Episode 2 is out", 13: "LIVE 3", 14: "LIVE 5", 15: "Sixth"}
|
| 101 |
+
assert forwarded_ids(dst) == [] # not even those: a restricted post is never copied by Telegram itself
|
| 102 |
+
await db.close()
|
| 103 |
+
|
| 104 |
+
run(go())
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_a_post_of_a_struck_channel_with_only_the_notice_and_no_saved_copy_is_skipped(tmp_path):
|
| 108 |
+
async def go():
|
| 109 |
+
src, dst, db, dst_row, shift = await struck(tmp_path, held=(3,), no_row=(3,), flagged=False)
|
| 110 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 111 |
+
assert res.held_back == [3] and res.from_saved == 4 and res.failed == []
|
| 112 |
+
assert shown(dst) == {11: "Welcome", 12: "Episode 2 is out", 13: "Fifth", 14: "Sixth"}
|
| 113 |
+
assert not any(NOTICE in (m.message or "") for m in dst.msgs.values())
|
| 114 |
+
await db.close()
|
| 115 |
+
|
| 116 |
+
run(go())
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_a_saved_copy_that_is_telegrams_notice_is_never_posted(tmp_path):
|
| 120 |
+
async def go():
|
| 121 |
+
src, dst, db, dst_row, shift = await struck(tmp_path, no_row=(2, 3))
|
| 122 |
+
for mid in (2, 3): # what an older version saved when it synced the struck channel
|
| 123 |
+
await db.create_post(
|
| 124 |
+
channel_id=1, message_id=mid, status="sent", source="adopted", created_by=1, link_preview=False,
|
| 125 |
+
text=NOTICE, entities=[], buttons=[], media_kind=None, media_file_id=None,
|
| 126 |
+
)
|
| 127 |
+
src.msgs[3] = held_version(originals()[3], flagged=False) # post 3: the notice from Telegram as well
|
| 128 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 129 |
+
# post 2: the saved copy is useless but Telegram hands the post out; post 3: nothing but notices
|
| 130 |
+
assert res.held_back == [3] and res.live_restricted == 1 and res.from_saved == 3 and res.failed == []
|
| 131 |
+
assert shown(dst) == {11: "Welcome", 12: "LIVE 2", 13: "Fifth", 14: "Sixth"}
|
| 132 |
+
assert not any(NOTICE in (m.message or "") for m in dst.msgs.values())
|
| 133 |
+
await db.close()
|
| 134 |
+
|
| 135 |
+
run(go())
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_a_source_that_cannot_be_read_never_gets_a_saved_notice_posted(tmp_path):
|
| 139 |
+
async def go():
|
| 140 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=(), no_row=(2,))
|
| 141 |
+
await db.create_post(
|
| 142 |
+
channel_id=1, message_id=2, status="sent", source="adopted", created_by=1, link_preview=False,
|
| 143 |
+
text=NOTICE, entities=[], buttons=[], media_kind=None, media_file_id=None,
|
| 144 |
+
)
|
| 145 |
+
unreadable(src)
|
| 146 |
+
await db.set_mark("s1", "fromdb")
|
| 147 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 148 |
+
assert res.copied_units == 4 and res.failed == []
|
| 149 |
+
assert shown(dst) == {11: "Welcome", 12: "Caption with link", 13: "Fifth", 14: "Sixth"}
|
| 150 |
+
await db.close()
|
| 151 |
+
|
| 152 |
+
run(go())
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def test_a_channel_nothing_restricts_is_copied_as_before(tmp_path):
|
| 156 |
+
async def go():
|
| 157 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=())
|
| 158 |
+
differ(src, (1, 2, 3, 5, 6))
|
| 159 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 160 |
+
assert res.from_saved == 0 and res.live_restricted == 0 and res.held_why is None and res.held_back == []
|
| 161 |
+
assert shown(dst) == {11: "LIVE 1", 12: "LIVE 2", 13: "LIVE 3", 14: "LIVE 5", 15: "LIVE 6"} # what the channel says
|
| 162 |
+
await db.close()
|
| 163 |
+
|
| 164 |
+
run(go())
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_lifting_the_strike_makes_the_next_run_an_ordinary_one(tmp_path):
|
| 168 |
+
async def go():
|
| 169 |
+
src, dst, db, dst_row, shift = await struck(tmp_path, last=3)
|
| 170 |
+
stopper = {"n": 0}
|
| 171 |
+
|
| 172 |
+
async def prog(r):
|
| 173 |
+
stopper["n"] = r.copied_units
|
| 174 |
+
|
| 175 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0, progress=prog, should_stop=lambda: stopper["n"] >= 1)
|
| 176 |
+
assert res.stopped and shown(dst) == {11: "Welcome"} # post 1, from My posts
|
| 177 |
+
src.restriction = None # the strike is lifted: the channel says what it says now
|
| 178 |
+
again = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 179 |
+
assert again.held_why is None and again.from_saved == 0
|
| 180 |
+
assert shown(dst) == {11: "Welcome", 12: "LIVE 2", 13: "LIVE 3"}
|
| 181 |
+
await db.close()
|
| 182 |
+
|
| 183 |
+
run(go())
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# ================================================================================================ the plan
|
| 187 |
+
def test_the_plan_counts_a_struck_channel_as_copied_from_my_posts(tmp_path):
|
| 188 |
+
async def go():
|
| 189 |
+
src, dst, db, dst_row, shift = await struck(tmp_path, no_row=(5,))
|
| 190 |
+
lookup = saved_lookup(db, 1)
|
| 191 |
+
plan = await plan_repost(src, SRC, RepostOptions(), saved=lookup, use_saved=True)
|
| 192 |
+
assert plan.channel_restricted == CHANNEL_NOTICE and plan.held_why == CHANNEL_NOTICE
|
| 193 |
+
# 1 2 3 6 have a saved copy; 5 has none but Telegram still hands it out
|
| 194 |
+
assert (plan.units, plan.held_saved, plan.live_restricted, plan.held_skipped, plan.service) == (5, 4, 1, 0, 1)
|
| 195 |
+
assert plan.media == 1 # post 3 is counted from its saved copy, not from what Telegram hands out now
|
| 196 |
+
# /repost copies the posts of the channel itself, post by post: a struck channel changes nothing about that
|
| 197 |
+
plain = await plan_repost(src, SRC, RepostOptions(), saved=lookup)
|
| 198 |
+
assert (plain.units, plain.held_saved, plain.live_restricted, plain.held_skipped) == (5, 0, 0, 0)
|
| 199 |
+
assert plain.channel_restricted == CHANNEL_NOTICE
|
| 200 |
+
await db.close()
|
| 201 |
+
|
| 202 |
+
run(go())
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
# ========================================================================================= the read-back
|
| 206 |
+
def test_every_copy_of_a_struck_channel_is_read_back_and_what_telegram_hides_is_counted(tmp_path):
|
| 207 |
+
async def go():
|
| 208 |
+
src, dst, db, dst_row, shift = await struck(tmp_path)
|
| 209 |
+
dst.restrict_new = lambda m: CHANNEL_NOTICE if m.media is not None else None # the file of post 3 is blocked
|
| 210 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 211 |
+
assert res.copied_units == 5 and res.failed == [] and res.held_back == []
|
| 212 |
+
assert res.checked == 5 # every copy of a restricted source
|
| 213 |
+
assert res.hidden == [(3, True)] and res.hidden_why == CHANNEL_NOTICE and res.dst_restricted is None
|
| 214 |
+
# the bot posted the saved text; the notice is Telegram's mark on the new post
|
| 215 |
+
assert dst.msgs[13].message == "Caption with link" and message_restriction(dst.msgs[13]) == CHANNEL_NOTICE
|
| 216 |
+
assert not any(NOTICE in (m.message or "") for m in dst.msgs.values())
|
| 217 |
+
await db.close()
|
| 218 |
+
|
| 219 |
+
run(go())
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def test_a_destination_that_is_restricted_itself_is_named(tmp_path):
|
| 223 |
+
async def go():
|
| 224 |
+
src, dst, db, dst_row, shift = await struck(tmp_path)
|
| 225 |
+
dst.restriction = CHANNEL_NOTICE
|
| 226 |
+
dst.restrict_new = lambda m: CHANNEL_NOTICE
|
| 227 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 228 |
+
assert [i for i, _ in res.hidden] == [1, 2, 3] and res.dst_restricted == CHANNEL_NOTICE # it stopped after 3
|
| 229 |
+
assert res.hidden_stop
|
| 230 |
+
await db.close()
|
| 231 |
+
|
| 232 |
+
run(go())
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def test_a_normal_channel_is_only_sampled_when_the_copies_are_read_back(tmp_path):
|
| 236 |
+
async def go():
|
| 237 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path, held=())
|
| 238 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 239 |
+
assert res.copied_units == 5 and res.checked == CHECK_FIRST and res.hidden == [] and res.dst_restricted is None
|
| 240 |
+
assert not [r for r in dst.requests if isinstance(r, functions.channels.GetFullChannelRequest)] # nothing to ask
|
| 241 |
+
await db.close()
|
| 242 |
+
|
| 243 |
+
run(go())
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def test_a_read_back_that_fails_does_not_fail_the_copy(tmp_path):
|
| 247 |
+
async def go():
|
| 248 |
+
for error in (rpc("ChannelPrivateError"), ConnectionError("the network dropped")): # Telegram's refusal, or not
|
| 249 |
+
src, dst, db, dst_row, shift = await struck(tmp_path / type(error).__name__, last=1) # post 1: no buttons to read back
|
| 250 |
+
dst.fail["GetMessagesRequest"] = error
|
| 251 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 252 |
+
assert res.copied_units == 1 and res.failed == [] and res.checked == 0 and res.hidden == []
|
| 253 |
+
assert shown(dst) == {11: "Welcome"}
|
| 254 |
+
await db.close()
|
| 255 |
+
|
| 256 |
+
run(go())
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def test_a_source_that_cannot_be_asked_about_its_restriction_is_copied_as_an_unrestricted_one(tmp_path):
|
| 260 |
+
async def go():
|
| 261 |
+
for error in (rpc("ChannelPrivateError"), ConnectionError("the network dropped")):
|
| 262 |
+
src, dst, db, dst_row, shift = await held_setup(tmp_path / type(error).__name__, held=())
|
| 263 |
+
src.fail["GetFullChannelRequest"] = error
|
| 264 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 265 |
+
assert res.copied_units == 5 and res.failed == [] and res.held_why is None and res.from_saved == 0
|
| 266 |
+
await db.close()
|
| 267 |
+
|
| 268 |
+
run(go())
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
# ================================================================================================ the owner's view
|
| 272 |
+
def test_the_plan_and_the_result_for_a_struck_channel_read_as_they_should(tmp_path):
|
| 273 |
+
async def go():
|
| 274 |
+
app, dst = await held_app(tmp_path, held=())
|
| 275 |
+
app.tg.restriction = CHANNEL_NOTICE
|
| 276 |
+
dst.fail["ForwardMessagesRequest"] = rpc("MessageIdInvalidError")
|
| 277 |
+
await app.text("/shift @srcch @dstch")
|
| 278 |
+
plan = norm(app.out.last_text)
|
| 279 |
+
assert "Telegram restricts Anime Source itself" in plan
|
| 280 |
+
assert "5 post(s) of Anime Source are restricted by Telegram" in plan and "copied from what My posts saved" in plan
|
| 281 |
+
assert "5 posts" in plan and "The new posts are read back" in plan
|
| 282 |
+
|
| 283 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 284 |
+
done = norm(app.out.last_text)
|
| 285 |
+
assert "5 post(s) are restricted by Telegram" in done and "copied from what My posts saved of them" in done
|
| 286 |
+
assert "5 message(s) copied" in done and "hides" not in done
|
| 287 |
+
assert [m.message for i, m in sorted(dst.msgs.items()) if i > 10] == list(COPIES.values())
|
| 288 |
+
await app.db.close()
|
| 289 |
+
|
| 290 |
+
run(go())
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def test_the_report_says_when_telegram_itself_hides_the_new_posts(tmp_path):
|
| 294 |
+
async def go():
|
| 295 |
+
app, dst = await held_app(tmp_path, held=())
|
| 296 |
+
app.tg.restriction = CHANNEL_NOTICE
|
| 297 |
+
dst.restrict_new = lambda m: CHANNEL_NOTICE if m.media is not None else None
|
| 298 |
+
await app.text("/shift @srcch @dstch")
|
| 299 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 300 |
+
done = norm(app.out.last_text)
|
| 301 |
+
assert "Telegram itself hides 1 of the 5 new post(s) I read back" in done
|
| 302 |
+
assert "carry its restriction mark" in done and "never posts one" in done
|
| 303 |
+
assert "1 post(s) of these carry a file" in done and "--no-media" in done
|
| 304 |
+
assert "The saved posts in My posts are intact" in done
|
| 305 |
+
assert "5 message(s) copied" in done # the copies exist: Undo removes them
|
| 306 |
+
assert any("Undo" in t for row in app.out.last_buttons() for t in row)
|
| 307 |
+
await app.db.close()
|
| 308 |
+
|
| 309 |
+
run(go())
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def test_the_report_names_a_restricted_destination(tmp_path):
|
| 313 |
+
async def go():
|
| 314 |
+
app, dst = await held_app(tmp_path, held=())
|
| 315 |
+
app.tg.restriction = CHANNEL_NOTICE
|
| 316 |
+
dst.restriction = CHANNEL_NOTICE
|
| 317 |
+
dst.restrict_new = lambda m: CHANNEL_NOTICE
|
| 318 |
+
await app.text("/shift @srcch @dstch")
|
| 319 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 320 |
+
done = norm(app.out.last_text)
|
| 321 |
+
assert "Telegram itself hides 3 of the 3 new post(s) I read back" in done
|
| 322 |
+
assert "Telegram restricts the destination channel itself" in done
|
| 323 |
+
await app.db.close()
|
| 324 |
+
|
| 325 |
+
run(go())
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def test_a_repost_plan_for_a_struck_channel_points_at_shift(tmp_path):
|
| 329 |
+
from .test_repost_flow import make_app
|
| 330 |
+
|
| 331 |
+
async def go():
|
| 332 |
+
app = await make_app(tmp_path, msgs={1: make_msg(1, "First post"), 2: make_msg(2, "Second post")})
|
| 333 |
+
app.tg.restriction = CHANNEL_NOTICE
|
| 334 |
+
await app.text("/repost --no-typed")
|
| 335 |
+
await app.press(app.out.callback_data("Test"))
|
| 336 |
+
plan = norm(app.out.last_text)
|
| 337 |
+
assert "Telegram restricts Test itself" in plan and "use /shift" in plan
|
| 338 |
+
assert "2 posts" in plan and "no saved copy" not in plan # /repost does not look at My posts
|
| 339 |
+
await app.db.close()
|
| 340 |
+
|
| 341 |
+
run(go())
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
# ====================================================================== stopping when Telegram hides the copies
|
| 345 |
+
def test_a_shift_stops_by_itself_when_telegram_hides_every_new_copy(tmp_path):
|
| 346 |
+
async def go():
|
| 347 |
+
src, dst, db, dst_row, shift = await struck(tmp_path)
|
| 348 |
+
dst.restrict_new = lambda m: CHANNEL_NOTICE # every new post is hidden
|
| 349 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 350 |
+
assert res.stopped and res.hidden_stop and res.copied_units == 3 # not 5: it did not go on
|
| 351 |
+
assert [i for i, _ in res.hidden] == [1, 2, 3] and res.checked == 3
|
| 352 |
+
assert shown(dst) == {11: "Welcome", 12: "Episode 2 is out", 13: "Caption with link"}
|
| 353 |
+
assert res.phase == "copy" and res.final is None # the finishing touches wait for the rest
|
| 354 |
+
await db.close()
|
| 355 |
+
|
| 356 |
+
run(go())
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def test_the_owner_can_go_on_anyway_and_it_does_not_stop_again(tmp_path):
|
| 360 |
+
async def go():
|
| 361 |
+
src, dst, db, dst_row, shift = await struck(tmp_path)
|
| 362 |
+
dst.restrict_new = lambda m: CHANNEL_NOTICE
|
| 363 |
+
first = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 364 |
+
assert first.hidden_stop and first.copied_units == 3
|
| 365 |
+
await db.set_mark("s1", "hiddenok") # what pressing Continue anyway rests on
|
| 366 |
+
again = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 367 |
+
assert not again.stopped and not again.hidden_stop and again.copied_units == 2 and again.skipped_done == 3
|
| 368 |
+
assert shown(dst) == COPIES
|
| 369 |
+
await db.close()
|
| 370 |
+
|
| 371 |
+
run(go())
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def test_a_shift_does_not_stop_when_only_some_copies_are_hidden(tmp_path):
|
| 375 |
+
async def go():
|
| 376 |
+
src, dst, db, dst_row, shift = await struck(tmp_path)
|
| 377 |
+
dst.restrict_new = lambda m: CHANNEL_NOTICE if m.media is not None else None # only the photo
|
| 378 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 379 |
+
assert not res.stopped and res.copied_units == 5 and res.hidden == [(3, True)]
|
| 380 |
+
await db.close()
|
| 381 |
+
|
| 382 |
+
run(go())
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def test_a_shift_with_fewer_copies_than_the_threshold_is_not_stopped(tmp_path):
|
| 386 |
+
async def go():
|
| 387 |
+
src, dst, db, dst_row, shift = await struck(tmp_path, last=2)
|
| 388 |
+
dst.restrict_new = lambda m: CHANNEL_NOTICE
|
| 389 |
+
res = await run_shift(src, db, shift, dst_row, 99, delay=0, settle_pause=0)
|
| 390 |
+
assert not res.stopped and res.copied_units == 2 and len(res.hidden) == 2 # reported, nothing left to stop
|
| 391 |
+
await db.close()
|
| 392 |
+
|
| 393 |
+
run(go())
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def test_the_stopped_shift_explains_itself_and_continue_anyway_goes_on(tmp_path):
|
| 397 |
+
async def go():
|
| 398 |
+
app, dst = await held_app(tmp_path, held=())
|
| 399 |
+
app.tg.restriction = CHANNEL_NOTICE
|
| 400 |
+
dst.restrict_new = lambda m: CHANNEL_NOTICE
|
| 401 |
+
await app.text("/shift @srcch @dstch")
|
| 402 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 403 |
+
done = norm(app.out.last_text)
|
| 404 |
+
assert "stopped" in done and "3 message(s) copied so far" in done
|
| 405 |
+
assert "I stopped after 3 post(s)" in done and "Telegram hides every new copy I read back" in done
|
| 406 |
+
assert "Telegram itself hides 3 of the 3 new post(s) I read back" in done
|
| 407 |
+
assert any("Continue" in t for t in buttons(app)) and any("Undo" in t for t in buttons(app))
|
| 408 |
+
sid = app.out.callback_data("Undo").split(":")[1]
|
| 409 |
+
assert await app.db.marks_of(sid) == {"hiddenok"}
|
| 410 |
+
assert len([i for i in dst.msgs if i > 10]) == 3 # three posts, not the whole channel
|
| 411 |
+
|
| 412 |
+
await app.press(app.out.callback_data("Continue"))
|
| 413 |
+
done = norm(app.out.last_text)
|
| 414 |
+
assert "5 message(s) copied" in done and "I stopped" not in done # it went on, as the owner chose
|
| 415 |
+
assert len([i for i in dst.msgs if i > 10]) == 5
|
| 416 |
+
await app.db.close()
|
| 417 |
+
|
| 418 |
+
run(go())
|
tests/test_shift.py
CHANGED
|
@@ -390,6 +390,62 @@ def test_the_whole_flow_copy_then_undo(tmp_path):
|
|
| 390 |
run(go())
|
| 391 |
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
def test_a_destination_the_bot_does_not_know_yet_becomes_one_of_its_channels(tmp_path):
|
| 394 |
async def go():
|
| 395 |
app, dst = await shift_app(tmp_path)
|
|
|
|
| 390 |
run(go())
|
| 391 |
|
| 392 |
|
| 393 |
+
def test_without_arguments_a_finished_shift_can_still_be_undone(tmp_path):
|
| 394 |
+
"""The result message of a finished shift may be far up the chat: /shift alone offers its Undo again."""
|
| 395 |
+
async def go():
|
| 396 |
+
app, dst = await shift_app(tmp_path)
|
| 397 |
+
before = len(app.out.log)
|
| 398 |
+
await app.text("/shift") # nothing was shifted yet: only the usage
|
| 399 |
+
assert len(app.out.log) - before == 1 and "Usage: /shift" in norm(app.out.last_text)
|
| 400 |
+
|
| 401 |
+
await app.text("/shift @srcch @dstch")
|
| 402 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 403 |
+
assert "7 message(s) copied" in norm(app.out.last_text)
|
| 404 |
+
|
| 405 |
+
await app.text("/shift")
|
| 406 |
+
assert "Usage: /shift" in norm(app.out.log[-2][1]) # the usage first, the finished shift right under it
|
| 407 |
+
text = norm(app.out.last_text)
|
| 408 |
+
assert "Latest finished shift - you can still undo it" in text and "7 message(s) copied" in text
|
| 409 |
+
await app.press(app.out.callback_data("Undo"))
|
| 410 |
+
await app.press(app.out.callback_data("Yes, remove"))
|
| 411 |
+
assert "7 copied message(s) removed from Anime Backup" in norm(app.out.last_text)
|
| 412 |
+
assert sorted(dst.msgs) == list(range(1, 11))
|
| 413 |
+
|
| 414 |
+
before = len(app.out.log)
|
| 415 |
+
await app.text("/shift") # undone: nothing is left to offer
|
| 416 |
+
assert len(app.out.log) - before == 1 and "Latest finished shift" not in norm(app.out.last_text)
|
| 417 |
+
await app.db.close()
|
| 418 |
+
|
| 419 |
+
run(go())
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def test_only_a_finished_shift_that_still_has_copies_is_offered(tmp_path):
|
| 423 |
+
async def go():
|
| 424 |
+
app, dst = await shift_app(tmp_path)
|
| 425 |
+
await app.text("/shift @srcch @dstch")
|
| 426 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 427 |
+
first = await app.db.last_finished_shift()
|
| 428 |
+
assert first is not None
|
| 429 |
+
# every copy of it was deleted some other way: nothing to undo, so nothing is offered
|
| 430 |
+
await app.db.mark_shift_deleted(first.id, 2, await app.db.shift_pending_new(first.id))
|
| 431 |
+
assert await app.db.last_finished_shift() is None
|
| 432 |
+
before = len(app.out.log)
|
| 433 |
+
await app.text("/shift")
|
| 434 |
+
assert len(app.out.log) - before == 1 and "Latest finished shift" not in norm(app.out.last_text)
|
| 435 |
+
|
| 436 |
+
# a newer shift is the one offered
|
| 437 |
+
await app.text("/shift @srcch @dstch")
|
| 438 |
+
await app.press(app.out.callback_data("Start copying"))
|
| 439 |
+
second = await app.db.last_finished_shift()
|
| 440 |
+
assert second is not None and second.id != first.id
|
| 441 |
+
await app.text("/shift")
|
| 442 |
+
assert "Latest finished shift" in norm(app.out.last_text)
|
| 443 |
+
assert app.out.callback_data("Undo") == f"sfu:{second.id}"
|
| 444 |
+
await app.db.close()
|
| 445 |
+
|
| 446 |
+
run(go())
|
| 447 |
+
|
| 448 |
+
|
| 449 |
def test_a_destination_the_bot_does_not_know_yet_becomes_one_of_its_channels(tmp_path):
|
| 450 |
async def go():
|
| 451 |
app, dst = await shift_app(tmp_path)
|
tests/test_shift_extras.py
CHANGED
|
@@ -381,7 +381,7 @@ def test_an_unknown_option_is_explained(tmp_path):
|
|
| 381 |
async def go():
|
| 382 |
app, dst, other = await extras_app(tmp_path)
|
| 383 |
await app.text("/shift @srcch @dstch -x")
|
| 384 |
-
assert "Unknown option -x" in norm(app.out.last_text) and "-c and -
|
| 385 |
assert dst.title == "Anime Backup"
|
| 386 |
await app.db.close()
|
| 387 |
|
|
|
|
| 381 |
async def go():
|
| 382 |
app, dst, other = await extras_app(tmp_path)
|
| 383 |
await app.text("/shift @srcch @dstch -x")
|
| 384 |
+
assert "Unknown option -x" in norm(app.out.last_text) and "-c, -all and --no-media" in norm(app.out.last_text)
|
| 385 |
assert dst.title == "Anime Backup"
|
| 386 |
await app.db.close()
|
| 387 |
|