github-actions[bot] commited on
Commit
6c64e94
Β·
1 Parent(s): a670808

Automated sync from GitHub

Browse files
Files changed (4) hide show
  1. .gitattributes +3 -3
  2. bot/dbflow.py +447 -16
  3. tests/test_dbflow.py +417 -6
  4. tests/tgfakes.py +3 -0
.gitattributes CHANGED
@@ -1,8 +1,8 @@
1
- *.otf filter=lfs diff=lfs merge=lfs -text
2
- *.webp filter=lfs diff=lfs merge=lfs -text
3
- *.mp4 filter=lfs diff=lfs merge=lfs -text
4
  *.mp3 filter=lfs diff=lfs merge=lfs -text
5
  *.jpg filter=lfs diff=lfs merge=lfs -text
6
  *.jpeg filter=lfs diff=lfs merge=lfs -text
7
  *.png filter=lfs diff=lfs merge=lfs -text
8
  *.ttf filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
1
  *.mp3 filter=lfs diff=lfs merge=lfs -text
2
  *.jpg filter=lfs diff=lfs merge=lfs -text
3
  *.jpeg filter=lfs diff=lfs merge=lfs -text
4
  *.png filter=lfs diff=lfs merge=lfs -text
5
  *.ttf filter=lfs diff=lfs merge=lfs -text
6
+ *.otf filter=lfs diff=lfs merge=lfs -text
7
+ *.webp filter=lfs diff=lfs merge=lfs -text
8
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
bot/dbflow.py CHANGED
@@ -13,8 +13,20 @@ All of this exists only while DB_CHANNEL and COMMUNICATION_CHAT are both set.
13
 
14
  A format without a Channel is only uploaded: no notice is written and nothing is published.
15
 
16
- Nothing about a running upload is stored: the buttons carry what is needed, and a restart in the middle just leaves the
17
- Processing message as it was.
 
 
 
 
 
 
 
 
 
 
 
 
18
  """
19
  from __future__ import annotations
20
 
@@ -22,6 +34,7 @@ import asyncio
22
  import logging
23
  import re
24
  import secrets
 
25
  from dataclasses import dataclass, field
26
  from typing import Any, Dict, Optional, Set, Tuple
27
 
@@ -34,13 +47,19 @@ from .utils import clip, esc
34
 
35
  log = logging.getLogger(__name__)
36
 
37
- PREFIX = "dbu" # callback data: dbu:y:<chat>:<message>:<channel or 0> (Yes), dbu:n (No)
 
38
  ACK_WAIT = 60.0 # seconds the channel post bot has to say "message received" before it counts as down
39
  RESULT_WAIT = 900.0 # ... and then to say whether the post was published (it waits at least 10 s for the Share URL button)
 
40
  NOTICE_HEAD = "File uploaded to the Database Channel"
 
 
 
41
 
42
- _FAILED = re.compile(r"(?i)publication\s+failed\s*[:\-–]?[ \t]*(.*)")
43
- _PUBLISHED = re.compile(r"(?i)post\s+published\s+successfully")
 
44
  _RECEIVED = re.compile(r"(?i)message\s+received")
45
 
46
 
@@ -61,7 +80,7 @@ def classify(text: Optional[str]) -> Tuple[str, str]:
61
  failed = _FAILED.search(text)
62
  if failed:
63
  return "fail", " ".join(failed.group(1).split())
64
- if _PUBLISHED.search(text):
65
  return "ok", ""
66
  if _RECEIVED.search(text):
67
  return "ack", ""
@@ -81,6 +100,48 @@ def notice_text(*, episode: str, channel: int, file: str, post_id: Optional[int]
81
  return "\n".join(lines)
82
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  def reply_target(event: Any) -> Optional[int]:
85
  """The id of the message an incoming message replies to."""
86
  message = getattr(event, "message", None)
@@ -144,9 +205,84 @@ def silent_text(name: str, seconds: float) -> str:
144
  )
145
 
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  @dataclass
148
  class Flow:
149
- """An upload that waits for the channel post bot's answers."""
150
 
151
  ref: str # in the notice and echoed in every answer: how the answers find their way back
152
  inbox: "asyncio.Queue[Tuple[str, str]]" = field(default_factory=asyncio.Queue) # (ack | ok | fail, the message)
@@ -154,13 +290,18 @@ class Flow:
154
 
155
 
156
  class DbFlow:
157
- def __init__(self, client: Any, cfg: Config, *, ack_wait: float = ACK_WAIT, result_wait: float = RESULT_WAIT):
 
 
158
  self.client = client
159
  self.cfg = cfg
160
  self.ack_wait = ack_wait
161
  self.result_wait = result_wait
 
 
 
162
  self.flows: Dict[str, Flow] = {}
163
- self._busy: Set[Tuple[int, int]] = set() # (chat, offer message) of the uploads under way: a second tap does nothing
164
  self._tasks: Set["asyncio.Future[Any]"] = set()
165
 
166
  # ------------------------------------------------------------------ the offer
@@ -170,7 +311,7 @@ class DbFlow:
170
  if not isinstance(sent_id, int):
171
  return # nothing to point at
172
  channel = (job.fmt or {}).get("channel")
173
- rows = [[("βœ… Yes", f"{PREFIX}:y:{target}:{sent_id}:{channel or 0}"), ("❌ No", f"{PREFIX}:n")]]
174
  await self.client.send_message(
175
  job.chat_id, offer_text(job.new_name, episode_of(job.new_name), channel), buttons=panels.to_buttons(rows),
176
  parse_mode="html", link_preview=False, reply_to=sent_id if target == job.chat_id else job.msg_id,
@@ -178,6 +319,12 @@ class DbFlow:
178
 
179
  async def on_callback(self, event: Any, parts: list) -> None:
180
  action = parts[1] if len(parts) > 1 else ""
 
 
 
 
 
 
181
  if action == "n":
182
  await event.answer()
183
  await event.edit("πŸ‘ Okay, it is not uploaded to the Database Channel.", buttons=None, parse_mode="html")
@@ -266,16 +413,22 @@ class DbFlow:
266
  flow.notice_id = _id_of(note) or 0
267
 
268
  # ---- 3. what the channel post bot says
269
- verdict, reply = await self._answers(flow, chat_id, msg_id, name, uploaded)
 
 
 
 
270
  if verdict == "ok":
271
  text = published_text(name, reply, channel)
 
 
272
  elif verdict == "fail":
273
  text = failed_text(name, classify(reply)[1])
274
  elif verdict == "down":
275
  text = down_text(name, self.ack_wait)
276
  else:
277
  text = silent_text(name, self.result_wait)
278
- await self._show(chat_id, msg_id, text)
279
  except asyncio.CancelledError:
280
  raise
281
  except Exception as exc: # noqa: BLE001
@@ -300,9 +453,10 @@ class DbFlow:
300
  formatting_entities=getattr(msg, "entities", None) or None, parse_mode=None,
301
  )
302
 
303
- async def _answers(self, flow: Flow, chat_id: int, msg_id: int, name: str, uploaded: str) -> Tuple[str, str]:
304
  """Wait for the channel post bot: ``("ok" | "fail", its message)``, ``("down", "")`` when it never acknowledged the notice,
305
- ``("silent", "")`` when it acknowledged it but never said how it went."""
 
306
  loop = asyncio.get_running_loop()
307
  acked = False
308
  deadline = loop.time() + self.ack_wait
@@ -315,8 +469,8 @@ class DbFlow:
315
  return kind, text
316
  if not acked:
317
  acked = True
318
- deadline = loop.time() + self.result_wait
319
- await self._show(chat_id, msg_id, processing_text(name, uploaded, "πŸ“₯ The channel post bot got it and is publishing…"))
320
 
321
  async def _show(self, chat_id: int, msg_id: int, text: str, rows: Optional[list] = None) -> None:
322
  try:
@@ -328,6 +482,283 @@ class DbFlow:
328
  except Exception as exc: # noqa: BLE001
329
  log.warning("could not update the upload message: %s: %s", type(exc).__name__, exc)
330
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  # ------------------------------------------------- the channel post bot's answers
332
  async def on_chat_message(self, event: Any) -> None:
333
  """A message of the channel post bot in the communication chat."""
 
13
 
14
  A format without a Channel is only uploaded: no notice is written and nothing is published.
15
 
16
+ No to the offer asks "upload it and get the Share URL?": Yes copies the episode into the Database Channel, waits for the file
17
+ store's Share URL button on the copy, and shows its link in monospace. When this bot cannot see the button, it asks the channel
18
+ post bot ("Get the share URL" in the communication chat) whose userbot can. Nothing is published.
19
+
20
+ Deleting what was published: the message that says "Post published successfully" has a "πŸ—‘ Delete post" button.
21
+ 5. It asks "Are you sure you want to delete this post?" in a message of its own (Yes / No).
22
+ 6. Yes: "Delete the published post" is written in the communication chat, with the channel and the id of the post (the channel
23
+ post bot reported both). The channel post bot deletes the post in its channel and in My posts and answers "post deleted
24
+ successfully" or "deletion failed: <reason>" (the same Ref / reply conventions as above).
25
+ 7. Only when that answer is "deleted" is the episode's copy in the Database Channel deleted here. Any other outcome leaves
26
+ everything as it was and offers "Try again" - asking again is harmless, a post that is already gone counts as deleted.
27
+
28
+ Nothing about a running upload or deletion is stored: the buttons carry what is needed, and a restart in the middle just leaves
29
+ the Processing message as it was.
30
  """
31
  from __future__ import annotations
32
 
 
34
  import logging
35
  import re
36
  import secrets
37
+ from urllib.parse import unquote
38
  from dataclasses import dataclass, field
39
  from typing import Any, Dict, Optional, Set, Tuple
40
 
 
47
 
48
  log = logging.getLogger(__name__)
49
 
50
+ PREFIX = "dbu" # callback data: dbu:y:<chat>:<message>:<channel or 0> (Yes), dbu:n (No);
51
+ # dbu:d:<copy>:<channel>:<post> (πŸ—‘ Delete post), dbu:dy:<copy>:<channel>:<post>:<that message> (Yes), dbu:dn (No)
52
  ACK_WAIT = 60.0 # seconds the channel post bot has to say "message received" before it counts as down
53
  RESULT_WAIT = 900.0 # ... and then to say whether the post was published (it waits at least 10 s for the Share URL button)
54
+ DELETE_WAIT = 300.0 # ... or whether the post was deleted (it may be busy making another post first)
55
  NOTICE_HEAD = "File uploaded to the Database Channel"
56
+ DELETE_HEAD = "Delete the published post"
57
+ LINK_WAIT = 15.0
58
+ CALLBACK_MAX = 64 # bytes of callback data Telegram accepts
59
 
60
+ _SHARE_SHEET = re.compile(r"(?i)^https?://(?:t\.me|telegram\.me|telegram\.dog)/share(?:/url)?\?(.*)$")
61
+ _FAILED = re.compile(r"(?i)(?:publication|deletion|share\s+url)\s+failed\s*[:\-–]?[ \t]*(.*)")
62
+ _SUCCESS = re.compile(r"(?i)post\s+(?:published|deleted)\s+successfully|share\s+url\s+ready")
63
  _RECEIVED = re.compile(r"(?i)message\s+received")
64
 
65
 
 
80
  failed = _FAILED.search(text)
81
  if failed:
82
  return "fail", " ".join(failed.group(1).split())
83
+ if _SUCCESS.search(text):
84
  return "ok", ""
85
  if _RECEIVED.search(text):
86
  return "ack", ""
 
100
  return "\n".join(lines)
101
 
102
 
103
+ def delete_notice_text(*, channel: int, message: int, ref: str) -> str:
104
+ """The message for the communication chat that has a published post deleted (channel and post as the channel post bot reported them)."""
105
+ return "\n".join([DELETE_HEAD, f"Channel: {channel}", f"Message: {message}", f"Ref: {ref}"])
106
+
107
+
108
+ def post_message_id(reply: str) -> Optional[int]:
109
+ """The new post as a message of its channel, from "Post published successfully": its "Message:" line, else the last number of its
110
+ "Post:" link (https://t.me/name/57, https://t.me/c/123/57); None when the reply has neither."""
111
+ number = line_of("message", reply)
112
+ if re.fullmatch(r"[0-9]{1,12}", number):
113
+ return int(number)
114
+ found = re.search(r"/([0-9]{1,12})/?$", line_of("post", reply))
115
+ return int(found.group(1)) if found else None
116
+
117
+
118
+ def delete_button(copy_id: int, channel: int, post: int) -> Optional[list]:
119
+ """The πŸ—‘ Delete post button of a published post's message; None when its data would not fit Telegram's limit."""
120
+ data = f"{PREFIX}:d:{copy_id}:{channel}:{post}"
121
+ return [[("πŸ—‘ Delete post", data)]] if len(data.encode()) <= CALLBACK_MAX else None
122
+
123
+
124
+ def link_notice_text(*, post_id: int, ref: str) -> str:
125
+ """Asks the channel post bot (which has the userbot) for the Share URL of a file in the Database Channel; nothing is published."""
126
+ return "\n".join(["Get the share URL", f"Post: {post_id}", f"Ref: {ref}"])
127
+
128
+
129
+ def share_link(msg: Any) -> str:
130
+ """The file link of the "Share URL" button of a message ("" when it has none). A Share URL button usually opens Telegram's share
131
+ sheet (t.me/share/url?url=<file link>): that wrapper is taken off."""
132
+ for row in getattr(getattr(msg, "reply_markup", None), "rows", None) or []:
133
+ for button in getattr(row, "buttons", None) or []:
134
+ url = getattr(button, "url", None) or getattr(getattr(button, "type", None), "url", None) # (older layers: button.url)
135
+ if url and re.search(r"(?i)\bshare\s*url\b", getattr(button, "text", "") or ""):
136
+ m = _SHARE_SHEET.match(url.strip())
137
+ if not m:
138
+ return url.strip()
139
+ inner = re.search(r"(?:^|&)url=(.*?)(?=&text=|$)", m.group(1))
140
+ link = unquote(inner.group(1)) if inner else ""
141
+ return link if re.match(r"(?i)^(https?://|tg://)\S+$", link) else ""
142
+ return ""
143
+
144
+
145
  def reply_target(event: Any) -> Optional[int]:
146
  """The id of the message an incoming message replies to."""
147
  message = getattr(event, "message", None)
 
205
  )
206
 
207
 
208
+ def link_question_text() -> str:
209
+ return (
210
+ "πŸ”— <b>Upload it to the Database Channel and get the Share URL?</b>\n"
211
+ "You get the link here. Nothing is published: the channel post bot makes no post in the channel of the format."
212
+ )
213
+
214
+
215
+ def link_text(name: str, url: str) -> str:
216
+ return "\n".join(["βœ… <b>Uploaded to the Database Channel</b>", _name(name), "πŸ”— Share URL:", f"<code>{esc(url)}</code>"])
217
+
218
+
219
+ def confirm_text(channel: int) -> str:
220
+ return (
221
+ "πŸ—‘ <b>Are you sure you want to delete this post?</b>\n"
222
+ f"It is deleted from the channel <code>{channel}</code> and from My posts of the channel post bot, "
223
+ "and its file from the Database Channel. This cannot be undone."
224
+ )
225
+
226
+
227
+ def deleting_text(*steps: str) -> str:
228
+ return "\n".join(line for line in ["⏳ <b>Deleting the post…</b>", *steps] if line)
229
+
230
+
231
+ def deleted_text(reply: str, copy: bool) -> str:
232
+ """The post is gone: what the channel post bot reported (its channel and notes), and the copy in the Database Channel."""
233
+ lines = ["πŸ—‘ <b>Post deleted</b>"]
234
+ where = line_of("channel", reply)
235
+ if where:
236
+ lines.append(f"πŸ“£ {esc(clip(where, 80))}")
237
+ lines.append("βœ… Deleted from the channel and from My posts")
238
+ lines += [esc(clip(line.strip(), 200)) for line in (reply or "").splitlines() if line.strip().startswith("ℹ️")]
239
+ lines.append("βœ… Deleted from the Database Channel" if copy else "ℹ️ There was no copy in the Database Channel to delete.")
240
+ return "\n".join(lines)
241
+
242
+
243
+ def delete_failed_text(reason: str) -> str:
244
+ return "\n".join(
245
+ [
246
+ "❌ <b>The post was not deleted</b>",
247
+ esc(clip(reason or "No reason was given.", 600)),
248
+ "The file in the Database Channel was not touched.",
249
+ ]
250
+ )
251
+
252
+
253
+ def delete_down_text(seconds: float) -> str:
254
+ return "\n".join(
255
+ [
256
+ "⚠️ <b>The channel post bot is down or not responding</b>",
257
+ f"It did not answer in the communication chat within {int(seconds)} seconds, so the post is not deleted. "
258
+ "The file in the Database Channel was not touched.",
259
+ ]
260
+ )
261
+
262
+
263
+ def delete_silent_text(seconds: float) -> str:
264
+ return "\n".join(
265
+ [
266
+ "⚠️ <b>No word from the channel post bot</b>",
267
+ f"It got the message but did not say whether the post was deleted in {int(seconds // 60)} minutes. "
268
+ "Check the channel, then try again. The file in the Database Channel was not touched.",
269
+ ]
270
+ )
271
+
272
+
273
+ def copy_kept_text(reason: str) -> str:
274
+ return "\n".join(
275
+ [
276
+ "⚠️ <b>The post is deleted, but not its file in the Database Channel</b>",
277
+ esc(clip(reason, 400)),
278
+ "Check that the bot is an admin of the Database Channel and may delete messages there, then try again.",
279
+ ]
280
+ )
281
+
282
+
283
  @dataclass
284
  class Flow:
285
+ """An upload or a deletion that waits for the channel post bot's answers."""
286
 
287
  ref: str # in the notice and echoed in every answer: how the answers find their way back
288
  inbox: "asyncio.Queue[Tuple[str, str]]" = field(default_factory=asyncio.Queue) # (ack | ok | fail, the message)
 
290
 
291
 
292
  class DbFlow:
293
+ def __init__(
294
+ self, client: Any, cfg: Config, *, ack_wait: float = ACK_WAIT, result_wait: float = RESULT_WAIT, delete_wait: float = DELETE_WAIT
295
+ ):
296
  self.client = client
297
  self.cfg = cfg
298
  self.ack_wait = ack_wait
299
  self.result_wait = result_wait
300
+ self.delete_wait = delete_wait
301
+ self.link_wait = LINK_WAIT # seconds this bot looks for the Share URL button itself before it asks the channel post bot
302
+ self.link_poll = 3.0
303
  self.flows: Dict[str, Flow] = {}
304
+ self._busy: Set[Tuple[int, int]] = set() # (chat, message) of the uploads and deletions under way: a second tap does nothing
305
  self._tasks: Set["asyncio.Future[Any]"] = set()
306
 
307
  # ------------------------------------------------------------------ the offer
 
311
  if not isinstance(sent_id, int):
312
  return # nothing to point at
313
  channel = (job.fmt or {}).get("channel")
314
+ rows = [[("βœ… Yes", f"{PREFIX}:y:{target}:{sent_id}:{channel or 0}"), ("❌ No", f"{PREFIX}:n:{target}:{sent_id}")]]
315
  await self.client.send_message(
316
  job.chat_id, offer_text(job.new_name, episode_of(job.new_name), channel), buttons=panels.to_buttons(rows),
317
  parse_mode="html", link_preview=False, reply_to=sent_id if target == job.chat_id else job.msg_id,
 
319
 
320
  async def on_callback(self, event: Any, parts: list) -> None:
321
  action = parts[1] if len(parts) > 1 else ""
322
+ if action in ("d", "dy", "dn", "x"):
323
+ await self._on_delete(event, parts)
324
+ return
325
+ if action in ("l", "g", "z") or (action == "n" and len(parts) == 4):
326
+ await self._on_link(event, parts)
327
+ return
328
  if action == "n":
329
  await event.answer()
330
  await event.edit("πŸ‘ Okay, it is not uploaded to the Database Channel.", buttons=None, parse_mode="html")
 
413
  flow.notice_id = _id_of(note) or 0
414
 
415
  # ---- 3. what the channel post bot says
416
+ async def got_it() -> None:
417
+ await self._show(chat_id, msg_id, processing_text(name, uploaded, "πŸ“₯ The channel post bot got it and is publishing…"))
418
+
419
+ rows = None
420
+ verdict, reply = await self._answers(flow, got_it, self.result_wait)
421
  if verdict == "ok":
422
  text = published_text(name, reply, channel)
423
+ post = post_message_id(reply)
424
+ rows = delete_button(_id_of(copy) or 0, channel, post) if post else None # no post id: nothing to delete later
425
  elif verdict == "fail":
426
  text = failed_text(name, classify(reply)[1])
427
  elif verdict == "down":
428
  text = down_text(name, self.ack_wait)
429
  else:
430
  text = silent_text(name, self.result_wait)
431
+ await self._show(chat_id, msg_id, text, rows)
432
  except asyncio.CancelledError:
433
  raise
434
  except Exception as exc: # noqa: BLE001
 
453
  formatting_entities=getattr(msg, "entities", None) or None, parse_mode=None,
454
  )
455
 
456
+ async def _answers(self, flow: Flow, on_ack: Any, result_wait: float) -> Tuple[str, str]:
457
  """Wait for the channel post bot: ``("ok" | "fail", its message)``, ``("down", "")`` when it never acknowledged the notice,
458
+ ``("silent", "")`` when it acknowledged it but never said how it went in ``result_wait`` seconds. ``on_ack()`` is awaited once,
459
+ when the acknowledgement comes."""
460
  loop = asyncio.get_running_loop()
461
  acked = False
462
  deadline = loop.time() + self.ack_wait
 
469
  return kind, text
470
  if not acked:
471
  acked = True
472
+ deadline = loop.time() + result_wait
473
+ await on_ack()
474
 
475
  async def _show(self, chat_id: int, msg_id: int, text: str, rows: Optional[list] = None) -> None:
476
  try:
 
482
  except Exception as exc: # noqa: BLE001
483
  log.warning("could not update the upload message: %s: %s", type(exc).__name__, exc)
484
 
485
+ # ------------------------------------------------------- only the Share URL
486
+ async def _on_link(self, event: Any, parts: list) -> None:
487
+ """No to the upload offer asks about the link only (``n``); Yes (``l``) uploads and returns it; ``g`` looks for it again."""
488
+ action = parts[1]
489
+ if action == "z":
490
+ await event.answer()
491
+ await event.edit("πŸ‘ Okay, it is not uploaded to the Database Channel.", buttons=None, parse_mode="html")
492
+ return
493
+ try:
494
+ if len(parts) != {"n": 4, "l": 4, "g": 3}[action]:
495
+ raise ValueError(parts)
496
+ numbers = [int(x) for x in parts[2:]]
497
+ if action != "g" and numbers[1] <= 0:
498
+ raise ValueError(parts)
499
+ except ValueError:
500
+ await event.answer()
501
+ return
502
+ if action == "n":
503
+ await event.answer()
504
+ yes = [[("βœ… Yes", f"{PREFIX}:l:{numbers[0]}:{numbers[1]}"), ("❌ No", f"{PREFIX}:z")]]
505
+ await event.edit(link_question_text(), buttons=panels.to_buttons(yes), parse_mode="html", link_preview=False)
506
+ return
507
+ key = (event.chat_id, event.message_id)
508
+ if key in self._busy:
509
+ await event.answer("I'm already on it…")
510
+ return
511
+ self._busy.add(key)
512
+ try:
513
+ await event.answer()
514
+ try:
515
+ await event.edit(processing_text(""), buttons=None, parse_mode="html")
516
+ except errors.MessageNotModifiedError:
517
+ pass
518
+ except BaseException:
519
+ self._busy.discard(key)
520
+ raise
521
+ self._spawn(self._run_link(key, *numbers) if action == "l" else self._run_url(key, numbers[0]))
522
+
523
+ async def _run_link(self, key: Tuple[int, int], source_chat: int, source_id: int) -> None:
524
+ chat_id, msg_id = key
525
+ name = ""
526
+ try:
527
+ msg = await self.client.get_messages(source_chat, ids=source_id)
528
+ if msg is None or getattr(msg, "file", None) is None:
529
+ await self._show(chat_id, msg_id, "❌ <b>That episode is not there anymore</b>\nIt was deleted from the chat, so there is nothing to upload.")
530
+ return
531
+ name = getattr(msg.file, "name", None) or ""
532
+ try:
533
+ copy = await self._copy(msg, source_chat)
534
+ except asyncio.CancelledError:
535
+ raise
536
+ except Exception as exc: # noqa: BLE001
537
+ log.warning("could not upload %s to the Database Channel: %s: %s", name, type(exc).__name__, exc)
538
+ retry = [[("πŸ” Try again", f"{PREFIX}:l:{source_chat}:{source_id}"), ("βœ– Close", "x:close")]]
539
+ await self._show(
540
+ chat_id, msg_id,
541
+ f"❌ <b>The upload to the Database Channel failed</b>\n{_name(name)}\n{_why(exc)}\n"
542
+ "Check that the bot is an admin of the Database Channel and can post there.",
543
+ retry,
544
+ )
545
+ return
546
+ await self._url(chat_id, msg_id, name, _id_of(copy) or 0)
547
+ except asyncio.CancelledError:
548
+ raise
549
+ except Exception as exc: # noqa: BLE001
550
+ log.exception("getting the share url went wrong")
551
+ await self._show(chat_id, msg_id, f"❌ <b>Something went wrong</b>\n{_name(name)}\n{_why(exc)}")
552
+ finally:
553
+ self._busy.discard(key)
554
+
555
+ async def _run_url(self, key: Tuple[int, int], copy_id: int) -> None:
556
+ try:
557
+ await self._url(key[0], key[1], "", copy_id)
558
+ except asyncio.CancelledError:
559
+ raise
560
+ except Exception as exc: # noqa: BLE001
561
+ log.exception("getting the share url went wrong")
562
+ await self._show(key[0], key[1], f"❌ <b>Something went wrong</b>\n{_why(exc)}")
563
+ finally:
564
+ self._busy.discard(key)
565
+
566
+ async def _url(self, chat_id: int, msg_id: int, name: str, copy_id: int) -> None:
567
+ """The Share URL of the copy: looked for here first, and when this bot cannot see it, the channel post bot (whose userbot can) is asked."""
568
+ done = "βœ… Uploaded to the Database Channel" if name else ""
569
+ again = [[("πŸ” Look for the URL again", f"{PREFIX}:g:{copy_id}"), ("βœ– Close", "x:close")]]
570
+ if not copy_id:
571
+ await self._show(chat_id, msg_id, f"⚠️ <b>Uploaded, but I do not know which post it is</b>\n{_name(name)}\nLook for the Share URL in the Database Channel.")
572
+ return
573
+ await self._show(chat_id, msg_id, processing_text(name, done, "⏳ Waiting for the Share URL button…"))
574
+ url = await self._bot_url(copy_id)
575
+ if not url:
576
+ flow = Flow(ref=secrets.token_hex(3))
577
+ self.flows[flow.ref] = flow
578
+ try:
579
+ try:
580
+ note = await self.client.send_message(
581
+ self.cfg.comm_chat, link_notice_text(post_id=copy_id, ref=flow.ref), parse_mode=None, link_preview=False
582
+ )
583
+ except asyncio.CancelledError:
584
+ raise
585
+ except Exception as exc: # noqa: BLE001
586
+ await self._show(
587
+ chat_id, msg_id,
588
+ f"⚠️ <b>I could not see the Share URL, and could not write in the communication chat</b>\n{_name(name)}\n{_why(exc)}", again,
589
+ )
590
+ return
591
+ flow.notice_id = _id_of(note) or 0
592
+
593
+ async def got_it() -> None:
594
+ await self._show(chat_id, msg_id, processing_text(name, done, "πŸ“₯ The channel post bot is looking for the Share URL…"))
595
+
596
+ await self._show(chat_id, msg_id, processing_text(name, done, "⏳ Asking the channel post bot (its userbot can see it)…"))
597
+ verdict, reply = await self._answers(flow, got_it, self.delete_wait)
598
+ finally:
599
+ self.flows.pop(flow.ref, None)
600
+ if verdict == "ok":
601
+ url = line_of("url", reply)
602
+ if not url:
603
+ why = {
604
+ "fail": classify(reply)[1], "down": f"The channel post bot did not answer within {int(self.ack_wait)} seconds (down?).",
605
+ "silent": "The channel post bot did not say how it went.", "ok": "The channel post bot sent no link.",
606
+ }[verdict]
607
+ await self._show(
608
+ chat_id, msg_id,
609
+ f"⚠️ <b>Uploaded, but no Share URL</b>\n{_name(name)}\n{esc(clip(why or 'No reason was given.', 500))}", again,
610
+ )
611
+ return
612
+ await self._show(chat_id, msg_id, link_text(name, url))
613
+
614
+ async def _bot_url(self, copy_id: int) -> str:
615
+ """Look at the copy for its Share URL button for ``link_wait`` seconds (the file store adds it a few seconds after the post)."""
616
+ loop = asyncio.get_running_loop()
617
+ end = loop.time() + self.link_wait
618
+ while True:
619
+ try:
620
+ url = share_link(await self.client.get_messages(self.cfg.db_channel, ids=copy_id))
621
+ except asyncio.CancelledError:
622
+ raise
623
+ except Exception as exc: # noqa: BLE001
624
+ log.info("could not read the copy in the Database Channel: %s: %s", type(exc).__name__, exc)
625
+ return "" # this bot cannot read it: no use waiting, the channel post bot is asked
626
+ if url or loop.time() >= end:
627
+ return url
628
+ await asyncio.sleep(self.link_poll)
629
+
630
+ # ------------------------------------------------------- deleting what was published
631
+ async def _on_delete(self, event: Any, parts: list) -> None:
632
+ """The buttons of a published post: πŸ—‘ Delete post (``d``), Yes (``dy``), No (``dn``), and the note it turns into (``x``)."""
633
+ action = parts[1]
634
+ if action == "x":
635
+ await event.answer("This post is already deleted.")
636
+ return
637
+ if action == "dn":
638
+ await event.answer("Okay, nothing is deleted.")
639
+ await self._close(event)
640
+ return
641
+ try:
642
+ if len(parts) != (5 if action == "d" else 6):
643
+ raise ValueError(parts)
644
+ copy_id, channel, post = int(parts[2]), int(parts[3]), int(parts[4])
645
+ shown = int(parts[5]) if action == "dy" else event.message_id # the message of the post, whose button this is
646
+ if copy_id < 0 or post <= 0 or channel == 0:
647
+ raise ValueError(parts)
648
+ except ValueError:
649
+ await event.answer()
650
+ return
651
+ if action == "d": # ask first, in a message of its own: the post's message stays as it is
652
+ await event.answer()
653
+ yes = f"{PREFIX}:dy:{copy_id}:{channel}:{post}:{shown}"
654
+ await self.client.send_message(
655
+ event.chat_id, confirm_text(channel), buttons=panels.to_buttons([[("βœ… Yes, delete it", yes), ("❌ No", f"{PREFIX}:dn")]]),
656
+ parse_mode="html", link_preview=False, reply_to=shown,
657
+ )
658
+ return
659
+ key = (event.chat_id, event.message_id)
660
+ if key in self._busy:
661
+ await event.answer("I'm already on it…")
662
+ return
663
+ self._busy.add(key)
664
+ try:
665
+ await event.answer()
666
+ try:
667
+ await event.edit(deleting_text(), buttons=None, parse_mode="html")
668
+ except errors.MessageNotModifiedError:
669
+ pass
670
+ except BaseException:
671
+ self._busy.discard(key)
672
+ raise
673
+ self._spawn(self._delete(key, copy_id, channel, post, shown))
674
+
675
+ @staticmethod
676
+ async def _close(event: Any) -> None:
677
+ try:
678
+ await event.delete()
679
+ except Exception: # noqa: BLE001 - Telegram refuses to delete messages older than 48 hours
680
+ try:
681
+ await event.edit("Closed.", buttons=None)
682
+ except Exception: # noqa: BLE001
683
+ pass
684
+
685
+ async def _delete(self, key: Tuple[int, int], copy_id: int, channel: int, post: int, shown: int) -> None:
686
+ """Have the channel post bot delete the post, and when it says it is gone, delete the copy in the Database Channel."""
687
+ chat_id, msg_id = key
688
+ flow: Optional[Flow] = None
689
+ again = [[("πŸ” Try again", f"{PREFIX}:dy:{copy_id}:{channel}:{post}:{shown}"), ("βœ– Close", f"{PREFIX}:dn")]]
690
+ try:
691
+ flow = Flow(ref=secrets.token_hex(3))
692
+ self.flows[flow.ref] = flow # before the notice goes out, so that no answer can come back too early
693
+ await self._show(chat_id, msg_id, deleting_text("⏳ Waiting for the channel post bot…"))
694
+ try:
695
+ note = await self.client.send_message(
696
+ self.cfg.comm_chat, delete_notice_text(channel=channel, message=post, ref=flow.ref), parse_mode=None, link_preview=False
697
+ )
698
+ except asyncio.CancelledError:
699
+ raise
700
+ except Exception as exc: # noqa: BLE001
701
+ log.warning("could not write in the communication chat: %s: %s", type(exc).__name__, exc)
702
+ await self._show(
703
+ chat_id, msg_id,
704
+ f"⚠️ <b>I could not write in the communication chat</b>\n{_why(exc)}\nNothing was deleted. Is the bot a member of that chat?",
705
+ again,
706
+ )
707
+ return
708
+ flow.notice_id = _id_of(note) or 0
709
+
710
+ async def got_it() -> None:
711
+ await self._show(chat_id, msg_id, deleting_text("πŸ“₯ The channel post bot got it and is deleting the post…"))
712
+
713
+ rows = again
714
+ verdict, reply = await self._answers(flow, got_it, self.delete_wait)
715
+ if verdict == "ok":
716
+ problem = await self._delete_copy(copy_id)
717
+ if problem is None:
718
+ text, rows = deleted_text(reply, bool(copy_id)), None
719
+ await self._mark_deleted(chat_id, shown)
720
+ else:
721
+ text = copy_kept_text(problem)
722
+ elif verdict == "fail":
723
+ text = delete_failed_text(classify(reply)[1])
724
+ elif verdict == "down":
725
+ text = delete_down_text(self.ack_wait)
726
+ else:
727
+ text = delete_silent_text(self.delete_wait)
728
+ await self._show(chat_id, msg_id, text, rows)
729
+ except asyncio.CancelledError:
730
+ raise
731
+ except Exception as exc: # noqa: BLE001
732
+ log.exception("deleting the published post went wrong")
733
+ await self._show(chat_id, msg_id, f"❌ <b>Something went wrong</b>\n{_why(exc)}", again)
734
+ finally:
735
+ self._busy.discard(key)
736
+ if flow is not None:
737
+ self.flows.pop(flow.ref, None)
738
+
739
+ async def _delete_copy(self, copy_id: int) -> Optional[str]:
740
+ """Delete the episode's copy in the Database Channel: ``None`` when it is gone (it also counts when it was gone already),
741
+ else why not, as text for the message."""
742
+ if not copy_id:
743
+ return None
744
+ try:
745
+ await self.client.delete_messages(self.cfg.db_channel, [copy_id])
746
+ except asyncio.CancelledError:
747
+ raise
748
+ except Exception as exc: # noqa: BLE001
749
+ log.warning("could not delete message %s of the Database Channel: %s: %s", copy_id, type(exc).__name__, exc)
750
+ return clip(f"{type(exc).__name__}: {exc}", 250)
751
+ return None
752
+
753
+ async def _mark_deleted(self, chat_id: int, msg_id: int) -> None:
754
+ """The message of the post keeps its text; its Delete button turns into a note that the post is gone."""
755
+ try:
756
+ await self.client.edit_message(chat_id, msg_id, buttons=panels.to_buttons([[("πŸ—‘ Post deleted", f"{PREFIX}:x")]]))
757
+ except errors.MessageNotModifiedError:
758
+ pass
759
+ except Exception as exc: # noqa: BLE001
760
+ log.warning("could not change the button of the post message: %s: %s", type(exc).__name__, exc)
761
+
762
  # ------------------------------------------------- the channel post bot's answers
763
  async def on_chat_message(self, event: Any) -> None:
764
  """A message of the channel post bot in the communication chat."""
tests/test_dbflow.py CHANGED
@@ -7,20 +7,20 @@ import time
7
  from types import SimpleNamespace
8
 
9
  import pytest
10
- from telethon import errors
11
 
12
  from bot import panels
13
  from bot.app import App
14
  from bot.config import ConfigError, load_config
15
  from bot.db import Database, Format
16
- from bot.dbflow import classify, episode_of, line_of, notice_text, reply_target
17
  from bot.formats import default_auto_format
18
  from bot.pipeline import Renamer
19
  from bot.queue import Job
20
  from bot.settings_ops import parse_format_channel
21
 
22
  from .appkit import ADMIN_CFG, OWNER, STRANGER, click, data_of, env_for, say
23
- from .tgfakes import FakeClient, FakeEvent, dispatch, document_message, flat_buttons
24
 
25
  DB = -1002000000001 # the Database Channel
26
  COMM = -1003000000002 # the communication chat
@@ -42,7 +42,7 @@ async def up(tmp_path):
42
  await db.init()
43
  client = FakeClient()
44
  app = App(client, cfg, db)
45
- app.dbflow.ack_wait, app.dbflow.result_wait = 2.0, 4.0
46
  app._register()
47
  yield SimpleNamespace(app=app, client=client, db=db, cfg=cfg, tmp=tmp_path, flow=app.dbflow)
48
  await app.shutdown()
@@ -338,7 +338,7 @@ async def test_the_offer_asks_yes_or_no_after_the_episode(up):
338
  assert entity == OWNER and kw["reply_to"] == 555 and kw["parse_mode"] == "html"
339
  assert "Upload this episode to the Database Channel?" in text and NAME in text
340
  assert "Episode: <b>05</b>" in text and f"<code>{SHOW}</code>" in text
341
- assert flat_buttons(kw["buttons"]) == [("βœ… Yes", f"dbu:y:{OWNER}:555:{SHOW}"), ("❌ No", "dbu:n")]
342
 
343
 
344
  def test_the_buttons_fit_telegrams_limit_even_for_the_biggest_ids():
@@ -487,7 +487,9 @@ async def test_the_whole_story_from_yes_to_published(up):
487
  assert "Post published successfully" in texts[2] and "Black Clover English" in texts[2] and "https://t.me/c/1/2" in texts[2]
488
  assert NAME in texts[2]
489
  assert {kw["message"] for _e, _t, kw in up.client.edited} == {77} and {e for e, _t, _k in up.client.edited} == {OWNER}
490
- assert all(kw["buttons"] is None for _e, _t, kw in up.client.edited)
 
 
491
  assert up.flow.flows == {} and up.flow._busy == set()
492
 
493
 
@@ -709,3 +711,412 @@ async def test_strangers_cannot_press_the_buttons(up):
709
  async def test_a_broken_button_does_nothing(up, data):
710
  cb = await click(up, OWNER, data)
711
  assert cb.answers and not up.client.forwarded and up.flow._busy == set() and not up.flow._tasks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  from types import SimpleNamespace
8
 
9
  import pytest
10
+ from telethon import errors, types
11
 
12
  from bot import panels
13
  from bot.app import App
14
  from bot.config import ConfigError, load_config
15
  from bot.db import Database, Format
16
+ from bot.dbflow import link_notice_text, share_link, classify, delete_button, delete_notice_text, episode_of, line_of, notice_text, post_message_id, reply_target
17
  from bot.formats import default_auto_format
18
  from bot.pipeline import Renamer
19
  from bot.queue import Job
20
  from bot.settings_ops import parse_format_channel
21
 
22
  from .appkit import ADMIN_CFG, OWNER, STRANGER, click, data_of, env_for, say
23
+ from .tgfakes import FakeCallback, FakeClient, FakeEvent, dispatch, document_message, flat_buttons
24
 
25
  DB = -1002000000001 # the Database Channel
26
  COMM = -1003000000002 # the communication chat
 
42
  await db.init()
43
  client = FakeClient()
44
  app = App(client, cfg, db)
45
+ app.dbflow.ack_wait, app.dbflow.result_wait, app.dbflow.delete_wait = 2.0, 4.0, 4.0
46
  app._register()
47
  yield SimpleNamespace(app=app, client=client, db=db, cfg=cfg, tmp=tmp_path, flow=app.dbflow)
48
  await app.shutdown()
 
338
  assert entity == OWNER and kw["reply_to"] == 555 and kw["parse_mode"] == "html"
339
  assert "Upload this episode to the Database Channel?" in text and NAME in text
340
  assert "Episode: <b>05</b>" in text and f"<code>{SHOW}</code>" in text
341
+ assert flat_buttons(kw["buttons"]) == [("βœ… Yes", f"dbu:y:{OWNER}:555:{SHOW}"), ("❌ No", f"dbu:n:{OWNER}:555")]
342
 
343
 
344
  def test_the_buttons_fit_telegrams_limit_even_for_the_biggest_ids():
 
487
  assert "Post published successfully" in texts[2] and "Black Clover English" in texts[2] and "https://t.me/c/1/2" in texts[2]
488
  assert NAME in texts[2]
489
  assert {kw["message"] for _e, _t, kw in up.client.edited} == {77} and {e for e, _t, _k in up.client.edited} == {OWNER}
490
+ buttons = [kw["buttons"] for _e, _t, kw in up.client.edited]
491
+ assert buttons[0] is None and buttons[1] is None # while it works there is nothing to press ...
492
+ assert flat_buttons(buttons[2]) == [("πŸ—‘ Delete post", f"dbu:d:4001:{SHOW}:2")] # ... and the published post can be deleted again
493
  assert up.flow.flows == {} and up.flow._busy == set()
494
 
495
 
 
711
  async def test_a_broken_button_does_nothing(up, data):
712
  cb = await click(up, OWNER, data)
713
  assert cb.answers and not up.client.forwarded and up.flow._busy == set() and not up.flow._tasks
714
+
715
+
716
+ # ---------------------------------------------------------------------------
717
+ # πŸ—‘ Delete post: the button, the question, the channel post bot, the copy in the Database Channel
718
+ # ---------------------------------------------------------------------------
719
+
720
+ DELETE = f"dbu:d:4001:{SHOW}:57" # the button of the published post (copy 4001 in the Database Channel, post 57 in the channel)
721
+ YES = f"dbu:dy:4001:{SHOW}:57:77" # ... and the Yes of the question about it (77: the message of the published post)
722
+
723
+
724
+ async def published(up, *, extra=""):
725
+ """The whole upload, answered with "Post published successfully": its message (77) now has the Delete button."""
726
+ await start_upload(up)
727
+ nid, text, _ = await the_notice(up)
728
+ ref = ref_of(text)
729
+ await post_bot_says(up, f"πŸ“₯ Message received - checking the Database Channel…\nRef: {ref}", reply_to=nid)
730
+ done = f"βœ… Post published successfully\nChannel: Black Clover English\nPost: https://t.me/c/1/57\nMessage: 57\n{extra}File: {NAME}\nRef: {ref}"
731
+ await post_bot_says(up, done, reply_to=nid)
732
+ await up.flow.idle()
733
+
734
+
735
+ def to_owner(up):
736
+ return [(2000 + n, text, kw) for n, (entity, text, kw) in enumerate(up.client.sent, 1) if entity == OWNER]
737
+
738
+
739
+ async def ask(up):
740
+ """Press Delete post, then Yes; the id of the question's message."""
741
+ await click(up, OWNER, DELETE, msg_id=77)
742
+ qid = to_owner(up)[-1][0]
743
+ await click(up, OWNER, YES, msg_id=qid)
744
+ return qid
745
+
746
+
747
+ async def post_bot_deletes(up, nth, *answer):
748
+ """What the channel post bot says to the nth notice: each of ``answer`` (with REF in it) as a reply."""
749
+ nid, text, _ = await the_notice(up, nth)
750
+ ref = ref_of(text)
751
+ for line in answer:
752
+ await post_bot_says(up, line.replace("REF", ref), reply_to=nid)
753
+ return ref
754
+
755
+
756
+ OK = "πŸ—‘ Post deleted successfully\nChannel: Black Clover English\nMessage: 57\nRef: REF"
757
+ GOT = "πŸ“₯ Message received - deleting the post…\nRef: REF"
758
+
759
+
760
+ async def test_the_published_post_gets_a_delete_button_only_when_its_id_is_known(up):
761
+ await published(up)
762
+ assert flat_buttons(up.client.edited[-1][2]["buttons"]) == [("πŸ—‘ Delete post", DELETE)]
763
+ assert up.client.edited[0][2]["buttons"] is None and up.client.edited[1][2]["buttons"] is None
764
+
765
+
766
+ async def test_without_a_post_id_there_is_no_button(up):
767
+ await start_upload(up)
768
+ nid, text, _ = await the_notice(up)
769
+ await post_bot_says(up, f"βœ… Post published successfully\nChannel: X\nRef: {ref_of(text)}", reply_to=nid)
770
+ await up.flow.idle()
771
+ assert up.client.edited[-1][2]["buttons"] is None
772
+
773
+
774
+ @pytest.mark.parametrize(
775
+ "reply, expected",
776
+ [
777
+ ("Post: https://t.me/x/58\nMessage: 57", 57),
778
+ ("Post: https://t.me/blackclover/58", 58),
779
+ ("Post: https://t.me/c/123/59", 59),
780
+ ("Message: abc\nPost: https://t.me/c/123/60", 60),
781
+ ("Channel: X", None),
782
+ ("Post: https://t.me/c/123/5?single", None),
783
+ ],
784
+ )
785
+ def test_the_post_id_comes_from_the_message_line_or_the_link(reply, expected):
786
+ assert post_message_id(reply) == expected
787
+
788
+
789
+ def test_the_delete_notice_and_the_button_limit():
790
+ assert delete_notice_text(channel=SHOW, message=57, ref="ab12").splitlines() == [
791
+ "Delete the published post", f"Channel: {SHOW}", "Message: 57", "Ref: ab12"
792
+ ]
793
+ assert delete_button(10**40, SHOW, 57) is None
794
+
795
+
796
+ @pytest.mark.parametrize(
797
+ "text, expected",
798
+ [
799
+ ("πŸ—‘ Post deleted successfully\nRef: x", ("ok", "")),
800
+ ("❌ Deletion failed: no right\nRef: x", ("fail", "no right")),
801
+ ("πŸ“₯ Message received - deleting the post…", ("ack", "")),
802
+ ("❌ Publication failed: nope", ("fail", "nope")),
803
+ ],
804
+ )
805
+ def test_the_deletion_answers_are_understood(text, expected):
806
+ assert classify(text) == expected
807
+
808
+
809
+ async def test_pressing_the_button_only_asks(up):
810
+ await published(up)
811
+ cb = await click(up, OWNER, DELETE, msg_id=77)
812
+ (qid, text, kw), = to_owner(up)[-1:]
813
+ assert cb.answers and "Are you sure you want to delete this post?" in text and str(SHOW) in text
814
+ assert kw["reply_to"] == 77 and kw["parse_mode"] == "html"
815
+ assert flat_buttons(kw["buttons"]) == [("βœ… Yes, delete it", YES), ("❌ No", "dbu:dn")]
816
+ assert all(len(d.encode()) <= 64 for _l, d in flat_buttons(kw["buttons"]))
817
+ assert up.client.deleted == [] and len(notices(up)) == 1 and len(up.client.edited) == 3 # nothing happened yet
818
+
819
+
820
+ async def test_no_closes_the_question(up):
821
+ await published(up)
822
+ cb = await click(up, OWNER, "dbu:dn", msg_id=500)
823
+ assert cb.deleted and cb.toasts == ["Okay, nothing is deleted."] and up.client.deleted == [] and len(notices(up)) == 1
824
+ stuck = FakeCallback(OWNER, "dbu:dn", msg_id=500)
825
+ stuck.delete_error = RuntimeError("too old")
826
+ await dispatch(up.client, stuck)
827
+ assert stuck.edits[-1][0] == "Closed."
828
+
829
+
830
+ async def test_yes_deletes_the_copy_only_after_the_channel_post_bot_says_the_post_is_gone(up):
831
+ await published(up)
832
+ await click(up, OWNER, DELETE, msg_id=77)
833
+ qid = to_owner(up)[-1][0]
834
+ cb = await click(up, OWNER, YES, msg_id=qid)
835
+ assert cb.edits[-1][0].startswith("⏳ <b>Deleting the post…</b>") and cb.edits[-1][1] is None
836
+ nid, text, kw = await the_notice(up, 2)
837
+ ref = ref_of(text)
838
+ assert text.splitlines() == ["Delete the published post", f"Channel: {SHOW}", "Message: 57", f"Ref: {ref}"] and kw["parse_mode"] is None
839
+ await post_bot_says(up, GOT.replace("REF", ref), reply_to=nid)
840
+ await wait_for(lambda: "got it" in str(up.client.edited[-1][1]))
841
+ assert up.client.deleted == [] # not before the channel post bot has said so
842
+ await post_bot_says(up, OK.replace("REF", ref) + "\nℹ️ The post was already gone from the channel and from My posts.", reply_to=nid)
843
+ await up.flow.idle()
844
+ assert up.client.deleted == [(DB, [4001])]
845
+ chat, final, kw = up.client.edited[-1]
846
+ assert kw["message"] == qid and kw["buttons"] is None
847
+ assert "Post deleted" in final and "Black Clover English" in final and "already gone" in final and "Deleted from the Database Channel" in final
848
+ mark = up.client.edited[-2]
849
+ assert mark[1] is None and mark[2]["message"] == 77 and flat_buttons(mark[2]["buttons"]) == [("πŸ—‘ Post deleted", "dbu:x")]
850
+ assert up.flow.flows == {} and up.flow._busy == set()
851
+ left = await click(up, OWNER, "dbu:x", msg_id=77)
852
+ assert left.toasts == ["This post is already deleted."]
853
+
854
+
855
+ async def test_a_failure_leaves_everything_and_can_be_tried_again(up):
856
+ await published(up)
857
+ qid = await ask(up)
858
+ await post_bot_deletes(up, 2, GOT, "❌ Deletion failed: Telegram did not delete it & more\nRef: REF")
859
+ await up.flow.idle()
860
+ final = up.client.edited[-1]
861
+ assert "The post was not deleted" in final[1] and "did not delete it &amp; more" in final[1] and "was not touched" in final[1]
862
+ assert flat_buttons(final[2]["buttons"]) == [("πŸ” Try again", YES), ("βœ– Close", "dbu:dn")]
863
+ assert up.client.deleted == [] and up.flow._busy == set()
864
+ await click(up, OWNER, YES, msg_id=qid)
865
+ ref2 = await post_bot_deletes(up, 3, GOT, OK)
866
+ await up.flow.idle()
867
+ assert ref2 != ref_of(notices(up)[1][1]) and up.client.deleted == [(DB, [4001])]
868
+
869
+
870
+ async def test_a_channel_post_bot_that_does_not_answer_is_reported(up):
871
+ await published(up)
872
+ up.flow.ack_wait = 0.1
873
+ await ask(up)
874
+ await up.flow.idle()
875
+ final = up.client.edited[-1]
876
+ assert "down or not responding" in final[1] and "post is not deleted" in final[1] and up.client.deleted == []
877
+ assert flat_buttons(final[2]["buttons"])[0][0] == "πŸ” Try again"
878
+
879
+
880
+ async def test_an_acknowledged_deletion_that_is_never_answered_is_not_called_down(up):
881
+ await published(up)
882
+ up.flow.delete_wait = 0.15
883
+ await ask(up)
884
+ await post_bot_deletes(up, 2, GOT)
885
+ await up.flow.idle()
886
+ final = up.client.edited[-1][1]
887
+ assert "No word from the channel post bot" in final and "down" not in final and up.client.deleted == []
888
+
889
+
890
+ async def test_a_copy_that_cannot_be_deleted_is_reported_and_retried(up):
891
+ await published(up)
892
+ up.client.delete_errors.append(RuntimeError("no right"))
893
+ qid = await ask(up)
894
+ await post_bot_deletes(up, 2, GOT, OK)
895
+ await up.flow.idle()
896
+ chat, final, kw = up.client.edited[-1]
897
+ assert "deleted, but not its file in the Database Channel" in final and "RuntimeError: no right" in final
898
+ assert flat_buttons(kw["buttons"])[0] == ("πŸ” Try again", YES) and up.client.deleted == []
899
+ assert [e for e in up.client.edited if e[1] is None] == [] # the post's own button is left alone until everything is done
900
+ await click(up, OWNER, YES, msg_id=qid)
901
+ await post_bot_deletes(up, 3, GOT, OK)
902
+ await up.flow.idle()
903
+ assert up.client.deleted == [(DB, [4001])] and "Post deleted" in up.client.edited[-1][1]
904
+
905
+
906
+ async def test_a_second_tap_on_yes_does_nothing_while_it_works(up):
907
+ await published(up)
908
+ await click(up, OWNER, DELETE, msg_id=77)
909
+ qid = to_owner(up)[-1][0]
910
+ await click(up, OWNER, YES, msg_id=qid)
911
+ again = await click(up, OWNER, YES, msg_id=qid)
912
+ assert again.toasts == ["I'm already on it…"] and again.edits == []
913
+ await the_notice(up, 2)
914
+ await asyncio.sleep(0.05)
915
+ assert len(notices(up)) == 2
916
+
917
+
918
+ async def test_a_notice_that_cannot_be_written_is_reported(up):
919
+ await published(up)
920
+ up.client.message_errors[COMM] = RuntimeError("chat not found")
921
+ await ask(up)
922
+ await up.flow.idle()
923
+ final = up.client.edited[-1][1]
924
+ assert "could not write in the communication chat" in final and "chat not found" in final and "Nothing was deleted" in final
925
+ assert up.client.deleted == [] and up.flow.flows == {} and up.flow._busy == set()
926
+
927
+
928
+ async def test_an_unexpected_error_is_shown(up):
929
+ await published(up)
930
+
931
+ async def broken(*args, **kw):
932
+ raise RuntimeError("boom")
933
+
934
+ up.flow._answers = broken
935
+ await ask(up)
936
+ await up.flow.idle()
937
+ assert "Something went wrong" in up.client.edited[-1][1] and "RuntimeError: boom" in up.client.edited[-1][1]
938
+ assert up.flow._busy == set() and up.flow.flows == {} and up.client.deleted == []
939
+
940
+
941
+ async def test_deleting_needs_nothing_but_the_buttons_so_it_survives_a_restart(up):
942
+ await click(up, OWNER, DELETE, msg_id=77) # no upload in this run of the bot
943
+ qid = to_owner(up)[-1][0]
944
+ await click(up, OWNER, YES, msg_id=qid)
945
+ await post_bot_deletes(up, 1, GOT, OK)
946
+ await up.flow.idle()
947
+ assert up.client.deleted == [(DB, [4001])]
948
+
949
+
950
+ async def test_a_post_without_a_known_copy_is_deleted_without_touching_the_database_channel(up):
951
+ await click(up, OWNER, f"dbu:d:0:{SHOW}:57", msg_id=77)
952
+ await click(up, OWNER, f"dbu:dy:0:{SHOW}:57:77", msg_id=to_owner(up)[-1][0])
953
+ await post_bot_deletes(up, 1, OK)
954
+ await up.flow.idle()
955
+ assert up.client.deleted == [] and "no copy in the Database Channel" in up.client.edited[-1][1]
956
+
957
+
958
+ async def test_the_question_message_deleted_meanwhile_does_not_stop_the_deletion(up):
959
+ await published(up)
960
+ await ask(up)
961
+
962
+ async def gone(*args, **kw):
963
+ raise errors.MessageIdInvalidError(None)
964
+
965
+ up.client.edit_message = gone
966
+ await post_bot_deletes(up, 2, GOT, OK)
967
+ await up.flow.idle()
968
+ assert up.client.deleted == [(DB, [4001])] and up.flow._busy == set()
969
+
970
+
971
+ async def test_shutdown_stops_a_deletion_that_is_waiting(up):
972
+ await published(up)
973
+ await ask(up)
974
+ await the_notice(up, 2)
975
+ await up.app.shutdown()
976
+ assert up.flow._tasks == set() and up.flow.flows == {} and up.flow._busy == set()
977
+
978
+
979
+ async def test_strangers_cannot_press_the_delete_buttons(up):
980
+ await click(up, STRANGER, DELETE)
981
+ await click(up, STRANGER, f"dbu:dy:4001:{SHOW}:57:77")
982
+ assert not [t for _e, t, _k in up.client.sent if "Are you sure" in t] and not notices(up) and not up.flow._tasks
983
+
984
+
985
+ @pytest.mark.parametrize(
986
+ "data",
987
+ [
988
+ "dbu:d", "dbu:d:1:2", "dbu:d:a:b:c", f"dbu:d:-1:{SHOW}:5", f"dbu:d:1:{SHOW}:0", "dbu:d:1:0:5",
989
+ f"dbu:dy:1:{SHOW}:5", f"dbu:d:1:{SHOW}:5:9", f"dbu:dy:1:{SHOW}:5:x",
990
+ ],
991
+ )
992
+ async def test_a_broken_delete_button_does_nothing(up, data):
993
+ cb = await click(up, OWNER, data)
994
+ assert cb.answers and not up.client.sent and not up.client.deleted and up.flow._busy == set() and not up.flow._tasks
995
+
996
+
997
+ async def test_without_the_chats_the_delete_button_says_it_is_switched_off(ctx):
998
+ cb = await click(ctx, OWNER, DELETE)
999
+ assert cb.alerts and "switched off" in cb.alerts[0] and not ctx.client.sent
1000
+
1001
+
1002
+ # ---------------------------------------------------------------------------
1003
+ # No -> "upload it and get the Share URL?" (nothing is published)
1004
+ # ---------------------------------------------------------------------------
1005
+
1006
+ FILE_LINK = "https://t.me/filestore_bot?start=Zm9v"
1007
+ SHEET = "https://t.me/share/url?url=https%3A%2F%2Ft.me%2Ffilestore_bot%3Fstart%3DZm9v"
1008
+ ASK = f"dbu:n:{OWNER}:555"
1009
+ LINK_YES = f"dbu:l:{OWNER}:555"
1010
+
1011
+
1012
+ def with_button(link=SHEET, text="πŸ” Share URL"):
1013
+ row = types.KeyboardInlineButtonRow([types.KeyboardInlineButton(text, types.InlineButtonTypeUrl(link))])
1014
+ return SimpleNamespace(id=4001, reply_markup=types.ReplyInlineMarkup([row]))
1015
+
1016
+
1017
+ def test_the_share_button_is_found_and_the_share_sheet_taken_off():
1018
+ assert share_link(with_button()) == FILE_LINK and share_link(with_button(FILE_LINK)) == FILE_LINK
1019
+ assert share_link(with_button(text="Other")) == "" and share_link(SimpleNamespace(reply_markup=None)) == "" and share_link(None) == ""
1020
+ assert share_link(with_button("https://t.me/share/url?text=hi")) == ""
1021
+
1022
+
1023
+ async def test_no_asks_about_the_link_and_uploads_nothing(up):
1024
+ cb = await click(up, OWNER, ASK, msg_id=77)
1025
+ text, buttons = cb.edits[-1]
1026
+ assert "Share URL" in text and "Nothing is published" in text
1027
+ assert flat_buttons(buttons) == [("βœ… Yes", LINK_YES), ("❌ No", "dbu:z")]
1028
+ assert not up.client.forwarded and not notices(up)
1029
+ cb = await click(up, OWNER, "dbu:z", msg_id=77)
1030
+ assert "not uploaded" in cb.edits[-1][0] and not up.client.forwarded
1031
+
1032
+
1033
+ async def test_yes_uploads_and_shows_the_link_in_monospace(up):
1034
+ up.client.messages[555] = document_message(NAME, 10, mid=555)
1035
+ up.client.messages[4001] = with_button()
1036
+ up.flow.link_poll = 0.01
1037
+ await click(up, OWNER, LINK_YES, msg_id=77)
1038
+ await up.flow.idle()
1039
+ assert up.client.forwarded == [(DB, 555, OWNER, {"drop_author": True})] and not notices(up) # nothing for the channel post bot
1040
+ final = last_edit(up)
1041
+ assert "Uploaded to the Database Channel" in final and f"<code>{FILE_LINK}</code>" in final and up.flow._busy == set()
1042
+
1043
+
1044
+ async def test_the_button_that_shows_up_later_is_waited_for(up):
1045
+ up.client.messages[555] = document_message(NAME, 10, mid=555)
1046
+ up.flow.link_poll, up.flow.link_wait = 0.01, 3.0
1047
+ await click(up, OWNER, LINK_YES, msg_id=77)
1048
+ await asyncio.sleep(0.1)
1049
+ up.client.messages[4001] = with_button()
1050
+ await up.flow.idle()
1051
+ assert f"<code>{FILE_LINK}</code>" in last_edit(up) and not notices(up)
1052
+
1053
+
1054
+ async def test_when_this_bot_cannot_see_it_the_channel_post_bot_is_asked(up):
1055
+ up.client.messages[555] = document_message(NAME, 10, mid=555)
1056
+ up.flow.link_poll, up.flow.link_wait = 0.01, 0.05
1057
+ await click(up, OWNER, LINK_YES, msg_id=77)
1058
+ nid, text, kw = await the_notice(up)
1059
+ ref = ref_of(text)
1060
+ assert text.splitlines() == ["Get the share URL", "Post: 4001", f"Ref: {ref}"] and kw["parse_mode"] is None
1061
+ await post_bot_says(up, f"πŸ“₯ Message received - looking for the Share URL…\nRef: {ref}", reply_to=nid)
1062
+ await post_bot_says(up, f"πŸ”— Share URL ready\nURL: {FILE_LINK}\nRef: {ref}", reply_to=nid)
1063
+ await up.flow.idle()
1064
+ assert f"<code>{FILE_LINK}</code>" in last_edit(up) and up.flow.flows == {} and up.flow._busy == set()
1065
+
1066
+
1067
+ async def test_a_failure_of_the_channel_post_bot_offers_to_look_again(up):
1068
+ up.client.messages[555] = document_message(NAME, 10, mid=555)
1069
+ up.flow.link_poll, up.flow.link_wait = 0.01, 0.05
1070
+ await click(up, OWNER, LINK_YES, msg_id=77)
1071
+ nid, text, _ = await the_notice(up)
1072
+ await post_bot_says(up, f"❌ Share URL failed: No button after 45 seconds\nRef: {ref_of(text)}", reply_to=nid)
1073
+ await up.flow.idle()
1074
+ chat, final, kw = up.client.edited[-1]
1075
+ assert "no Share URL" in final and "No button after 45 seconds" in final
1076
+ assert flat_buttons(kw["buttons"]) == [("πŸ” Look for the URL again", "dbu:g:4001"), ("βœ– Close", "x:close")]
1077
+ assert len(up.client.forwarded) == 1 # looking again does not upload again
1078
+ up.client.messages[4001] = with_button()
1079
+ await click(up, OWNER, "dbu:g:4001", msg_id=77)
1080
+ await up.flow.idle()
1081
+ assert f"<code>{FILE_LINK}</code>" in last_edit(up) and len(up.client.forwarded) == 1
1082
+
1083
+
1084
+ async def test_a_channel_post_bot_that_is_down_is_reported(up):
1085
+ up.client.messages[555] = document_message(NAME, 10, mid=555)
1086
+ up.flow.link_poll, up.flow.link_wait, up.flow.ack_wait = 0.01, 0.03, 0.1
1087
+ await click(up, OWNER, LINK_YES, msg_id=77)
1088
+ await up.flow.idle()
1089
+ assert "no Share URL" in last_edit(up) and "did not answer" in last_edit(up)
1090
+
1091
+
1092
+ async def test_an_episode_that_was_deleted_or_cannot_be_uploaded_is_reported(up):
1093
+ await click(up, OWNER, f"dbu:l:{OWNER}:999", msg_id=77)
1094
+ await up.flow.idle()
1095
+ assert "not there anymore" in last_edit(up)
1096
+ up.client.messages[555] = document_message(NAME, 10, mid=555)
1097
+ up.client.forward_errors.append(RuntimeError("no rights"))
1098
+ up.client.fail_send_file = True
1099
+ await click(up, OWNER, LINK_YES, msg_id=77)
1100
+ await up.flow.idle()
1101
+ chat, text, kw = up.client.edited[-1]
1102
+ assert "upload to the Database Channel failed" in text and flat_buttons(kw["buttons"])[0] == ("πŸ” Try again", LINK_YES)
1103
+ assert up.flow._busy == set()
1104
+
1105
+
1106
+ async def test_a_second_tap_does_nothing_and_broken_buttons_are_ignored(up):
1107
+ up.client.messages[555] = document_message(NAME, 10, mid=555)
1108
+ up.flow.link_poll, up.flow.link_wait = 0.01, 1.0
1109
+ await click(up, OWNER, LINK_YES, msg_id=77)
1110
+ again = await click(up, OWNER, LINK_YES, msg_id=77)
1111
+ assert again.toasts == ["I'm already on it…"]
1112
+ up.client.messages[4001] = with_button()
1113
+ await up.flow.idle()
1114
+ assert len(up.client.forwarded) == 1
1115
+ for data in ("dbu:l", "dbu:l:1", "dbu:l:a:b", f"dbu:l:{OWNER}:0", "dbu:g", "dbu:g:x", "dbu:n:1"):
1116
+ cb = await click(up, OWNER, data)
1117
+ assert cb.answers and up.flow._busy == set() and len(up.client.forwarded) == 1
1118
+
1119
+
1120
+ def test_the_new_answers_are_understood():
1121
+ assert classify("πŸ”— Share URL ready\nURL: x") == ("ok", "") and classify("❌ Share URL failed: why") == ("fail", "why")
1122
+ assert link_notice_text(post_id=5, ref="r").splitlines() == ["Get the share URL", "Post: 5", "Ref: r"]
tests/tgfakes.py CHANGED
@@ -235,6 +235,7 @@ class FakeClient:
235
  self.forwarded: List[tuple] = [] # forward_messages(entity, messages, from_peer, **kw)
236
  self.forward_errors: List[Exception] = [] # raised by forward_messages(), one per call
237
  self.message_errors: dict = {} # entity -> exception that send_message() raises for it
 
238
 
239
  async def get_me(self) -> SimpleNamespace:
240
  self.me_calls += 1
@@ -277,6 +278,8 @@ class FakeClient:
277
  return source.get(getattr(message, "id", None))
278
 
279
  async def delete_messages(self, entity: Any, ids: Any, **kw: Any) -> None:
 
 
280
  self.deleted.append((entity, list(ids) if isinstance(ids, (list, tuple)) else [ids]))
281
 
282
  async def send_file(self, entity: Any, file: Any, **kw: Any) -> SimpleNamespace:
 
235
  self.forwarded: List[tuple] = [] # forward_messages(entity, messages, from_peer, **kw)
236
  self.forward_errors: List[Exception] = [] # raised by forward_messages(), one per call
237
  self.message_errors: dict = {} # entity -> exception that send_message() raises for it
238
+ self.delete_errors: List[Exception] = [] # raised by delete_messages(), one per call
239
 
240
  async def get_me(self) -> SimpleNamespace:
241
  self.me_calls += 1
 
278
  return source.get(getattr(message, "id", None))
279
 
280
  async def delete_messages(self, entity: Any, ids: Any, **kw: Any) -> None:
281
+ if self.delete_errors:
282
+ raise self.delete_errors.pop(0)
283
  self.deleted.append((entity, list(ids) if isinstance(ids, (list, tuple)) else [ids]))
284
 
285
  async def send_file(self, entity: Any, file: Any, **kw: Any) -> SimpleNamespace: