github-actions[bot] commited on
Commit
c71e4ac
·
1 Parent(s): 65651b1

Automated sync from GitHub

Browse files
.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
- "-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\n"
 
 
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 holds back (it shows a notice, for example for a copyright strike, instead of them)."""
 
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.held_saved:
 
 
 
 
 
 
 
 
157
  lines.append(
158
- f"• 📋 Telegram holds back {p.held_saved} post(s) of {name}{said} - it shows a notice instead of them. "
159
- "Those are copied from what My posts saved of them: text, formatting, buttons and media (a file that "
 
 
 
 
 
160
  "Telegram refuses goes out without it)."
161
  )
 
 
 
 
 
 
 
 
 
 
162
  if p.held_skipped and shift:
163
  lines.append(
164
- f"• ⚠️ Telegram holds back {p.held_skipped} more post(s) of {name}{said} and My posts has no saved copy of "
165
- "them, so they are skipped and the shift will show as not complete. If you have a backup made before that, "
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 holds back, for the end of a repost or shift."""
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
- "text, formatting, buttons and media - without replies, pins and albums."
189
  )
190
  elif res.from_saved:
191
  notes.append(
192
- f"📋 {res.from_saved} post(s) are held back by Telegram - it shows a notice instead of them. They were "
193
- "copied from what My posts saved of them: text, formatting, buttons and media."
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) are held back by Telegram and My posts has no saved copy of them, so they "
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 finishing_notes(res) -> list:
216
- """Lines about answers, links and pins for the end of a repost."""
217
- notes = held_notes(res)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 post that Telegram holds back in the source (a copyright strike: it shows “This message couldn't be displayed on "
35
- "your device due to copyright infringement” instead of the post) is copied from what My posts saved of it. A post "
36
- "without a saved copy is skipped.\n\n"
37
- "Two extras, each optional, in any order, with or without a range:\n"
 
 
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>/shift @source @destination -c -all</code> does both."
 
 
42
  )
43
 
44
 
45
  @dataclass
46
  class ShiftFlags:
47
- """The two optional extras of /shift."""
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 -all can stand anywhere, in any order ("- all" with a space is understood too); a channel id such as
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
- elif m.group(1).lower() == "c":
 
 
84
  flags.links = True
85
- elif m.group(1).lower() == "all":
86
  flags.profile = True
 
 
87
  else:
88
- raise ValueError(f"Unknown option {t}. The options are -c and -all.")
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 - its text, formatting, buttons and "
165
- "media (a file that Telegram refuses goes out without it) - and every button is read back to make sure it "
166
- "is there. Copies go to the end of the destination silently, in the original order, and are registered in "
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
- kb = [[Button.inline("▶️ Continue", f"sfc:{sh.id}")], undo]
 
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
- else:
245
- await say(event, USAGE)
 
 
 
 
 
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 holds back (a copyright strike shows "This message couldn't be displayed on your device due to
21
- copyright infringement" instead of the post) is never copied as it looks now - that would copy the notice. /shift
22
- rebuilds such a post from what My posts saved of it (text, formatting, buttons, media); /repost leaves it alone.
23
- A post that has no saved copy is skipped and reported.
 
 
 
 
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, norm_text, probe_channel
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 held_back_reason(unit: list, rows: dict) -> Optional[str]:
182
- """What Telegram says when it holds this post back (a copyright strike ...), else None.
183
-
184
- "Held back" means that what Telegram hands out is its notice - or nothing - instead of the post: copying that would
185
- copy the notice. A post that carries Telegram's restriction mark but is handed out as it is (its text, its media)
186
- is an ordinary post for a copy, and so is a text that the saved copy has too (the owner's own wording)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- notice = is_placeholder_text(live) and (saved_text is None or norm_text(live) != norm_text(saved_text))
195
- nothing = not live.strip() and getattr(m, "media", None) is None
196
- if notice or nothing:
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 held-back post, or None when My posts does not have all of them (or one
223
- of them has nothing to post)."""
224
  out = []
225
  for m in unit:
226
  row = rows.get(m.id)
227
- if row is None or not ((row.text or "").strip() or row.media_file_id):
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, between two message ids."""
 
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.text or "").strip() or row.media_file_id:
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 Telegram holds back that are copied from what My posts saved of them (counted in units)
286
- held_skipped: int = 0 # posts Telegram holds back that can't be copied (not counted in units)
 
 
 
 
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(ch, rows: list, opts: RepostOptions, *, first_id=None, last_id=None, unreadable=None) -> RepostPlan:
 
 
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(channel=ch, partial=bool(first_id or last_id), from_db=True, unreadable=unreadable)
 
 
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, saved_message(row, ch.id), opts, links)
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
- why = held_back_reason([m], rows)
 
 
 
417
  if why:
418
  plan.held_why = plan.held_why or why
419
- if not (use_saved and stand_ins([m], rows) is not None):
420
- plan.held_skipped += 1 if new_unit else 0 # nothing to copy it from
 
 
 
 
 
 
 
 
421
  prev_gid = gid
422
  continue
 
 
423
  plan.held_saved += 1 if new_unit else 0
424
- m = stand_in(m, row) # counted as the saved post it will be copied from
 
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 holds back - never forwarded."""
 
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 Telegram holds back that were copied from their saved copy in My 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 Telegram holds back that were not copied (first old id)
727
- held_why: Optional[str] = None # what Telegram says about them
728
- used_saved: bool = False # the run could take held-back posts from My posts (/shift)
 
 
 
 
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
- why = None if job.from_db else held_back_reason(unit, rows)
810
- from_saved = job.from_db # saved posts already
811
- if why: # Telegram shows a notice instead of this post: the notice must not become the copy
 
 
812
  res.held_why = res.held_why or why
813
- standing = stand_ins(unit, rows) if job.use_saved else None
 
 
 
814
  if standing is None:
815
- log.info("post %s is held back by Telegram (%s) and has no saved copy to take its place", ids[0], why)
816
- res.held_back.append(ids[0])
817
- continue
 
 
 
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(client, src, unit, finals, st, reply_to, dst, from_saved=from_saved)
 
 
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
- Two 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 (profile_copy.copy_profile).
11
-
12
- A source that Telegram holds back (a copyright strike: it shows a notice instead of its posts) is copied from what
13
- My posts saved of the posts (repost_engine: held_back_reason / stand_ins). A source that can't be read at all (a banned
14
- or private channel) is copied from My posts alone: mark "fromdb", set when the plan was made that way.
 
 
 
 
 
 
 
 
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(db, src, first_id=None, last_id=None, unreadable=None) -> Optional[RepostPlan]:
 
 
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
- from_db = "fromdb" in await db.marks_of(shift.id) # the source can't be read: its posts come from My posts alone
 
 
 
 
 
 
 
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 test_a_post_with_telegrams_mark_that_is_still_handed_out_as_it_is_is_an_ordinary_post(tmp_path):
 
 
 
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 with link", [types.MessageEntityTextUrl(13, 4, "https://t.me/srcch/5")], media=photo_media(31)))
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
- # the live posts are what they are: nothing is rebuilt from My posts, nothing is skipped
153
- assert res.from_saved == 0 and res.held_back == [] and res.failed == [] and res.copied_units == 5
 
154
  assert shown(dst) == {11: "Welcome", 12: "Episode 2 is out", 13: "Caption with link", 14: "Fifth", 15: "Sixth"}
155
- assert 3 in forwarded_ids(dst)
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 test_a_text_that_my_posts_has_too_is_the_owners_own_wording_not_a_notice(tmp_path):
 
 
 
182
  async def go():
183
  orig = originals()
184
- orig[6] = make_msg(6, NOTICE) # the owner really wrote this
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.msgs[15].message == NOTICE and 6 in forwarded_ids(dst) # copied as it is
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = NOTICE
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 "Telegram holds back 2 post(s) of Anime Source" in plan and "copied from what My posts saved of them" in plan
384
- assert "holds back 1 more post(s)" in plan and "no saved copy" in plan and "/import" in plan
 
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 "2 post(s) are held back by Telegram" in done and "copied from what My posts saved of them" in done
391
- assert "1 post(s) are held back by Telegram and My posts has no saved copy of them" in done and "#5" in done
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 -all" in norm(app.out.last_text)
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