Spaces:
Running
Running
github-actions[bot] commited on
Commit Β·
6c64e94
1
Parent(s): a670808
Automated sync from GitHub
Browse files- .gitattributes +3 -3
- bot/dbflow.py +447 -16
- tests/test_dbflow.py +417 -6
- 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 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 43 |
-
|
|
|
|
| 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
|
| 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__(
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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() +
|
| 319 |
-
await
|
| 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 |
-
|
|
|
|
|
|
|
| 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 & 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:
|