Spaces:
Paused
Paused
github-actions[bot] commited on
Commit ·
1dfae7a
1
Parent(s): 848377f
Automated sync from GitHub
Browse files- app/common.py +41 -18
- app/handlers/basic.py +9 -3
- app/handlers/posts.py +2 -2
- app/handlers/replace.py +2 -2
- app/tgutil.py +28 -1
- app/userbot.py +134 -9
- tests/fakes.py +143 -6
- tests/harness.py +4 -0
- tests/test_replace_userbot.py +206 -16
- tests/test_userbot_dm.py +417 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 259 |
if notify is not None:
|
| 260 |
-
await notify()
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
media
|
| 273 |
-
|
| 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>.
|
|
|
|
| 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
|
| 135 |
-
await say(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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,
|
| 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 |
-
|
| 483 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
await self._begin()
|
| 485 |
client = self.ub.client
|
| 486 |
try:
|
| 487 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
|
| 499 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 61 |
-
|
| 62 |
-
assert
|
|
|
|
| 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]
|
| 67 |
-
# the owner is told what happened, in order
|
| 68 |
-
|
| 69 |
-
assert "
|
|
|
|
| 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
|
| 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
|
| 237 |
-
assert
|
| 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())
|