github-actions[bot] commited on
Commit
a670808
·
1 Parent(s): 676933a

Automated sync from GitHub

Browse files
Files changed (10) hide show
  1. .gitattributes +3 -3
  2. bot/app.py +58 -2
  3. bot/config.py +43 -0
  4. bot/db.py +14 -3
  5. bot/dbflow.py +382 -0
  6. bot/panels.py +18 -2
  7. bot/pipeline.py +16 -3
  8. bot/settings_ops.py +25 -0
  9. tests/test_dbflow.py +711 -0
  10. 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
- data = _format_json({**_merge_format(_stored(source.data)), "kind": target})
 
 
 
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 &amp; Jerry &lt;HD&gt;" in final and "bad &lt;b&gt;thing&lt;/b&gt; &amp; 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(text, mid=mid, sender=sender, entities=entities)
 
 
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