github-actions[bot] commited on
Commit
1dfae7a
·
1 Parent(s): 848377f

Automated sync from GitHub

Browse files
app/common.py CHANGED
@@ -18,7 +18,7 @@ from .config import Config
18
  from .db import Database
19
  from .replace_engine import fetch_messages, is_skippable
20
  from .tgutil import build_markup, de_entities, edit_raw, esc, explain_rpc, fetch_media, media_info, peer_of, with_media
21
- from .userbot import UserbotError, edit_problem_text
22
 
23
  log = logging.getLogger(__name__)
24
 
@@ -248,30 +248,53 @@ async def live_media(client, ch, message_id: int) -> Optional[tuple]:
248
  async def edit_by_userbot(ctx: Ctx, ch, p, *, replace_media: bool, bot_error: str, notify=None) -> Optional[EditReport]:
249
  """The userbot makes the live post look like `p`. None when there is no userbot to ask; UserbotError when it fails.
250
 
251
- The new photo / file is downloaded by the bot (the userbot can't see the owner's chat with the bot) and uploaded again."""
 
 
 
252
  ub = ctx.userbot
253
  editor = await ub.editor_for(ch) if ub is not None else None
254
  if editor is None:
255
  return None
256
  report = EditReport(by_userbot=True, bot_error=bot_error)
257
  folder = None
258
- try:
 
259
  if notify is not None:
260
- await notify()
261
- media = None
262
- if replace_media and p.media_file_id:
263
- folder = tempfile.mkdtemp(prefix="cpb-media-")
264
- try:
265
- media = await fetch_media(ctx.client, p.media_file_id, p.media_kind, folder)
266
- except (errors.RPCError, OSError, ValueError) as e:
267
- raise UserbotError(f"I could not download the new media for the userbot ({type(e).__name__}).")
268
- await editor.edit(
269
- p.message_id,
270
- text=p.text or "",
271
- entities=de_entities(p.entities),
272
- media=media,
273
- preview=None if p.media_kind else bool(p.link_preview),
274
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  finally:
276
  await editor.close()
277
  report.notes = list(editor.notes)
 
18
  from .db import Database
19
  from .replace_engine import fetch_messages, is_skippable
20
  from .tgutil import build_markup, de_entities, edit_raw, esc, explain_rpc, fetch_media, media_info, peer_of, with_media
21
+ from .userbot import UserbotError, UserbotRefused, edit_problem_text
22
 
23
  log = logging.getLogger(__name__)
24
 
 
248
  async def edit_by_userbot(ctx: Ctx, ch, p, *, replace_media: bool, bot_error: str, notify=None) -> Optional[EditReport]:
249
  """The userbot makes the live post look like `p`. None when there is no userbot to ask; UserbotError when it fails.
250
 
251
+ A new photo / file goes through the userbot's own chat with the bot: the bot sends it there and the userbot edits the
252
+ post with that copy, so nothing is downloaded or uploaded - a big file is as quick as a small one and keeps its
253
+ thumbnail. Only when that does not work does the bot download the file (with its thumbnail) and the userbot upload
254
+ it again, which is slow for a big file. `notify(text)` tells the owner what is going on."""
255
  ub = ctx.userbot
256
  editor = await ub.editor_for(ch) if ub is not None else None
257
  if editor is None:
258
  return None
259
  report = EditReport(by_userbot=True, bot_error=bot_error)
260
  folder = None
261
+
262
+ async def tell(text: str) -> None:
263
  if notify is not None:
264
+ await notify(text)
265
+
266
+ fields = dict(
267
+ text=p.text or "", entities=de_entities(p.entities), preview=None if p.media_kind else bool(p.link_preview)
268
+ )
269
+ try:
270
+ await tell("⏳ Telegram refused the bot, so the userbot is making the edit.")
271
+ if not (replace_media and p.media_file_id):
272
+ await editor.edit(p.message_id, **fields)
273
+ else:
274
+ done = False
275
+ media = await editor.media_by_reference(p.media_file_id)
276
+ if media is not None:
277
+ try:
278
+ await editor.edit(p.message_id, media=media, **fields)
279
+ done = True
280
+ editor.notes.append("the new file went through the userbot's chat with the bot, so nothing was uploaded")
281
+ except UserbotRefused as e:
282
+ if not e.about_media:
283
+ raise
284
+ editor.dm_problem = f"Telegram would not take the userbot's copy of the file ({e.name})"
285
+ if not done:
286
+ why = editor.dm_problem or "that did not work"
287
+ editor.notes.append(f"the file was downloaded and uploaded again, because {why}")
288
+ await tell(
289
+ "⏳ The file can't go through the userbot's chat with the bot, so it is downloaded and uploaded "
290
+ f"again - a big file takes a while. (Reason: {esc(why)}.)"
291
+ )
292
+ folder = tempfile.mkdtemp(prefix="cpb-media-")
293
+ try:
294
+ media = await fetch_media(ctx.client, p.media_file_id, p.media_kind, folder)
295
+ except (errors.RPCError, OSError, ValueError) as e:
296
+ raise UserbotError(f"I could not download the new media for the userbot ({type(e).__name__}).")
297
+ await editor.edit(p.message_id, media=media, **fields)
298
  finally:
299
  await editor.close()
300
  report.notes = list(editor.notes)
app/handlers/basic.py CHANGED
@@ -126,13 +126,19 @@ def register(ctx: Ctx) -> None:
126
  "if it has the “Add new admins” right, and gives the rights back afterwards.\n\n"
127
  "To set it up: on your own computer run <code>python make_userbot_session.py</code> with a "
128
  "<b>separate</b> Telegram account, and put the printed text into the bot's secrets as "
129
- "<code>USERBOT_SESSION</code>. See the README.",
 
130
  )
131
  return
132
  if not ub.ready:
133
  await ub.connect()
134
- kept = "stays an admin" if ub.keep_admin else "is made admin only while deleting"
135
- await say(event, f"🤖 <b>Userbot:</b> {esc(ub.describe())}\nIt {kept} (USERBOT_KEEP_ADMIN).")
 
 
 
 
 
136
 
137
  @client.on(
138
  events.NewMessage(incoming=True, func=lambda e: e.is_private and not (e.raw_text or "").startswith("/"))
 
126
  "if it has the “Add new admins” right, and gives the rights back afterwards.\n\n"
127
  "To set it up: on your own computer run <code>python make_userbot_session.py</code> with a "
128
  "<b>separate</b> Telegram account, and put the printed text into the bot's secrets as "
129
+ "<code>USERBOT_SESSION</code>. Then send /start to this bot from that account (new media reaches the "
130
+ "userbot through its chat with the bot - that is what makes a replace quick). See the README.",
131
  )
132
  return
133
  if not ub.ready:
134
  await ub.connect()
135
+ kept = "stays an admin" if ub.keep_admin else "is made admin only while it deletes or edits"
136
+ await say(
137
+ event,
138
+ f"🤖 <b>Userbot:</b> {esc(ub.describe())}\nIt {kept} (USERBOT_KEEP_ADMIN).\n"
139
+ "It must have started this bot (/start from that account): new media for an edit goes through its chat "
140
+ "with the bot, which is what makes a big file as quick as a small one.",
141
+ )
142
 
143
  @client.on(
144
  events.NewMessage(incoming=True, func=lambda e: e.is_private and not (e.raw_text or "").startswith("/"))
app/handlers/posts.py CHANGED
@@ -160,8 +160,8 @@ def register(ctx: Ctx) -> None:
160
  post, ch = await load_post(ctx, st["pid"])
161
  check_lengths(post.text or "", True)
162
 
163
- async def notify() -> None:
164
- await say(event, "⏳ Telegram refused the bot, so the userbot is making the edit - a big file takes a while.")
165
 
166
  post, report = await apply_edit_reported(
167
  ctx, post, ch, replace_media=True, notify=notify, media_kind=kind, media_file_id=pack_file_id(m)
 
160
  post, ch = await load_post(ctx, st["pid"])
161
  check_lengths(post.text or "", True)
162
 
163
+ async def notify(text: str) -> None:
164
+ await say(event, text)
165
 
166
  post, report = await apply_edit_reported(
167
  ctx, post, ch, replace_media=True, notify=notify, media_kind=kind, media_file_id=pack_file_id(m)
app/handlers/replace.py CHANGED
@@ -268,8 +268,8 @@ def register(ctx: Ctx) -> None:
268
  caption, entities, how = message_text(reply, cfg.markup_input) # Telegram's formatting, or HTML / Markdown
269
  check_lengths(caption, True)
270
 
271
- async def notify() -> None:
272
- await say(event, "⏳ Telegram refused the bot, so the userbot is making the edit - a big file takes a while.")
273
 
274
  _, report = await apply_edit_reported(
275
  ctx, post, channel, replace_media=True, notify=notify, media_kind=kind, media_file_id=pack_file_id(reply),
 
268
  caption, entities, how = message_text(reply, cfg.markup_input) # Telegram's formatting, or HTML / Markdown
269
  check_lengths(caption, True)
270
 
271
+ async def notify(text: str) -> None:
272
+ await say(event, text)
273
 
274
  _, report = await apply_edit_reported(
275
  ctx, post, channel, replace_media=True, notify=notify, media_kind=kind, media_file_id=pack_file_id(reply),
app/tgutil.py CHANGED
@@ -296,6 +296,15 @@ def media_info(m) -> tuple:
296
  return kind, (media_ref(m) if kind else None)
297
 
298
 
 
 
 
 
 
 
 
 
 
299
  @dataclass
300
  class MediaFile:
301
  """A photo or file downloaded from Telegram, with what is needed to upload it again as the same kind of post."""
@@ -305,10 +314,13 @@ class MediaFile:
305
  name: str
306
  mime_type: Optional[str] = None
307
  attributes: list = field(default_factory=list) # the file's name, video size and length, GIF mark ... as it had them
 
308
 
309
 
310
  async def fetch_media(client, ref: str, kind: str, folder: str) -> MediaFile:
311
- """Download the photo / file behind the stored media reference `ref` (as `client` sees it) into `folder`."""
 
 
312
  path = await with_media(ref, lambda media: client.download_media(media, file=folder + os.sep))
313
  if not path:
314
  raise ValueError("Telegram gave me nothing to download for that media")
@@ -319,9 +331,24 @@ async def fetch_media(client, ref: str, kind: str, folder: str) -> MediaFile:
319
  name=os.path.basename(str(path)),
320
  mime_type=getattr(doc, "mime_type", None),
321
  attributes=list(getattr(doc, "attributes", None) or []),
 
322
  )
323
 
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  def utf16_len(s: str) -> int:
326
  return len(s.encode("utf-16-le")) // 2
327
 
 
296
  return kind, (media_ref(m) if kind else None)
297
 
298
 
299
+ def media_key(media) -> Optional[tuple]:
300
+ """("photo" | "doc", id) of the photo / file in a message's media; the id is the same on every account."""
301
+ if isinstance(media, types.MessageMediaPhoto) and getattr(media, "photo", None):
302
+ return "photo", media.photo.id
303
+ if isinstance(media, types.MessageMediaDocument) and getattr(media, "document", None):
304
+ return "doc", media.document.id
305
+ return None
306
+
307
+
308
  @dataclass
309
  class MediaFile:
310
  """A photo or file downloaded from Telegram, with what is needed to upload it again as the same kind of post."""
 
314
  name: str
315
  mime_type: Optional[str] = None
316
  attributes: list = field(default_factory=list) # the file's name, video size and length, GIF mark ... as it had them
317
+ thumb_path: Optional[str] = None # the picture Telegram shows for the file (JPEG); None: it has none
318
 
319
 
320
  async def fetch_media(client, ref: str, kind: str, folder: str) -> MediaFile:
321
+ """Download the photo / file behind the stored media reference `ref` (as `client` sees it) into `folder`.
322
+
323
+ A file's thumbnail comes along: without it the file would be uploaded again with a plain file icon."""
324
  path = await with_media(ref, lambda media: client.download_media(media, file=folder + os.sep))
325
  if not path:
326
  raise ValueError("Telegram gave me nothing to download for that media")
 
331
  name=os.path.basename(str(path)),
332
  mime_type=getattr(doc, "mime_type", None),
333
  attributes=list(getattr(doc, "attributes", None) or []),
334
+ thumb_path=await fetch_thumb(client, ref, folder) if doc is not None else None,
335
  )
336
 
337
 
338
+ async def fetch_thumb(client, ref: str, folder: str) -> Optional[str]:
339
+ """The thumbnail of the file behind `ref` (the largest one Telegram has) as a JPEG in `folder`, or None.
340
+
341
+ Best effort: a file that goes out without its thumbnail is better than an edit that fails."""
342
+ where = os.path.join(folder, "thumb")
343
+ try:
344
+ os.makedirs(where, exist_ok=True)
345
+ path = await with_media(ref, lambda media: client.download_media(media, file=where + os.sep, thumb=-1))
346
+ except (errors.RPCError, OSError, ValueError, TypeError) as e:
347
+ log.info("could not download the thumbnail: %s", type(e).__name__)
348
+ return None
349
+ return str(path) if path and os.path.isfile(str(path)) else None
350
+
351
+
352
  def utf16_len(s: str) -> int:
353
  return len(s.encode("utf-16-le")) // 2
354
 
app/userbot.py CHANGED
@@ -6,6 +6,10 @@ Editing: Telegram refuses the bot an edit of a post that belongs to another bot
6
  or it was posted through it). The bot always tries first; when Telegram refuses, the userbot makes the edit (text,
7
  caption or a new photo / file). A user account can't set inline buttons, so Telegram may drop them with that edit -
8
  the bot puts them back where it can.
 
 
 
 
9
 
10
  How it gets into the channel:
11
  1. the bot makes a one-time invite link (or reads the channel's own link) and the userbot joins with it
@@ -27,7 +31,7 @@ from typing import Optional
27
  from telethon import TelegramClient, errors, functions, types, utils
28
  from telethon.sessions import StringSession
29
 
30
- from .tgutil import MediaFile, edit_raw, flood_retry, get_rights, peer_of
31
 
32
  log = logging.getLogger(__name__)
33
 
@@ -41,6 +45,32 @@ class UserbotError(Exception):
41
  """Something the owner can fix; the text is meant to be shown."""
42
 
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  def parse_invite_hash(link: str) -> Optional[str]:
45
  m = _INVITE_RX.search(link or "")
46
  return (m.group(1) or m.group(2)) if m else None
@@ -479,32 +509,127 @@ class UserbotEditor(_ChannelJob):
479
  WHAT = "edit other people's posts"
480
  VERB = "edit"
481
 
482
- async def edit(self, message_id: int, *, text: str, entities: list, media: Optional[MediaFile] = None, preview=None) -> None:
483
- """Make the post show `text` (with `entities`) and, when given, the photo / file `media` (uploaded again)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  await self._begin()
485
  client = self.ub.client
486
  try:
487
- input_media = await self._input_media(media) if media is not None else None
 
488
  await flood_retry(
489
- lambda: edit_raw(
490
- client, self.peer, message_id, text=text, entities=entities, preview=preview, media=input_media
491
- )
492
  )
493
  except errors.MessageNotModifiedError:
494
  return # it already looks like that
495
  except errors.RPCError as e:
496
  name = type(e).__name__
497
- raise UserbotError(f"Telegram refused the userbot as well ({name}). {_EDIT_HINTS.get(name) or str(e)[:200]}")
 
 
 
 
 
 
498
 
499
- async def _input_media(self, f: MediaFile):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
500
  client = self.ub.client
501
  handle = await flood_retry(lambda: client.upload_file(f.path, file_name=f.name))
502
  if f.kind == "photo":
503
  return types.InputMediaUploadedPhoto(file=handle)
 
 
 
 
 
 
504
  return types.InputMediaUploadedDocument(
505
  file=handle,
506
  mime_type=f.mime_type or "application/octet-stream",
507
  attributes=list(f.attributes),
 
508
  force_file=True if f.kind == "document" else None, # a file sent "as a file" stays one
509
  )
510
 
 
6
  or it was posted through it). The bot always tries first; when Telegram refuses, the userbot makes the edit (text,
7
  caption or a new photo / file). A user account can't set inline buttons, so Telegram may drop them with that edit -
8
  the bot puts them back where it can.
9
+ A new photo / file goes to the userbot through the userbot's own chat with the bot: the bot sends it there (Telegram
10
+ just points to the file it already has - nothing is uploaded), the userbot finds it in that chat and edits the post
11
+ with that copy, again by reference. So a big file is as quick as a small one and keeps its thumbnail. Only when that
12
+ chat does not work (the userbot account never started the bot) is the file downloaded and uploaded again.
13
 
14
  How it gets into the channel:
15
  1. the bot makes a one-time invite link (or reads the channel's own link) and the userbot joins with it
 
31
  from telethon import TelegramClient, errors, functions, types, utils
32
  from telethon.sessions import StringSession
33
 
34
+ from .tgutil import MediaFile, edit_raw, flood_retry, get_rights, media_from_ref, media_key, peer_of, with_media
35
 
36
  log = logging.getLogger(__name__)
37
 
 
45
  """Something the owner can fix; the text is meant to be shown."""
46
 
47
 
48
+ class UserbotRefused(UserbotError):
49
+ """Telegram refused what the userbot tried; `name` is the Telegram error (e.g. MediaInvalidError)."""
50
+
51
+ def __init__(self, text: str, name: str = ""):
52
+ super().__init__(text)
53
+ self.name = name
54
+
55
+ @property
56
+ def about_media(self) -> bool:
57
+ """The refusal is about the photo / file the userbot used (so trying it another way may help)."""
58
+ return self.name in _MEDIA_REFUSED
59
+
60
+
61
+ # What Telegram answers when the photo / file the userbot points to can't be used for an edit
62
+ _MEDIA_REFUSED = {
63
+ "MediaInvalidError", "MediaEmptyError", "DocumentInvalidError", "PhotoInvalidError",
64
+ "FileReferenceExpiredError", "FileReferenceInvalidError", "FileReferenceEmptyError",
65
+ }
66
+ # What Telegram answers when the bot may not write to the userbot account (it never started the bot, or blocked it)
67
+ _NOT_STARTED = {
68
+ "UserIsBlockedError", "PeerIdInvalidError", "InputUserDeactivatedError", "UserDeactivatedError", "UserIdInvalidError",
69
+ }
70
+ _DM_POLLS = 10 # how often, and how long apart, the userbot looks in its chat with the bot for the file it was sent
71
+ _DM_WAIT = 0.5
72
+
73
+
74
  def parse_invite_hash(link: str) -> Optional[str]:
75
  m = _INVITE_RX.search(link or "")
76
  return (m.group(1) or m.group(2)) if m else None
 
509
  WHAT = "edit other people's posts"
510
  VERB = "edit"
511
 
512
+ def __init__(self, ub: Userbot, ch):
513
+ super().__init__(ub, ch)
514
+ self._handed: list = [] # (the userbot account as the bot addresses it, the bot's id of the message): files put in its chat
515
+ self.dm_problem: Optional[str] = None # why a file could not go through that chat (None: it did, or it was not tried)
516
+
517
+ async def close(self) -> None:
518
+ """Clean up the channel (rights, link) and the files the bot put into the userbot's chat with the bot."""
519
+ handed, self._handed = self._handed, []
520
+ for peer, message_id in handed:
521
+ try:
522
+ await self.ub.bot.delete_messages(peer, [message_id])
523
+ except Exception as e: # noqa: BLE001 - at worst a message is left in the helper account's chat
524
+ log.info("could not delete the helper message in the userbot's chat: %s", type(e).__name__)
525
+ await super().close()
526
+
527
+ async def edit(self, message_id: int, *, text: str, entities: list, media=None, preview=None) -> None:
528
+ """Make the post show `text` (with `entities`) and, when given, `media`: what media_by_reference() returned (the
529
+ file does not move at all) or a MediaFile (uploaded again)."""
530
  await self._begin()
531
  client = self.ub.client
532
  try:
533
+ if isinstance(media, MediaFile):
534
+ media = await self._upload(media)
535
  await flood_retry(
536
+ lambda: edit_raw(client, self.peer, message_id, text=text, entities=entities, preview=preview, media=media)
 
 
537
  )
538
  except errors.MessageNotModifiedError:
539
  return # it already looks like that
540
  except errors.RPCError as e:
541
  name = type(e).__name__
542
+ raise UserbotRefused(
543
+ f"Telegram refused the userbot as well ({name}). {_EDIT_HINTS.get(name) or str(e)[:200]}", name
544
+ )
545
+
546
+ # ----------------------------------------------------------- a file by way of the userbot's chat with the bot
547
+ async def media_by_reference(self, ref: str):
548
+ """The new photo / file as the userbot's own account holds it, ready to edit the post with - or None.
549
 
550
+ The bot sends it to the userbot's chat with the bot (Telegram reuses the file it already has, nothing is
551
+ downloaded or uploaded) and the userbot picks the message up there. The access data of a file belongs to one
552
+ account, so the bot's own reference would be useless to the userbot: this is how the userbot gets its own.
553
+ None when it does not work; `dm_problem` says why, and the caller uploads the file instead."""
554
+ await self._begin()
555
+ self.dm_problem = None
556
+ try:
557
+ return await self._hand_over(ref)
558
+ except (errors.RPCError, ValueError, TypeError, OSError) as e:
559
+ self.dm_problem = f"it failed with {type(e).__name__}"
560
+ log.info("could not hand the file to the userbot: %s", type(e).__name__, exc_info=True)
561
+ return None
562
+
563
+ async def _hand_over(self, ref: str):
564
+ bot, client = self.ub.bot, self.ub.client
565
+ want = media_key(media_from_ref(ref))
566
+ if want is None:
567
+ raise ValueError("not a photo or a file")
568
+ try: # both ends of the chat first: nothing is sent when one of them can't be found
569
+ user = await self._bot_view_of_userbot()
570
+ except UserbotError:
571
+ self.dm_problem = "the bot cannot find the userbot account"
572
+ return None
573
+ try:
574
+ bot_peer = await self._userbot_view_of_bot()
575
+ except UserbotError as e:
576
+ self.dm_problem = str(e)
577
+ return None
578
+ peer = types.InputPeerUser(user.user_id, user.access_hash)
579
+ try:
580
+ sent = await with_media(ref, lambda media: flood_retry(lambda: bot.send_file(peer, media, silent=True)))
581
+ except errors.RPCError as e:
582
+ name = type(e).__name__
583
+ self.dm_problem = (
584
+ "the userbot account has not started the bot yet (send /start to the bot from that account)"
585
+ if name in _NOT_STARTED
586
+ else f"Telegram would not let the bot send the file to the userbot ({name})"
587
+ )
588
+ return None
589
+ self._handed.append((peer, sent.id)) # deleted again in close()
590
+ for i in range(_DM_POLLS): # the message has to reach the userbot's side first
591
+ if i:
592
+ await asyncio.sleep(_DM_WAIT)
593
+ try:
594
+ msgs = await flood_retry(lambda: client.get_messages(bot_peer, limit=10))
595
+ except errors.RPCError as e:
596
+ self.dm_problem = f"the userbot could not read its chat with the bot ({type(e).__name__})"
597
+ return None
598
+ for m in msgs: # newest first; the same photo / file has the same id on both accounts
599
+ if media_key(getattr(m, "media", None)) == want:
600
+ return utils.get_input_media(m.media)
601
+ self.dm_problem = "the file did not show up in the userbot's chat with the bot"
602
+ return None
603
+
604
+ async def _userbot_view_of_bot(self):
605
+ """The bot as the userbot account addresses it (found by the bot's username)."""
606
+ me = await self.ub.bot.get_me()
607
+ name = getattr(me, "username", None)
608
+ if not name:
609
+ raise UserbotError("the bot has no username, so the userbot cannot find its chat with it")
610
+ try:
611
+ return await self.ub.client.get_input_entity(name)
612
+ except (errors.RPCError, ValueError):
613
+ raise UserbotError("the userbot could not find the bot by its username")
614
+
615
+ # --------------------------------------------------------------------------------- the slow way
616
+ async def _upload(self, f: MediaFile):
617
+ """Upload a downloaded photo / file (with its thumbnail) as the new media of the post."""
618
  client = self.ub.client
619
  handle = await flood_retry(lambda: client.upload_file(f.path, file_name=f.name))
620
  if f.kind == "photo":
621
  return types.InputMediaUploadedPhoto(file=handle)
622
+ thumb = None
623
+ if f.thumb_path:
624
+ try:
625
+ thumb = await flood_retry(lambda: client.upload_file(f.thumb_path))
626
+ except (errors.RPCError, OSError) as e: # the file goes without it rather than not at all
627
+ log.info("could not upload the thumbnail: %s", type(e).__name__)
628
  return types.InputMediaUploadedDocument(
629
  file=handle,
630
  mime_type=f.mime_type or "application/octet-stream",
631
  attributes=list(f.attributes),
632
+ thumb=thumb,
633
  force_file=True if f.kind == "document" else None, # a file sent "as a file" stays one
634
  )
635
 
tests/fakes.py CHANGED
@@ -1,12 +1,13 @@
1
  """Tiny in-memory stand-ins for Telethon, good enough to exercise the engine offline."""
2
  import datetime
 
3
  import os
4
  from types import SimpleNamespace
5
 
6
  from telethon import errors, functions, types
7
  from telethon.tl.custom import Message
8
 
9
- from app.tgutil import make_url_button
10
 
11
 
12
  def make_msg(i, text="", entities=None, markup=None, out=False, media=None, grouped_id=None, reply_to=None, pinned=False):
@@ -81,11 +82,52 @@ def photo_media(pid=11):
81
  return types.MessageMediaPhoto(photo=photo)
82
 
83
 
84
- def video_media(did=21):
85
- doc = types.Document(did, did * 7, b"fr%d" % did, NOW, "video/mp4", 1000, 2, [types.DocumentAttributeVideo(5.0, 320, 240)])
 
 
 
 
86
  return types.MessageMediaDocument(document=doc)
87
 
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  def service_msg(i):
90
  return types.MessageService(id=i, peer_id=types.PeerChannel(1), date=NOW, action=types.MessageActionPinMessage())
91
 
@@ -150,6 +192,7 @@ class FakeChannelClient:
150
  self.user_edit_frees_message = False # after a user account edited it, the bot may edit the post again
151
  self.user_edits = [] # the EditMessageRequests the userbot account made
152
  self._counter = [9000] # ids for photos and uploads; shared
 
153
  self.pinned_order = [] # message ids in the order they were pinned
154
  self._n_invites = 0
155
 
@@ -164,6 +207,7 @@ class FakeChannelClient:
164
  other._photo_data, other._uploads, other._counter = self._photo_data, self._uploads, self._counter
165
  self._media_data.update(other._media_data)
166
  other._media_data = self._media_data
 
167
  return other
168
 
169
  def set_photo(self, data: bytes) -> int:
@@ -191,7 +235,18 @@ class FakeChannelClient:
191
  raise ValueError("this fake only has the bytes of a photo or a file")
192
  return self._media_data.get(key, b"bytes of %s %d" % (key[0].encode(), key[1]))
193
 
194
- async def download_media(self, media, file=None):
 
 
 
 
 
 
 
 
 
 
 
195
  if isinstance(media, types.Photo) and media.id in self._photo_data:
196
  return self._photo_data[media.id]
197
  data = self.media_bytes(media)
@@ -325,6 +380,47 @@ class FakeChannelClient:
325
  return types.InputPeerUser(u.id, u.access_hash)
326
  raise ValueError(f"Could not find the input entity for {peer!r}")
327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  # ------------------------------------------------------------------------- message requests
329
  def _do_GetMessagesRequest(self, req):
330
  """channels.getMessages as Telegram answers it: the posts as they are now, MessageEmpty for missing ones.
@@ -564,6 +660,7 @@ class FakeUserClient:
564
  self.chan = chan
565
  self.me = types.User(id=user_id, access_hash=555, first_name=first_name, username=username, bot=bot or None)
566
  chan.users[user_id] = self.me
 
567
  self.authorized = authorized
568
  self.connected = False
569
  self.known_channel = False
@@ -588,8 +685,26 @@ class FakeUserClient:
588
  return types.InputPeerChannel(peer.channel_id, USER_HASH)
589
  if isinstance(peer, str) and self.chan.username and peer.lstrip("@").lower() == self.chan.username.lower():
590
  return types.InputPeerChannel(1, USER_HASH)
 
 
 
591
  raise ValueError(f"Could not find the input entity for {peer!r}")
592
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
593
  async def iter_dialogs(self):
594
  if self.known_channel and self.me.id in self.chan.members:
595
  yield SimpleNamespace(entity=self.chan._channel_obj())
@@ -619,10 +734,27 @@ class FakeUserClient:
619
  return types.MessageMediaPhoto(photo=photo)
620
  if isinstance(inp, types.InputMediaUploadedDocument):
621
  chan._media_data[("doc", n)] = data
622
- doc = types.Document(n, n * 7, b"up%d" % n, NOW, inp.mime_type, len(data), 2, list(inp.attributes))
 
 
 
 
623
  return types.MessageMediaDocument(document=doc)
624
  raise rpc("MediaInvalidError")
625
 
 
 
 
 
 
 
 
 
 
 
 
 
 
626
  def _edit(self, req):
627
  """messages.editMessage by a user account: it needs the "Edit messages of others" right and can't set a keyboard."""
628
  chan, uid = self.chan, self.me.id
@@ -634,7 +766,12 @@ class FakeUserClient:
634
  m = chan.msgs.get(req.id)
635
  if m is None:
636
  raise rpc("MessageIdInvalidError")
637
- media = self._uploaded_media(req.media) if req.media is not None else None
 
 
 
 
 
638
  text = m.message if req.message is None else req.message
639
  ents = list(m.entities or []) if req.message is None else list(req.entities or [])
640
  if media is None and text == m.message and [_bytes(e) for e in ents] == [_bytes(e) for e in (m.entities or [])]:
 
1
  """Tiny in-memory stand-ins for Telethon, good enough to exercise the engine offline."""
2
  import datetime
3
+ import itertools
4
  import os
5
  from types import SimpleNamespace
6
 
7
  from telethon import errors, functions, types
8
  from telethon.tl.custom import Message
9
 
10
+ from app.tgutil import make_url_button, media_key
11
 
12
 
13
  def make_msg(i, text="", entities=None, markup=None, out=False, media=None, grouped_id=None, reply_to=None, pinned=False):
 
82
  return types.MessageMediaPhoto(photo=photo)
83
 
84
 
85
+ def video_media(did=21, thumb=False):
86
+ """A video; `thumb`: Telegram shows a picture for it (its bytes: b"THUMB OF <id>" unless the test sets `_media_data`)."""
87
+ thumbs = [types.PhotoStrippedSize("i", b"\x01\x28\x28"), types.PhotoSize("m", 320, 180, 1234)] if thumb else None
88
+ doc = types.Document(
89
+ did, did * 7, b"fr%d" % did, NOW, "video/mp4", 1000, 2, [types.DocumentAttributeVideo(5.0, 320, 240)], thumbs=thumbs
90
+ )
91
  return types.MessageMediaDocument(document=doc)
92
 
93
 
94
+ def as_the_userbot_holds_it(media):
95
+ """The same photo / file with the access data of ANOTHER account: its id is the same everywhere, its access_hash and
96
+ file_reference belong to one account (so one account's copy is refused when another presents it)."""
97
+ if isinstance(media, types.MessageMediaPhoto):
98
+ p = media.photo
99
+ return types.MessageMediaPhoto(
100
+ photo=types.Photo(p.id, p.access_hash + 1000, b"ub-" + p.file_reference, p.date, p.sizes, p.dc_id)
101
+ )
102
+ d = media.document
103
+ return types.MessageMediaDocument(
104
+ document=types.Document(
105
+ d.id, d.access_hash + 1000, b"ub-" + d.file_reference, d.date, d.mime_type, d.size, d.dc_id, d.attributes,
106
+ thumbs=d.thumbs,
107
+ )
108
+ )
109
+
110
+
111
+ class FakeDM:
112
+ """The chat between the bot and the userbot account: what the bot put there and what the account holds of it."""
113
+
114
+ def __init__(self):
115
+ self.bot_user = types.User(id=9100, access_hash=31337, first_name="Post Bot", username="the_post_bot", bot=True)
116
+ self.userbot_id = None # the userbot account (set when its FakeUserClient is made) ...
117
+ self.userbot_user = None # ... and its types.User
118
+ self.msgs = [] # the chat as the userbot sees it, oldest first
119
+ self.sent = [] # (the bot's id of the message, its kwargs) for everything the bot sent there
120
+ self.bot_ids = {} # the bot's id of a message -> the userbot's message
121
+ self.deleted = [] # the bot's ids of the messages it deleted
122
+ self.error = None # raised when the bot sends there (the account never started the bot, or blocked it)
123
+ self.read_error = None # raised when the userbot reads the chat
124
+ self.reference_error = None # raised when the userbot edits a post with a file it holds (not with an upload)
125
+ self.late = 0 # the next N reads find nothing: the message has not reached the userbot yet
126
+ self.known = {} # ("photo" | "doc", id) -> (access_hash, file_reference) the userbot account holds
127
+ self._ids = itertools.count(500) # the userbot's message ids
128
+ self._bot_ids = itertools.count(1000) # the bot's message ids
129
+
130
+
131
  def service_msg(i):
132
  return types.MessageService(id=i, peer_id=types.PeerChannel(1), date=NOW, action=types.MessageActionPinMessage())
133
 
 
192
  self.user_edit_frees_message = False # after a user account edited it, the bot may edit the post again
193
  self.user_edits = [] # the EditMessageRequests the userbot account made
194
  self._counter = [9000] # ids for photos and uploads; shared
195
+ self._dm = FakeDM() # the chat between the bot and the userbot account; shared
196
  self.pinned_order = [] # message ids in the order they were pinned
197
  self._n_invites = 0
198
 
 
207
  other._photo_data, other._uploads, other._counter = self._photo_data, self._uploads, self._counter
208
  self._media_data.update(other._media_data)
209
  other._media_data = self._media_data
210
+ other._dm = self._dm
211
  return other
212
 
213
  def set_photo(self, data: bytes) -> int:
 
235
  raise ValueError("this fake only has the bytes of a photo or a file")
236
  return self._media_data.get(key, b"bytes of %s %d" % (key[0].encode(), key[1]))
237
 
238
+ async def download_media(self, media, file=None, thumb=None):
239
+ if thumb is not None: # the thumbnail of a file: None when Telegram has none
240
+ doc = getattr(media, "document", None)
241
+ if not isinstance(doc, types.Document) or not doc.thumbs:
242
+ return None
243
+ data = self._media_data.get(("thumb", doc.id), b"THUMB OF %d" % doc.id)
244
+ if file is None or file is bytes:
245
+ return data
246
+ path = str(file) + f"photo_{doc.id}.jpg" if str(file).endswith(os.sep) else str(file)
247
+ with open(path, "wb") as f:
248
+ f.write(data)
249
+ return path
250
  if isinstance(media, types.Photo) and media.id in self._photo_data:
251
  return self._photo_data[media.id]
252
  data = self.media_bytes(media)
 
380
  return types.InputPeerUser(u.id, u.access_hash)
381
  raise ValueError(f"Could not find the input entity for {peer!r}")
382
 
383
+ # ------------------------------------------------------ the bot and its chat with the userbot account
384
+ async def get_me(self):
385
+ return self._dm.bot_user
386
+
387
+ def _is_dm(self, peer):
388
+ """Is `peer` the userbot account (the only user this simulation sends files to)?"""
389
+ return isinstance(peer, types.InputPeerUser) and peer.user_id == self._dm.userbot_id
390
+
391
+ async def send_file(self, peer, file, **kw):
392
+ """The bot sends a photo / file to the userbot account: Telegram reuses the file, the account gets its own copy."""
393
+ dm = self._dm
394
+ if not self._is_dm(peer):
395
+ raise NotImplementedError("this fake only sends files to the userbot account")
396
+ if dm.error is not None:
397
+ raise dm.error
398
+ if peer.access_hash != dm.userbot_user.access_hash: # the bot has to know the account (its access hash)
399
+ raise rpc("PeerIdInvalidError")
400
+ key = media_key(file)
401
+ if key is None:
402
+ raise rpc("MediaInvalidError")
403
+ mine = as_the_userbot_holds_it(file)
404
+ inner = mine.photo if key[0] == "photo" else mine.document
405
+ dm.known[key] = (inner.access_hash, inner.file_reference)
406
+ self._media[key] = file # the file as the bot holds it: what the channel shows after an edit by reference
407
+ msg = Message(id=next(dm._ids), peer_id=types.PeerUser(dm.bot_user.id), date=NOW, message="", out=False, media=mine)
408
+ dm.msgs.append(msg)
409
+ bot_id = next(dm._bot_ids)
410
+ dm.bot_ids[bot_id] = msg
411
+ dm.sent.append((bot_id, kw))
412
+ return SimpleNamespace(id=bot_id)
413
+
414
+ async def delete_messages(self, peer, ids, **kw):
415
+ dm = self._dm
416
+ if not self._is_dm(peer):
417
+ raise NotImplementedError("this fake only deletes in the chat with the userbot account")
418
+ for i in ids:
419
+ dm.deleted.append(i)
420
+ msg = dm.bot_ids.pop(i, None)
421
+ dm.msgs = [m for m in dm.msgs if m is not msg] # (identity: Telethon's Message has no usable ==)
422
+ return [SimpleNamespace(pts_count=len(ids))]
423
+
424
  # ------------------------------------------------------------------------- message requests
425
  def _do_GetMessagesRequest(self, req):
426
  """channels.getMessages as Telegram answers it: the posts as they are now, MessageEmpty for missing ones.
 
660
  self.chan = chan
661
  self.me = types.User(id=user_id, access_hash=555, first_name=first_name, username=username, bot=bot or None)
662
  chan.users[user_id] = self.me
663
+ chan._dm.userbot_id, chan._dm.userbot_user = user_id, self.me
664
  self.authorized = authorized
665
  self.connected = False
666
  self.known_channel = False
 
685
  return types.InputPeerChannel(peer.channel_id, USER_HASH)
686
  if isinstance(peer, str) and self.chan.username and peer.lstrip("@").lower() == self.chan.username.lower():
687
  return types.InputPeerChannel(1, USER_HASH)
688
+ bot = self.chan._dm.bot_user
689
+ if isinstance(peer, str) and peer.lstrip("@").lower() == bot.username.lower():
690
+ return types.InputPeerUser(bot.id, bot.access_hash)
691
  raise ValueError(f"Could not find the input entity for {peer!r}")
692
 
693
+ async def get_messages(self, peer, limit=None, **kw):
694
+ """The newest messages of this account's chat with the bot (newest first, like Telethon)."""
695
+ dm, bot = self.chan._dm, self.chan._dm.bot_user
696
+ err = self.chan.fail.get("user:GetHistoryRequest")
697
+ if err is not None:
698
+ raise err
699
+ if not (isinstance(peer, types.InputPeerUser) and peer.user_id == bot.id and peer.access_hash == bot.access_hash):
700
+ raise ValueError(f"Could not find the input entity for {peer!r}")
701
+ if dm.read_error is not None:
702
+ raise dm.read_error
703
+ if dm.late > 0:
704
+ dm.late -= 1
705
+ return []
706
+ return list(reversed(dm.msgs))[: limit or 1]
707
+
708
  async def iter_dialogs(self):
709
  if self.known_channel and self.me.id in self.chan.members:
710
  yield SimpleNamespace(entity=self.chan._channel_obj())
 
734
  return types.MessageMediaPhoto(photo=photo)
735
  if isinstance(inp, types.InputMediaUploadedDocument):
736
  chan._media_data[("doc", n)] = data
737
+ thumbs = None
738
+ if inp.thumb is not None: # an uploaded picture becomes the file's thumbnail
739
+ chan._media_data[("thumb", n)] = chan._uploads[inp.thumb.id]
740
+ thumbs = [types.PhotoSize("m", 320, 180, len(chan._uploads[inp.thumb.id]))]
741
+ doc = types.Document(n, n * 7, b"up%d" % n, NOW, inp.mime_type, len(data), 2, list(inp.attributes), thumbs=thumbs)
742
  return types.MessageMediaDocument(document=doc)
743
  raise rpc("MediaInvalidError")
744
 
745
+ def _by_reference(self, inp):
746
+ """An InputMediaPhoto / InputMediaDocument: only a photo / file this account holds, with ITS OWN access data."""
747
+ chan = self.chan
748
+ if chan._dm.reference_error is not None:
749
+ raise chan._dm.reference_error
750
+ key = ("photo", inp.id.id) if isinstance(inp, types.InputMediaPhoto) else ("doc", inp.id.id)
751
+ mine = chan._dm.known.get(key)
752
+ if mine is None:
753
+ raise rpc("MediaEmptyError") # this account has never seen that file
754
+ if (inp.id.access_hash, inp.id.file_reference) != mine:
755
+ raise rpc("FileReferenceExpiredError") # somebody else's access data
756
+ return chan._media[key] # the file as the channel shows it (as the bot sees it)
757
+
758
  def _edit(self, req):
759
  """messages.editMessage by a user account: it needs the "Edit messages of others" right and can't set a keyboard."""
760
  chan, uid = self.chan, self.me.id
 
766
  m = chan.msgs.get(req.id)
767
  if m is None:
768
  raise rpc("MessageIdInvalidError")
769
+ if req.media is None:
770
+ media = None
771
+ elif isinstance(req.media, (types.InputMediaPhoto, types.InputMediaDocument)):
772
+ media = self._by_reference(req.media)
773
+ else:
774
+ media = self._uploaded_media(req.media)
775
  text = m.message if req.message is None else req.message
776
  ents = list(m.entities or []) if req.message is None else list(req.entities or [])
777
  if media is None and text == m.message and [_bytes(e) for e in ents] == [_bytes(e) for e in (m.entities or [])]:
tests/harness.py CHANGED
@@ -131,6 +131,8 @@ class FakeTG(FakeChannelClient):
131
  return m
132
 
133
  async def send_file(self, peer, file, **kw):
 
 
134
  m = SimpleNamespace(id=next(self.ids))
135
  self.sent.append(("file", peer, file, kw))
136
  return m
@@ -146,6 +148,8 @@ class FakeTG(FakeChannelClient):
146
  raise rpc("MessageIdInvalidError")
147
 
148
  async def delete_messages(self, peer, ids, **kw):
 
 
149
  self.deleted.append(list(ids))
150
  return [SimpleNamespace(pts_count=len(ids))]
151
 
 
131
  return m
132
 
133
  async def send_file(self, peer, file, **kw):
134
+ if self._is_dm(peer): # to the userbot account: the chat simulation
135
+ return await super().send_file(peer, file, **kw)
136
  m = SimpleNamespace(id=next(self.ids))
137
  self.sent.append(("file", peer, file, kw))
138
  return m
 
148
  raise rpc("MessageIdInvalidError")
149
 
150
  async def delete_messages(self, peer, ids, **kw):
151
+ if self._is_dm(peer):
152
+ return await super().delete_messages(peer, ids, **kw)
153
  self.deleted.append(list(ids))
154
  return [SimpleNamespace(pts_count=len(ids))]
155
 
tests/test_replace_userbot.py CHANGED
@@ -4,11 +4,12 @@ import asyncio
4
  import pytest
5
  from telethon import types
6
 
 
7
  from app.common import UserError, apply_edit_reported
8
  from app.tgutil import media_info, pack_file_id
9
  from app.userbot import Userbot
10
 
11
- from .fakes import FakeChannelClient, FakeUserClient, make_msg, markup, photo_media, rpc, url_btn
12
  from .harness import App, norm
13
 
14
  CID, MID = 1723201445, 9084
@@ -39,12 +40,25 @@ def replacement(caption="new caption", entities=None):
39
  return make_msg(1, caption, entities, media=photo_media(20))
40
 
41
 
 
 
 
 
42
  def said(app):
43
  return [norm(t) for kind, t, _ in app.out.log if kind == "say"]
44
 
45
 
46
- async def replace(app, **kw):
47
- await app.text(f"/replace https://t.me/c/{CID}/{MID}", reply=replacement(**kw))
 
 
 
 
 
 
 
 
 
48
 
49
 
50
  async def the_post(app):
@@ -57,17 +71,20 @@ def test_when_telegram_refuses_the_bot_the_userbot_replaces_media_and_caption(tm
57
  app, chan, user = await make_app(tmp_path)
58
  await replace(app)
59
  m = chan.msgs[MID]
60
- assert m.message == "new caption" and m.media.photo.id != 10 and m.media.photo.id != 20
61
- assert chan._media_data[("photo", m.media.photo.id)] == b"NEW PHOTO" # the bot downloaded it, the userbot uploaded it
62
- assert len(user.uploads) == 1 and user.uploads[0][2] == len(b"NEW PHOTO")
 
63
  # the post in My posts shows what the channel shows now, and keeps its saved buttons
64
  post = await the_post(app)
65
- assert post.text == "new caption" and post.buttons == BUTTONS
66
- assert post.media_file_id == media_info(m)[1] != pack_file_id(replacement())
67
- # the owner is told what happened, in order
68
- first, last = said(app)[0], said(app)[-1]
69
- assert "Telegram refused the bot, so the userbot is making the edit" in first
 
70
  assert "Replaced the media in Database Channel" in last and "Telegram refused the bot (MessageIdInvalidError)" in last
 
71
  # the helper left things as it found them
72
  assert chan.admins == {} and all(i["revoked"] for i in chan.invites.values()) and not app.ctx.userbot.lock.locked()
73
  await app.db.close()
@@ -119,6 +136,7 @@ def test_the_userbot_is_not_asked_when_the_bot_can_do_it(tmp_path):
119
  app, chan, user = await make_app(tmp_path, foreign=False)
120
  await replace(app)
121
  assert user.requests == [] and not user.connected # nobody else was bothered
 
122
  assert app.tg.edits[-1][1] == MID and "Replaced the media" in said(app)[-1]
123
  assert "userbot" not in said(app)[-1]
124
  await app.db.close()
@@ -213,7 +231,7 @@ def test_a_caption_typed_in_markdown_is_edited_by_the_userbot_when_the_bot_is_re
213
  m = chan.msgs[MID]
214
  assert m.message == "new caption" and [(type(e).__name__, e.offset, e.length) for e in m.entities] == [("MessageEntityBold", 0, 3)]
215
  assert m.media.photo.id == 10 # only the caption changed, the photo stays
216
- assert user.uploads == []
217
  note = norm(app.out.last_text)
218
  assert "Formatting read from Markdown" in note and "the userbot made the edit" in note
219
  saved = await app.db.get_post(post.id)
@@ -223,7 +241,7 @@ def test_a_caption_typed_in_markdown_is_edited_by_the_userbot_when_the_bot_is_re
223
  asyncio.run(run())
224
 
225
 
226
- def test_new_media_sent_to_the_post_panel_is_uploaded_by_the_userbot(tmp_path):
227
  async def run():
228
  app, chan, user = await make_app(tmp_path)
229
  post = await app.db.create_post(
@@ -233,9 +251,8 @@ def test_new_media_sent_to_the_post_panel_is_uploaded_by_the_userbot(tmp_path):
233
  await app.press(f"em:{post.id}")
234
  await app.text("", message=replacement(caption=""))
235
  m = chan.msgs[MID]
236
- assert m.message == "old caption" and m.media.photo.id not in (10, 20)
237
- assert chan._media_data[("photo", m.media.photo.id)] == b"NEW PHOTO"
238
- assert any("Telegram refused the bot, so the userbot is making the edit" in t for t in said(app))
239
  assert "the userbot made the edit" in norm(app.out.last_text)
240
  assert (await app.db.get_post(post.id)).media_file_id == media_info(m)[1]
241
  await app.db.close()
@@ -243,6 +260,179 @@ def test_new_media_sent_to_the_post_panel_is_uploaded_by_the_userbot(tmp_path):
243
  asyncio.run(run())
244
 
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  # ------------------------------------------------------------- Telegram's own formatting, HTML, Markdown
247
  def sent_caption(app):
248
  kw = app.tg.edits[-1][2]
 
4
  import pytest
5
  from telethon import types
6
 
7
+ import app.userbot as userbot_module
8
  from app.common import UserError, apply_edit_reported
9
  from app.tgutil import media_info, pack_file_id
10
  from app.userbot import Userbot
11
 
12
+ from .fakes import FakeChannelClient, FakeUserClient, make_msg, markup, photo_media, rpc, url_btn, video_media
13
  from .harness import App, norm
14
 
15
  CID, MID = 1723201445, 9084
 
40
  return make_msg(1, caption, entities, media=photo_media(20))
41
 
42
 
43
+ def video_replacement(caption="new caption", did=31, thumb=True):
44
+ return make_msg(1, caption, None, media=video_media(did, thumb=thumb))
45
+
46
+
47
  def said(app):
48
  return [norm(t) for kind, t, _ in app.out.log if kind == "say"]
49
 
50
 
51
+ def waiting(app):
52
+ """The "⏳ ..." messages the owner got while the userbot worked."""
53
+ return [t for t in said(app) if t.startswith("⏳")]
54
+
55
+
56
+ def user_edits(user):
57
+ return [r for r in user.requests if type(r).__name__ == "EditMessageRequest"]
58
+
59
+
60
+ async def replace(app, message=None, **kw):
61
+ await app.text(f"/replace https://t.me/c/{CID}/{MID}", reply=message or replacement(**kw))
62
 
63
 
64
  async def the_post(app):
 
71
  app, chan, user = await make_app(tmp_path)
72
  await replace(app)
73
  m = chan.msgs[MID]
74
+ assert m.message == "new caption" and m.media.photo.id == 20 # the very file the owner sent: nothing was copied
75
+ # the file went bot -> the userbot's chat with the bot -> the post: nothing downloaded, nothing uploaded
76
+ assert user.uploads == [] and len(chan._dm.sent) == 1 and len(user_edits(user)) == 1
77
+ assert chan._dm.msgs == [] and len(chan._dm.deleted) == 1 # the helper message is gone again
78
  # the post in My posts shows what the channel shows now, and keeps its saved buttons
79
  post = await the_post(app)
80
+ assert post.text == "new caption" and post.buttons == BUTTONS and post.media_kind == "photo"
81
+ assert post.media_file_id == media_info(m)[1]
82
+ # the owner is told what happened, in order - one waiting message, no "big file" warning
83
+ assert len(waiting(app)) == 1 and "Telegram refused the bot, so the userbot is making the edit" in waiting(app)[0]
84
+ assert "big file" not in waiting(app)[0]
85
+ last = said(app)[-1]
86
  assert "Replaced the media in Database Channel" in last and "Telegram refused the bot (MessageIdInvalidError)" in last
87
+ assert "the new file went through the userbot's chat with the bot, so nothing was uploaded" in last
88
  # the helper left things as it found them
89
  assert chan.admins == {} and all(i["revoked"] for i in chan.invites.values()) and not app.ctx.userbot.lock.locked()
90
  await app.db.close()
 
136
  app, chan, user = await make_app(tmp_path, foreign=False)
137
  await replace(app)
138
  assert user.requests == [] and not user.connected # nobody else was bothered
139
+ assert chan._dm.sent == [] and waiting(app) == []
140
  assert app.tg.edits[-1][1] == MID and "Replaced the media" in said(app)[-1]
141
  assert "userbot" not in said(app)[-1]
142
  await app.db.close()
 
231
  m = chan.msgs[MID]
232
  assert m.message == "new caption" and [(type(e).__name__, e.offset, e.length) for e in m.entities] == [("MessageEntityBold", 0, 3)]
233
  assert m.media.photo.id == 10 # only the caption changed, the photo stays
234
+ assert user.uploads == [] and chan._dm.sent == [] # no file moved, the chat was not used
235
  note = norm(app.out.last_text)
236
  assert "Formatting read from Markdown" in note and "the userbot made the edit" in note
237
  saved = await app.db.get_post(post.id)
 
241
  asyncio.run(run())
242
 
243
 
244
+ def test_new_media_sent_to_the_post_panel_reaches_the_userbot_through_its_chat(tmp_path):
245
  async def run():
246
  app, chan, user = await make_app(tmp_path)
247
  post = await app.db.create_post(
 
251
  await app.press(f"em:{post.id}")
252
  await app.text("", message=replacement(caption=""))
253
  m = chan.msgs[MID]
254
+ assert m.message == "old caption" and m.media.photo.id == 20 and user.uploads == []
255
+ assert len(waiting(app)) == 1 and "Telegram refused the bot, so the userbot is making the edit" in waiting(app)[0]
 
256
  assert "the userbot made the edit" in norm(app.out.last_text)
257
  assert (await app.db.get_post(post.id)).media_file_id == media_info(m)[1]
258
  await app.db.close()
 
260
  asyncio.run(run())
261
 
262
 
263
+ # ------------------------------------------------------------------------------- the thumbnail stays
264
+ def test_a_video_keeps_its_thumbnail_when_it_goes_through_the_chat(tmp_path):
265
+ async def run():
266
+ app, chan, user = await make_app(tmp_path)
267
+ await replace(app, message=video_replacement())
268
+ m = chan.msgs[MID]
269
+ assert m.media.document.id == 31 and m.media.document.thumbs # the same file: its thumbnail comes with it
270
+ assert user.uploads == [] and (await the_post(app)).media_kind == "video"
271
+ await app.db.close()
272
+
273
+ asyncio.run(run())
274
+
275
+
276
+ def test_a_video_keeps_its_thumbnail_when_it_has_to_be_uploaded_again(tmp_path):
277
+ async def run():
278
+ app, chan, user = await make_app(tmp_path)
279
+ chan._dm.error = rpc("UserIsBlockedError") # the chat does not work: the slow way
280
+ chan._media_data[("thumb", 31)] = b"THE THUMBNAIL"
281
+ await replace(app, message=video_replacement())
282
+ doc = chan.msgs[MID].media.document
283
+ assert doc.id != 31 and doc.thumbs # a new upload, but not a bare file icon
284
+ assert chan._media_data[("thumb", doc.id)] == b"THE THUMBNAIL"
285
+ assert [u[2] for u in user.uploads] == [len(b"bytes of doc 31"), len(b"THE THUMBNAIL")] # the file, then its picture
286
+ await app.db.close()
287
+
288
+ asyncio.run(run())
289
+
290
+
291
+ def test_a_file_without_a_thumbnail_is_uploaded_without_one(tmp_path):
292
+ async def run():
293
+ app, chan, user = await make_app(tmp_path)
294
+ chan._dm.error = rpc("UserIsBlockedError")
295
+ await replace(app, message=video_replacement(thumb=False))
296
+ assert chan.msgs[MID].media.document.thumbs is None and len(user.uploads) == 1
297
+ await app.db.close()
298
+
299
+ asyncio.run(run())
300
+
301
+
302
+ # ----------------------------------------------------------------------------- when the chat does not work
303
+ def test_when_the_userbot_never_started_the_bot_the_file_is_downloaded_and_uploaded_again(tmp_path):
304
+ async def run():
305
+ app, chan, user = await make_app(tmp_path)
306
+ chan._dm.error = rpc("UserIsBlockedError")
307
+ await replace(app)
308
+ m = chan.msgs[MID]
309
+ assert m.message == "new caption" and m.media.photo.id not in (10, 20) # a new upload
310
+ assert chan._media_data[("photo", m.media.photo.id)] == b"NEW PHOTO" # the bot downloaded it, the userbot uploaded it
311
+ assert len(user.uploads) == 1 and user.uploads[0][2] == len(b"NEW PHOTO") and chan._dm.sent == []
312
+ # the owner is told, twice while waiting, why it is the slow way and what fixes that
313
+ first, second = waiting(app)
314
+ assert "Telegram refused the bot, so the userbot is making the edit" in first
315
+ assert "downloaded and uploaded again" in second and "a big file takes a while" in second
316
+ assert "has not started the bot" in second and "/start" in second
317
+ last = said(app)[-1]
318
+ assert "Replaced the media" in last and "the file was downloaded and uploaded again, because the userbot account has not started the bot" in last
319
+ post = await the_post(app)
320
+ assert post.media_file_id == media_info(m)[1] and post.text == "new caption"
321
+ assert chan.admins == {} and not app.ctx.userbot.lock.locked()
322
+ await app.db.close()
323
+
324
+ asyncio.run(run())
325
+
326
+
327
+ def test_a_refusal_about_the_userbots_copy_of_the_file_falls_back_to_the_upload(tmp_path):
328
+ async def run():
329
+ app, chan, user = await make_app(tmp_path)
330
+ chan._dm.reference_error = rpc("FileReferenceExpiredError") # Telegram will not take the userbot's copy
331
+ await replace(app)
332
+ m = chan.msgs[MID]
333
+ assert m.message == "new caption" and m.media.photo.id not in (10, 20) and len(user.uploads) == 1
334
+ assert len(user_edits(user)) == 2 # the edit with the copy was refused, the one with the upload was not
335
+ assert chan._dm.msgs == [] and len(chan._dm.deleted) == 1 # the helper message was cleaned up all the same
336
+ second = waiting(app)[1]
337
+ assert "Telegram would not take the userbot's copy of the file (FileReferenceExpiredError)" in second
338
+ assert "Telegram would not take the userbot's copy of the file" in said(app)[-1]
339
+ await app.db.close()
340
+
341
+ asyncio.run(run())
342
+
343
+
344
+ def test_a_refusal_that_is_not_about_the_file_is_not_answered_with_a_long_upload(tmp_path):
345
+ async def run():
346
+ app, chan, user = await make_app(tmp_path)
347
+ chan.fail["user:EditMessageRequest"] = rpc("MessageIdInvalidError") # the post itself can't be edited
348
+ await replace(app)
349
+ text = said(app)[-1]
350
+ assert "The userbot could not do it either" in text and "Telegram refused the userbot as well (MessageIdInvalidError)" in text
351
+ assert user.uploads == [] and len(user_edits(user)) == 1 # trying again with an upload would fail the same way
352
+ assert len(waiting(app)) == 1 and "Nothing was changed" in text
353
+ assert chan._dm.msgs == [] and len(chan._dm.deleted) == 1 and chan.admins == {}
354
+ assert chan.msgs[MID].message == "old caption" and (await the_post(app)).text == "old caption"
355
+ await app.db.close()
356
+
357
+ asyncio.run(run())
358
+
359
+
360
+ def test_when_the_upload_fails_as_well_the_owner_hears_why_and_nothing_changes(tmp_path):
361
+ async def run():
362
+ app, chan, user = await make_app(tmp_path)
363
+ chan._dm.error = rpc("UserIsBlockedError")
364
+ chan.fail["user:EditMessageRequest"] = rpc("MediaInvalidError")
365
+ await replace(app)
366
+ text = said(app)[-1]
367
+ assert "The userbot could not do it either" in text and "MediaInvalidError" in text and "Nothing was changed" in text
368
+ assert chan.msgs[MID].media.photo.id == 10 and (await the_post(app)).text == "old caption"
369
+ assert chan.admins == {} and not app.ctx.userbot.lock.locked()
370
+ await app.db.close()
371
+
372
+ asyncio.run(run())
373
+
374
+
375
+ @pytest.mark.parametrize("edit_error", [None, "MessageIdInvalidError", "FileReferenceExpiredError", "MediaInvalidError"])
376
+ @pytest.mark.parametrize("copy_error", [None, "FileReferenceExpiredError"])
377
+ @pytest.mark.parametrize("send_error", [None, "UserIsBlockedError", "ChatWriteForbiddenError"])
378
+ @pytest.mark.parametrize("reading", ["fine", "late", "unreadable"])
379
+ def test_whatever_goes_wrong_the_channel_is_left_clean_and_the_owner_is_told(
380
+ tmp_path, monkeypatch, edit_error, copy_error, send_error, reading
381
+ ):
382
+ """Every combination of: the chat refusing the bot, the chat being slow or unreadable, Telegram refusing the userbot's
383
+ copy, Telegram refusing every edit. Whatever happens: no helper message, admin right or invite link is left behind, the
384
+ userbot's lock is free, and the post and My posts are either both changed or both untouched - and the owner is told."""
385
+ monkeypatch.setattr(userbot_module, "_DM_WAIT", 0)
386
+ monkeypatch.setattr(userbot_module, "_DM_POLLS", 3)
387
+
388
+ async def run():
389
+ app, chan, user = await make_app(tmp_path)
390
+ if edit_error:
391
+ chan.fail["user:EditMessageRequest"] = rpc(edit_error)
392
+ if copy_error:
393
+ chan._dm.reference_error = rpc(copy_error)
394
+ if send_error:
395
+ chan._dm.error = rpc(send_error)
396
+ if reading == "late":
397
+ chan._dm.late = 99
398
+ elif reading == "unreadable":
399
+ chan._dm.read_error = rpc("PeerIdInvalidError")
400
+ await replace(app)
401
+ last = said(app)[-1]
402
+ assert chan.admins == {} and all(i["revoked"] for i in chan.invites.values()) and not app.ctx.userbot.lock.locked()
403
+ assert chan._dm.msgs == [] # no helper message is left in the userbot's chat
404
+ post, live = await the_post(app), chan.msgs[MID]
405
+ if "Replaced the media" in last: # it worked: the channel and My posts agree
406
+ assert live.message == "new caption" and live.media.photo.id != 10 and post.text == "new caption"
407
+ assert post.media_file_id == media_info(live)[1]
408
+ assert user.uploads or live.media.photo.id == 20 # a copy by reference, or an upload
409
+ else: # it did not: nothing was changed anywhere, and the owner is told why
410
+ assert "Nothing was changed" in last and "The userbot could not do it either" in last
411
+ assert live.message == "old caption" and live.media.photo.id == 10 and post.text == "old caption"
412
+ await app.db.close()
413
+
414
+ asyncio.run(run())
415
+
416
+
417
+ # --------------------------------------------------------------------------------------------- /userbot
418
+ def test_the_userbot_status_says_it_has_to_start_the_bot(tmp_path):
419
+ async def run():
420
+ app, chan, user = await make_app(tmp_path)
421
+ await app.text("/userbot")
422
+ text = said(app)[-1]
423
+ assert "connected as @helper_acct" in text and "made admin only while it deletes or edits" in text
424
+ assert "must have started this bot" in text and "/start" in text
425
+ (tmp_path / "off").mkdir()
426
+ off, _, _ = await make_app(tmp_path / "off", userbot=False)
427
+ await off.text("/userbot")
428
+ text = said(off)[-1]
429
+ assert "not set up" in text and "send /start to this bot from that account" in text
430
+ await app.db.close()
431
+ await off.db.close()
432
+
433
+ asyncio.run(run())
434
+
435
+
436
  # ------------------------------------------------------------- Telegram's own formatting, HTML, Markdown
437
  def sent_caption(app):
438
  kw = app.tg.edits[-1][2]
tests/test_userbot_dm.py ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A new photo / file reaches the userbot through its own chat with the bot: no download, no upload, the thumbnail stays.
2
+
3
+ The bot sends the media to the userbot account (Telegram reuses the file it already has), the userbot finds it there and
4
+ edits the post with ITS copy. When that chat does not work, the file is downloaded and uploaded again - with its thumbnail."""
5
+ import os
6
+
7
+ import pytest
8
+ from telethon import TelegramClient, errors, types
9
+ from telethon.sessions import MemorySession
10
+
11
+ import app.userbot as userbot_module
12
+ from app.tgutil import MediaFile, fetch_media, fetch_thumb, media_ref
13
+ from app.userbot import UserbotError, UserbotRefused
14
+
15
+ from .fakes import NOW, make_msg, photo_media, rpc, video_media
16
+ from .test_userbot import FULL, channel, run
17
+ from .test_userbot_edit import ready_editor
18
+
19
+
20
+ @pytest.fixture(autouse=True)
21
+ def quick_polling(monkeypatch):
22
+ monkeypatch.setattr(userbot_module, "_DM_WAIT", 0) # the tests that wait for a message must not wait for real
23
+ monkeypatch.setattr(userbot_module, "_DM_POLLS", 4)
24
+
25
+
26
+ def ref_of(media):
27
+ """The stored reference of a photo / file the owner sent to the bot."""
28
+ return media_ref(make_msg(1, "", media=media))
29
+
30
+
31
+ # ------------------------------------------------------------------------------------------- the quick way
32
+ def test_a_file_goes_through_the_userbots_chat_and_the_post_shows_that_same_file():
33
+ async def go():
34
+ bot = channel()
35
+ bot.msgs[5].media = photo_media(10)
36
+ new = video_media(31, thumb=True)
37
+ ub, made, e = await ready_editor(bot)
38
+ got = await e.media_by_reference(ref_of(new))
39
+ assert isinstance(got, types.InputMediaDocument) and got.id.id == 31 and e.dm_problem is None
40
+ # what the userbot presents is ITS copy: the same file, but with the access data of its own account
41
+ assert (got.id.access_hash, got.id.file_reference) == bot._dm.known[("doc", 31)]
42
+ assert (got.id.access_hash, got.id.file_reference) != (new.document.access_hash, new.document.file_reference)
43
+ await e.edit(5, text="cap", entities=[], media=got)
44
+ doc = bot.msgs[5].media.document
45
+ assert doc.id == 31 and doc.thumbs # the very same file, so it keeps its thumbnail
46
+ assert made["client"].uploads == [] and bot.user_edits[-1].media is got and bot.user_edits[-1].message == "cap"
47
+ await e.close()
48
+
49
+ run(go())
50
+
51
+
52
+ def test_a_photo_goes_through_the_chat_too():
53
+ async def go():
54
+ bot = channel()
55
+ ub, made, e = await ready_editor(bot)
56
+ got = await e.media_by_reference(ref_of(photo_media(20)))
57
+ assert isinstance(got, types.InputMediaPhoto) and got.id.id == 20
58
+ await e.edit(5, text="", entities=[], media=got)
59
+ assert bot.msgs[5].media.photo.id == 20 and made["client"].uploads == []
60
+ await e.close()
61
+
62
+ run(go())
63
+
64
+
65
+ def test_the_helper_message_is_sent_quietly_and_deleted_at_the_end():
66
+ async def go():
67
+ bot = channel()
68
+ ub, made, e = await ready_editor(bot)
69
+ await e.edit(5, text="x", entities=[], media=await e.media_by_reference(ref_of(photo_media(20))))
70
+ (bot_id, kw), = bot._dm.sent
71
+ assert kw == {"silent": True} # the account's phone does not ring for it
72
+ assert len(bot._dm.msgs) == 1 # still there while the job runs ...
73
+ await e.close()
74
+ assert bot._dm.deleted == [bot_id] and bot._dm.msgs == [] # ... and gone afterwards
75
+ assert bot.admins == {} and not ub.lock.locked()
76
+
77
+ run(go())
78
+
79
+
80
+ def test_the_userbot_finds_its_file_among_the_other_messages_of_the_chat():
81
+ async def go():
82
+ bot = channel()
83
+ bot._dm.msgs += [make_msg(901, "hello", media=None), make_msg(902, "", media=photo_media(98)), make_msg(903, "", media=video_media(97))]
84
+ ub, made, e = await ready_editor(bot)
85
+ got = await e.media_by_reference(ref_of(video_media(31)))
86
+ assert got.id.id == 31 # picked by the id of the file, not by position
87
+ await e.close()
88
+ assert [m.id for m in bot._dm.msgs] == [901, 902, 903] # the others were not touched
89
+
90
+ run(go())
91
+
92
+
93
+ def test_a_file_that_reaches_the_userbot_late_is_waited_for():
94
+ async def go():
95
+ bot = channel()
96
+ bot._dm.late = 2 # the first two reads find nothing yet
97
+ ub, made, e = await ready_editor(bot)
98
+ got = await e.media_by_reference(ref_of(photo_media(20)))
99
+ assert got is not None and got.id.id == 20 and e.dm_problem is None
100
+ await e.close()
101
+
102
+ run(go())
103
+
104
+
105
+ # ------------------------------------------------------------------------------- when the chat does not work
106
+ def test_a_file_that_never_arrives_is_reported_and_the_helper_message_is_still_cleaned_up():
107
+ async def go():
108
+ bot = channel()
109
+ bot._dm.late = 99
110
+ ub, made, e = await ready_editor(bot)
111
+ assert await e.media_by_reference(ref_of(photo_media(20))) is None
112
+ assert e.dm_problem == "the file did not show up in the userbot's chat with the bot"
113
+ await e.close()
114
+ assert len(bot._dm.deleted) == 1 and bot._dm.msgs == []
115
+
116
+ run(go())
117
+
118
+
119
+ @pytest.mark.parametrize("name", ["UserIsBlockedError", "PeerIdInvalidError", "InputUserDeactivatedError"])
120
+ def test_an_account_that_never_started_the_bot_is_told_what_to_do(name):
121
+ async def go():
122
+ bot = channel()
123
+ bot._dm.error = rpc(name)
124
+ ub, made, e = await ready_editor(bot)
125
+ assert await e.media_by_reference(ref_of(photo_media(20))) is None
126
+ assert "has not started the bot" in e.dm_problem and "/start" in e.dm_problem
127
+ await e.close()
128
+ assert bot._dm.deleted == [] and bot._dm.sent == [] # nothing was sent, so nothing is cleaned up
129
+
130
+ run(go())
131
+
132
+
133
+ def test_any_other_refusal_of_the_send_is_named():
134
+ async def go():
135
+ bot = channel()
136
+ bot._dm.error = rpc("ChatWriteForbiddenError")
137
+ ub, made, e = await ready_editor(bot)
138
+ assert await e.media_by_reference(ref_of(photo_media(20))) is None
139
+ assert e.dm_problem == "Telegram would not let the bot send the file to the userbot (ChatWriteForbiddenError)"
140
+ await e.close()
141
+
142
+ run(go())
143
+
144
+
145
+ def test_a_chat_the_userbot_cannot_read_is_named():
146
+ async def go():
147
+ bot = channel()
148
+ bot._dm.read_error = rpc("PeerIdInvalidError")
149
+ ub, made, e = await ready_editor(bot)
150
+ assert await e.media_by_reference(ref_of(photo_media(20))) is None
151
+ assert e.dm_problem == "the userbot could not read its chat with the bot (PeerIdInvalidError)"
152
+ await e.close()
153
+ assert bot._dm.msgs == [] # the helper message is deleted all the same
154
+
155
+ run(go())
156
+
157
+
158
+ def test_a_bot_without_a_username_cannot_be_found_and_nothing_is_sent():
159
+ async def go():
160
+ bot = channel()
161
+ bot._dm.bot_user.username = None
162
+ ub, made, e = await ready_editor(bot)
163
+ assert await e.media_by_reference(ref_of(photo_media(20))) is None
164
+ assert "no username" in e.dm_problem
165
+ await e.close()
166
+ assert bot._dm.sent == []
167
+
168
+ run(go())
169
+
170
+
171
+ def test_a_bot_the_userbot_cannot_resolve_is_named_and_nothing_is_sent():
172
+ async def go():
173
+ bot = channel()
174
+ ub, made, e = await ready_editor(bot)
175
+
176
+ async def nothing(peer):
177
+ raise ValueError("Cannot find any entity")
178
+
179
+ made["client"].get_input_entity = nothing
180
+ assert await e.media_by_reference(ref_of(photo_media(20))) is None
181
+ assert e.dm_problem == "the userbot could not find the bot by its username"
182
+ await e.close()
183
+ assert bot._dm.sent == []
184
+
185
+ run(go())
186
+
187
+
188
+ def test_a_userbot_account_the_bot_cannot_find_gets_nothing():
189
+ async def go():
190
+ bot = channel()
191
+ bot.participants_visible = False # the bot can't list the members, and has never met the account
192
+ ub, made, e = await ready_editor(bot)
193
+ made["client"]._join()
194
+ bot.admins[777] = types.ChatAdminRights(edit_messages=True) # it may edit already: no promotion, no lookup needed so far
195
+ assert await e.media_by_reference(ref_of(photo_media(20))) is None
196
+ assert e.dm_problem == "the bot cannot find the userbot account"
197
+ await e.close()
198
+ assert bot._dm.sent == []
199
+
200
+ run(go())
201
+
202
+
203
+ def test_the_channel_comes_first_nothing_is_sent_when_the_userbot_cannot_get_in():
204
+ async def go():
205
+ bot = channel(rights=dict(FULL, invite=False)) # no invite link: the userbot can't join
206
+ ub, made, e = await ready_editor(bot)
207
+ with pytest.raises(UserbotError) as exc:
208
+ await e.media_by_reference(ref_of(photo_media(20)))
209
+ assert "“Edit messages of others”" in str(exc.value)
210
+ await e.close()
211
+ assert bot._dm.sent == []
212
+
213
+ run(go())
214
+
215
+
216
+ # ------------------------------------------------------------------------------- when Telegram refuses the edit
217
+ def test_a_refused_edit_is_named_and_the_helper_message_is_still_deleted():
218
+ async def go():
219
+ bot = channel()
220
+ bot.fail["user:EditMessageRequest"] = rpc("MessageIdInvalidError")
221
+ ub, made, e = await ready_editor(bot)
222
+ got = await e.media_by_reference(ref_of(photo_media(20)))
223
+ with pytest.raises(UserbotRefused) as exc:
224
+ await e.edit(5, text="x", entities=[], media=got)
225
+ assert exc.value.name == "MessageIdInvalidError" and not exc.value.about_media
226
+ assert "Telegram refused the userbot as well (MessageIdInvalidError)" in str(exc.value)
227
+ await e.close()
228
+ assert bot._dm.msgs == [] and bot.admins == {} and not ub.lock.locked()
229
+
230
+ run(go())
231
+
232
+
233
+ @pytest.mark.parametrize(
234
+ "name,about_media",
235
+ [
236
+ ("FileReferenceExpiredError", True), ("FileReferenceInvalidError", True), ("MediaEmptyError", True),
237
+ ("MediaInvalidError", True), ("DocumentInvalidError", True), ("PhotoInvalidError", True),
238
+ ("MessageIdInvalidError", False), ("ChatAdminRequiredError", False), ("MessageAuthorRequiredError", False),
239
+ ],
240
+ )
241
+ def test_a_refusal_about_the_file_is_told_apart_from_the_others(name, about_media):
242
+ assert UserbotRefused("x", name).about_media is about_media
243
+
244
+
245
+ def test_the_userbots_edit_with_somebody_elses_access_data_is_refused_by_the_simulation():
246
+ """Guards the simulation itself: the bot's own reference must NOT work for the userbot (that is why the chat exists)."""
247
+
248
+ async def go():
249
+ bot = channel()
250
+ new = photo_media(20)
251
+ ub, made, e = await ready_editor(bot)
252
+ await e.media_by_reference(ref_of(new)) # the userbot now holds its own copy ...
253
+ bots_own = types.InputMediaPhoto(id=types.InputPhoto(20, new.photo.access_hash, new.photo.file_reference))
254
+ with pytest.raises(UserbotRefused) as exc: # ... but not the bot's
255
+ await e.edit(5, text="x", entities=[], media=bots_own)
256
+ assert exc.value.name == "FileReferenceExpiredError" and exc.value.about_media
257
+ unknown = types.InputMediaPhoto(id=types.InputPhoto(77, 1, b"x"))
258
+ with pytest.raises(UserbotRefused) as exc:
259
+ await e.edit(5, text="x", entities=[], media=unknown)
260
+ assert exc.value.name == "MediaEmptyError"
261
+ await e.close()
262
+
263
+ run(go())
264
+
265
+
266
+ # ----------------------------------------------------------------------------------- the slow way keeps the thumbnail
267
+ def test_an_uploaded_file_takes_its_thumbnail_along(tmp_path):
268
+ async def go():
269
+ bot = channel()
270
+ video, thumb = tmp_path / "Episode 12.mkv", tmp_path / "photo_1.jpg"
271
+ video.write_bytes(b"VIDEO BYTES")
272
+ thumb.write_bytes(b"THE THUMBNAIL")
273
+ attrs = [types.DocumentAttributeFilename("Episode 12.mkv"), types.DocumentAttributeVideo(5.0, 320, 240)]
274
+ ub, made, e = await ready_editor(bot)
275
+ await e.edit(5, text="", entities=[], media=MediaFile(str(video), "video", "Episode 12.mkv", "video/x-matroska", attrs, str(thumb)))
276
+ await e.close()
277
+ sent = bot.user_edits[-1].media
278
+ assert isinstance(sent, types.InputMediaUploadedDocument) and sent.thumb is not None
279
+ assert made["client"].uploads == [(str(video), "Episode 12.mkv", 11), (str(thumb), None, 13)]
280
+ doc = bot.msgs[5].media.document
281
+ assert doc.thumbs and bot._media_data[("thumb", doc.id)] == b"THE THUMBNAIL"
282
+
283
+ run(go())
284
+
285
+
286
+ def test_a_thumbnail_that_cannot_be_uploaded_does_not_stop_the_edit(tmp_path):
287
+ async def go():
288
+ bot = channel()
289
+ video = tmp_path / "x.mkv"
290
+ video.write_bytes(b"V")
291
+ ub, made, e = await ready_editor(bot)
292
+ await e.edit(5, text="", entities=[], media=MediaFile(str(video), "video", "x.mkv", thumb_path=str(tmp_path / "gone.jpg")))
293
+ await e.close()
294
+ assert bot.user_edits[-1].media.thumb is None and bot.msgs[5].media.document.thumbs is None # it went without
295
+
296
+ run(go())
297
+
298
+
299
+ def test_a_file_without_a_thumbnail_is_uploaded_without_one(tmp_path):
300
+ async def go():
301
+ bot = channel()
302
+ video = tmp_path / "x.mkv"
303
+ video.write_bytes(b"V")
304
+ ub, made, e = await ready_editor(bot)
305
+ await e.edit(5, text="", entities=[], media=MediaFile(str(video), "document", "x.mkv"))
306
+ await e.close()
307
+ assert bot.user_edits[-1].media.thumb is None and len(made["client"].uploads) == 1
308
+
309
+ run(go())
310
+
311
+
312
+ # ---------------------------------------------------------------------------------------- downloading the thumbnail
313
+ def patch_download(monkeypatch, wanted_sizes):
314
+ """Telethon's real download_media runs; only the transfer of the bytes is replaced (it records what was asked for)."""
315
+
316
+ async def fake_download(self, input_location, file, **kw):
317
+ wanted_sizes.append(getattr(input_location, "thumb_size", None))
318
+ with open(file, "wb") as f:
319
+ f.write(b"THUMB" if getattr(input_location, "thumb_size", "") else b"THE BYTES")
320
+ return file
321
+
322
+ monkeypatch.setattr(TelegramClient, "_download_file", fake_download)
323
+
324
+
325
+ def doc_ref(thumbs, attrs=None):
326
+ attrs = attrs or [types.DocumentAttributeFilename("Episode 12.mkv"), types.DocumentAttributeVideo(5.0, 320, 240)]
327
+ doc = types.Document(21, 7, b"fr", NOW, "video/x-matroska", 1000, 2, attrs, thumbs=thumbs)
328
+ return ref_of(types.MessageMediaDocument(document=doc))
329
+
330
+
331
+ def test_the_largest_thumbnail_is_downloaded_with_the_file(tmp_path, monkeypatch):
332
+ asked = []
333
+ patch_download(monkeypatch, asked)
334
+ ref = doc_ref([types.PhotoStrippedSize("i", b"\x01\x28\x28"), types.PhotoSize("s", 100, 56, 900), types.PhotoSize("m", 320, 180, 4000)])
335
+
336
+ async def go():
337
+ client = TelegramClient(MemorySession(), 1, "h")
338
+ got = await fetch_media(client, ref, "video", str(tmp_path))
339
+ assert open(got.path, "rb").read() == b"THE BYTES" and got.name == "Episode 12.mkv"
340
+ assert got.thumb_path and got.thumb_path.endswith(".jpg") and os.path.dirname(got.thumb_path) != os.path.dirname(got.path)
341
+ assert open(got.thumb_path, "rb").read() == b"THUMB"
342
+ assert asked == ["", "m"] # the file itself, then the biggest picture of the file
343
+
344
+ run(go())
345
+
346
+
347
+ def test_a_file_without_a_thumbnail_has_none(tmp_path, monkeypatch):
348
+ asked = []
349
+ patch_download(monkeypatch, asked)
350
+
351
+ async def go():
352
+ client = TelegramClient(MemorySession(), 1, "h")
353
+ got = await fetch_media(client, doc_ref(None), "video", str(tmp_path))
354
+ assert got.thumb_path is None and os.path.isfile(got.path) and asked == [""]
355
+
356
+ run(go())
357
+
358
+
359
+ def test_a_file_that_only_has_the_tiny_inline_thumbnail_still_gets_one(tmp_path, monkeypatch):
360
+ asked = []
361
+ patch_download(monkeypatch, asked)
362
+ stripped = types.PhotoStrippedSize("i", b"\x01\x28\x28\xa2\x8a\x28\x03")
363
+
364
+ async def go():
365
+ client = TelegramClient(MemorySession(), 1, "h")
366
+ got = await fetch_media(client, doc_ref([stripped]), "video", str(tmp_path))
367
+ assert got.thumb_path and os.path.getsize(got.thumb_path) > 0 # Telethon turns the inline bytes into a JPEG itself
368
+ assert asked == [""] # no download needed: the picture was inside the message
369
+
370
+ run(go())
371
+
372
+
373
+ def test_a_thumbnail_that_cannot_be_downloaded_is_left_out_and_the_file_is_kept(tmp_path, monkeypatch):
374
+ async def fake_download(self, input_location, file, **kw):
375
+ if getattr(input_location, "thumb_size", ""):
376
+ raise errors.FileReferenceExpiredError(request=None)
377
+ with open(file, "wb") as f:
378
+ f.write(b"THE BYTES")
379
+ return file
380
+
381
+ monkeypatch.setattr(TelegramClient, "_download_file", fake_download)
382
+ ref = doc_ref([types.PhotoSize("m", 320, 180, 4000)])
383
+
384
+ async def go():
385
+ client = TelegramClient(MemorySession(), 1, "h")
386
+ got = await fetch_media(client, ref, "video", str(tmp_path))
387
+ assert got.thumb_path is None and open(got.path, "rb").read() == b"THE BYTES"
388
+
389
+ run(go())
390
+
391
+
392
+ def test_a_photo_is_fetched_without_a_thumbnail_step(tmp_path, monkeypatch):
393
+ asked = []
394
+ patch_download(monkeypatch, asked)
395
+
396
+ async def go():
397
+ client = TelegramClient(MemorySession(), 1, "h")
398
+ got = await fetch_media(client, ref_of(photo_media(11)), "photo", str(tmp_path))
399
+ assert got.thumb_path is None and len(asked) == 1 # the photo itself, nothing else
400
+
401
+ run(go())
402
+
403
+
404
+ def test_a_client_that_cannot_give_thumbnails_is_not_a_problem(tmp_path):
405
+ class Plain: # a download_media that knows nothing of thumbnails
406
+ async def download_media(self, media, file=None):
407
+ path = str(file) + "x.bin"
408
+ with open(path, "wb") as f:
409
+ f.write(b"B")
410
+ return path
411
+
412
+ async def go():
413
+ got = await fetch_media(Plain(), doc_ref([types.PhotoSize("m", 320, 180, 4000)]), "document", str(tmp_path))
414
+ assert got.thumb_path is None and os.path.isfile(got.path)
415
+ assert await fetch_thumb(Plain(), doc_ref(None), str(tmp_path)) is None
416
+
417
+ run(go())