Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
84e63a1
1
Parent(s): 5f400f8
Automated sync from GitHub
Browse files- .gitattributes +2 -2
- bot/app.py +6 -3
- bot/errorlog.py +242 -0
- bot/main.py +4 -1
- bot/pipeline.py +8 -18
- tests/conftest.py +11 -0
- tests/test_app.py +35 -1
- tests/test_errorlog.py +402 -0
- tests/test_pipeline.py +31 -12
- tests/test_web_main.py +11 -0
.gitattributes
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.ttf filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.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
|
| 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("
|
| 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,
|
| 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
|
| 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("
|
| 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 "<b>this</b> & 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 |
-
|
| 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
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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"):
|