Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
a670808
1
Parent(s): 676933a
Automated sync from GitHub
Browse files- .gitattributes +3 -3
- bot/app.py +58 -2
- bot/config.py +43 -0
- bot/db.py +14 -3
- bot/dbflow.py +382 -0
- bot/panels.py +18 -2
- bot/pipeline.py +16 -3
- bot/settings_ops.py +25 -0
- tests/test_dbflow.py +711 -0
- tests/tgfakes.py +17 -3
.gitattributes
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.png filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.ttf filter=lfs diff=lfs merge=lfs -text
|
bot/app.py
CHANGED
|
@@ -24,6 +24,8 @@ from . import fasttransfer as transfer
|
|
| 24 |
from . import errorlog, media, mediainfo, panels, richtext, speed, sysinfo
|
| 25 |
from .config import Config
|
| 26 |
from .db import MAX_FORMATS, Database
|
|
|
|
|
|
|
| 27 |
from .errors import FormatError
|
| 28 |
from .formats import (
|
| 29 |
FIELD_KINDS,
|
|
@@ -40,7 +42,7 @@ from .parser import compute_new_name, missing_variables, parse_caption, parse_in
|
|
| 40 |
from .pipeline import PROBE_EXTS, Renamer, embedded_thumb, original_name_of, source_ext
|
| 41 |
from .queue import Job, JobQueue
|
| 42 |
from .sequence import SeqItem, SequenceState, analyse, format_report
|
| 43 |
-
from .settings_ops import CLEAR_WORDS, META_COMMANDS, apply_setting, parse_channel
|
| 44 |
from .telegraph import Telegraph, TelegraphError
|
| 45 |
from .utils import clip, esc, human_size, human_uptime
|
| 46 |
|
|
@@ -223,6 +225,10 @@ class App:
|
|
| 223 |
self._tasks: Set["asyncio.Future[Any]"] = set()
|
| 224 |
self.renamer = Renamer(client, cfg, db)
|
| 225 |
self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled, settle=QUEUE_SETTLE)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
self._warned: Set[int] = set()
|
| 227 |
self._username: Optional[str] = None # the bot's own @username, for the links on shared-format cards
|
| 228 |
self._inline_on = False # is inline mode on for the bot? See _check_inline
|
|
@@ -247,11 +253,34 @@ class App:
|
|
| 247 |
await self.client.get_input_entity(self.cfg.log_channel)
|
| 248 |
except Exception as exc: # noqa: BLE001
|
| 249 |
log.warning("cannot access LOG_CHANNEL %s: %s (add the bot there as an admin)", self.cfg.log_channel, exc)
|
|
|
|
| 250 |
self.queue.start()
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
async def shutdown(self) -> None:
|
| 253 |
for task in list(self._tasks):
|
| 254 |
task.cancel()
|
|
|
|
|
|
|
| 255 |
await self.queue.stop()
|
| 256 |
|
| 257 |
async def _set_commands(self) -> None:
|
|
@@ -342,6 +371,11 @@ class App:
|
|
| 342 |
add(self._guard(self.on_text), events.NewMessage(incoming=True, func=self._wants_text))
|
| 343 |
add(self._guard(self.on_callback), events.CallbackQuery())
|
| 344 |
add(self._guard_inline(self.on_inline), events.InlineQuery())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
add(self._on_stranger, events.NewMessage(incoming=True, func=lambda e: e.is_private and not self.is_allowed(e.sender_id)))
|
| 346 |
|
| 347 |
async def _on_stranger(self, event: Any) -> None:
|
|
@@ -1508,6 +1542,13 @@ class App:
|
|
| 1508 |
await self._cb_rename(event, uid, parts)
|
| 1509 |
return
|
| 1510 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1511 |
if head == "sq":
|
| 1512 |
await self._cb_sequence(event, uid, parts)
|
| 1513 |
return
|
|
@@ -1615,7 +1656,7 @@ class App:
|
|
| 1615 |
active = current is not None and current["id"] == fmt["id"]
|
| 1616 |
if share_inline is None:
|
| 1617 |
share_inline = await self._check_inline()
|
| 1618 |
-
return panels.format_view(fmt, share_inline=share_inline, active=active)
|
| 1619 |
|
| 1620 |
async def _show_format(self, event: Any, uid: int, fid: int, note: str = "") -> bool:
|
| 1621 |
fmt = await self.db.get_format(uid, fid)
|
|
@@ -1673,6 +1714,15 @@ class App:
|
|
| 1673 |
if action in ("v", "cx"):
|
| 1674 |
await self._show_format(event, uid, fid)
|
| 1675 |
await event.answer()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1676 |
elif action == "in" and len(parts) == 4 and parts[3] in (*FIELD_KINDS, "name"):
|
| 1677 |
await self._format_prompt(event, uid, parts[3], fid)
|
| 1678 |
elif action == "th":
|
|
@@ -1769,6 +1819,12 @@ class App:
|
|
| 1769 |
msg = f"✅ Renamed to <b>{esc(value)}</b>."
|
| 1770 |
else:
|
| 1771 |
msg = value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1772 |
else:
|
| 1773 |
ok, msg = await apply_setting(FormatSettings(self.db, p.fid), uid, FIELD_KINDS[field], text)
|
| 1774 |
except FormatError as exc:
|
|
|
|
| 24 |
from . import errorlog, media, mediainfo, panels, richtext, speed, sysinfo
|
| 25 |
from .config import Config
|
| 26 |
from .db import MAX_FORMATS, Database
|
| 27 |
+
from .dbflow import PREFIX as DBFLOW_PREFIX
|
| 28 |
+
from .dbflow import DbFlow
|
| 29 |
from .errors import FormatError
|
| 30 |
from .formats import (
|
| 31 |
FIELD_KINDS,
|
|
|
|
| 42 |
from .pipeline import PROBE_EXTS, Renamer, embedded_thumb, original_name_of, source_ext
|
| 43 |
from .queue import Job, JobQueue
|
| 44 |
from .sequence import SeqItem, SequenceState, analyse, format_report
|
| 45 |
+
from .settings_ops import CLEAR_WORDS, META_COMMANDS, apply_setting, parse_channel, parse_format_channel
|
| 46 |
from .telegraph import Telegraph, TelegraphError
|
| 47 |
from .utils import clip, esc, human_size, human_uptime
|
| 48 |
|
|
|
|
| 225 |
self._tasks: Set["asyncio.Future[Any]"] = set()
|
| 226 |
self.renamer = Renamer(client, cfg, db)
|
| 227 |
self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled, settle=QUEUE_SETTLE)
|
| 228 |
+
# uploading a manually renamed episode to the Database Channel (and having it published) needs both chats
|
| 229 |
+
self.dbflow: Optional[DbFlow] = DbFlow(client, cfg) if cfg.upload_enabled else None
|
| 230 |
+
if self.dbflow is not None:
|
| 231 |
+
self.renamer.on_delivered = self.dbflow.offer
|
| 232 |
self._warned: Set[int] = set()
|
| 233 |
self._username: Optional[str] = None # the bot's own @username, for the links on shared-format cards
|
| 234 |
self._inline_on = False # is inline mode on for the bot? See _check_inline
|
|
|
|
| 253 |
await self.client.get_input_entity(self.cfg.log_channel)
|
| 254 |
except Exception as exc: # noqa: BLE001
|
| 255 |
log.warning("cannot access LOG_CHANNEL %s: %s (add the bot there as an admin)", self.cfg.log_channel, exc)
|
| 256 |
+
await self._check_upload_chats()
|
| 257 |
self.queue.start()
|
| 258 |
|
| 259 |
+
async def _check_upload_chats(self) -> None:
|
| 260 |
+
"""Say in the log what the upload to the Database Channel cannot reach (or that it is switched off)."""
|
| 261 |
+
cfg = self.cfg
|
| 262 |
+
if self.dbflow is None:
|
| 263 |
+
if cfg.db_channel or cfg.comm_chat:
|
| 264 |
+
log.warning(
|
| 265 |
+
"uploading to the Database Channel is off: it needs both DB_CHANNEL (%s) and COMMUNICATION_CHAT (%s)",
|
| 266 |
+
cfg.db_channel or "missing", cfg.comm_chat or "missing",
|
| 267 |
+
)
|
| 268 |
+
return
|
| 269 |
+
log.info(
|
| 270 |
+
"uploading to the Database Channel is on: Database Channel %s, communication chat %s, channel post bot %s",
|
| 271 |
+
cfg.db_channel, cfg.comm_chat, cfg.post_bot_id,
|
| 272 |
+
)
|
| 273 |
+
for name, ref in (("DB_CHANNEL", cfg.db_channel), ("COMMUNICATION_CHAT", cfg.comm_chat)):
|
| 274 |
+
try:
|
| 275 |
+
await self.client.get_input_entity(ref)
|
| 276 |
+
except Exception as exc: # noqa: BLE001
|
| 277 |
+
log.warning("cannot access %s %s: %s (add the bot there as an admin)", name, ref, exc)
|
| 278 |
+
|
| 279 |
async def shutdown(self) -> None:
|
| 280 |
for task in list(self._tasks):
|
| 281 |
task.cancel()
|
| 282 |
+
if self.dbflow is not None:
|
| 283 |
+
await self.dbflow.shutdown()
|
| 284 |
await self.queue.stop()
|
| 285 |
|
| 286 |
async def _set_commands(self) -> None:
|
|
|
|
| 371 |
add(self._guard(self.on_text), events.NewMessage(incoming=True, func=self._wants_text))
|
| 372 |
add(self._guard(self.on_callback), events.CallbackQuery())
|
| 373 |
add(self._guard_inline(self.on_inline), events.InlineQuery())
|
| 374 |
+
if self.dbflow is not None: # the channel post bot's answers in the communication chat (not a user of this bot: no guard)
|
| 375 |
+
add(
|
| 376 |
+
self.dbflow.on_chat_message,
|
| 377 |
+
events.NewMessage(incoming=True, chats=[self.cfg.comm_chat], from_users=[self.cfg.post_bot_id]),
|
| 378 |
+
)
|
| 379 |
add(self._on_stranger, events.NewMessage(incoming=True, func=lambda e: e.is_private and not self.is_allowed(e.sender_id)))
|
| 380 |
|
| 381 |
async def _on_stranger(self, event: Any) -> None:
|
|
|
|
| 1542 |
await self._cb_rename(event, uid, parts)
|
| 1543 |
return
|
| 1544 |
|
| 1545 |
+
if head == DBFLOW_PREFIX:
|
| 1546 |
+
if self.dbflow is None:
|
| 1547 |
+
await event.answer("Uploading to the Database Channel is switched off (DB_CHANNEL and COMMUNICATION_CHAT).", alert=True)
|
| 1548 |
+
else:
|
| 1549 |
+
await self.dbflow.on_callback(event, parts)
|
| 1550 |
+
return
|
| 1551 |
+
|
| 1552 |
if head == "sq":
|
| 1553 |
await self._cb_sequence(event, uid, parts)
|
| 1554 |
return
|
|
|
|
| 1656 |
active = current is not None and current["id"] == fmt["id"]
|
| 1657 |
if share_inline is None:
|
| 1658 |
share_inline = await self._check_inline()
|
| 1659 |
+
return panels.format_view(fmt, share_inline=share_inline, active=active, upload_on=self.cfg.upload_enabled)
|
| 1660 |
|
| 1661 |
async def _show_format(self, event: Any, uid: int, fid: int, note: str = "") -> bool:
|
| 1662 |
fmt = await self.db.get_format(uid, fid)
|
|
|
|
| 1714 |
if action in ("v", "cx"):
|
| 1715 |
await self._show_format(event, uid, fid)
|
| 1716 |
await event.answer()
|
| 1717 |
+
elif action == "in" and len(parts) == 4 and parts[3] == "channel":
|
| 1718 |
+
if fmt["kind"] != "manual" or not self.cfg.upload_enabled:
|
| 1719 |
+
await event.answer("Channel is an option of manual formats, while uploading to the Database Channel is set up.", alert=True)
|
| 1720 |
+
return
|
| 1721 |
+
await self._format_prompt(event, uid, "channel", fid)
|
| 1722 |
+
elif action == "chc":
|
| 1723 |
+
await self.db.update_format(uid, fid, channel=None)
|
| 1724 |
+
await self._show_format(event, uid, fid)
|
| 1725 |
+
await event.answer("Channel cleared.")
|
| 1726 |
elif action == "in" and len(parts) == 4 and parts[3] in (*FIELD_KINDS, "name"):
|
| 1727 |
await self._format_prompt(event, uid, parts[3], fid)
|
| 1728 |
elif action == "th":
|
|
|
|
| 1819 |
msg = f"✅ Renamed to <b>{esc(value)}</b>."
|
| 1820 |
else:
|
| 1821 |
msg = value
|
| 1822 |
+
elif field == "channel":
|
| 1823 |
+
ok, channel, msg = parse_format_channel(text)
|
| 1824 |
+
if ok:
|
| 1825 |
+
if await self.db.update_format(uid, p.fid, channel=channel) is None:
|
| 1826 |
+
raise LookupError
|
| 1827 |
+
msg = f"✅ Channel saved: <code>{channel}</code>" if channel else "✅ Channel cleared."
|
| 1828 |
else:
|
| 1829 |
ok, msg = await apply_setting(FormatSettings(self.db, p.fid), uid, FIELD_KINDS[field], text)
|
| 1830 |
except FormatError as exc:
|
bot/config.py
CHANGED
|
@@ -10,6 +10,9 @@ class ConfigError(RuntimeError):
|
|
| 10 |
pass
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
| 13 |
def _parse_ids(value: Optional[str]) -> Tuple[int, ...]:
|
| 14 |
ids = []
|
| 15 |
for chunk in (value or "").replace(";", ",").replace(" ", ",").split(","):
|
|
@@ -33,6 +36,27 @@ def _get_int(env: Mapping[str, str], name: str, default: int) -> int:
|
|
| 33 |
raise ConfigError(f"{name} must be an integer, got {raw!r}") from exc
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
def _get_bool(env: Mapping[str, str], name: str, default: bool) -> bool:
|
| 37 |
raw = env.get(name, "").strip().lower()
|
| 38 |
if not raw:
|
|
@@ -58,11 +82,19 @@ class Config:
|
|
| 58 |
fast_transfer: bool
|
| 59 |
progress_interval: float
|
| 60 |
stream_transfer: bool = True # big files go straight from the download into the upload instead of through the disk
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
@property
|
| 63 |
def max_file_bytes(self) -> int:
|
| 64 |
return self.max_file_mb * 1024 * 1024
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
def load_config(env: Optional[Mapping[str, str]] = None) -> Config:
|
| 68 |
env = os.environ if env is None else env
|
|
@@ -79,6 +111,14 @@ def load_config(env: Optional[Mapping[str, str]] = None) -> Config:
|
|
| 79 |
except ValueError as exc:
|
| 80 |
raise ConfigError("LOG_CHANNEL must be a numeric chat id like -1001234567890") from exc
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
connections = max(1, min(_get_int(env, "TRANSFER_CONNECTIONS", 8), 16))
|
| 83 |
max_jobs = max(1, min(_get_int(env, "MAX_CONCURRENT_JOBS", 1), 4))
|
| 84 |
|
|
@@ -99,4 +139,7 @@ def load_config(env: Optional[Mapping[str, str]] = None) -> Config:
|
|
| 99 |
fast_transfer=_get_bool(env, "FAST_TRANSFER", True),
|
| 100 |
progress_interval=max(2.0, float(env.get("PROGRESS_INTERVAL", "4") or 4)),
|
| 101 |
stream_transfer=_get_bool(env, "STREAM_RENAME", True),
|
|
|
|
|
|
|
|
|
|
| 102 |
)
|
|
|
|
| 10 |
pass
|
| 11 |
|
| 12 |
|
| 13 |
+
DEFAULT_POST_BOT_ID = 8950016484 # the channel post bot: the only account whose answers in the communication chat count
|
| 14 |
+
|
| 15 |
+
|
| 16 |
def _parse_ids(value: Optional[str]) -> Tuple[int, ...]:
|
| 17 |
ids = []
|
| 18 |
for chunk in (value or "").replace(";", ",").replace(" ", ",").split(","):
|
|
|
|
| 36 |
raise ConfigError(f"{name} must be an integer, got {raw!r}") from exc
|
| 37 |
|
| 38 |
|
| 39 |
+
def _env_any(env: Mapping[str, str], name: str) -> str:
|
| 40 |
+
"""A variable by name whatever its capital letters (``DB_Channel``, ``DB_CHANNEL`` and ``db_channel`` are one setting)."""
|
| 41 |
+
raw = env.get(name)
|
| 42 |
+
if raw is None:
|
| 43 |
+
wanted = name.lower()
|
| 44 |
+
raw = next((v for k, v in env.items() if k.lower() == wanted), "")
|
| 45 |
+
return (raw or "").strip()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _get_chat(env: Mapping[str, str], name: str) -> Optional[int]:
|
| 49 |
+
"""A chat id such as ``-1001234567890`` (unset or empty: ``None``; a bare channel id gets its ``-100`` in front)."""
|
| 50 |
+
raw = _env_any(env, name)
|
| 51 |
+
if not raw or raw.lower() in {"0", "off", "none", "false", "no"}:
|
| 52 |
+
return None
|
| 53 |
+
try:
|
| 54 |
+
value = int(raw)
|
| 55 |
+
except ValueError as exc:
|
| 56 |
+
raise ConfigError(f"{name} must be a numeric chat id like -1001234567890, got {raw!r}") from exc
|
| 57 |
+
return -1_000_000_000_000 - value if value > 0 else value
|
| 58 |
+
|
| 59 |
+
|
| 60 |
def _get_bool(env: Mapping[str, str], name: str, default: bool) -> bool:
|
| 61 |
raw = env.get(name, "").strip().lower()
|
| 62 |
if not raw:
|
|
|
|
| 82 |
fast_transfer: bool
|
| 83 |
progress_interval: float
|
| 84 |
stream_transfer: bool = True # big files go straight from the download into the upload instead of through the disk
|
| 85 |
+
db_channel: Optional[int] = None # the Database Channel a manually renamed episode can be uploaded to
|
| 86 |
+
comm_chat: Optional[int] = None # the chat where this bot and the channel post bot talk to each other
|
| 87 |
+
post_bot_id: int = DEFAULT_POST_BOT_ID
|
| 88 |
|
| 89 |
@property
|
| 90 |
def max_file_bytes(self) -> int:
|
| 91 |
return self.max_file_mb * 1024 * 1024
|
| 92 |
|
| 93 |
+
@property
|
| 94 |
+
def upload_enabled(self) -> bool:
|
| 95 |
+
"""Uploading to the Database Channel (and having the post published) needs both chats."""
|
| 96 |
+
return bool(self.db_channel and self.comm_chat)
|
| 97 |
+
|
| 98 |
|
| 99 |
def load_config(env: Optional[Mapping[str, str]] = None) -> Config:
|
| 100 |
env = os.environ if env is None else env
|
|
|
|
| 111 |
except ValueError as exc:
|
| 112 |
raise ConfigError("LOG_CHANNEL must be a numeric chat id like -1001234567890") from exc
|
| 113 |
|
| 114 |
+
db_channel = _get_chat(env, "DB_CHANNEL")
|
| 115 |
+
comm_chat = _get_chat(env, "COMMUNICATION_CHAT")
|
| 116 |
+
post_bot_raw = _env_any(env, "CHANNEL_POST_BOT_ID")
|
| 117 |
+
try:
|
| 118 |
+
post_bot_id = int(post_bot_raw) if post_bot_raw else DEFAULT_POST_BOT_ID
|
| 119 |
+
except ValueError as exc:
|
| 120 |
+
raise ConfigError(f"CHANNEL_POST_BOT_ID must be a numeric user id, got {post_bot_raw!r}") from exc
|
| 121 |
+
|
| 122 |
connections = max(1, min(_get_int(env, "TRANSFER_CONNECTIONS", 8), 16))
|
| 123 |
max_jobs = max(1, min(_get_int(env, "MAX_CONCURRENT_JOBS", 1), 4))
|
| 124 |
|
|
|
|
| 139 |
fast_transfer=_get_bool(env, "FAST_TRANSFER", True),
|
| 140 |
progress_interval=max(2.0, float(env.get("PROGRESS_INTERVAL", "4") or 4)),
|
| 141 |
stream_transfer=_get_bool(env, "STREAM_RENAME", True),
|
| 142 |
+
db_channel=db_channel,
|
| 143 |
+
comm_chat=comm_chat,
|
| 144 |
+
post_bot_id=post_bot_id,
|
| 145 |
)
|
bot/db.py
CHANGED
|
@@ -59,6 +59,8 @@ DEFAULT_FORMAT: Dict[str, Any] = {
|
|
| 59 |
"meta": {"title": "", "author": "", "artist": "", "audio": "", "subtitle": "", "video": ""},
|
| 60 |
"keep_audio": [],
|
| 61 |
"keep_subtitle": [],
|
|
|
|
|
|
|
| 62 |
}
|
| 63 |
|
| 64 |
|
|
@@ -165,6 +167,9 @@ def _merge_format(stored: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 165 |
for key in ("keep_audio", "keep_subtitle"):
|
| 166 |
if isinstance(stored.get(key), list):
|
| 167 |
merged[key] = [str(t) for t in stored[key]]
|
|
|
|
|
|
|
|
|
|
| 168 |
return merged
|
| 169 |
|
| 170 |
|
|
@@ -389,7 +394,7 @@ class Database:
|
|
| 389 |
return _format_dict(fid, name, raw, False)
|
| 390 |
|
| 391 |
async def update_format(self, uid: int, fid: int, **changes: Any) -> Optional[Dict[str, Any]]:
|
| 392 |
-
"""Change ``name``, ``template``, ``caption``, ``keep_audio``, ``keep_subtitle`` or merge a ``meta`` dict; ``None`` if it is gone."""
|
| 393 |
async with self._sessions() as session:
|
| 394 |
row = await session.get(Format, fid)
|
| 395 |
if row is None or row.user_id != uid:
|
|
@@ -402,6 +407,8 @@ class Database:
|
|
| 402 |
row.name = value
|
| 403 |
elif key == "meta":
|
| 404 |
current["meta"].update(value)
|
|
|
|
|
|
|
| 405 |
elif key in DEFAULT_FORMAT:
|
| 406 |
current[key] = value
|
| 407 |
else:
|
|
@@ -478,7 +485,8 @@ class Database:
|
|
| 478 |
|
| 479 |
The copy is of the same kind as the original unless ``kind`` says otherwise: that is how a user copies an auto format
|
| 480 |
to the manual ones and the other way round (``owner_id`` is then the user themselves). ``None`` when that format is
|
| 481 |
-
gone; ``FormatError("limit")`` when ``uid`` already has ``MAX_FORMATS`` of that kind.
|
|
|
|
| 482 |
"""
|
| 483 |
if kind is not None and kind not in FORMAT_KINDS:
|
| 484 |
raise KeyError(kind)
|
|
@@ -491,7 +499,10 @@ class Database:
|
|
| 491 |
if len(names) >= MAX_FORMATS:
|
| 492 |
raise FormatError("limit")
|
| 493 |
name = _free_name(source.name, names, name_max)
|
| 494 |
-
|
|
|
|
|
|
|
|
|
|
| 495 |
row = Format(user_id=uid, name=name, data=data, thumb=source.thumb)
|
| 496 |
session.add(row)
|
| 497 |
await session.flush()
|
|
|
|
| 59 |
"meta": {"title": "", "author": "", "artist": "", "audio": "", "subtitle": "", "video": ""},
|
| 60 |
"keep_audio": [],
|
| 61 |
"keep_subtitle": [],
|
| 62 |
+
# a manual format can name the channel its episodes are published in (see dbflow); None: no post is made
|
| 63 |
+
"channel": None,
|
| 64 |
}
|
| 65 |
|
| 66 |
|
|
|
|
| 167 |
for key in ("keep_audio", "keep_subtitle"):
|
| 168 |
if isinstance(stored.get(key), list):
|
| 169 |
merged[key] = [str(t) for t in stored[key]]
|
| 170 |
+
channel = stored.get("channel")
|
| 171 |
+
if merged["kind"] == "manual" and isinstance(channel, int) and not isinstance(channel, bool):
|
| 172 |
+
merged["channel"] = channel # only a manual format has one: an auto format renames files that nobody uploads on
|
| 173 |
return merged
|
| 174 |
|
| 175 |
|
|
|
|
| 394 |
return _format_dict(fid, name, raw, False)
|
| 395 |
|
| 396 |
async def update_format(self, uid: int, fid: int, **changes: Any) -> Optional[Dict[str, Any]]:
|
| 397 |
+
"""Change ``name``, ``template``, ``caption``, ``keep_audio``, ``keep_subtitle``, ``channel`` or merge a ``meta`` dict; ``None`` if it is gone."""
|
| 398 |
async with self._sessions() as session:
|
| 399 |
row = await session.get(Format, fid)
|
| 400 |
if row is None or row.user_id != uid:
|
|
|
|
| 407 |
row.name = value
|
| 408 |
elif key == "meta":
|
| 409 |
current["meta"].update(value)
|
| 410 |
+
elif key == "channel":
|
| 411 |
+
current["channel"] = value if current["kind"] == "manual" else None
|
| 412 |
elif key in DEFAULT_FORMAT:
|
| 413 |
current[key] = value
|
| 414 |
else:
|
|
|
|
| 485 |
|
| 486 |
The copy is of the same kind as the original unless ``kind`` says otherwise: that is how a user copies an auto format
|
| 487 |
to the manual ones and the other way round (``owner_id`` is then the user themselves). ``None`` when that format is
|
| 488 |
+
gone; ``FormatError("limit")`` when ``uid`` already has ``MAX_FORMATS`` of that kind. The Channel of a manual format is
|
| 489 |
+
kept only by its owner's own manual copy.
|
| 490 |
"""
|
| 491 |
if kind is not None and kind not in FORMAT_KINDS:
|
| 492 |
raise KeyError(kind)
|
|
|
|
| 499 |
if len(names) >= MAX_FORMATS:
|
| 500 |
raise FormatError("limit")
|
| 501 |
name = _free_name(source.name, names, name_max)
|
| 502 |
+
copied = _merge_format(_stored(source.data))
|
| 503 |
+
if uid != owner_id or target != "manual":
|
| 504 |
+
copied["channel"] = None # somebody else's copy (or an auto one) does not publish in the original's channel
|
| 505 |
+
data = _format_json({**copied, "kind": target})
|
| 506 |
row = Format(user_id=uid, name=name, data=data, thumb=source.thumb)
|
| 507 |
session.add(row)
|
| 508 |
await session.flush()
|
bot/dbflow.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Upload a manually renamed episode to the Database Channel, and have the channel post bot publish it.
|
| 2 |
+
|
| 3 |
+
All of this exists only while DB_CHANNEL and COMMUNICATION_CHAT are both set.
|
| 4 |
+
|
| 5 |
+
1. After /rename with a manual format has sent the renamed episode, ``DbFlow.offer`` asks (Yes / No) whether to upload it.
|
| 6 |
+
2. Yes: the message becomes "Processing…" and the episode is copied into the Database Channel on Telegram's side (forwarded
|
| 7 |
+
without the "Forwarded from" line): nothing is downloaded or uploaded again.
|
| 8 |
+
3. When the format has a Channel, "File uploaded to the Database Channel" is then written in the communication chat, with the
|
| 9 |
+
episode number, that channel id, the file name and the id of the copy. The channel post bot answers there, replying to it:
|
| 10 |
+
"message received", then "post published successfully" or "publication failed: <reason>".
|
| 11 |
+
4. Those answers are read here (``on_chat_message``) and shown in the Processing message. No answer at all: the channel post
|
| 12 |
+
bot is down.
|
| 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 |
+
|
| 21 |
+
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 |
+
|
| 28 |
+
from telethon import errors
|
| 29 |
+
|
| 30 |
+
from . import panels
|
| 31 |
+
from .config import Config
|
| 32 |
+
from .parser import parse_info, split_ext
|
| 33 |
+
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 |
+
|
| 47 |
+
def episode_of(file_name: str) -> str:
|
| 48 |
+
"""The episode number of a file name, as written in it (``05``); empty when it has none."""
|
| 49 |
+
return parse_info(split_ext(file_name or "")[0]).episode
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def line_of(label: str, text: str) -> str:
|
| 53 |
+
"""The value of a ``Label: value`` line of a message; empty when there is no such line."""
|
| 54 |
+
m = re.search(rf"(?im)^[ \t]*{label}[ \t]*:[ \t]*(.+?)[ \t]*$", text or "")
|
| 55 |
+
return m.group(1).strip() if m else ""
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def classify(text: Optional[str]) -> Tuple[str, str]:
|
| 59 |
+
"""What a message of the channel post bot says: ``("fail", reason)``, ``("ok", "")``, ``("ack", "")`` or ``("other", "")``."""
|
| 60 |
+
text = text or ""
|
| 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", ""
|
| 68 |
+
return "other", ""
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def notice_text(*, episode: str, channel: int, file: str, post_id: Optional[int], ref: str) -> str:
|
| 72 |
+
"""The message for the communication chat. The channel post bot reads the Episode and the Channel from it."""
|
| 73 |
+
lines = [NOTICE_HEAD]
|
| 74 |
+
if episode:
|
| 75 |
+
lines.append(f"Episode: {episode}")
|
| 76 |
+
lines.append(f"Channel: {channel}")
|
| 77 |
+
lines.append(f"File: {file}")
|
| 78 |
+
if post_id:
|
| 79 |
+
lines.append(f"Post: {post_id}")
|
| 80 |
+
lines.append(f"Ref: {ref}")
|
| 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)
|
| 87 |
+
return getattr(message, "reply_to_msg_id", None) or getattr(event, "reply_to_msg_id", None) or None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _name(name: str) -> str:
|
| 91 |
+
return f"<code>{esc(clip(name, 120))}</code>" if name else ""
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _why(exc: BaseException) -> str:
|
| 95 |
+
return esc(clip(f"{type(exc).__name__}: {exc}", 250))
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def offer_text(name: str, episode: str, channel: Optional[int]) -> str:
|
| 99 |
+
lines = ["📤 <b>Upload this episode to the Database Channel?</b>", _name(name)]
|
| 100 |
+
if channel:
|
| 101 |
+
lines.append(f"🎞 Episode: <b>{esc(episode)}</b>" if episode else "⚠️ No episode number found in the file name (the post uses it).")
|
| 102 |
+
lines.append(f"📣 Channel: <code>{channel}</code>: the channel post bot posts it there.")
|
| 103 |
+
else:
|
| 104 |
+
lines.append("This format has no Channel, so the episode is only uploaded: no post is made.")
|
| 105 |
+
return "\n".join(line for line in lines if line)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def processing_text(name: str, *steps: str) -> str:
|
| 109 |
+
return "\n".join(line for line in ["⏳ <b>Processing…</b>", _name(name), *steps] if line)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def published_text(name: str, reply: str, channel: int) -> str:
|
| 113 |
+
where = line_of("channel", reply) or str(channel)
|
| 114 |
+
link = line_of("post", reply)
|
| 115 |
+
lines = ["✅ <b>Post published successfully</b>", _name(name), f"📣 {esc(clip(where, 80))}"]
|
| 116 |
+
if link:
|
| 117 |
+
lines.append(esc(clip(link, 200)))
|
| 118 |
+
return "\n".join(line for line in lines if line)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def failed_text(name: str, reason: str) -> str:
|
| 122 |
+
lines = ["❌ <b>Publication failed</b>", _name(name), esc(clip(reason or "No reason was given.", 600)), "The episode is in the Database Channel."]
|
| 123 |
+
return "\n".join(line for line in lines if line)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def down_text(name: str, seconds: float) -> str:
|
| 127 |
+
return "\n".join(
|
| 128 |
+
[
|
| 129 |
+
"⚠️ <b>The channel post bot is down or not responding</b>",
|
| 130 |
+
_name(name),
|
| 131 |
+
f"It did not answer in the communication chat within {int(seconds)} seconds. "
|
| 132 |
+
"The episode is in the Database Channel, but nothing was published.",
|
| 133 |
+
]
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def silent_text(name: str, seconds: float) -> str:
|
| 138 |
+
return "\n".join(
|
| 139 |
+
[
|
| 140 |
+
"⚠️ <b>No word from the channel post bot</b>",
|
| 141 |
+
_name(name),
|
| 142 |
+
f"It got the message but did not say whether the post was published in {int(seconds // 60)} minutes. Check the channel.",
|
| 143 |
+
]
|
| 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)
|
| 153 |
+
notice_id: int = 0 # the notice in the communication chat; an answer is a reply to it
|
| 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
|
| 167 |
+
async def offer(self, job: Any, target: int, sent: Any) -> None:
|
| 168 |
+
"""Ask whether the episode that was just sent (``sent``, in ``target``) goes to the Database Channel too."""
|
| 169 |
+
sent_id = getattr(sent, "id", None)
|
| 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,
|
| 177 |
+
)
|
| 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")
|
| 184 |
+
return
|
| 185 |
+
try:
|
| 186 |
+
if action != "y" or len(parts) != 5:
|
| 187 |
+
raise ValueError(parts)
|
| 188 |
+
source_chat, source_id, channel = int(parts[2]), int(parts[3]), int(parts[4])
|
| 189 |
+
except ValueError:
|
| 190 |
+
await event.answer()
|
| 191 |
+
return
|
| 192 |
+
key = (event.chat_id, event.message_id)
|
| 193 |
+
if key in self._busy:
|
| 194 |
+
await event.answer("I'm already on it…")
|
| 195 |
+
return
|
| 196 |
+
self._busy.add(key)
|
| 197 |
+
try:
|
| 198 |
+
await event.answer()
|
| 199 |
+
try:
|
| 200 |
+
await event.edit(processing_text(""), buttons=None, parse_mode="html")
|
| 201 |
+
except errors.MessageNotModifiedError:
|
| 202 |
+
pass
|
| 203 |
+
except BaseException:
|
| 204 |
+
self._busy.discard(key)
|
| 205 |
+
raise
|
| 206 |
+
self._spawn(self._run(key, source_chat, source_id, channel or None))
|
| 207 |
+
|
| 208 |
+
# --------------------------------------------------------------- the upload
|
| 209 |
+
async def _run(self, key: Tuple[int, int], source_chat: int, source_id: int, channel: Optional[int]) -> None:
|
| 210 |
+
chat_id, msg_id = key
|
| 211 |
+
flow: Optional[Flow] = None
|
| 212 |
+
name = ""
|
| 213 |
+
try:
|
| 214 |
+
msg = await self.client.get_messages(source_chat, ids=source_id)
|
| 215 |
+
if msg is None or getattr(msg, "file", None) is None:
|
| 216 |
+
await self._show(
|
| 217 |
+
chat_id, msg_id, "❌ <b>That episode is not there anymore</b>\nIt was deleted from the chat, so there is nothing to upload."
|
| 218 |
+
)
|
| 219 |
+
return
|
| 220 |
+
name = getattr(msg.file, "name", None) or ""
|
| 221 |
+
episode = episode_of(name)
|
| 222 |
+
|
| 223 |
+
# ---- 1. the Database Channel
|
| 224 |
+
try:
|
| 225 |
+
copy = await self._copy(msg, source_chat)
|
| 226 |
+
except asyncio.CancelledError:
|
| 227 |
+
raise
|
| 228 |
+
except Exception as exc: # noqa: BLE001
|
| 229 |
+
log.warning("could not upload %s to the Database Channel: %s: %s", name, type(exc).__name__, exc)
|
| 230 |
+
retry = [[("🔁 Try again", f"{PREFIX}:y:{source_chat}:{source_id}:{channel or 0}"), ("✖ Close", "x:close")]]
|
| 231 |
+
await self._show(
|
| 232 |
+
chat_id, msg_id,
|
| 233 |
+
f"❌ <b>The upload to the Database Channel failed</b>\n{_name(name)}\n{_why(exc)}\n"
|
| 234 |
+
"Check that the bot is an admin of the Database Channel and can post there.",
|
| 235 |
+
retry,
|
| 236 |
+
)
|
| 237 |
+
return
|
| 238 |
+
if channel is None:
|
| 239 |
+
await self._show(
|
| 240 |
+
chat_id, msg_id,
|
| 241 |
+
f"✅ <b>Uploaded to the Database Channel</b>\n{_name(name)}\nThis format has no Channel, so no post is made.",
|
| 242 |
+
)
|
| 243 |
+
return
|
| 244 |
+
|
| 245 |
+
# ---- 2. the communication chat
|
| 246 |
+
flow = Flow(ref=secrets.token_hex(3))
|
| 247 |
+
self.flows[flow.ref] = flow # before the notice goes out, so that no answer can come back too early
|
| 248 |
+
uploaded = "✅ Uploaded to the Database Channel"
|
| 249 |
+
await self._show(chat_id, msg_id, processing_text(name, uploaded, "⏳ Waiting for the channel post bot…"))
|
| 250 |
+
try:
|
| 251 |
+
note = await self.client.send_message(
|
| 252 |
+
self.cfg.comm_chat,
|
| 253 |
+
notice_text(episode=episode, channel=channel, file=name, post_id=_id_of(copy), ref=flow.ref),
|
| 254 |
+
parse_mode=None, link_preview=False,
|
| 255 |
+
)
|
| 256 |
+
except asyncio.CancelledError:
|
| 257 |
+
raise
|
| 258 |
+
except Exception as exc: # noqa: BLE001
|
| 259 |
+
log.warning("could not write in the communication chat: %s: %s", type(exc).__name__, exc)
|
| 260 |
+
await self._show(
|
| 261 |
+
chat_id, msg_id,
|
| 262 |
+
f"⚠️ <b>Uploaded, but I could not write in the communication chat</b>\n{_name(name)}\n{_why(exc)}\n"
|
| 263 |
+
"Nothing was published. Is the bot a member of that chat?",
|
| 264 |
+
)
|
| 265 |
+
return
|
| 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
|
| 282 |
+
log.exception("the upload to the Database Channel went wrong")
|
| 283 |
+
await self._show(chat_id, msg_id, f"❌ <b>Something went wrong</b>\n{_name(name)}\n{_why(exc)}")
|
| 284 |
+
finally:
|
| 285 |
+
self._busy.discard(key)
|
| 286 |
+
if flow is not None:
|
| 287 |
+
self.flows.pop(flow.ref, None)
|
| 288 |
+
|
| 289 |
+
async def _copy(self, msg: Any, source_chat: int) -> Any:
|
| 290 |
+
"""The episode in the Database Channel, copied on Telegram's side: forwarded without the author, so nothing is downloaded or
|
| 291 |
+
uploaded. When Telegram refuses that (a chat that forbids forwarding), the same file is sent again by its id, which is as quick."""
|
| 292 |
+
try:
|
| 293 |
+
return await self.client.forward_messages(self.cfg.db_channel, msg.id, from_peer=source_chat, drop_author=True)
|
| 294 |
+
except asyncio.CancelledError:
|
| 295 |
+
raise
|
| 296 |
+
except Exception as exc: # noqa: BLE001
|
| 297 |
+
log.info("forwarding to the Database Channel was refused (%s: %s); sending the file by its id", type(exc).__name__, exc)
|
| 298 |
+
return await self.client.send_file(
|
| 299 |
+
self.cfg.db_channel, msg.media, caption=getattr(msg, "message", "") or "",
|
| 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
|
| 309 |
+
while True:
|
| 310 |
+
try:
|
| 311 |
+
kind, text = await asyncio.wait_for(flow.inbox.get(), timeout=max(0.01, deadline - loop.time()))
|
| 312 |
+
except asyncio.TimeoutError:
|
| 313 |
+
return ("silent" if acked else "down"), ""
|
| 314 |
+
if kind != "ack":
|
| 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:
|
| 323 |
+
await self.client.edit_message(
|
| 324 |
+
chat_id, msg_id, text, buttons=panels.to_buttons(rows) if rows else None, parse_mode="html", link_preview=False
|
| 325 |
+
)
|
| 326 |
+
except errors.MessageNotModifiedError:
|
| 327 |
+
pass
|
| 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."""
|
| 334 |
+
try:
|
| 335 |
+
if getattr(event, "chat_id", None) != self.cfg.comm_chat or getattr(event, "sender_id", None) != self.cfg.post_bot_id:
|
| 336 |
+
return # (the event filter does this already)
|
| 337 |
+
text = getattr(event, "raw_text", None) or ""
|
| 338 |
+
kind = classify(text)[0]
|
| 339 |
+
if kind == "other":
|
| 340 |
+
return
|
| 341 |
+
flow = self._flow_for(event, text)
|
| 342 |
+
if flow is not None:
|
| 343 |
+
flow.inbox.put_nowait((kind, text))
|
| 344 |
+
except Exception: # noqa: BLE001
|
| 345 |
+
log.exception("could not read a message in the communication chat")
|
| 346 |
+
|
| 347 |
+
def _flow_for(self, event: Any, text: str) -> Optional[Flow]:
|
| 348 |
+
"""The upload an answer belongs to: the one whose Ref it repeats, or whose notice it replies to."""
|
| 349 |
+
ref = line_of("ref", text)
|
| 350 |
+
if ref and ref in self.flows:
|
| 351 |
+
return self.flows[ref]
|
| 352 |
+
target = reply_target(event)
|
| 353 |
+
if target:
|
| 354 |
+
return next((f for f in self.flows.values() if f.notice_id == target), None)
|
| 355 |
+
if len(self.flows) == 1: # a plain message, and only one upload waiting for an answer
|
| 356 |
+
return next(iter(self.flows.values()))
|
| 357 |
+
return None
|
| 358 |
+
|
| 359 |
+
# ---------------------------------------------------------------- plumbing
|
| 360 |
+
def _spawn(self, coro: Any) -> "asyncio.Future[Any]":
|
| 361 |
+
task = asyncio.ensure_future(coro)
|
| 362 |
+
self._tasks.add(task)
|
| 363 |
+
task.add_done_callback(self._tasks.discard)
|
| 364 |
+
return task
|
| 365 |
+
|
| 366 |
+
async def idle(self) -> None:
|
| 367 |
+
"""Wait until no upload is under way (the tests use this)."""
|
| 368 |
+
while self._tasks:
|
| 369 |
+
await asyncio.gather(*list(self._tasks), return_exceptions=True)
|
| 370 |
+
|
| 371 |
+
async def shutdown(self) -> None:
|
| 372 |
+
for task in list(self._tasks):
|
| 373 |
+
task.cancel()
|
| 374 |
+
await asyncio.gather(*list(self._tasks), return_exceptions=True)
|
| 375 |
+
self._tasks.clear()
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def _id_of(sent: Any) -> Optional[int]:
|
| 379 |
+
if isinstance(sent, (list, tuple)):
|
| 380 |
+
sent = sent[0] if sent else None
|
| 381 |
+
value = getattr(sent, "id", None)
|
| 382 |
+
return value if isinstance(value, int) else None
|
bot/panels.py
CHANGED
|
@@ -346,13 +346,17 @@ def _format_lines(fmt: Dict[str, Any], *, thumb_yes: str, thumb_no: str) -> List
|
|
| 346 |
return lines
|
| 347 |
|
| 348 |
|
| 349 |
-
def format_view(fmt: Dict[str, Any], *, share_inline: bool = False, active: bool = False) -> Panel:
|
| 350 |
"""A format's screen. ``active``: it is the auto format in use. ``share_inline``: the Share format button opens the chat
|
| 351 |
picker straight away; that only works while inline mode is on for the bot (Telegram refuses the message otherwise), so
|
| 352 |
-
without it the button explains how.
|
|
|
|
| 353 |
kind = fmt.get("kind", "manual")
|
|
|
|
| 354 |
lines = [f"<b>🗂 {esc(clip(fmt['name'], 30))}</b> · {KIND_ICONS[kind]} {KIND_LABELS[kind]}", ""]
|
| 355 |
lines += _format_lines(fmt, thumb_yes="✅ saved", thumb_no=THUMB_NONE)
|
|
|
|
|
|
|
| 356 |
if kind == "manual":
|
| 357 |
lines += ["", "Use it with /rename. Mode, file type and the rest come from your normal settings."]
|
| 358 |
elif active:
|
|
@@ -375,6 +379,13 @@ def format_view(fmt: Dict[str, Any], *, share_inline: bool = False, active: bool
|
|
| 375 |
[("Title", f"fm:in:{fid}:title"), ("Author", f"fm:in:{fid}:author"), ("Artist", f"fm:in:{fid}:artist")],
|
| 376 |
[("Audio", f"fm:in:{fid}:audio"), ("Subtitle", f"fm:in:{fid}:subtitle"), ("Video", f"fm:in:{fid}:video")],
|
| 377 |
[("Keep Audio", f"fm:in:{fid}:keep_audio"), ("Keep Subtitle", f"fm:in:{fid}:keep_subtitle")],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
[("🏷 Apply default metadata", f"fm:dm:{fid}")],
|
| 379 |
[(f"📋 Copy to {other_kind(kind)} formats", f"fm:cp:{fid}")],
|
| 380 |
]
|
|
@@ -612,6 +623,11 @@ FORMAT_PROMPTS: Dict[str, str] = {
|
|
| 612 |
"title": PROMPTS["mt_title"], "author": PROMPTS["mt_author"], "artist": PROMPTS["mt_artist"],
|
| 613 |
"audio": PROMPTS["mt_audio"], "subtitle": PROMPTS["mt_subtitle"], "video": PROMPTS["mt_video"],
|
| 614 |
"keep_audio": PROMPTS["keep_audio"], "keep_subtitle": PROMPTS["keep_subtitle"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
}
|
| 616 |
|
| 617 |
|
|
|
|
| 346 |
return lines
|
| 347 |
|
| 348 |
|
| 349 |
+
def format_view(fmt: Dict[str, Any], *, share_inline: bool = False, active: bool = False, upload_on: bool = False) -> Panel:
|
| 350 |
"""A format's screen. ``active``: it is the auto format in use. ``share_inline``: the Share format button opens the chat
|
| 351 |
picker straight away; that only works while inline mode is on for the bot (Telegram refuses the message otherwise), so
|
| 352 |
+
without it the button explains how. ``upload_on``: uploading to the Database Channel is set up, so a manual format shows
|
| 353 |
+
its Channel option."""
|
| 354 |
kind = fmt.get("kind", "manual")
|
| 355 |
+
has_channel_option = upload_on and kind == "manual"
|
| 356 |
lines = [f"<b>🗂 {esc(clip(fmt['name'], 30))}</b> · {KIND_ICONS[kind]} {KIND_LABELS[kind]}", ""]
|
| 357 |
lines += _format_lines(fmt, thumb_yes="✅ saved", thumb_no=THUMB_NONE)
|
| 358 |
+
if has_channel_option:
|
| 359 |
+
lines.append(f"{DOT} <b>Channel</b> ▸ " + (_code(str(fmt["channel"])) if fmt.get("channel") else "— (optional)"))
|
| 360 |
if kind == "manual":
|
| 361 |
lines += ["", "Use it with /rename. Mode, file type and the rest come from your normal settings."]
|
| 362 |
elif active:
|
|
|
|
| 379 |
[("Title", f"fm:in:{fid}:title"), ("Author", f"fm:in:{fid}:author"), ("Artist", f"fm:in:{fid}:artist")],
|
| 380 |
[("Audio", f"fm:in:{fid}:audio"), ("Subtitle", f"fm:in:{fid}:subtitle"), ("Video", f"fm:in:{fid}:video")],
|
| 381 |
[("Keep Audio", f"fm:in:{fid}:keep_audio"), ("Keep Subtitle", f"fm:in:{fid}:keep_subtitle")],
|
| 382 |
+
]
|
| 383 |
+
if has_channel_option:
|
| 384 |
+
channel_row: Row = [("Channel", f"fm:in:{fid}:channel")]
|
| 385 |
+
if fmt.get("channel"):
|
| 386 |
+
channel_row.append(("🗑 Clear Channel", f"fm:chc:{fid}"))
|
| 387 |
+
rows.append(channel_row)
|
| 388 |
+
rows += [
|
| 389 |
[("🏷 Apply default metadata", f"fm:dm:{fid}")],
|
| 390 |
[(f"📋 Copy to {other_kind(kind)} formats", f"fm:cp:{fid}")],
|
| 391 |
]
|
|
|
|
| 623 |
"title": PROMPTS["mt_title"], "author": PROMPTS["mt_author"], "artist": PROMPTS["mt_artist"],
|
| 624 |
"audio": PROMPTS["mt_audio"], "subtitle": PROMPTS["mt_subtitle"], "video": PROMPTS["mt_video"],
|
| 625 |
"keep_audio": PROMPTS["keep_audio"], "keep_subtitle": PROMPTS["keep_subtitle"],
|
| 626 |
+
"channel": (
|
| 627 |
+
"<b>Send the id of the channel</b> this format's episodes are published in, like <code>-1001234567890</code>.\n\n"
|
| 628 |
+
"After /rename I can upload the episode to the Database Channel; the channel post bot then posts it in this channel.\n"
|
| 629 |
+
"It is optional: send <code>off</code> to clear it (the episode is then only uploaded, nothing is posted)."
|
| 630 |
+
),
|
| 631 |
}
|
| 632 |
|
| 633 |
|
bot/pipeline.py
CHANGED
|
@@ -7,7 +7,7 @@ import shutil
|
|
| 7 |
import time
|
| 8 |
from dataclasses import dataclass
|
| 9 |
from pathlib import Path
|
| 10 |
-
from typing import Any, Dict, List, Optional, Tuple
|
| 11 |
|
| 12 |
from telethon import errors, types
|
| 13 |
|
|
@@ -112,6 +112,9 @@ class Renamer:
|
|
| 112 |
self.client = client
|
| 113 |
self.cfg = cfg
|
| 114 |
self.db = db
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
# ------------------------------------------------------------------
|
| 117 |
async def _fresh_message(self, job: Job) -> Any:
|
|
@@ -260,11 +263,11 @@ class Renamer:
|
|
| 260 |
if target == job.chat_id:
|
| 261 |
kwargs["reply_to"] = job.msg_id
|
| 262 |
try:
|
| 263 |
-
await self.client.send_file(target, input_file, **kwargs)
|
| 264 |
except (errors.EntityBoundsInvalidError, errors.MediaCaptionTooLongError, errors.MessageTooLongError):
|
| 265 |
# the caption template produced something Telegram rejects: send the file without a caption
|
| 266 |
kwargs.update(caption=None)
|
| 267 |
-
await self.client.send_file(target, input_file, **kwargs)
|
| 268 |
warnings.append("The caption was rejected by Telegram (check your caption template), so it was left out.")
|
| 269 |
|
| 270 |
await self.db.record_job(job.user_id, final_size)
|
|
@@ -275,6 +278,16 @@ class Renamer:
|
|
| 275 |
await reporter.finish(
|
| 276 |
f"✅ <b>Done</b> in {human_duration(took)}\n<code>{esc(clip(job.new_name, 120))}</code>\n{human_size(final_size)}{rates}{where}{note}"
|
| 277 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
|
| 279 |
async def _run(self, job: Job, s: Dict[str, Any], work: Path, reporter: ProgressReporter, started: float) -> None:
|
| 280 |
client, cfg = self.client, self.cfg
|
|
|
|
| 7 |
import time
|
| 8 |
from dataclasses import dataclass
|
| 9 |
from pathlib import Path
|
| 10 |
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
| 11 |
|
| 12 |
from telethon import errors, types
|
| 13 |
|
|
|
|
| 112 |
self.client = client
|
| 113 |
self.cfg = cfg
|
| 114 |
self.db = db
|
| 115 |
+
# called as ``await on_delivered(job, chat, sent)`` once a file renamed with a manual format is in its chat (the offer
|
| 116 |
+
# to upload it to the Database Channel, see dbflow); ``None``: nothing to do
|
| 117 |
+
self.on_delivered: Optional[Callable[[Job, int, Any], Awaitable[None]]] = None
|
| 118 |
|
| 119 |
# ------------------------------------------------------------------
|
| 120 |
async def _fresh_message(self, job: Job) -> Any:
|
|
|
|
| 263 |
if target == job.chat_id:
|
| 264 |
kwargs["reply_to"] = job.msg_id
|
| 265 |
try:
|
| 266 |
+
sent = await self.client.send_file(target, input_file, **kwargs)
|
| 267 |
except (errors.EntityBoundsInvalidError, errors.MediaCaptionTooLongError, errors.MessageTooLongError):
|
| 268 |
# the caption template produced something Telegram rejects: send the file without a caption
|
| 269 |
kwargs.update(caption=None)
|
| 270 |
+
sent = await self.client.send_file(target, input_file, **kwargs)
|
| 271 |
warnings.append("The caption was rejected by Telegram (check your caption template), so it was left out.")
|
| 272 |
|
| 273 |
await self.db.record_job(job.user_id, final_size)
|
|
|
|
| 278 |
await reporter.finish(
|
| 279 |
f"✅ <b>Done</b> in {human_duration(took)}\n<code>{esc(clip(job.new_name, 120))}</code>\n{human_size(final_size)}{rates}{where}{note}"
|
| 280 |
)
|
| 281 |
+
await self._after_delivery(job, target, sent)
|
| 282 |
+
|
| 283 |
+
async def _after_delivery(self, job: Job, target: int, sent: Any) -> None:
|
| 284 |
+
"""A file renamed with a manual format may go on to the Database Channel: the offer comes after the file itself."""
|
| 285 |
+
if self.on_delivered is None or job.fmt is None or job.fmt.get("kind") != "manual":
|
| 286 |
+
return
|
| 287 |
+
try:
|
| 288 |
+
await self.on_delivered(job, target, sent)
|
| 289 |
+
except Exception: # noqa: BLE001 - the rename is done whatever happens here
|
| 290 |
+
log.exception("could not offer the upload to the Database Channel")
|
| 291 |
|
| 292 |
async def _run(self, job: Job, s: Dict[str, Any], work: Path, reporter: ProgressReporter, started: float) -> None:
|
| 293 |
client, cfg = self.client, self.cfg
|
bot/settings_ops.py
CHANGED
|
@@ -136,3 +136,28 @@ def parse_channel(text: str) -> Tuple[bool, Optional[int], str]:
|
|
| 136 |
if re.fullmatch(r"-?\d{5,20}", text):
|
| 137 |
return True, int(text), ""
|
| 138 |
return False, None, "❌ Send the numeric chat id, like <code>-1001234567890</code> (not @username)."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
if re.fullmatch(r"-?\d{5,20}", text):
|
| 137 |
return True, int(text), ""
|
| 138 |
return False, None, "❌ Send the numeric chat id, like <code>-1001234567890</code> (not @username)."
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
_CHANNEL_LINK = re.compile(r"(?:https?://)?(?:www\.)?t\.me/c/(\d{5,15})(?:/\d+){0,2}/?", re.I)
|
| 142 |
+
CHANNEL_ID_HINT = "❌ Send the channel id, like <code>-1001234567890</code> (a channel id starts with -100), or <code>off</code> to clear it."
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def parse_format_channel(text: str) -> Tuple[bool, Optional[int], str]:
|
| 146 |
+
"""The Channel option of a manual format: ``-1001234567890`` -> that id, ``off`` -> ``None``.
|
| 147 |
+
|
| 148 |
+
A bare channel id (``1234567890``) and a link to a post of a private channel (``t.me/c/1234567890/5``) work too: both get
|
| 149 |
+
the ``-100`` in front that Telegram puts on channel ids.
|
| 150 |
+
"""
|
| 151 |
+
text = (text or "").strip()
|
| 152 |
+
if _is_clear(text):
|
| 153 |
+
return True, None, ""
|
| 154 |
+
link = _CHANNEL_LINK.fullmatch(text)
|
| 155 |
+
if link:
|
| 156 |
+
return True, -1_000_000_000_000 - int(link.group(1)), ""
|
| 157 |
+
if re.fullmatch(r"-?\d{5,20}", text):
|
| 158 |
+
value = int(text)
|
| 159 |
+
if value > 0:
|
| 160 |
+
return True, -1_000_000_000_000 - value, ""
|
| 161 |
+
if value <= -1_000_000_000_001:
|
| 162 |
+
return True, value, ""
|
| 163 |
+
return False, None, CHANNEL_ID_HINT
|
tests/test_dbflow.py
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Uploading a manually renamed episode to the Database Channel and having the channel post bot publish it:
|
| 2 |
+
the two chats in the settings, the Channel option of a manual format, the offer, the upload, the notice in the
|
| 3 |
+
communication chat and the answers of the channel post bot."""
|
| 4 |
+
import asyncio
|
| 5 |
+
import re
|
| 6 |
+
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
|
| 27 |
+
POST_BOT = 8950016484
|
| 28 |
+
SHOW = -1004000000003 # the channel a format publishes in
|
| 29 |
+
NAME = "[S01E05] Tokyo Revengers [1080p].mkv"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# helpers
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@pytest.fixture
|
| 38 |
+
async def up(tmp_path):
|
| 39 |
+
"""Like ``ctx``, with uploading to the Database Channel switched on (and short waits)."""
|
| 40 |
+
cfg = load_config(env_for(tmp_path, DB_Channel=str(DB), Communication_chat=str(COMM)))
|
| 41 |
+
db = Database(f"sqlite+aiosqlite:///{tmp_path}/app.db")
|
| 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()
|
| 49 |
+
await db.close()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def notices(up):
|
| 53 |
+
"""What was written in the communication chat: ``[(message id, text, kwargs)]``."""
|
| 54 |
+
return [(2000 + n, text, kw) for n, (entity, text, kw) in enumerate(up.client.sent, 1) if entity == COMM]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def wait_for(predicate, timeout=3.0):
|
| 58 |
+
end = time.monotonic() + timeout
|
| 59 |
+
while not predicate():
|
| 60 |
+
assert time.monotonic() < end, "timed out"
|
| 61 |
+
await asyncio.sleep(0.005)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def the_notice(up, nth=1):
|
| 65 |
+
await wait_for(lambda: len(notices(up)) >= nth)
|
| 66 |
+
return notices(up)[nth - 1]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def ref_of(text):
|
| 70 |
+
return re.search(r"(?m)^Ref: (\w+)$", text).group(1)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def last_edit(up):
|
| 74 |
+
return up.client.edited[-1][1]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
async def post_bot_says(up, text, *, reply_to=None, sender=POST_BOT, chat=COMM):
|
| 78 |
+
ev = FakeEvent(sender, text, chat=chat, private=False, reply_to_id=reply_to)
|
| 79 |
+
await dispatch(up.client, ev)
|
| 80 |
+
return ev
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def start_upload(up, *, channel=SHOW, name=NAME, mid=555, offer_id=77, caption=""):
|
| 84 |
+
"""The renamed episode is in the user's chat, and the offer is answered with Yes."""
|
| 85 |
+
up.client.messages[mid] = document_message(name, 10 * 1024 * 1024, mid=mid, caption=caption)
|
| 86 |
+
return await click(up, OWNER, f"dbu:y:{OWNER}:{mid}:{channel or 0}", msg_id=offer_id)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def manual_job(name=NAME, channel=SHOW, kind="manual", chat=OWNER):
|
| 90 |
+
fmt = {"id": 5, "name": "One Piece", "kind": kind, "channel": channel}
|
| 91 |
+
return Job(
|
| 92 |
+
id=1, user_id=OWNER, chat_id=chat, msg_id=40, file_name="original.mkv", size=1, new_name=name, fmt=fmt,
|
| 93 |
+
status_msg=SimpleNamespace(id=99),
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
# settings
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_both_chats_switch_uploading_on(tmp_path):
|
| 103 |
+
cfg = load_config(env_for(tmp_path, DB_CHANNEL=str(DB), COMMUNICATION_CHAT=str(COMM)))
|
| 104 |
+
assert (cfg.db_channel, cfg.comm_chat, cfg.upload_enabled, cfg.post_bot_id) == (DB, COMM, True, 8950016484)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@pytest.mark.parametrize("db_name, comm_name", [("DB_CHANNEL", "COMMUNICATION_CHAT"), ("db_channel", "communication_chat"), ("DB_Channel", "Communication_chat")])
|
| 108 |
+
def test_the_names_may_be_written_in_any_case(tmp_path, db_name, comm_name):
|
| 109 |
+
cfg = load_config(env_for(tmp_path, **{db_name: str(DB), comm_name: str(COMM)}))
|
| 110 |
+
assert (cfg.db_channel, cfg.comm_chat, cfg.upload_enabled) == (DB, COMM, True)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@pytest.mark.parametrize("extra", [{"DB_CHANNEL": str(DB)}, {"COMMUNICATION_CHAT": str(COMM)}, {}])
|
| 114 |
+
def test_one_chat_alone_does_not_switch_it_on(tmp_path, extra):
|
| 115 |
+
assert load_config(env_for(tmp_path, **extra)).upload_enabled is False
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_a_bare_channel_id_gets_its_minus_100(tmp_path):
|
| 119 |
+
cfg = load_config(env_for(tmp_path, DB_CHANNEL="2000000001", COMMUNICATION_CHAT="3000000002"))
|
| 120 |
+
assert (cfg.db_channel, cfg.comm_chat) == (-1002000000001, -1003000000002)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@pytest.mark.parametrize("name", ["DB_CHANNEL", "COMMUNICATION_CHAT", "CHANNEL_POST_BOT_ID"])
|
| 124 |
+
def test_an_id_that_is_not_a_number_is_a_configuration_error(tmp_path, name):
|
| 125 |
+
with pytest.raises(ConfigError, match=name):
|
| 126 |
+
load_config(env_for(tmp_path, **{name: "@somewhere"}))
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@pytest.mark.parametrize("value", ["", "0", "off", "OFF", "none"])
|
| 130 |
+
def test_empty_or_off_means_not_set(tmp_path, value):
|
| 131 |
+
cfg = load_config(env_for(tmp_path, DB_CHANNEL=value, COMMUNICATION_CHAT=value))
|
| 132 |
+
assert cfg.db_channel is None and cfg.comm_chat is None and cfg.upload_enabled is False
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def test_the_post_bot_id_can_be_changed(tmp_path):
|
| 136 |
+
assert load_config(env_for(tmp_path, CHANNEL_POST_BOT_ID="12345")).post_bot_id == 12345
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
async def test_without_both_chats_nothing_is_offered_and_the_buttons_say_so(ctx):
|
| 140 |
+
assert ctx.app.dbflow is None and ctx.app.renamer.on_delivered is None
|
| 141 |
+
cb = await click(ctx, OWNER, f"dbu:y:{OWNER}:555:{SHOW}")
|
| 142 |
+
assert cb.alerts and "switched off" in cb.alerts[0] and not ctx.client.forwarded and not ctx.client.sent
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
async def test_with_both_chats_the_offer_is_hooked_into_the_pipeline(up):
|
| 146 |
+
assert up.app.renamer.on_delivered == up.flow.offer
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ---------------------------------------------------------------------------
|
| 150 |
+
# the Channel option of a manual format
|
| 151 |
+
# ---------------------------------------------------------------------------
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
async def test_a_manual_format_keeps_its_channel(ctx):
|
| 155 |
+
fmt = await ctx.db.create_format(OWNER, "One Piece")
|
| 156 |
+
assert fmt["channel"] is None
|
| 157 |
+
assert (await ctx.db.update_format(OWNER, fmt["id"], channel=SHOW))["channel"] == SHOW
|
| 158 |
+
assert (await ctx.db.get_format(OWNER, fmt["id"]))["channel"] == SHOW
|
| 159 |
+
assert [f["channel"] for f in await ctx.db.list_formats(OWNER, "manual")] == [SHOW]
|
| 160 |
+
assert (await ctx.db.update_format(OWNER, fmt["id"], channel=None))["channel"] is None
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
async def test_an_auto_format_has_no_channel(ctx):
|
| 164 |
+
fmt = await ctx.db.create_format(OWNER, "Auto One", kind="auto")
|
| 165 |
+
assert (await ctx.db.update_format(OWNER, fmt["id"], channel=SHOW))["channel"] is None
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
async def test_formats_saved_before_the_option_have_no_channel(ctx):
|
| 169 |
+
async with ctx.db._sessions() as session:
|
| 170 |
+
session.add(Format(user_id=OWNER, name="Old", data='{"template": "{title}", "kind": "manual"}'))
|
| 171 |
+
session.add(Format(user_id=OWNER, name="Odd", data='{"kind": "manual", "channel": "-100123"}')) # not a number: ignored
|
| 172 |
+
await session.commit()
|
| 173 |
+
assert [f["channel"] for f in await ctx.db.list_formats(OWNER)] == [None, None]
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
async def test_a_copy_keeps_the_channel_only_for_the_owners_own_manual_copy(ctx):
|
| 177 |
+
fmt = await ctx.db.create_format(OWNER, "One Piece")
|
| 178 |
+
await ctx.db.update_format(OWNER, fmt["id"], channel=SHOW)
|
| 179 |
+
auto = await ctx.db.copy_format(OWNER, OWNER, fmt["id"], kind="auto")
|
| 180 |
+
assert auto["kind"] == "auto" and auto["channel"] is None
|
| 181 |
+
again = await ctx.db.copy_format(OWNER, OWNER, fmt["id"])
|
| 182 |
+
assert again["kind"] == "manual" and again["channel"] == SHOW
|
| 183 |
+
theirs = await ctx.db.copy_format(ADMIN_CFG, OWNER, fmt["id"]) # somebody else adds it from a share card
|
| 184 |
+
assert theirs["kind"] == "manual" and theirs["channel"] is None
|
| 185 |
+
back = await ctx.db.copy_format(OWNER, OWNER, auto["id"], kind="manual")
|
| 186 |
+
assert back["channel"] is None
|
| 187 |
+
assert (await ctx.db.get_format(OWNER, fmt["id"]))["channel"] == SHOW # the original is untouched
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@pytest.mark.parametrize(
|
| 191 |
+
"text, expected",
|
| 192 |
+
[
|
| 193 |
+
("-1001234567890", -1001234567890),
|
| 194 |
+
(" -1001234567890 ", -1001234567890),
|
| 195 |
+
("1234567890", -1001234567890),
|
| 196 |
+
("https://t.me/c/1234567890/5", -1001234567890),
|
| 197 |
+
("t.me/c/1234567890", -1001234567890),
|
| 198 |
+
("off", None), ("None", None), ("-", None), ("clear", None),
|
| 199 |
+
],
|
| 200 |
+
)
|
| 201 |
+
def test_a_channel_id_is_read(text, expected):
|
| 202 |
+
assert parse_format_channel(text) == (True, expected, "")
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
@pytest.mark.parametrize("text", ["", "abc", "@mychannel", "https://t.me/mychannel", "-12345", "123", "-1001234567890x", "12 34567890"])
|
| 206 |
+
def test_anything_else_is_refused_with_the_way_to_write_it(text):
|
| 207 |
+
ok, value, message = parse_format_channel(text)
|
| 208 |
+
assert not ok and value is None and "-1001234567890" in message
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
async def test_without_the_two_chats_the_format_has_no_channel_option(ctx):
|
| 212 |
+
fmt = await ctx.db.create_format(OWNER, "One Piece")
|
| 213 |
+
text, buttons = (await click(ctx, OWNER, f"fm:v:{fmt['id']}")).edits[-1]
|
| 214 |
+
assert "Channel" not in text and not [d for d in data_of(buttons) if "channel" in d]
|
| 215 |
+
cb = await click(ctx, OWNER, f"fm:in:{fmt['id']}:channel")
|
| 216 |
+
assert cb.alerts and OWNER not in ctx.app.pending
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
async def test_a_manual_format_shows_the_channel_option_and_an_auto_format_does_not(up):
|
| 220 |
+
fmt = await up.db.create_format(OWNER, "One Piece")
|
| 221 |
+
i = fmt["id"]
|
| 222 |
+
text, buttons = (await click(up, OWNER, f"fm:v:{i}")).edits[-1]
|
| 223 |
+
assert "<b>Channel</b> ▸ — (optional)" in text
|
| 224 |
+
assert f"fm:in:{i}:channel" in data_of(buttons) and f"fm:chc:{i}" not in data_of(buttons)
|
| 225 |
+
auto = await up.db.create_format(OWNER, "Auto One", kind="auto")
|
| 226 |
+
text, buttons = (await click(up, OWNER, f"fm:v:{auto['id']}")).edits[-1]
|
| 227 |
+
assert "Channel" not in text and f"fm:in:{auto['id']}:channel" not in data_of(buttons)
|
| 228 |
+
cb = await click(up, OWNER, f"fm:in:{auto['id']}:channel")
|
| 229 |
+
assert cb.alerts and OWNER not in up.app.pending
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
async def test_the_channel_is_typed_in_checked_and_cleared(up):
|
| 233 |
+
fmt = await up.db.create_format(OWNER, "One Piece")
|
| 234 |
+
i = fmt["id"]
|
| 235 |
+
cb = await click(up, OWNER, f"fm:in:{i}:channel", msg_id=710)
|
| 236 |
+
assert cb.edits[-1][0] == panels.FORMAT_PROMPTS["channel"] and up.app.pending[OWNER].kind == "fmt:channel"
|
| 237 |
+
assert data_of(cb.edits[-1][1]) == [f"fm:cx:{i}"]
|
| 238 |
+
ev = await say(up, OWNER, "@not_an_id")
|
| 239 |
+
assert "❌" in ev.texts[0] and "Try again" in ev.texts[0] and OWNER in up.app.pending
|
| 240 |
+
ev = await say(up, OWNER, "1234567890") # a bare channel id
|
| 241 |
+
assert ev.deleted and OWNER not in up.app.pending
|
| 242 |
+
assert (await up.db.get_format(OWNER, i))["channel"] == -1001234567890
|
| 243 |
+
chat, text, kw = up.client.edited[-1]
|
| 244 |
+
assert kw["message"] == 710 and "Channel saved" in text and "<code>-1001234567890</code>" in text
|
| 245 |
+
text, buttons = (await click(up, OWNER, f"fm:v:{i}")).edits[-1]
|
| 246 |
+
assert "<b>Channel</b> ▸ <code>-1001234567890</code>" in text and f"fm:chc:{i}" in data_of(buttons)
|
| 247 |
+
cb = await click(up, OWNER, f"fm:chc:{i}") # the button clears it
|
| 248 |
+
assert (await up.db.get_format(OWNER, i))["channel"] is None and "— (optional)" in cb.edits[-1][0]
|
| 249 |
+
await up.db.update_format(OWNER, i, channel=SHOW)
|
| 250 |
+
await click(up, OWNER, f"fm:in:{i}:channel", msg_id=711)
|
| 251 |
+
await say(up, OWNER, "off") # and so does typing off
|
| 252 |
+
assert (await up.db.get_format(OWNER, i))["channel"] is None
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
async def test_the_channel_of_a_deleted_format_is_handled(up):
|
| 256 |
+
fmt = await up.db.create_format(OWNER, "One Piece")
|
| 257 |
+
await click(up, OWNER, f"fm:in:{fmt['id']}:channel")
|
| 258 |
+
await up.db.delete_format(OWNER, fmt["id"])
|
| 259 |
+
ev = await say(up, OWNER, "-1001234567890")
|
| 260 |
+
assert "no longer exists" in ev.texts[0] and OWNER not in up.app.pending
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
async def test_the_format_screen_with_a_channel_still_fits_a_caption(up):
|
| 264 |
+
fmt = await up.db.create_format(OWNER, "N" * 30, template="T" * 200)
|
| 265 |
+
await up.db.update_format(OWNER, fmt["id"], channel=-1009999999999, caption="C" * 700)
|
| 266 |
+
text, _rows = panels.format_view(await up.db.get_format(OWNER, fmt["id"]), upload_on=True)
|
| 267 |
+
assert len(re.sub(r"<[^>]+>", "", text)) < 1024
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def test_a_shared_format_card_does_not_show_the_channel():
|
| 271 |
+
fmt = {**default_auto_format(), "kind": "manual", "name": "One Piece", "id": 5, "channel": SHOW}
|
| 272 |
+
text, _rows = panels.shared_format(fmt, "token")
|
| 273 |
+
assert str(SHOW) not in text and "Channel" not in text
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# ---------------------------------------------------------------------------
|
| 277 |
+
# the messages
|
| 278 |
+
# ---------------------------------------------------------------------------
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@pytest.mark.parametrize(
|
| 282 |
+
"name, episode",
|
| 283 |
+
[
|
| 284 |
+
(NAME, "05"), ("One Piece - E1147.mkv", "1147"), ("Black Clover - S2 E05.mkv", "05"),
|
| 285 |
+
("Tokyo Revengers Movie.mkv", ""), ("", ""),
|
| 286 |
+
],
|
| 287 |
+
)
|
| 288 |
+
def test_the_episode_number_is_read_from_the_renamed_file(name, episode):
|
| 289 |
+
assert episode_of(name) == episode
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def test_the_notice_has_what_the_post_bot_needs():
|
| 293 |
+
text = notice_text(episode="05", channel=SHOW, file=NAME, post_id=4001, ref="ab12cd")
|
| 294 |
+
assert text.splitlines() == [
|
| 295 |
+
"File uploaded to the Database Channel", "Episode: 05", f"Channel: {SHOW}", f"File: {NAME}", "Post: 4001", "Ref: ab12cd"
|
| 296 |
+
]
|
| 297 |
+
bare = notice_text(episode="", channel=SHOW, file=NAME, post_id=None, ref="ab12cd")
|
| 298 |
+
assert "Episode:" not in bare and "Post:" not in bare and f"Channel: {SHOW}" in bare
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def test_the_label_lines_of_a_message_are_found():
|
| 302 |
+
text = "✅ Post published successfully\nChannel: Black Clover English\nPost: https://t.me/c/1/2\nRef: ab12cd"
|
| 303 |
+
assert line_of("channel", text) == "Black Clover English" and line_of("post", text) == "https://t.me/c/1/2"
|
| 304 |
+
assert line_of("ref", text) == "ab12cd" and line_of("episode", text) == "" and line_of("ref", None) == ""
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
@pytest.mark.parametrize(
|
| 308 |
+
"text, expected",
|
| 309 |
+
[
|
| 310 |
+
("📥 Message received - checking the Database Channel…\nRef: ab12", ("ack", "")),
|
| 311 |
+
("message received", ("ack", "")),
|
| 312 |
+
("✅ Post published successfully\nChannel: X", ("ok", "")),
|
| 313 |
+
("POST PUBLISHED SUCCESSFULLY", ("ok", "")),
|
| 314 |
+
("❌ Publication failed: no button found\nFile: a.mkv\nRef: ab12", ("fail", "no button found")),
|
| 315 |
+
("publication failed: many spaces here", ("fail", "many spaces here")),
|
| 316 |
+
("Publication failed", ("fail", "")),
|
| 317 |
+
("❌ Publication failed: no post published successfully", ("fail", "no post published successfully")), # a failure wins
|
| 318 |
+
("hello", ("other", "")), ("", ("other", "")), (None, ("other", "")),
|
| 319 |
+
],
|
| 320 |
+
)
|
| 321 |
+
def test_what_the_post_bot_says_is_told_apart(text, expected):
|
| 322 |
+
assert classify(text) == expected
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def test_the_reply_target_is_read_from_the_message():
|
| 326 |
+
assert reply_target(FakeEvent(POST_BOT, "x", reply_to_id=7)) == 7
|
| 327 |
+
assert reply_target(FakeEvent(POST_BOT, "x")) is None
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
# ---------------------------------------------------------------------------
|
| 331 |
+
# the offer after /rename
|
| 332 |
+
# ---------------------------------------------------------------------------
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
async def test_the_offer_asks_yes_or_no_after_the_episode(up):
|
| 336 |
+
await up.app.dbflow.offer(manual_job(), OWNER, SimpleNamespace(id=555))
|
| 337 |
+
(entity, text, kw), = up.client.sent
|
| 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():
|
| 345 |
+
data = f"dbu:y:{-1009999999999}:{2147483647}:{-1009999999999}"
|
| 346 |
+
assert len(data.encode()) <= 64
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
async def test_the_offer_for_a_format_without_a_channel_says_it_is_only_uploaded(up):
|
| 350 |
+
await up.app.dbflow.offer(manual_job(channel=None), OWNER, SimpleNamespace(id=555))
|
| 351 |
+
(_entity, text, kw), = up.client.sent
|
| 352 |
+
assert "has no Channel" in text and "no post is made" in text and "Episode:" not in text
|
| 353 |
+
assert data_of(kw["buttons"])[0] == f"dbu:y:{OWNER}:555:0"
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
async def test_the_offer_warns_when_the_file_name_has_no_episode_number(up):
|
| 357 |
+
await up.app.dbflow.offer(manual_job(name="Tokyo Revengers Movie.mkv"), OWNER, SimpleNamespace(id=555))
|
| 358 |
+
assert "No episode number found" in up.client.sent[0][1]
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
async def test_a_file_sent_to_a_channel_is_still_offered_in_the_chat_of_the_user(up):
|
| 362 |
+
await up.app.dbflow.offer(manual_job(), SHOW, SimpleNamespace(id=555))
|
| 363 |
+
(entity, _text, kw), = up.client.sent
|
| 364 |
+
assert entity == OWNER and kw["reply_to"] == 40 # the file the user sent, not the one in the channel
|
| 365 |
+
assert f"dbu:y:{SHOW}:555:{SHOW}" in data_of(kw["buttons"])
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
async def test_nothing_is_offered_without_a_message_to_point_at(up):
|
| 369 |
+
await up.app.dbflow.offer(manual_job(), OWNER, None)
|
| 370 |
+
assert not up.client.sent
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
class Reporter:
|
| 374 |
+
def __init__(self, log):
|
| 375 |
+
self.log = log
|
| 376 |
+
|
| 377 |
+
async def finish(self, text):
|
| 378 |
+
self.log.append("finished")
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
async def deliver(up, job, hook, target_channel=None, log=None):
|
| 382 |
+
"""Run the delivery step of a rename; ``log`` says in what order the file, the Done message and the hook came."""
|
| 383 |
+
log = [] if log is None else log
|
| 384 |
+
renamer = Renamer(up.client, up.cfg, up.db)
|
| 385 |
+
renamer.on_delivered = hook
|
| 386 |
+
if target_channel:
|
| 387 |
+
await up.db.update_settings(OWNER, target_channel=target_channel)
|
| 388 |
+
original_send = up.client.send_file
|
| 389 |
+
|
| 390 |
+
async def send_file(*args, **kw):
|
| 391 |
+
log.append("sent")
|
| 392 |
+
return await original_send(*args, **kw)
|
| 393 |
+
|
| 394 |
+
up.client.send_file = send_file
|
| 395 |
+
await renamer._deliver(
|
| 396 |
+
job, await up.db.get_settings(OWNER), document_message(NAME, 10), "input", caption=None, attrs=[], as_document=True,
|
| 397 |
+
thumb=None, final_size=10, warnings=[], rates="", started=time.monotonic(), reporter=Reporter(log),
|
| 398 |
+
)
|
| 399 |
+
return log
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
async def test_a_manual_rename_is_offered_after_the_file_and_the_done_message(up):
|
| 403 |
+
log, calls = [], []
|
| 404 |
+
|
| 405 |
+
async def hook(job, target, sent):
|
| 406 |
+
log.append("offer")
|
| 407 |
+
calls.append((job.id, target, sent.id))
|
| 408 |
+
|
| 409 |
+
job = manual_job()
|
| 410 |
+
await deliver(up, job, hook, log=log)
|
| 411 |
+
assert job.state == "done" and calls == [(1, OWNER, 3001)] and log == ["sent", "finished", "offer"]
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
@pytest.mark.parametrize("kind", ["auto", None])
|
| 415 |
+
async def test_auto_renames_are_not_offered(up, kind):
|
| 416 |
+
called = []
|
| 417 |
+
|
| 418 |
+
async def hook(*args):
|
| 419 |
+
called.append(args)
|
| 420 |
+
|
| 421 |
+
job = manual_job(kind=kind or "auto")
|
| 422 |
+
if kind is None:
|
| 423 |
+
job.fmt = None # an auto rename of someone who has no auto format
|
| 424 |
+
await deliver(up, job, hook)
|
| 425 |
+
assert called == [] and job.state == "done"
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
async def test_a_failing_offer_does_not_spoil_the_rename(up):
|
| 429 |
+
async def hook(*args):
|
| 430 |
+
raise RuntimeError("telegram said no")
|
| 431 |
+
|
| 432 |
+
job = manual_job()
|
| 433 |
+
log = await deliver(up, job, hook)
|
| 434 |
+
assert job.state == "done" and log == ["sent", "finished"]
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
async def test_without_the_hook_nothing_happens(up):
|
| 438 |
+
job = manual_job()
|
| 439 |
+
assert await deliver(up, job, None) == ["sent", "finished"] and job.state == "done"
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
async def test_a_file_sent_to_the_target_channel_is_offered_with_that_channel_as_its_chat(up):
|
| 443 |
+
seen = []
|
| 444 |
+
|
| 445 |
+
async def hook(job, target, sent):
|
| 446 |
+
seen.append(target)
|
| 447 |
+
|
| 448 |
+
await deliver(up, manual_job(), hook, target_channel=SHOW)
|
| 449 |
+
assert seen == [SHOW]
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
# ---------------------------------------------------------------------------
|
| 453 |
+
# Yes: upload, tell the communication chat, read the answers
|
| 454 |
+
# ---------------------------------------------------------------------------
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
async def test_yes_copies_the_episode_and_tells_the_communication_chat(up):
|
| 458 |
+
cb = await start_upload(up, caption="<b>x</b>")
|
| 459 |
+
assert cb.edits[-1][0].startswith("⏳ <b>Processing…</b>") and cb.edits[-1][1] is None and cb.answers
|
| 460 |
+
nid, text, kw = await the_notice(up)
|
| 461 |
+
# copied inside Telegram: nothing was downloaded, nothing was uploaded again
|
| 462 |
+
assert up.client.forwarded == [(DB, 555, OWNER, {"drop_author": True})]
|
| 463 |
+
assert up.client.downloads == [] and up.client.files == []
|
| 464 |
+
ref = ref_of(text)
|
| 465 |
+
assert text.splitlines() == [
|
| 466 |
+
"File uploaded to the Database Channel", "Episode: 05", f"Channel: {SHOW}", f"File: {NAME}", "Post: 4001", f"Ref: {ref}"
|
| 467 |
+
]
|
| 468 |
+
assert kw["parse_mode"] is None # plain text: nothing in a file name can be taken for formatting
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
async def test_the_whole_story_from_yes_to_published(up):
|
| 472 |
+
await start_upload(up)
|
| 473 |
+
nid, text, _ = await the_notice(up)
|
| 474 |
+
ref = ref_of(text)
|
| 475 |
+
await post_bot_says(up, f"📥 Message received - checking the Database Channel…\nRef: {ref}", reply_to=nid)
|
| 476 |
+
await wait_for(lambda: len(up.client.edited) >= 2)
|
| 477 |
+
assert "channel post bot got it" in last_edit(up) and "Waiting" not in last_edit(up)
|
| 478 |
+
done = (
|
| 479 |
+
"✅ Post published successfully\nChannel: Black Clover English\nPost: https://t.me/c/1/2\n"
|
| 480 |
+
f"Button: Download Episode 05 → https://t.me/x\nEpisode: 05\nFile: {NAME}\nRef: {ref}"
|
| 481 |
+
)
|
| 482 |
+
await post_bot_says(up, done, reply_to=nid)
|
| 483 |
+
await up.flow.idle()
|
| 484 |
+
texts = [t for _e, t, _k in up.client.edited]
|
| 485 |
+
assert len(texts) == 3
|
| 486 |
+
assert "Waiting for the channel post bot" in texts[0] and "Uploaded to the Database Channel" in texts[0]
|
| 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 |
+
|
| 494 |
+
async def test_a_failure_of_the_post_bot_is_shown_with_its_reason(up):
|
| 495 |
+
await start_upload(up)
|
| 496 |
+
nid, text, _ = await the_notice(up)
|
| 497 |
+
await post_bot_says(up, f"📥 Message received - checking the Database Channel…\nRef: {ref_of(text)}", reply_to=nid)
|
| 498 |
+
reason = "The latest post of the Database Channel is the file “Other.mkv”, not the one that was reported - nothing was published."
|
| 499 |
+
await post_bot_says(up, f"❌ Publication failed: {reason}\nFile: {NAME}\nRef: {ref_of(text)}", reply_to=nid)
|
| 500 |
+
await up.flow.idle()
|
| 501 |
+
final = last_edit(up)
|
| 502 |
+
assert final.startswith("❌ <b>Publication failed</b>") and reason in final and NAME in final
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
async def test_names_and_reasons_are_escaped_in_the_edits_but_not_in_the_notice(up):
|
| 506 |
+
await start_upload(up, name="[S01E05] Tom & Jerry <HD>.mkv")
|
| 507 |
+
nid, text, _ = await the_notice(up)
|
| 508 |
+
assert "File: [S01E05] Tom & Jerry <HD>.mkv" in text
|
| 509 |
+
await post_bot_says(up, "❌ Publication failed: bad <b>thing</b> & more\nRef: " + ref_of(text), reply_to=nid)
|
| 510 |
+
await up.flow.idle()
|
| 511 |
+
final = last_edit(up)
|
| 512 |
+
assert "Tom & Jerry <HD>" in final and "bad <b>thing</b> & more" in final
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
async def test_no_answer_at_all_means_the_channel_post_bot_is_down(up):
|
| 516 |
+
up.flow.ack_wait = 0.1
|
| 517 |
+
await start_upload(up)
|
| 518 |
+
await up.flow.idle()
|
| 519 |
+
final = last_edit(up)
|
| 520 |
+
assert "down or not responding" in final and "The episode is in the Database Channel" in final and "nothing was published" in final
|
| 521 |
+
assert up.flow.flows == {} and up.flow._busy == set()
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
async def test_an_acknowledged_notice_that_is_never_answered_is_not_called_down(up):
|
| 525 |
+
up.flow.ack_wait, up.flow.result_wait = 2.0, 0.15
|
| 526 |
+
await start_upload(up)
|
| 527 |
+
nid, text, _ = await the_notice(up)
|
| 528 |
+
await post_bot_says(up, f"📥 Message received - checking the Database Channel…\nRef: {ref_of(text)}", reply_to=nid)
|
| 529 |
+
await up.flow.idle()
|
| 530 |
+
final = last_edit(up)
|
| 531 |
+
assert "No word from the channel post bot" in final and "down" not in final
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
async def test_an_answer_without_the_acknowledgement_still_counts(up):
|
| 535 |
+
await start_upload(up)
|
| 536 |
+
nid, text, _ = await the_notice(up)
|
| 537 |
+
await post_bot_says(up, f"✅ Post published successfully\nRef: {ref_of(text)}", reply_to=nid)
|
| 538 |
+
await up.flow.idle()
|
| 539 |
+
assert "Post published successfully" in last_edit(up)
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
async def test_answers_are_matched_by_the_ref_alone_or_by_the_reply_alone(up):
|
| 543 |
+
await start_upload(up, offer_id=71, mid=601)
|
| 544 |
+
nid, text, _ = await the_notice(up)
|
| 545 |
+
await post_bot_says(up, f"✅ Post published successfully\nRef: {ref_of(text)}") # not a reply: the Ref says whose it is
|
| 546 |
+
await up.flow.idle()
|
| 547 |
+
assert "Post published successfully" in last_edit(up)
|
| 548 |
+
|
| 549 |
+
await start_upload(up, offer_id=72, mid=602)
|
| 550 |
+
nid, text, _ = await the_notice(up, 2)
|
| 551 |
+
await post_bot_says(up, "❌ Publication failed: nope", reply_to=nid) # no Ref: the reply says whose it is
|
| 552 |
+
await up.flow.idle()
|
| 553 |
+
assert "Publication failed" in last_edit(up) and "nope" in last_edit(up)
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
async def test_with_two_uploads_waiting_the_ref_tells_whose_answer_it_is(up):
|
| 557 |
+
up.client.messages[601] = document_message("[S01E05] A [1080p].mkv", 10, mid=601)
|
| 558 |
+
up.client.messages[602] = document_message("[S01E06] A [1080p].mkv", 10, mid=602)
|
| 559 |
+
await click(up, OWNER, f"dbu:y:{OWNER}:601:{SHOW}", msg_id=71)
|
| 560 |
+
await click(up, OWNER, f"dbu:y:{OWNER}:602:{SHOW}", msg_id=72)
|
| 561 |
+
first, second = await the_notice(up, 1), await the_notice(up, 2)
|
| 562 |
+
await post_bot_says(up, f"✅ Post published successfully\nRef: {ref_of(second[1])}") # not a reply, and two are waiting
|
| 563 |
+
await wait_for(lambda: any(kw["message"] == 72 and "published successfully" in t for _e, t, kw in up.client.edited))
|
| 564 |
+
assert not any(kw["message"] == 71 and "published successfully" in t for _e, t, kw in up.client.edited)
|
| 565 |
+
assert len(up.flow.flows) == 1 and ref_of(first[1]) in up.flow.flows # the first is still waiting
|
| 566 |
+
await post_bot_says(up, f"✅ Post published successfully\nRef: {ref_of(first[1])}")
|
| 567 |
+
await up.flow.idle()
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
async def test_a_plain_message_counts_when_only_one_upload_is_waiting(up):
|
| 571 |
+
await start_upload(up)
|
| 572 |
+
await the_notice(up)
|
| 573 |
+
await post_bot_says(up, "✅ Post published successfully")
|
| 574 |
+
await up.flow.idle()
|
| 575 |
+
assert "Post published successfully" in last_edit(up)
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
async def test_other_senders_other_chats_other_replies_and_other_words_are_ignored(up):
|
| 579 |
+
await start_upload(up)
|
| 580 |
+
nid, text, _ = await the_notice(up)
|
| 581 |
+
ref = ref_of(text)
|
| 582 |
+
ok = f"✅ Post published successfully\nRef: {ref}"
|
| 583 |
+
await post_bot_says(up, ok, reply_to=nid, sender=STRANGER) # somebody else
|
| 584 |
+
await post_bot_says(up, ok, reply_to=nid, chat=-1005000000005) # another chat
|
| 585 |
+
await post_bot_says(up, f"hello there\nRef: {ref}", reply_to=nid) # not an answer
|
| 586 |
+
await post_bot_says(up, "✅ Post published successfully", reply_to=1) # a reply to something else
|
| 587 |
+
await asyncio.sleep(0.05)
|
| 588 |
+
assert up.flow.flows[ref].inbox.empty() and len(up.client.edited) == 1
|
| 589 |
+
await post_bot_says(up, ok, reply_to=nid)
|
| 590 |
+
await up.flow.idle()
|
| 591 |
+
assert "Post published successfully" in last_edit(up)
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
async def test_two_uploads_at_once_each_get_their_own_answer(up):
|
| 595 |
+
up.client.messages[601] = document_message("[S01E05] A [1080p].mkv", 10, mid=601)
|
| 596 |
+
up.client.messages[602] = document_message("[S01E06] A [1080p].mkv", 10, mid=602)
|
| 597 |
+
await click(up, OWNER, f"dbu:y:{OWNER}:601:{SHOW}", msg_id=71)
|
| 598 |
+
await click(up, OWNER, f"dbu:y:{OWNER}:602:{SHOW}", msg_id=72)
|
| 599 |
+
first, second = await the_notice(up, 1), await the_notice(up, 2)
|
| 600 |
+
assert "Episode: 05" in first[1] and "Episode: 06" in second[1] and ref_of(first[1]) != ref_of(second[1])
|
| 601 |
+
await post_bot_says(up, f"❌ Publication failed: nope\nRef: {ref_of(second[1])}", reply_to=second[0]) # out of order
|
| 602 |
+
await post_bot_says(up, f"✅ Post published successfully\nRef: {ref_of(first[1])}", reply_to=first[0])
|
| 603 |
+
await up.flow.idle()
|
| 604 |
+
last = {kw["message"]: text for _e, text, kw in up.client.edited}
|
| 605 |
+
assert "Post published successfully" in last[71] and "Publication failed" in last[72]
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
async def test_a_format_without_a_channel_is_only_uploaded(up):
|
| 609 |
+
await start_upload(up, channel=None)
|
| 610 |
+
await up.flow.idle()
|
| 611 |
+
assert len(up.client.forwarded) == 1 and not notices(up)
|
| 612 |
+
final = last_edit(up)
|
| 613 |
+
assert final.startswith("✅ <b>Uploaded to the Database Channel</b>") and "has no Channel" in final and NAME in final
|
| 614 |
+
assert up.flow.flows == {}
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
async def test_when_forwarding_is_refused_the_file_is_sent_again_by_its_id(up):
|
| 618 |
+
up.client.forward_errors.append(errors.ChatForwardsRestrictedError(None))
|
| 619 |
+
await start_upload(up, caption="Some caption")
|
| 620 |
+
nid, text, _ = await the_notice(up)
|
| 621 |
+
assert up.client.forwarded == []
|
| 622 |
+
(entity, file, kw), = up.client.files
|
| 623 |
+
assert entity == DB and file is up.client.messages[555].media # the file itself is not sent again, only its id
|
| 624 |
+
assert kw["caption"] == "Some caption" and kw["formatting_entities"] is None and kw["parse_mode"] is None
|
| 625 |
+
assert "Post: 3001" in text and up.client.downloads == []
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
async def test_a_failed_upload_can_be_tried_again(up):
|
| 629 |
+
up.client.forward_errors.append(RuntimeError("no rights"))
|
| 630 |
+
up.client.fail_send_file = True
|
| 631 |
+
await start_upload(up)
|
| 632 |
+
await up.flow.idle()
|
| 633 |
+
chat, text, kw = up.client.edited[-1]
|
| 634 |
+
assert "The upload to the Database Channel failed" in text and "RuntimeError: cannot send file" in text and "admin" in text
|
| 635 |
+
assert flat_buttons(kw["buttons"]) == [("🔁 Try again", f"dbu:y:{OWNER}:555:{SHOW}"), ("✖ Close", "x:close")]
|
| 636 |
+
assert not notices(up) and up.flow._busy == set() # nothing was announced
|
| 637 |
+
up.client.fail_send_file = False
|
| 638 |
+
await click(up, OWNER, f"dbu:y:{OWNER}:555:{SHOW}", msg_id=77)
|
| 639 |
+
await the_notice(up)
|
| 640 |
+
assert len(up.client.forwarded) == 1
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
async def test_an_episode_that_was_deleted_is_reported(up):
|
| 644 |
+
await click(up, OWNER, f"dbu:y:{OWNER}:999:{SHOW}", msg_id=77)
|
| 645 |
+
await up.flow.idle()
|
| 646 |
+
assert "not there anymore" in last_edit(up) and not up.client.forwarded and not notices(up)
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
async def test_a_second_tap_while_it_is_working_does_nothing(up):
|
| 650 |
+
await start_upload(up)
|
| 651 |
+
again = await click(up, OWNER, f"dbu:y:{OWNER}:555:{SHOW}", msg_id=77)
|
| 652 |
+
assert again.toasts == ["I'm already on it…"] and again.edits == []
|
| 653 |
+
await the_notice(up)
|
| 654 |
+
await asyncio.sleep(0.05)
|
| 655 |
+
assert len(up.client.forwarded) == 1 and len(notices(up)) == 1
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
async def test_no_does_not_upload(up):
|
| 659 |
+
cb = await click(up, OWNER, "dbu:n", msg_id=77)
|
| 660 |
+
assert "not uploaded" in cb.edits[-1][0] and cb.edits[-1][1] is None and cb.answers
|
| 661 |
+
assert not up.client.forwarded and not up.client.sent
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
async def test_a_notice_that_cannot_be_sent_is_reported(up):
|
| 665 |
+
up.client.message_errors[COMM] = RuntimeError("chat not found")
|
| 666 |
+
await start_upload(up)
|
| 667 |
+
await up.flow.idle()
|
| 668 |
+
final = last_edit(up)
|
| 669 |
+
assert "could not write in the communication chat" in final and "chat not found" in final and "Uploaded" in final
|
| 670 |
+
assert len(up.client.forwarded) == 1 and up.flow.flows == {} and up.flow._busy == set()
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
async def test_an_unexpected_error_is_shown_instead_of_leaving_processing_on_the_screen(up):
|
| 674 |
+
async def broken(*args, **kw):
|
| 675 |
+
raise RuntimeError("boom")
|
| 676 |
+
|
| 677 |
+
up.client.get_messages = broken
|
| 678 |
+
await start_upload(up)
|
| 679 |
+
await up.flow.idle()
|
| 680 |
+
assert "Something went wrong" in last_edit(up) and "RuntimeError: boom" in last_edit(up) and up.flow._busy == set()
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
async def test_a_message_that_was_deleted_meanwhile_does_not_break_the_flow(up):
|
| 684 |
+
async def gone(*args, **kw):
|
| 685 |
+
raise errors.MessageIdInvalidError(None)
|
| 686 |
+
|
| 687 |
+
up.client.edit_message = gone
|
| 688 |
+
await start_upload(up)
|
| 689 |
+
nid, text, _ = await the_notice(up)
|
| 690 |
+
await post_bot_says(up, f"✅ Post published successfully\nRef: {ref_of(text)}", reply_to=nid)
|
| 691 |
+
await up.flow.idle()
|
| 692 |
+
assert up.flow.flows == {} and up.flow._busy == set()
|
| 693 |
+
|
| 694 |
+
|
| 695 |
+
async def test_shutdown_stops_the_waiting(up):
|
| 696 |
+
await start_upload(up)
|
| 697 |
+
await the_notice(up)
|
| 698 |
+
await up.app.shutdown()
|
| 699 |
+
assert up.flow._tasks == set() and up.flow.flows == {} and up.flow._busy == set()
|
| 700 |
+
|
| 701 |
+
|
| 702 |
+
async def test_strangers_cannot_press_the_buttons(up):
|
| 703 |
+
up.client.messages[555] = document_message(NAME, 10, mid=555)
|
| 704 |
+
await click(up, STRANGER, f"dbu:y:{STRANGER}:555:{SHOW}")
|
| 705 |
+
assert not up.client.forwarded and not up.flow._tasks
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
@pytest.mark.parametrize("data", ["dbu", "dbu:y", "dbu:y:1:2", "dbu:y:a:b:c", "dbu:z", "dbu:y:1:2:3:4"])
|
| 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
|
tests/tgfakes.py
CHANGED
|
@@ -81,8 +81,9 @@ class FakeMessage:
|
|
| 81 |
def __init__(self, text: str = "", *, mid: int = 1, sender: int = 1, file: Any = None, video: bool = False,
|
| 82 |
audio: bool = False, photo: bool = False, sticker: bool = False, gif: bool = False,
|
| 83 |
voice: bool = False, video_note: bool = False, fwd: bool = False, out: bool = False,
|
| 84 |
-
entities: Any = None, thumbs: Any = None):
|
| 85 |
self.id = mid
|
|
|
|
| 86 |
self.message = text
|
| 87 |
self.raw_text = text
|
| 88 |
self.entities = entities
|
|
@@ -111,8 +112,10 @@ class FakeEvent:
|
|
| 111 |
|
| 112 |
def __init__(self, sender: int, text: str = "", *, message: Optional[FakeMessage] = None, reply: Any = None,
|
| 113 |
mid: int = 1, chat: Optional[int] = None, private: bool = True, photo_bytes: Optional[bytes] = None,
|
| 114 |
-
entities: Any = None):
|
| 115 |
-
self.message = message if message is not None else FakeMessage(
|
|
|
|
|
|
|
| 116 |
self.message.sender_id = sender
|
| 117 |
self.sender_id = sender
|
| 118 |
self.chat_id = sender if chat is None else chat
|
|
@@ -229,6 +232,9 @@ class FakeClient:
|
|
| 229 |
self.inline_placeholder: Optional[str] = "Pick a format" # what the bot's profile says; None = inline mode is off
|
| 230 |
self.server_inline = True # False: Telegram refuses messages that carry a share button
|
| 231 |
self.me_calls = 0
|
|
|
|
|
|
|
|
|
|
| 232 |
|
| 233 |
async def get_me(self) -> SimpleNamespace:
|
| 234 |
self.me_calls += 1
|
|
@@ -246,8 +252,16 @@ class FakeClient:
|
|
| 246 |
raise ValueError(f"Could not find the input entity for {entity!r}")
|
| 247 |
return SimpleNamespace(id=entity)
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
async def send_message(self, entity: Any, text: str = "", **kw: Any) -> SimpleNamespace:
|
| 250 |
refuse_share_button(self, kw.get("buttons"))
|
|
|
|
|
|
|
| 251 |
self.sent.append((entity, text, kw))
|
| 252 |
return SimpleNamespace(id=2000 + len(self.sent))
|
| 253 |
|
|
|
|
| 81 |
def __init__(self, text: str = "", *, mid: int = 1, sender: int = 1, file: Any = None, video: bool = False,
|
| 82 |
audio: bool = False, photo: bool = False, sticker: bool = False, gif: bool = False,
|
| 83 |
voice: bool = False, video_note: bool = False, fwd: bool = False, out: bool = False,
|
| 84 |
+
entities: Any = None, thumbs: Any = None, reply_to_id: Optional[int] = None):
|
| 85 |
self.id = mid
|
| 86 |
+
self.reply_to_msg_id = reply_to_id
|
| 87 |
self.message = text
|
| 88 |
self.raw_text = text
|
| 89 |
self.entities = entities
|
|
|
|
| 112 |
|
| 113 |
def __init__(self, sender: int, text: str = "", *, message: Optional[FakeMessage] = None, reply: Any = None,
|
| 114 |
mid: int = 1, chat: Optional[int] = None, private: bool = True, photo_bytes: Optional[bytes] = None,
|
| 115 |
+
entities: Any = None, reply_to_id: Optional[int] = None):
|
| 116 |
+
self.message = message if message is not None else FakeMessage(
|
| 117 |
+
text, mid=mid, sender=sender, entities=entities, reply_to_id=reply_to_id
|
| 118 |
+
)
|
| 119 |
self.message.sender_id = sender
|
| 120 |
self.sender_id = sender
|
| 121 |
self.chat_id = sender if chat is None else chat
|
|
|
|
| 232 |
self.inline_placeholder: Optional[str] = "Pick a format" # what the bot's profile says; None = inline mode is off
|
| 233 |
self.server_inline = True # False: Telegram refuses messages that carry a share button
|
| 234 |
self.me_calls = 0
|
| 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
|
|
|
|
| 252 |
raise ValueError(f"Could not find the input entity for {entity!r}")
|
| 253 |
return SimpleNamespace(id=entity)
|
| 254 |
|
| 255 |
+
async def forward_messages(self, entity: Any, messages: Any, from_peer: Any = None, **kw: Any) -> SimpleNamespace:
|
| 256 |
+
if self.forward_errors:
|
| 257 |
+
raise self.forward_errors.pop(0)
|
| 258 |
+
self.forwarded.append((entity, messages, from_peer, kw))
|
| 259 |
+
return SimpleNamespace(id=4000 + len(self.forwarded))
|
| 260 |
+
|
| 261 |
async def send_message(self, entity: Any, text: str = "", **kw: Any) -> SimpleNamespace:
|
| 262 |
refuse_share_button(self, kw.get("buttons"))
|
| 263 |
+
if entity in self.message_errors:
|
| 264 |
+
raise self.message_errors[entity]
|
| 265 |
self.sent.append((entity, text, kw))
|
| 266 |
return SimpleNamespace(id=2000 + len(self.sent))
|
| 267 |
|