github-actions[bot] commited on
Commit
84e63a1
·
1 Parent(s): 5f400f8

Automated sync from GitHub

Browse files
.gitattributes CHANGED
@@ -1,8 +1,8 @@
1
- *.otf filter=lfs diff=lfs merge=lfs -text
2
- *.webp filter=lfs diff=lfs merge=lfs -text
3
  *.mp4 filter=lfs diff=lfs merge=lfs -text
4
  *.mp3 filter=lfs diff=lfs merge=lfs -text
5
  *.jpg filter=lfs diff=lfs merge=lfs -text
6
  *.jpeg filter=lfs diff=lfs merge=lfs -text
7
  *.png filter=lfs diff=lfs merge=lfs -text
8
  *.ttf filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
1
  *.mp4 filter=lfs diff=lfs merge=lfs -text
2
  *.mp3 filter=lfs diff=lfs merge=lfs -text
3
  *.jpg filter=lfs diff=lfs merge=lfs -text
4
  *.jpeg filter=lfs diff=lfs merge=lfs -text
5
  *.png filter=lfs diff=lfs merge=lfs -text
6
  *.ttf filter=lfs diff=lfs merge=lfs -text
7
+ *.otf filter=lfs diff=lfs merge=lfs -text
8
+ *.webp filter=lfs diff=lfs merge=lfs -text
bot/app.py CHANGED
@@ -21,7 +21,7 @@ from telethon import errors, events, functions, types
21
  from telethon.tl.custom import Button
22
 
23
  from . import fasttransfer as transfer
24
- from . import media, mediainfo, panels, speed, sysinfo
25
  from .config import Config
26
  from .db import MAX_FORMATS, Database
27
  from .errors import FormatError
@@ -195,7 +195,7 @@ class App:
195
  self._speed_lock = asyncio.Lock() # one speed test at a time
196
  self.telegraph = Telegraph(self._load_telegraph_token, self._save_telegraph_token)
197
  self._tasks: Set["asyncio.Future[Any]"] = set()
198
- self.renamer = Renamer(client, cfg, db, self._log_send)
199
  self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled, settle=QUEUE_SETTLE)
200
  self._warned: Set[int] = set()
201
 
@@ -257,6 +257,7 @@ class App:
257
  else:
258
  await self._stranger(uid)
259
  return
 
260
  try:
261
  await fn(event)
262
  except events.StopPropagation:
@@ -264,7 +265,7 @@ class App:
264
  except errors.MessageNotModifiedError:
265
  pass
266
  except Exception: # noqa: BLE001
267
- log.exception("handler %s failed", getattr(fn, "__name__", fn))
268
  try:
269
  if hasattr(event, "data"):
270
  await event.answer("⚠️ Something went wrong.", alert=True)
@@ -272,6 +273,8 @@ class App:
272
  await event.respond("⚠️ Something went wrong. Please try again.")
273
  except Exception: # noqa: BLE001
274
  pass
 
 
275
 
276
  return wrapper
277
 
 
21
  from telethon.tl.custom import Button
22
 
23
  from . import fasttransfer as transfer
24
+ from . import errorlog, media, mediainfo, panels, speed, sysinfo
25
  from .config import Config
26
  from .db import MAX_FORMATS, Database
27
  from .errors import FormatError
 
195
  self._speed_lock = asyncio.Lock() # one speed test at a time
196
  self.telegraph = Telegraph(self._load_telegraph_token, self._save_telegraph_token)
197
  self._tasks: Set["asyncio.Future[Any]"] = set()
198
+ self.renamer = Renamer(client, cfg, db)
199
  self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled, settle=QUEUE_SETTLE)
200
  self._warned: Set[int] = set()
201
 
 
257
  else:
258
  await self._stranger(uid)
259
  return
260
+ context = errorlog.error_context.set(lambda: errorlog.describe_update(event)) # for the log channel report
261
  try:
262
  await fn(event)
263
  except events.StopPropagation:
 
265
  except errors.MessageNotModifiedError:
266
  pass
267
  except Exception: # noqa: BLE001
268
+ log.exception("Error handling update:")
269
  try:
270
  if hasattr(event, "data"):
271
  await event.answer("⚠️ Something went wrong.", alert=True)
 
273
  await event.respond("⚠️ Something went wrong. Please try again.")
274
  except Exception: # noqa: BLE001
275
  pass
276
+ finally:
277
+ errorlog.error_context.reset(context)
278
 
279
  return wrapper
280
 
bot/errorlog.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Error reports for the log channel.
2
+
3
+ Every ERROR-level log record becomes one message: what the bot was doing, then the full traceback, inside a Python code
4
+ block (Telegram shows it with a "Python" label and a copy button). An identical error is reported once per couple of
5
+ minutes, no more than ``PER_MINUTE`` reports go out in a minute, and secrets are masked before anything is sent.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextvars
11
+ import logging
12
+ import os
13
+ import re
14
+ import time
15
+ from collections import deque
16
+ from dataclasses import dataclass
17
+ from typing import Any, Awaitable, Callable, Deque, Dict, Iterable, List, Optional, Set, Tuple
18
+ from urllib.parse import urlsplit
19
+
20
+ from .utils import clip, esc
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ Send = Callable[[str], Awaitable[None]]
25
+
26
+ MAX_TEXT = 3800 # UTF-16 units of the code block: Telegram takes 4096 per message
27
+ HEAD_LIMIT = 700 # characters of "what was happening" kept when a traceback shares the message
28
+ REPEAT_WINDOW = 120.0 # seconds during which an identical error is reported only once
29
+ PER_MINUTE = 10 # reports sent per minute at most
30
+
31
+ # What the bot is working on right now: text, or a function that returns it. Errors logged anywhere below it mention
32
+ # it, so a failure deep inside a helper still says which message or file it belongs to.
33
+ error_context: "contextvars.ContextVar[Any]" = contextvars.ContextVar("error_context", default=None)
34
+
35
+
36
+ def describe_update(event: Any) -> str:
37
+ """One line about a Telethon event: who sent what, where. Never raises."""
38
+ try:
39
+ kind = type(event).__qualname__.removesuffix(".Event") # NewMessage / CallbackQuery
40
+ parts = [f"sender_id={getattr(event, 'sender_id', None)}", f"chat_id={getattr(event, 'chat_id', None)}"]
41
+ data = getattr(event, "data", None)
42
+ if data is not None:
43
+ parts.append(f"message_id={getattr(event, 'message_id', None)}")
44
+ parts.append("data=" + repr(bytes(data).decode("utf-8", "replace")))
45
+ else:
46
+ parts.append(f"message_id={getattr(event, 'id', None)}")
47
+ text = getattr(event, "raw_text", None)
48
+ if text:
49
+ parts.append("text=" + repr(clip(text, 200)))
50
+ file = getattr(getattr(event, "message", None), "file", None)
51
+ if file is not None:
52
+ parts.append(f"file={getattr(file, 'name', None)!r} ({getattr(file, 'mime_type', None)}, {getattr(file, 'size', None)} bytes)")
53
+ return f"{kind}({', '.join(parts)})"
54
+ except Exception: # noqa: BLE001
55
+ return type(event).__name__
56
+
57
+
58
+ _SQL_PARAMS = re.compile(r"\[parameters: [^\n]*\]") # SQLAlchemy prints the values it bound, e.g. a saved session
59
+ _TOKENISH = re.compile(r"[A-Za-z0-9_\-+/=]{60,}") # session strings, access tokens, long hashes
60
+
61
+
62
+ def _mask_token(match: "re.Match[str]") -> str:
63
+ text = match.group(0)
64
+ return "***" if re.search(r"[A-Za-z0-9]", text) else text # a line of dashes is not a secret
65
+
66
+
67
+ def redact(text: str, secrets: Iterable[str] = ()) -> str:
68
+ """Hide the configured secrets and anything that looks like a session string or access token."""
69
+ for secret in secrets:
70
+ if len(secret) >= 6:
71
+ text = text.replace(secret, "***")
72
+ return _TOKENISH.sub(_mask_token, _SQL_PARAMS.sub("[parameters: …]", text))
73
+
74
+
75
+ def secrets_from(cfg: Any) -> List[str]:
76
+ """The values of the configuration that must never appear in a report."""
77
+ values = [cfg.bot_token, cfg.api_hash, cfg.session_string, cfg.database_url]
78
+ try:
79
+ values.append(urlsplit(cfg.database_url).password or "")
80
+ except ValueError:
81
+ pass
82
+ return [v for v in values if v]
83
+
84
+
85
+ def _u16(text: str) -> int:
86
+ return len(text.encode("utf-16-le")) // 2
87
+
88
+
89
+ def _tail(text: str, units: int) -> str:
90
+ """The end of ``text`` that fits in ``units`` UTF-16 units, starting at the beginning of a line."""
91
+ used, start = 0, len(text)
92
+ for i in range(len(text) - 1, -1, -1):
93
+ used += 2 if ord(text[i]) > 0xFFFF else 1
94
+ if used > units:
95
+ break
96
+ start = i
97
+ tail = text[start:]
98
+ if start > 0:
99
+ newline = tail.find("\n")
100
+ tail = tail[newline + 1:] if newline != -1 else tail
101
+ return tail
102
+
103
+
104
+ def _fit(head: str, trace: str) -> str:
105
+ """``head`` + the traceback in at most ``MAX_TEXT``: when it is too long the end is kept, as that holds the error."""
106
+ if not trace:
107
+ return clip(head, MAX_TEXT // 2)
108
+ head = clip(head, HEAD_LIMIT)
109
+ room = MAX_TEXT - _u16(head) - 2
110
+ if _u16(trace) > room:
111
+ trace = "…\n" + _tail(trace, room - 2)
112
+ return head + "\n\n" + trace
113
+
114
+
115
+ def _signature(exc_info: Any) -> str:
116
+ """Same exception type raised on the same line = the same error."""
117
+ etype, _value, tb = exc_info
118
+ while tb is not None and tb.tb_next is not None:
119
+ tb = tb.tb_next
120
+ where = f"{os.path.basename(tb.tb_frame.f_code.co_filename)}:{tb.tb_lineno}" if tb is not None else ""
121
+ return f"{getattr(etype, '__name__', etype)}@{where}"
122
+
123
+
124
+ def build_report(record: logging.LogRecord, secrets: Iterable[str] = ()) -> Tuple[str, str]:
125
+ """``(signature, html)``: the record's message, what the bot was working on, and the traceback in a Python block."""
126
+ try:
127
+ head = record.getMessage()
128
+ except Exception: # noqa: BLE001
129
+ head = str(record.msg)
130
+ context = error_context.get()
131
+ if callable(context):
132
+ context = context()
133
+ if context and str(context) not in head:
134
+ head += "\n" + str(context)
135
+ trace, signature = "", f"{record.name}|{head[:100]}"
136
+ if record.exc_info and record.exc_info[0] is not None:
137
+ trace = logging.Formatter().formatException(record.exc_info)
138
+ signature = _signature(record.exc_info)
139
+ text = _fit(redact(head, secrets), redact(trace, secrets))
140
+ return signature, f'<pre><code class="language-python">{esc(text)}</code></pre>'
141
+
142
+
143
+ @dataclass
144
+ class _Seen:
145
+ at: float
146
+ repeats: int = 0
147
+
148
+
149
+ class ErrorReporter:
150
+ """Sends the reports to the log channel one after another and keeps repeats and floods out of it."""
151
+
152
+ def __init__(self, send: Send, *, repeat_window: float = REPEAT_WINDOW, per_minute: int = PER_MINUTE,
153
+ clock: Callable[[], float] = time.monotonic):
154
+ self._send = send
155
+ self._repeat_window = repeat_window
156
+ self._per_minute = per_minute
157
+ self._clock = clock
158
+ self._seen: Dict[str, _Seen] = {}
159
+ self._sent: Deque[float] = deque() # when the reports of the last minute went out
160
+ self._skipped = 0
161
+ self._lock = asyncio.Lock()
162
+ self._tasks: Set["asyncio.Future[None]"] = set()
163
+
164
+ def submit(self, signature: str, body: str) -> None:
165
+ """Queue one report (inside the event loop). A repeat or a flood is counted and mentioned in the next report."""
166
+ now = self._clock()
167
+ seen = self._seen.get(signature)
168
+ if seen is not None and now - seen.at < self._repeat_window:
169
+ seen.repeats += 1
170
+ return
171
+ while self._sent and now - self._sent[0] >= 60:
172
+ self._sent.popleft()
173
+ if len(self._sent) >= self._per_minute:
174
+ self._skipped += 1
175
+ return
176
+ notes = []
177
+ if seen is not None and seen.repeats:
178
+ notes.append(f"🔁 {seen.repeats} more of this error since the last report.")
179
+ if self._skipped:
180
+ notes.append(f"⚠️ {self._skipped} other error report(s) were left out to keep this chat readable.")
181
+ self._skipped = 0
182
+ self._seen[signature] = _Seen(now)
183
+ if len(self._seen) > 200:
184
+ self._seen = {k: v for k, v in self._seen.items() if now - v.at < self._repeat_window}
185
+ self._sent.append(now)
186
+ task = asyncio.ensure_future(self._deliver(body + "".join("\n" + note for note in notes)))
187
+ self._tasks.add(task)
188
+ task.add_done_callback(self._tasks.discard)
189
+
190
+ async def _deliver(self, text: str) -> None:
191
+ async with self._lock: # in the order they happened
192
+ try:
193
+ await self._send(text)
194
+ except Exception as exc: # noqa: BLE001
195
+ log.warning("could not send an error report to the log channel: %s", exc)
196
+
197
+ async def flush(self, timeout: float = 5.0) -> None:
198
+ """Wait (a little) for the reports that are still on their way."""
199
+ if self._tasks:
200
+ await asyncio.wait(set(self._tasks), timeout=timeout)
201
+
202
+
203
+ class ChannelLogHandler(logging.Handler):
204
+ """Turns every ERROR record, from any thread, into a report for the log channel."""
205
+
206
+ def __init__(self, reporter: ErrorReporter, loop: asyncio.AbstractEventLoop, secrets: Iterable[str] = ()):
207
+ super().__init__(level=logging.ERROR)
208
+ self._reporter = reporter
209
+ self._loop = loop
210
+ self._secrets = [s for s in secrets if s]
211
+ self._logger: Optional[logging.Logger] = None
212
+
213
+ def emit(self, record: logging.LogRecord) -> None:
214
+ if record.name == __name__: # a problem with the reports themselves must not become a report
215
+ return
216
+ try:
217
+ signature, body = build_report(record, self._secrets)
218
+ self._loop.call_soon_threadsafe(self._reporter.submit, signature, body)
219
+ except Exception: # noqa: BLE001 - logging must never break the code that logs
220
+ pass
221
+
222
+ def attach(self, logger: logging.Logger) -> None:
223
+ logger.addHandler(self)
224
+ self._logger = logger
225
+
226
+ def detach(self) -> None:
227
+ if self._logger is not None:
228
+ self._logger.removeHandler(self)
229
+ self._logger = None
230
+
231
+ async def aclose(self, timeout: float = 5.0) -> None:
232
+ """Stop listening, then give the reports that are on their way a moment to arrive."""
233
+ self.detach()
234
+ await asyncio.sleep(0) # reports handed over from another thread land in the loop first
235
+ await self._reporter.flush(timeout)
236
+
237
+
238
+ def install(send: Send, secrets: Iterable[str] = (), *, logger: Optional[logging.Logger] = None) -> ChannelLogHandler:
239
+ """Send the errors logged on ``logger`` (the root logger by default) through ``send``. Call it inside the event loop."""
240
+ handler = ChannelLogHandler(ErrorReporter(send), asyncio.get_running_loop(), secrets)
241
+ handler.attach(logger or logging.getLogger())
242
+ return handler
bot/main.py CHANGED
@@ -9,7 +9,7 @@ import sys
9
  from pathlib import Path
10
  from typing import Awaitable, Callable
11
 
12
- from . import __version__
13
  from .config import Config, ConfigError, load_config
14
  from .db import Database
15
 
@@ -68,6 +68,7 @@ async def amain() -> int:
68
  username = getattr(me, "username", "") or ""
69
  app.telegraph.set_author(getattr(me, "first_name", "") or username, f"https://t.me/{username}" if username else "")
70
  await app.setup()
 
71
  log.info("@%s is online (DC %s, %d connection(s) per transfer, fast transfer %s)",
72
  me.username, client.session.dc_id, cfg.connections, "on" if cfg.fast_transfer else "off")
73
  if cfg.log_channel:
@@ -90,6 +91,8 @@ async def amain() -> int:
90
 
91
  log.info("shutting down")
92
  await app.shutdown()
 
 
93
  await runner.cleanup()
94
  try:
95
  await client.disconnect()
 
9
  from pathlib import Path
10
  from typing import Awaitable, Callable
11
 
12
+ from . import __version__, errorlog
13
  from .config import Config, ConfigError, load_config
14
  from .db import Database
15
 
 
68
  username = getattr(me, "username", "") or ""
69
  app.telegraph.set_author(getattr(me, "first_name", "") or username, f"https://t.me/{username}" if username else "")
70
  await app.setup()
71
+ channel_log = errorlog.install(app._log_send, errorlog.secrets_from(cfg)) if cfg.log_channel else None # errors -> LOG_CHANNEL
72
  log.info("@%s is online (DC %s, %d connection(s) per transfer, fast transfer %s)",
73
  me.username, client.session.dc_id, cfg.connections, "on" if cfg.fast_transfer else "off")
74
  if cfg.log_channel:
 
91
 
92
  log.info("shutting down")
93
  await app.shutdown()
94
+ if channel_log is not None:
95
+ await channel_log.aclose() # sends the reports still on their way, while the client is connected
96
  await runner.cleanup()
97
  try:
98
  await client.disconnect()
bot/pipeline.py CHANGED
@@ -5,12 +5,12 @@ import logging
5
  import mimetypes
6
  import shutil
7
  import time
8
- import traceback
9
  from pathlib import Path
10
- from typing import Any, Awaitable, Callable, Dict, Optional
11
 
12
  from telethon import errors, types
13
 
 
14
  from . import fasttransfer as transfer
15
  from . import media
16
  from .config import Config
@@ -66,20 +66,12 @@ def original_name_of(message: Any) -> str:
66
 
67
 
68
  class Renamer:
69
- def __init__(self, client: Any, cfg: Config, db: Database, log_channel_send: Optional[Callable[[str], Awaitable[None]]] = None):
70
  self.client = client
71
  self.cfg = cfg
72
  self.db = db
73
- self._log_send = log_channel_send
74
 
75
  # ------------------------------------------------------------------
76
- async def _notify_log(self, text: str) -> None:
77
- if self._log_send is not None:
78
- try:
79
- await self._log_send(text)
80
- except Exception as exc: # noqa: BLE001
81
- log.warning("could not write to the log channel: %s", exc)
82
-
83
  async def _fresh_message(self, job: Job) -> Any:
84
  """Re-fetch the message so its file reference is valid even after a long queue wait."""
85
  msg = await self.client.get_messages(job.chat_id, ids=job.msg_id)
@@ -102,6 +94,9 @@ class Renamer:
102
  work.mkdir(parents=True, exist_ok=True)
103
  reporter = ProgressReporter(self.client, job.status_msg, job.id, job.new_name, self.cfg.progress_interval)
104
  started = time.monotonic()
 
 
 
105
  try:
106
  await self._run(job, s, work, reporter, started)
107
  except Cancelled:
@@ -109,12 +104,11 @@ class Renamer:
109
  await reporter.finish("❌ <b>Cancelled</b>\n<code>" + esc(clip(job.new_name, 120)) + "</code>")
110
  except Exception as exc: # noqa: BLE001
111
  job.state = "failed"
112
- log.exception("job %s failed", job.id)
113
  reason = clip(f"{type(exc).__name__}: {exc}", 300)
114
  await reporter.finish(f"❌ <b>Failed</b>\n<code>{esc(clip(job.new_name, 120))}</code>\n\n{esc(reason)}")
115
- tb = "".join(traceback.format_exception_only(type(exc), exc)).strip()
116
- await self._notify_log(f"❌ <b>Job failed</b> (user <code>{job.user_id}</code>)\n<code>{esc(clip(job.file_name, 100))}</code>\n<code>{esc(clip(tb, 600))}</code>")
117
  finally:
 
118
  shutil.rmtree(work, ignore_errors=True)
119
 
120
  async def _run(self, job: Job, s: Dict[str, Any], work: Path, reporter: ProgressReporter, started: float) -> None:
@@ -229,7 +223,3 @@ class Renamer:
229
  await reporter.finish(
230
  f"✅ <b>Done</b> in {human_duration(took)}\n<code>{esc(clip(job.new_name, 120))}</code>\n{human_size(final_size)}{where}{note}"
231
  )
232
- await self._notify_log(
233
- f"✅ <code>{job.user_id}</code>: <code>{esc(clip(job.file_name, 80))}</code> → "
234
- f"<code>{esc(clip(job.new_name, 80))}</code> ({human_size(final_size)}, {human_duration(took)})"
235
- )
 
5
  import mimetypes
6
  import shutil
7
  import time
 
8
  from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
 
11
  from telethon import errors, types
12
 
13
+ from . import errorlog
14
  from . import fasttransfer as transfer
15
  from . import media
16
  from .config import Config
 
66
 
67
 
68
  class Renamer:
69
+ def __init__(self, client: Any, cfg: Config, db: Database):
70
  self.client = client
71
  self.cfg = cfg
72
  self.db = db
 
73
 
74
  # ------------------------------------------------------------------
 
 
 
 
 
 
 
75
  async def _fresh_message(self, job: Job) -> Any:
76
  """Re-fetch the message so its file reference is valid even after a long queue wait."""
77
  msg = await self.client.get_messages(job.chat_id, ids=job.msg_id)
 
94
  work.mkdir(parents=True, exist_ok=True)
95
  reporter = ProgressReporter(self.client, job.status_msg, job.id, job.new_name, self.cfg.progress_interval)
96
  started = time.monotonic()
97
+ context = errorlog.error_context.set( # the log channel report says which file this was
98
+ f"user {job.user_id} · job #{job.id}\n{job.file_name} → {job.new_name} ({human_size(job.size)})"
99
+ )
100
  try:
101
  await self._run(job, s, work, reporter, started)
102
  except Cancelled:
 
104
  await reporter.finish("❌ <b>Cancelled</b>\n<code>" + esc(clip(job.new_name, 120)) + "</code>")
105
  except Exception as exc: # noqa: BLE001
106
  job.state = "failed"
107
+ log.exception("Error processing file:")
108
  reason = clip(f"{type(exc).__name__}: {exc}", 300)
109
  await reporter.finish(f"❌ <b>Failed</b>\n<code>{esc(clip(job.new_name, 120))}</code>\n\n{esc(reason)}")
 
 
110
  finally:
111
+ errorlog.error_context.reset(context)
112
  shutil.rmtree(work, ignore_errors=True)
113
 
114
  async def _run(self, job: Job, s: Dict[str, Any], work: Path, reporter: ProgressReporter, started: float) -> None:
 
223
  await reporter.finish(
224
  f"✅ <b>Done</b> in {human_duration(took)}\n<code>{esc(clip(job.new_name, 120))}</code>\n{human_size(final_size)}{where}{note}"
225
  )
 
 
 
 
tests/conftest.py CHANGED
@@ -1,4 +1,5 @@
1
  import json
 
2
  import os
3
  import shutil
4
  import subprocess
@@ -9,6 +10,8 @@ import pytest
9
 
10
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
11
 
 
 
12
  from .appkit import ctx # noqa: E402 the handler-level fixture, shared by every test module
13
 
14
  __all__ = ["ctx", "needs_ffmpeg"]
@@ -16,6 +19,14 @@ __all__ = ["ctx", "needs_ffmpeg"]
16
  HAVE_FFMPEG = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
17
  needs_ffmpeg = pytest.mark.skipif(not HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed")
18
 
 
 
 
 
 
 
 
 
19
  SRT_A = "1\n00:00:00,000 --> 00:00:01,000\nkonnichiwa\n\n"
20
  SRT_B = "1\n00:00:00,000 --> 00:00:01,000\nhello\n\n"
21
  FFMETA = """;FFMETADATA1
 
1
  import json
2
+ import logging
3
  import os
4
  import shutil
5
  import subprocess
 
10
 
11
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
12
 
13
+ from bot.errorlog import ChannelLogHandler # noqa: E402
14
+
15
  from .appkit import ctx # noqa: E402 the handler-level fixture, shared by every test module
16
 
17
  __all__ = ["ctx", "needs_ffmpeg"]
 
19
  HAVE_FFMPEG = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
20
  needs_ffmpeg = pytest.mark.skipif(not HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed")
21
 
22
+
23
+ @pytest.fixture(autouse=True)
24
+ def _no_stray_error_reporters():
25
+ """A test that stops halfway must not leave an error reporter on the root logger for the tests after it."""
26
+ yield
27
+ for handler in [h for h in logging.getLogger().handlers if isinstance(h, ChannelLogHandler)]:
28
+ handler.detach()
29
+
30
  SRT_A = "1\n00:00:00,000 --> 00:00:01,000\nkonnichiwa\n\n"
31
  SRT_B = "1\n00:00:00,000 --> 00:00:01,000\nhello\n\n"
32
  FFMETA = """;FFMETADATA1
tests/test_app.py CHANGED
@@ -8,7 +8,7 @@ import pytest
8
  from telethon import functions
9
 
10
  from bot import app as appmod
11
- from bot import panels
12
  from bot.app import COMMANDS, OWNER_COMMANDS, PANEL_NAMES, App, Pending, cmd_pattern, is_renameable
13
  from bot.config import load_config
14
  from bot.db import Database
@@ -788,6 +788,40 @@ async def test_handler_failures_are_reported_to_the_user(ctx, monkeypatch):
788
  assert "Auto Rename Bot" in (await say(ctx, OWNER, "/start")).texts[0]
789
 
790
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
791
  async def test_a_file_is_handled_by_exactly_one_handler(ctx):
792
  ctx.app.pending[OWNER] = Pending("template", OWNER, 1, "rename", False, time.time() + 100)
793
  ev = await send_doc(ctx, OWNER)
 
8
  from telethon import functions
9
 
10
  from bot import app as appmod
11
+ from bot import errorlog, panels
12
  from bot.app import COMMANDS, OWNER_COMMANDS, PANEL_NAMES, App, Pending, cmd_pattern, is_renameable
13
  from bot.config import load_config
14
  from bot.db import Database
 
788
  assert "Auto Rename Bot" in (await say(ctx, OWNER, "/start")).texts[0]
789
 
790
 
791
+ async def test_handler_failures_reach_the_log_channel_with_the_update_and_the_traceback(ctx, monkeypatch):
792
+ channel_log = errorlog.install(ctx.app._log_send, errorlog.secrets_from(ctx.cfg)) # the root logger, as in main.py
793
+
794
+ async def boom(*a, **k):
795
+ raise RuntimeError("database is down")
796
+
797
+ monkeypatch.setattr(ctx.db, "get_settings", boom)
798
+ ev = await say(ctx, OWNER, "/start")
799
+ await channel_log.aclose()
800
+ assert ev.texts == ["⚠️ Something went wrong. Please try again."]
801
+ (_chat, text, kw), = [s for s in ctx.client.sent if s[0] == LOG]
802
+ assert kw["parse_mode"] == "html"
803
+ assert text.startswith(
804
+ '<pre><code class="language-python">Error handling update:\n'
805
+ f"FakeEvent(sender_id={OWNER}, chat_id={OWNER}, message_id=1, text='/start')\n\nTraceback (most recent call last):"
806
+ )
807
+ assert text.endswith("RuntimeError: database is down</code></pre>")
808
+
809
+
810
+ async def test_an_error_inside_a_helper_still_names_the_update_that_started_it(ctx, monkeypatch):
811
+ channel_log = errorlog.install(ctx.app._log_send)
812
+
813
+ async def boom(*a, **k):
814
+ raise RuntimeError("telegraph exploded")
815
+
816
+ monkeypatch.setattr(ctx.app, "_mediainfo", boom)
817
+ reply = document_message("Movie.mkv", 5 * 1024 * 1024)
818
+ await say(ctx, OWNER, "/mediainfo", reply=reply)
819
+ await channel_log.aclose()
820
+ (_chat, text, _kw), = [s for s in ctx.client.sent if s[0] == LOG]
821
+ assert text.startswith('<pre><code class="language-python">/mediainfo failed\nFakeEvent(sender_id=')
822
+ assert "text='/mediainfo'" in text and text.endswith("RuntimeError: telegraph exploded</code></pre>")
823
+
824
+
825
  async def test_a_file_is_handled_by_exactly_one_handler(ctx):
826
  ctx.app.pending[OWNER] = Pending("template", OWNER, 1, "rename", False, time.time() + 100)
827
  ev = await send_doc(ctx, OWNER)
tests/test_errorlog.py ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Error reports for the log channel: what they look like, what is hidden or held back, and how they are delivered."""
2
+ import asyncio
3
+ import datetime
4
+ import html
5
+ import logging
6
+ import re
7
+ from types import SimpleNamespace
8
+
9
+ import pytest
10
+ from telethon import events, types
11
+
12
+ from bot import errorlog
13
+ from bot.config import load_config
14
+ from bot.errorlog import ChannelLogHandler, ErrorReporter, build_report, describe_update, redact
15
+
16
+ from .tgfakes import FakeCallback, FakeEvent, document_message
17
+
18
+ CODE = '<pre><code class="language-python">'
19
+
20
+
21
+ class Catch(logging.Handler):
22
+ def __init__(self):
23
+ super().__init__()
24
+ self.records = []
25
+
26
+ def emit(self, record):
27
+ self.records.append(record)
28
+
29
+
30
+ def make_record(message="Error handling update:", raiser=None, *args):
31
+ """A real log record, made the way the bot makes them: ``log.exception`` inside an ``except``."""
32
+ logger = logging.getLogger("errorlog.test.records")
33
+ logger.propagate = False
34
+ catch = Catch()
35
+ logger.addHandler(catch)
36
+ try:
37
+ try:
38
+ if raiser is not None:
39
+ raiser()
40
+ except Exception: # noqa: BLE001
41
+ logger.exception(message, *args)
42
+ else:
43
+ logger.error(message, *args)
44
+ finally:
45
+ logger.removeHandler(catch)
46
+ return catch.records[0]
47
+
48
+
49
+ def visible(report):
50
+ """What Telegram shows: the report without its HTML markup."""
51
+ return html.unescape(re.sub(r"</?(?:pre|code)[^>]*>", "", report))
52
+
53
+
54
+ def fail(text="bad"):
55
+ raise ValueError(text)
56
+
57
+
58
+ def fail_elsewhere():
59
+ raise ValueError("bad")
60
+
61
+
62
+ def chained():
63
+ try:
64
+ {}["missing"]
65
+ except KeyError:
66
+ raise RuntimeError("could not read <b>this</b> & that")
67
+
68
+
69
+ def very_deep(depth=200):
70
+ """A traceback with ``depth`` different frames (a recursion would be shortened by Python)."""
71
+ scope = {}
72
+ source = "\n".join(f"def f{i}():\n return f{i + 1}()" for i in range(depth)) + f"\ndef f{depth}():\n raise ValueError('bottom')"
73
+ exec(source, scope) # noqa: S102
74
+ scope["f0"]()
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # describing the update
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ def test_a_message_with_a_file_is_described_with_its_text_and_file():
83
+ message = document_message("1146 - One Piece [Dual] 720p @Anime_X_Universe.mkv", 316_000_000, caption="hello")
84
+ line = describe_update(FakeEvent(1000001, message=message))
85
+ assert line == (
86
+ "FakeEvent(sender_id=1000001, chat_id=1000001, message_id=10, text='hello', "
87
+ "file='1146 - One Piece [Dual] 720p @Anime_X_Universe.mkv' (video/x-matroska, 316000000 bytes))"
88
+ )
89
+
90
+
91
+ def test_a_button_press_is_described_with_its_data():
92
+ assert describe_update(FakeCallback(5, "fmt:v:3", msg_id=77)) == "FakeCallback(sender_id=5, chat_id=5, message_id=77, data='fmt:v:3')"
93
+
94
+
95
+ def test_real_telethon_events_are_described_too():
96
+ now = datetime.datetime.now(datetime.timezone.utc)
97
+ message = types.Message(id=5, peer_id=types.PeerUser(77), message="/mediainfo", date=now, from_id=types.PeerUser(77), out=False)
98
+ assert describe_update(events.NewMessage.Event(message)) == "NewMessage(sender_id=77, chat_id=77, message_id=5, text='/mediainfo')"
99
+ query = types.UpdateBotCallbackQuery(query_id=1, user_id=77, peer=types.PeerUser(77), msg_id=9, chat_instance=1, data=b"fmt:v:3")
100
+ assert describe_update(events.CallbackQuery.Event(query, query.peer, query.msg_id)) == (
101
+ "CallbackQuery(sender_id=77, chat_id=77, message_id=9, data='fmt:v:3')"
102
+ )
103
+
104
+
105
+ def test_a_long_text_is_cut_and_an_odd_object_never_raises():
106
+ line = describe_update(FakeEvent(1, "x" * 500))
107
+ assert "…" in line and len(line) < 400
108
+
109
+ class Odd:
110
+ @property
111
+ def sender_id(self):
112
+ raise RuntimeError("no")
113
+
114
+ assert describe_update(Odd()) == "Odd"
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # hiding secrets
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ def test_configured_secrets_are_hidden_but_short_values_are_left_alone():
123
+ assert redact("token abc123XYZ789 and abc", ["abc123XYZ789", "abc"]) == "token *** and abc"
124
+
125
+
126
+ def test_session_strings_and_access_tokens_are_hidden():
127
+ session = "1" + "A1b2C3_-" * 40
128
+ telegraph = "b968da509bb76866c35425099bc0989a5ec3b32997d55286c657e6994bbb"
129
+ text = f"bound {session} and {telegraph} here"
130
+ assert redact(text) == "bound *** and *** here"
131
+
132
+
133
+ def test_ordinary_text_survives():
134
+ for text in (
135
+ "File \"/home/user/app/bot/app.py\", line 1021, in _mediainfo",
136
+ "1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv → One Piece - E1147.mkv (276.61 MB)",
137
+ "-" * 80,
138
+ ):
139
+ assert redact(text) == text
140
+
141
+
142
+ def test_the_values_an_sql_error_prints_are_hidden():
143
+ text = "[SQL: INSERT INTO kv VALUES (?, ?)]\n[parameters: ('session', 'short')]\n(Background on this error)"
144
+ assert "short" not in redact(text) and "[SQL: INSERT INTO kv VALUES (?, ?)]" in redact(text)
145
+
146
+
147
+ def test_secrets_come_from_the_configuration():
148
+ cfg = load_config({
149
+ "API_ID": "1", "API_HASH": "0123456789abcdef", "BOT_TOKEN": "123:botsecret", "OWNER_ID": "1",
150
+ "DATABASE_URL": "postgresql://user:pw123456@host/db",
151
+ })
152
+ secrets = errorlog.secrets_from(cfg)
153
+ assert {"0123456789abcdef", "123:botsecret", "pw123456", "postgresql://user:pw123456@host/db"} <= set(secrets)
154
+ assert "" not in secrets # no SESSION_STRING set
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # the report itself
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ def test_the_report_has_the_message_then_the_whole_traceback_in_a_python_block():
163
+ _signature, body = build_report(make_record("Error handling update:", chained))
164
+ assert body.startswith(CODE + "Error handling update:\n\nTraceback (most recent call last):") and body.endswith("</code></pre>")
165
+ text = visible(body)
166
+ assert "KeyError: 'missing'" in text and "During handling of the above exception, another exception occurred:" in text
167
+ assert text.endswith("RuntimeError: could not read <b>this</b> & that")
168
+ assert "&lt;b&gt;this&lt;/b&gt; &amp; that" in body # escaped for Telegram's HTML
169
+
170
+
171
+ def test_a_message_without_an_exception_is_just_the_message():
172
+ _signature, body = build_report(make_record("something odd: %s", None, 42))
173
+ assert body == f"{CODE}something odd: 42</code></pre>"
174
+
175
+
176
+ def test_what_the_bot_was_working_on_comes_under_the_message():
177
+ token = errorlog.error_context.set("user 42 · job #7\nold.mkv → new.mkv (1.00 MB)")
178
+ try:
179
+ _signature, body = build_report(make_record("Error processing file:", fail))
180
+ finally:
181
+ errorlog.error_context.reset(token)
182
+ assert body.startswith(CODE + "Error processing file:\nuser 42 · job #7\nold.mkv → new.mkv (1.00 MB)\n\nTraceback")
183
+ assert "user 42" not in build_report(make_record("later", fail))[1] # only while it is set
184
+
185
+
186
+ def test_the_working_context_can_be_a_function_and_is_not_repeated():
187
+ token = errorlog.error_context.set(lambda: "FakeEvent(sender_id=1)")
188
+ try:
189
+ body = build_report(make_record("Error handling update:", fail))[1]
190
+ assert body.count("FakeEvent(sender_id=1)") == 1
191
+ assert build_report(make_record("FakeEvent(sender_id=1) failed", fail))[1].count("FakeEvent(sender_id=1)") == 1
192
+ finally:
193
+ errorlog.error_context.reset(token)
194
+
195
+
196
+ def test_secrets_are_hidden_in_the_message_and_in_the_traceback():
197
+ body = build_report(make_record("login with TOP-SECRET-VALUE failed", lambda: fail("TOP-SECRET-VALUE")), ["TOP-SECRET-VALUE"])[1]
198
+ assert "TOP-SECRET-VALUE" not in body and body.count("***") >= 2
199
+
200
+
201
+ def test_a_long_traceback_keeps_its_end_and_fits_in_one_message():
202
+ _signature, body = build_report(make_record("Error handling update:", very_deep))
203
+ text = visible(body)
204
+ assert errorlog._u16(text) <= errorlog.MAX_TEXT
205
+ assert text.startswith("Error handling update:\n\n…\n File ") # cut at the beginning of a line
206
+ assert text.endswith("ValueError: bottom")
207
+
208
+
209
+ def test_emoji_count_double_towards_the_limit():
210
+ text = visible(build_report(make_record("Error handling update:", lambda: fail("🔥" * 3000)))[1])
211
+ assert errorlog._u16(text) <= errorlog.MAX_TEXT and text.endswith("🔥")
212
+
213
+
214
+ def test_a_log_call_with_wrong_arguments_still_gives_a_report():
215
+ record = logging.LogRecord("bot.test", logging.ERROR, __file__, 1, "%d items", ("x",), None) # what such a call makes
216
+ assert build_report(record)[1] == f"{CODE}%d items</code></pre>"
217
+
218
+
219
+ def test_the_same_exception_on_the_same_line_is_the_same_error():
220
+ same = build_report(make_record("one", fail))[0], build_report(make_record("another message", fail))[0]
221
+ assert same[0] == same[1]
222
+ assert build_report(make_record("one", fail_elsewhere))[0] != same[0]
223
+ assert build_report(make_record("one", None))[0] != same[0]
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # keeping repeats and floods out
228
+ # ---------------------------------------------------------------------------
229
+
230
+
231
+ class Clock:
232
+ def __init__(self):
233
+ self.now = 1000.0
234
+
235
+ def __call__(self):
236
+ return self.now
237
+
238
+
239
+ @pytest.fixture
240
+ def sink():
241
+ sent = []
242
+
243
+ async def send(text):
244
+ sent.append(text)
245
+
246
+ return SimpleNamespace(sent=sent, send=send, clock=Clock())
247
+
248
+
249
+ async def test_an_identical_error_is_reported_once_and_counted(sink):
250
+ reporter = ErrorReporter(sink.send, clock=sink.clock)
251
+ for n in (1, 2, 3):
252
+ reporter.submit("same", f"<pre>{n}</pre>")
253
+ await reporter.flush()
254
+ assert sink.sent == ["<pre>1</pre>"]
255
+ sink.clock.now += 121
256
+ reporter.submit("same", "<pre>4</pre>")
257
+ await reporter.flush()
258
+ assert sink.sent[1] == "<pre>4</pre>\n🔁 2 more of this error since the last report."
259
+ sink.clock.now += 121
260
+ reporter.submit("same", "<pre>5</pre>")
261
+ await reporter.flush()
262
+ assert sink.sent[2] == "<pre>5</pre>" # nothing came back in between: no note
263
+
264
+
265
+ async def test_different_errors_all_go_out_in_the_order_they_happened(sink):
266
+ async def slow_first(text):
267
+ await asyncio.sleep(0.05 if text == "<pre>a</pre>" else 0)
268
+ await sink.send(text)
269
+
270
+ reporter = ErrorReporter(slow_first, clock=sink.clock)
271
+ for name in "abc":
272
+ reporter.submit(name, f"<pre>{name}</pre>")
273
+ await reporter.flush()
274
+ assert sink.sent == ["<pre>a</pre>", "<pre>b</pre>", "<pre>c</pre>"]
275
+
276
+
277
+ async def test_a_flood_is_cut_off_and_mentioned_in_the_next_report(sink):
278
+ reporter = ErrorReporter(sink.send, per_minute=3, clock=sink.clock)
279
+ for n in range(5):
280
+ reporter.submit(f"e{n}", f"<pre>{n}</pre>")
281
+ await reporter.flush()
282
+ assert sink.sent == ["<pre>0</pre>", "<pre>1</pre>", "<pre>2</pre>"]
283
+ sink.clock.now += 61
284
+ reporter.submit("later", "<pre>later</pre>")
285
+ await reporter.flush()
286
+ assert sink.sent[3] == "<pre>later</pre>\n⚠️ 2 other error report(s) were left out to keep this chat readable."
287
+
288
+
289
+ async def test_a_failing_send_does_not_stop_the_next_reports(sink, caplog):
290
+ calls = []
291
+
292
+ async def flaky(text):
293
+ calls.append(text)
294
+ if len(calls) == 1:
295
+ raise RuntimeError("CHAT_WRITE_FORBIDDEN")
296
+ await sink.send(text)
297
+
298
+ reporter = ErrorReporter(flaky, clock=sink.clock)
299
+ reporter.submit("a", "<pre>a</pre>")
300
+ reporter.submit("b", "<pre>b</pre>")
301
+ await reporter.flush()
302
+ assert sink.sent == ["<pre>b</pre>"]
303
+ assert "could not send an error report" in caplog.text and "CHAT_WRITE_FORBIDDEN" in caplog.text
304
+
305
+
306
+ async def test_old_entries_are_forgotten(sink):
307
+ reporter = ErrorReporter(sink.send, per_minute=10_000, clock=sink.clock)
308
+ for n in range(150):
309
+ reporter.submit(f"old{n}", "<pre>x</pre>")
310
+ sink.clock.now += 500
311
+ for n in range(100):
312
+ reporter.submit(f"new{n}", "<pre>x</pre>")
313
+ assert len(reporter._seen) <= 101
314
+ await reporter.flush()
315
+
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # the logging handler
319
+ # ---------------------------------------------------------------------------
320
+
321
+
322
+ @pytest.fixture
323
+ async def channel(sink):
324
+ logger = logging.getLogger("errorlog.test.channel")
325
+ logger.propagate = False
326
+ logger.setLevel(logging.DEBUG)
327
+ handler = errorlog.install(sink.send, ["TOP-SECRET-VALUE"], logger=logger)
328
+ yield SimpleNamespace(log=logger, sent=sink.sent, handler=handler)
329
+ handler.detach()
330
+
331
+
332
+ async def test_an_error_with_a_traceback_arrives_as_one_python_block(channel):
333
+ try:
334
+ fail("disk is full")
335
+ except ValueError:
336
+ channel.log.exception("Error processing file:")
337
+ await channel.handler.aclose()
338
+ (report,) = channel.sent
339
+ assert report.startswith(CODE + "Error processing file:\n\nTraceback (most recent call last):")
340
+ assert report.endswith("ValueError: disk is full</code></pre>")
341
+
342
+
343
+ async def test_only_errors_are_reported(channel):
344
+ channel.log.info("fine")
345
+ channel.log.warning("careful")
346
+ channel.log.error("broken")
347
+ channel.log.critical("very broken")
348
+ await channel.handler.aclose()
349
+ assert channel.sent == [f"{CODE}broken</code></pre>", f"{CODE}very broken</code></pre>"]
350
+
351
+
352
+ async def test_errors_logged_from_another_thread_arrive_too(channel):
353
+ await asyncio.to_thread(channel.log.error, "from a worker thread")
354
+ await channel.handler.aclose()
355
+ assert channel.sent == [f"{CODE}from a worker thread</code></pre>"]
356
+
357
+
358
+ async def test_the_working_context_is_added_to_errors_logged_below_it(channel):
359
+ token = errorlog.error_context.set("user 42 · job #7")
360
+ try:
361
+ channel.log.error("helper failed")
362
+ finally:
363
+ errorlog.error_context.reset(token)
364
+ await channel.handler.aclose()
365
+ assert channel.sent == [f"{CODE}helper failed\nuser 42 · job #7</code></pre>"]
366
+
367
+
368
+ async def test_secrets_never_leave_the_bot(channel):
369
+ channel.log.error("the key TOP-SECRET-VALUE was refused")
370
+ await channel.handler.aclose()
371
+ assert channel.sent == [f"{CODE}the key *** was refused</code></pre>"]
372
+
373
+
374
+ async def test_closing_stops_the_reports(channel):
375
+ await channel.handler.aclose()
376
+ channel.log.error("too late")
377
+ await asyncio.sleep(0.01)
378
+ assert channel.sent == []
379
+
380
+
381
+ async def test_the_reporters_own_trouble_is_not_reported(sink):
382
+ handler = errorlog.install(sink.send) # the root logger, as the bot does
383
+ try:
384
+ logging.getLogger("bot.errorlog").error("this would loop")
385
+ logging.getLogger("bot.other").error("this is reported")
386
+ await handler.aclose()
387
+ finally:
388
+ handler.detach()
389
+ assert sink.sent == [f"{CODE}this is reported</code></pre>"]
390
+
391
+
392
+ def test_a_closed_event_loop_does_not_break_logging(sink):
393
+ loop = asyncio.new_event_loop()
394
+ loop.close()
395
+ logger = logging.getLogger("errorlog.test.closed")
396
+ logger.propagate = False
397
+ handler = ChannelLogHandler(ErrorReporter(sink.send), loop)
398
+ logger.addHandler(handler)
399
+ try:
400
+ logger.error("nobody is listening") # must not raise
401
+ finally:
402
+ logger.removeHandler(handler)
tests/test_pipeline.py CHANGED
@@ -5,6 +5,7 @@ from types import SimpleNamespace
5
  import pytest
6
  from telethon import errors, types
7
 
 
8
  from bot.config import load_config
9
  from bot.db import Database
10
  from bot.media import probe
@@ -69,12 +70,7 @@ async def env(tmp_path, media_dir):
69
  })
70
  db = Database(f"sqlite+aiosqlite:///{tmp_path}/p.db")
71
  await db.init()
72
- logs = []
73
-
74
- async def log_send(text):
75
- logs.append(text)
76
-
77
- ns = SimpleNamespace(cfg=cfg, db=db, logs=logs, log_send=log_send, media_dir=media_dir, tmp=tmp_path)
78
  yield ns
79
  await db.close()
80
 
@@ -83,7 +79,7 @@ def make(env, name="[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv", s
83
  source = env.media_dir / src
84
  message = FakeMessage(name, source.stat().st_size, **msg_kwargs)
85
  client = FakeClient(source, message)
86
- renamer = Renamer(client, env.cfg, env.db, env.log_send)
87
  new_name, _ = compute_new_name(template=DEFAULT_TEMPLATE, mode="filename", original_name=name, original_ext=Path(name).suffix)
88
  job = Job(id=1, user_id=42, chat_id=42, msg_id=7, file_name=name, size=source.stat().st_size, new_name=new_name,
89
  status_msg=SimpleNamespace(id=99))
@@ -111,7 +107,6 @@ async def test_plain_rename_sends_video_with_thumbnail(env):
111
  assert job.state == "done"
112
  assert (await env.db.totals())[0] == 1
113
  assert not (Path(env.cfg.temp_dir) / "job1").exists() # temp files cleaned
114
- assert any("Tokyo Revengers" in t for t in env.logs)
115
 
116
 
117
  @needs_ffmpeg
@@ -164,7 +159,7 @@ async def test_forced_video_for_non_video_falls_back_to_document(env, tmp_path):
164
  blob.write_bytes(b"PK" + b"0" * 5000)
165
  message = FakeMessage("stuff.zip", 5002, video=False, mime="application/zip")
166
  client = FakeClient(blob, message)
167
- renamer = Renamer(client, env.cfg, env.db, env.log_send)
168
  await env.db.update_settings(42, file_type="video")
169
  job = Job(id=2, user_id=42, chat_id=42, msg_id=7, file_name="stuff.zip", size=5002, new_name="Stuff.zip", status_msg=object())
170
  await renamer.process(job)
@@ -215,7 +210,7 @@ async def test_unsupported_container_with_metadata_warns_but_still_renames(env,
215
  client = FakeClient(avi, message)
216
  await env.db.update_settings(42, metadata_on=True, meta={"title": "T"})
217
  job = Job(id=3, user_id=42, chat_id=42, msg_id=7, file_name="x.avi", size=avi.stat().st_size, new_name="Y.avi", status_msg=object())
218
- await Renamer(client, env.cfg, env.db, env.log_send).process(job)
219
  assert client.sent and "not supported" in client.edits[-1][0]
220
 
221
 
@@ -239,17 +234,41 @@ async def test_cancel_before_start(env):
239
  assert not (Path(env.cfg.temp_dir) / "job1").exists()
240
 
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  @needs_ffmpeg
243
- async def test_failure_is_reported_and_logged(env):
244
  client, renamer, job = make(env)
245
  client.download_error = RuntimeError("network down")
246
  await renamer.process(job)
 
247
  assert job.state == "failed" and client.sent == []
248
  assert "Failed" in client.edits[-1][0] and "network down" in client.edits[-1][0]
249
- assert any("Job failed" in t and "network down" in t for t in env.logs)
 
 
250
  assert not (Path(env.cfg.temp_dir) / "job1").exists()
251
 
252
 
 
 
 
 
 
 
 
 
253
  @needs_ffmpeg
254
  async def test_not_enough_disk_space(env, monkeypatch):
255
  client, renamer, job = make(env)
 
5
  import pytest
6
  from telethon import errors, types
7
 
8
+ from bot import errorlog
9
  from bot.config import load_config
10
  from bot.db import Database
11
  from bot.media import probe
 
70
  })
71
  db = Database(f"sqlite+aiosqlite:///{tmp_path}/p.db")
72
  await db.init()
73
+ ns = SimpleNamespace(cfg=cfg, db=db, media_dir=media_dir, tmp=tmp_path)
 
 
 
 
 
74
  yield ns
75
  await db.close()
76
 
 
79
  source = env.media_dir / src
80
  message = FakeMessage(name, source.stat().st_size, **msg_kwargs)
81
  client = FakeClient(source, message)
82
+ renamer = Renamer(client, env.cfg, env.db)
83
  new_name, _ = compute_new_name(template=DEFAULT_TEMPLATE, mode="filename", original_name=name, original_ext=Path(name).suffix)
84
  job = Job(id=1, user_id=42, chat_id=42, msg_id=7, file_name=name, size=source.stat().st_size, new_name=new_name,
85
  status_msg=SimpleNamespace(id=99))
 
107
  assert job.state == "done"
108
  assert (await env.db.totals())[0] == 1
109
  assert not (Path(env.cfg.temp_dir) / "job1").exists() # temp files cleaned
 
110
 
111
 
112
  @needs_ffmpeg
 
159
  blob.write_bytes(b"PK" + b"0" * 5000)
160
  message = FakeMessage("stuff.zip", 5002, video=False, mime="application/zip")
161
  client = FakeClient(blob, message)
162
+ renamer = Renamer(client, env.cfg, env.db)
163
  await env.db.update_settings(42, file_type="video")
164
  job = Job(id=2, user_id=42, chat_id=42, msg_id=7, file_name="stuff.zip", size=5002, new_name="Stuff.zip", status_msg=object())
165
  await renamer.process(job)
 
210
  client = FakeClient(avi, message)
211
  await env.db.update_settings(42, metadata_on=True, meta={"title": "T"})
212
  job = Job(id=3, user_id=42, chat_id=42, msg_id=7, file_name="x.avi", size=avi.stat().st_size, new_name="Y.avi", status_msg=object())
213
+ await Renamer(client, env.cfg, env.db).process(job)
214
  assert client.sent and "not supported" in client.edits[-1][0]
215
 
216
 
 
234
  assert not (Path(env.cfg.temp_dir) / "job1").exists()
235
 
236
 
237
+ @pytest.fixture
238
+ async def log_channel():
239
+ """What reaches the log channel while a test runs (the error reporter is attached to the root logger, as in main.py)."""
240
+ reports = []
241
+
242
+ async def send(text):
243
+ reports.append(text)
244
+
245
+ handler = errorlog.install(send)
246
+ yield SimpleNamespace(reports=reports, flush=handler.aclose)
247
+ handler.detach()
248
+
249
+
250
  @needs_ffmpeg
251
+ async def test_failure_is_reported_and_logged(env, log_channel):
252
  client, renamer, job = make(env)
253
  client.download_error = RuntimeError("network down")
254
  await renamer.process(job)
255
+ await log_channel.flush()
256
  assert job.state == "failed" and client.sent == []
257
  assert "Failed" in client.edits[-1][0] and "network down" in client.edits[-1][0]
258
+ (report,) = log_channel.reports # the log channel gets the whole traceback, as a Python code block
259
+ assert report.startswith(f'<pre><code class="language-python">Error processing file:\nuser 42 · job #1\n{job.file_name} → {job.new_name} (')
260
+ assert "Traceback (most recent call last):" in report and report.endswith("RuntimeError: network down</code></pre>")
261
  assert not (Path(env.cfg.temp_dir) / "job1").exists()
262
 
263
 
264
+ @needs_ffmpeg
265
+ async def test_a_finished_job_leaves_nothing_in_the_log_channel(env, log_channel):
266
+ client, renamer, job = make(env)
267
+ await renamer.process(job)
268
+ await log_channel.flush()
269
+ assert job.state == "done" and log_channel.reports == []
270
+
271
+
272
  @needs_ffmpeg
273
  async def test_not_enough_disk_space(env, monkeypatch):
274
  client, renamer, job = make(env)
tests/test_web_main.py CHANGED
@@ -1,5 +1,6 @@
1
  import asyncio
2
  import dataclasses
 
3
  import socket
4
  from types import SimpleNamespace
5
 
@@ -13,6 +14,7 @@ import bot.main as bot_main
13
  from bot import __version__
14
  from bot.config import load_config
15
  from bot.db import Database
 
16
  from bot.web import make_app, start_web
17
 
18
  from .tgfakes import FakeClient
@@ -187,6 +189,14 @@ async def test_amain_wires_everything_and_exits_nonzero_if_telegram_disconnects(
187
  assert any(isinstance(r, functions.bots.SetBotCommandsRequest) for r in client.requests)
188
  assert not (tmp_path / "work" / "job9").exists()
189
  assert client.sent[0][0] == -1001111111111
 
 
 
 
 
 
 
 
190
  gate.set() # Telegram connection lost -> exit non-zero so the container restarts
191
  assert await asyncio.wait_for(task, 10) == 1
192
  finally:
@@ -194,6 +204,7 @@ async def test_amain_wires_everything_and_exits_nonzero_if_telegram_disconnects(
194
  if not task.done():
195
  task.cancel()
196
  assert client.disconnected
 
197
  with pytest.raises(aiohttp.ClientError):
198
  async with aiohttp.ClientSession() as http:
199
  async with http.get(f"http://127.0.0.1:{port}/health"):
 
1
  import asyncio
2
  import dataclasses
3
+ import logging
4
  import socket
5
  from types import SimpleNamespace
6
 
 
14
  from bot import __version__
15
  from bot.config import load_config
16
  from bot.db import Database
17
+ from bot.errorlog import ChannelLogHandler
18
  from bot.web import make_app, start_web
19
 
20
  from .tgfakes import FakeClient
 
189
  assert any(isinstance(r, functions.bots.SetBotCommandsRequest) for r in client.requests)
190
  assert not (tmp_path / "work" / "job9").exists()
191
  assert client.sent[0][0] == -1001111111111
192
+ assert any(isinstance(h, ChannelLogHandler) for h in logging.getLogger().handlers)
193
+ logging.getLogger("bot.something").error("an error somewhere in the bot") # errors go to the log channel as well
194
+ for _ in range(200):
195
+ if any("an error somewhere in the bot" in text for _chat, text, _kw in client.sent):
196
+ break
197
+ await asyncio.sleep(0.05)
198
+ else:
199
+ pytest.fail("the error never reached the log channel")
200
  gate.set() # Telegram connection lost -> exit non-zero so the container restarts
201
  assert await asyncio.wait_for(task, 10) == 1
202
  finally:
 
204
  if not task.done():
205
  task.cancel()
206
  assert client.disconnected
207
+ assert not any(isinstance(h, ChannelLogHandler) for h in logging.getLogger().handlers) # detached again on the way out
208
  with pytest.raises(aiohttp.ClientError):
209
  async with aiohttp.ClientSession() as http:
210
  async with http.get(f"http://127.0.0.1:{port}/health"):