543 lines
25 KiB
Python
543 lines
25 KiB
Python
"""Telegram control commands (admin only), the /upload conversation, and the notifier.
|
|
|
|
The /upload conversation is persisted in the ``upload_tasks`` table (statuses
|
|
AWAITING_START -> AWAITING_END -> AWAITING_CONFIRMATION), keyed by the admin's
|
|
Telegram user ID, so every administrator has an independent setup and a restart
|
|
does not lose it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
|
|
from database import iso, utcnow
|
|
from errors import BotError
|
|
from link_parser import EXAMPLE, LinkError, parse_message_link
|
|
from models import ACTIVE_TASK_STATUSES, SETUP_TASK_STATUSES, State, TaskStatus, UploadTask, VideoRecord
|
|
from progress import HtmlReply, esc
|
|
from queue_manager import QueueManager, public_error
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
HELP = """Commands (administrators only):
|
|
/upload - process a range of channel messages (asks for start and end links)
|
|
/confirm - start the prepared upload task
|
|
/cancel - cancel your upload setup or your active task (/cancel ID for a specific task)
|
|
/taskstatus - status of upload tasks (/taskstatus ID for one task)
|
|
/uploadhistory - recent upload tasks and their results
|
|
/status - current processing state
|
|
/progress - processed / pending / failed counts
|
|
/run - process every discovered channel video
|
|
/pause - pause at the next safe checkpoint
|
|
/resume - continue after a pause or /stop
|
|
/stop - checkpoint the current video and stop
|
|
/sync - discover new channel videos and queue them
|
|
/retry - re-queue failed items
|
|
/failed - list failed items with error summaries
|
|
/folders - topic folders and uploaded files
|
|
/check ID - details for one Telegram message ID
|
|
/help - this message"""
|
|
|
|
START_PROMPT = (
|
|
"📥 <b>Start Upload Task</b>\n\n"
|
|
"Please send the Telegram link of the first message you want to process.\n\n"
|
|
"Example:\nhttps://t.me/c/3909337424/14"
|
|
)
|
|
END_PROMPT = (
|
|
"📌 <b>Enter the End Link</b>\n\n"
|
|
"Now send the Telegram link of the last message you want to process.\n\n"
|
|
"Example:\nhttps://t.me/c/3909337424/20"
|
|
)
|
|
_STEP = {
|
|
TaskStatus.AWAITING_START: "waiting for the start link",
|
|
TaskStatus.AWAITING_END: "waiting for the end link",
|
|
TaskStatus.AWAITING_CONFIRMATION: "waiting for /confirm",
|
|
}
|
|
|
|
_COMMAND_RE = re.compile(r"^/(?P<cmd>[A-Za-z_]+)(?:@\w+)?(?:\s+(?P<arg>.*))?$", re.DOTALL)
|
|
_LINK_HINT = re.compile(r"(?:t\.me|telegram\.me|telegram\.dog)/", re.IGNORECASE)
|
|
|
|
|
|
def _short(text: str | None, limit: int = 160) -> str:
|
|
if not text:
|
|
return "-"
|
|
text = " ".join(text.split())
|
|
# never echo filesystem paths back to chat
|
|
text = re.sub(r"(?:/[^\s/]+)+/([^\s/]+)", r"…/\1", text)
|
|
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
|
|
|
|
class CommandRouter:
|
|
def __init__(self, config, db, queue: QueueManager):
|
|
self.config = config
|
|
self.db = db
|
|
self.queue = queue
|
|
|
|
def is_admin(self, user_id: int | None) -> bool:
|
|
return user_id is not None and user_id in self.config.admin_ids
|
|
|
|
async def handle(self, user_id: int | None, text: str) -> str | None:
|
|
stripped = (text or "").strip()
|
|
match = _COMMAND_RE.match(stripped)
|
|
if not match:
|
|
if not self.is_admin(user_id):
|
|
return None # ignore chatter from non-admins
|
|
try:
|
|
return await self.on_text(user_id, stripped)
|
|
except Exception as exc:
|
|
log.exception("Handling a message from %s failed", user_id)
|
|
return f"Could not process that message: {public_error(exc)}"
|
|
if not self.is_admin(user_id):
|
|
log.warning("Rejected command from non-admin user %s", user_id)
|
|
return "You are not authorized to control this bot."
|
|
command = match.group("cmd").lower()
|
|
handler = getattr(self, f"cmd_{command}", None)
|
|
if handler is None:
|
|
return "Unknown command. Send /help for the list of commands."
|
|
try:
|
|
return await handler((match.group("arg") or "").strip(), user_id)
|
|
except Exception as exc:
|
|
log.exception("Command /%s failed", command)
|
|
return f"Command failed: {public_error(exc)}"
|
|
|
|
# -- /upload conversation --------------------------------------------------------------
|
|
|
|
def _active_setup(self, user_id: int) -> tuple[UploadTask | None, bool]:
|
|
"""This admin's unfinished setup, expiring it if it is older than the timeout."""
|
|
setups = self.db.tasks_with_status(*SETUP_TASK_STATUSES, admin_user_id=user_id)
|
|
if not setups:
|
|
return None, False
|
|
setup = setups[-1]
|
|
for stale in setups[:-1]: # defensive: only one setup per admin may exist
|
|
self.db.update_task(stale.id, status=TaskStatus.CANCELLED, last_error="superseded", completed_at=iso(utcnow()))
|
|
age = utcnow() - datetime.fromisoformat(setup.updated_at)
|
|
if age > timedelta(minutes=self.config.upload_setup_timeout):
|
|
self.db.update_task(
|
|
setup.id, status=TaskStatus.CANCELLED, last_error="setup expired", completed_at=iso(utcnow())
|
|
)
|
|
log.info("Upload setup #%s of admin %s expired", setup.id, user_id)
|
|
return None, True
|
|
return setup, False
|
|
|
|
async def on_text(self, user_id: int, text: str) -> str | None:
|
|
setup, expired = self._active_setup(user_id)
|
|
if setup is None:
|
|
if _LINK_HINT.search(text):
|
|
note = "Your previous upload setup expired. " if expired else ""
|
|
return f"{note}To process a message range, send /upload first."
|
|
return None
|
|
if setup.status == TaskStatus.AWAITING_START:
|
|
return await self._receive_start(setup, text)
|
|
if setup.status == TaskStatus.AWAITING_END:
|
|
return await self._receive_end(setup, text)
|
|
return f"Upload Task #{setup.id} is ready. Send /confirm to begin or /cancel to discard it."
|
|
|
|
async def _check_link(self, text: str, *, expected_channel_id: int | None = None):
|
|
"""Return (link, channel_id, title) or an error message."""
|
|
try:
|
|
link = parse_message_link(text)
|
|
except LinkError as exc:
|
|
return str(exc)
|
|
source = self.queue.source
|
|
if source is None:
|
|
return "Telegram access is not available right now."
|
|
try:
|
|
channel_id, title = await source.resolve_link(link)
|
|
except BotError as exc:
|
|
return exc.public
|
|
if expected_channel_id is not None and channel_id != expected_channel_id:
|
|
return "The end link belongs to a different channel than the start link. Both links must come from the same channel."
|
|
if (
|
|
expected_channel_id is None
|
|
and self.config.upload_restrict_to_source_channel
|
|
and channel_id != source.channel_id
|
|
):
|
|
return (
|
|
"This link is not from the configured source channel "
|
|
f"({source.channel_title or self.config.source_channel}). "
|
|
"Set UPLOAD_RESTRICT_TO_SOURCE_CHANNEL=false to allow other channels the bot can access."
|
|
)
|
|
try:
|
|
accessible = await source.message_accessible(channel_id, link.message_id)
|
|
except BotError as exc:
|
|
return exc.public
|
|
if not accessible:
|
|
return f"Message {link.message_id} was not found in that channel (deleted, or not visible to the bot's account)."
|
|
return link, channel_id, title
|
|
|
|
async def _receive_start(self, setup: UploadTask, text: str) -> str:
|
|
result = await self._check_link(text)
|
|
if isinstance(result, str):
|
|
return HtmlReply(f"❌ {esc(result)}\n\nPlease send a valid start link, for example:\n{EXAMPLE}")
|
|
link, channel_id, title = result
|
|
self.db.update_task(
|
|
setup.id,
|
|
status=TaskStatus.AWAITING_END,
|
|
channel_ref=link.channel_ref,
|
|
channel_id=channel_id,
|
|
channel_title=title,
|
|
start_message_id=link.message_id,
|
|
)
|
|
return HtmlReply(f"Start message: {link.message_id} ✔\n\n{END_PROMPT}")
|
|
|
|
async def _receive_end(self, setup: UploadTask, text: str) -> str:
|
|
result = await self._check_link(text, expected_channel_id=setup.channel_id)
|
|
if isinstance(result, str):
|
|
return HtmlReply(
|
|
f"❌ {esc(result)}\n\nThe start link (message {setup.start_message_id}) is kept. "
|
|
"Please send a valid end link."
|
|
)
|
|
link, _, _ = result
|
|
if link.message_id < setup.start_message_id:
|
|
return HtmlReply(
|
|
f"❌ The end message ID ({link.message_id}) must be greater than or equal to the start message ID "
|
|
f"({setup.start_message_id}). Please send a valid end link."
|
|
)
|
|
size = link.message_id - setup.start_message_id + 1
|
|
if size > self.config.upload_max_range:
|
|
return HtmlReply(
|
|
f"❌ That range covers {size} messages; the maximum is {self.config.upload_max_range} "
|
|
"(UPLOAD_MAX_RANGE). Please send a closer end link."
|
|
)
|
|
task = self.db.update_task(setup.id, status=TaskStatus.AWAITING_CONFIRMATION, end_message_id=link.message_id)
|
|
return self._confirmation(task)
|
|
|
|
def _channel_label(self, task: UploadTask) -> str:
|
|
source = self.queue.source
|
|
name = task.channel_title or str(task.channel_id)
|
|
if source is not None and task.channel_id == source.channel_id:
|
|
return f"{name} (configured source channel)"
|
|
return name
|
|
|
|
def _confirmation(self, task: UploadTask) -> HtmlReply:
|
|
return HtmlReply(
|
|
"✅ <b>Upload Task Ready</b>\n\n"
|
|
f"Start Message ID: {task.start_message_id}\n"
|
|
f"End Message ID: {task.end_message_id}\n"
|
|
f"Channel: {esc(self._channel_label(task))}\n\n"
|
|
"The bot will process eligible videos within this message range, organize them into Google Drive "
|
|
"topic folders, and clean up local temporary files after successful uploads.\n\n"
|
|
"Send /confirm to begin or /cancel to cancel this task."
|
|
)
|
|
|
|
# -- task commands ----------------------------------------------------------------------
|
|
|
|
async def cmd_upload(self, arg: str, user_id: int) -> str:
|
|
setup, expired = self._active_setup(user_id)
|
|
if setup is not None:
|
|
hint = {
|
|
TaskStatus.AWAITING_START: "Send the start link to continue.",
|
|
TaskStatus.AWAITING_END: f"Start message {setup.start_message_id} is set; send the end link to continue.",
|
|
TaskStatus.AWAITING_CONFIRMATION: "Send /confirm to begin.",
|
|
}[setup.status]
|
|
return (
|
|
f"⚠️ You already have an upload setup in progress (Task #{setup.id}, {_STEP[setup.status]}).\n"
|
|
f"{hint}\nOr send /cancel to discard it and start again."
|
|
)
|
|
self.db.create_task(user_id)
|
|
prefix = "Your previous unfinished setup expired.\n\n" if expired else ""
|
|
return HtmlReply(prefix + START_PROMPT)
|
|
|
|
async def cmd_confirm(self, arg: str, user_id: int) -> str:
|
|
setup, expired = self._active_setup(user_id)
|
|
if setup is None:
|
|
note = "Your previous setup expired. " if expired else ""
|
|
return f"{note}There is no upload task waiting for confirmation. Start one with /upload."
|
|
if setup.status != TaskStatus.AWAITING_CONFIRMATION:
|
|
return (
|
|
"Both the start and end links must be validated before /confirm. "
|
|
f"Task #{setup.id} is {_STEP[setup.status]}."
|
|
)
|
|
task = self.queue.submit_task(setup.id)
|
|
ahead = [t for t in self.db.tasks_with_status(*ACTIVE_TASK_STATUSES) if t.id < task.id]
|
|
lines = [f"🚀 <b>Upload Task #{task.id} confirmed</b>", f"Range: {task.range_text}"]
|
|
if self.queue.paused_flag:
|
|
lines.append("The queue is paused; send /resume to start processing.")
|
|
elif ahead:
|
|
lines.append("Queued after task(s) " + ", ".join(f"#{t.id}" for t in ahead) + ".")
|
|
else:
|
|
lines.append("Processing has started. Use /taskstatus to follow it.")
|
|
return HtmlReply("\n".join(lines))
|
|
|
|
async def cmd_cancel(self, arg: str, user_id: int) -> str:
|
|
if arg:
|
|
if not arg.isdigit():
|
|
return "Usage: /cancel or /cancel TASK_ID"
|
|
return self._cancel_reply(int(arg), await self.queue.cancel_task(int(arg)))
|
|
setup, _ = self._active_setup(user_id)
|
|
if setup is not None:
|
|
await self.queue.cancel_task(setup.id, reason="setup cancelled")
|
|
return f"Upload setup #{setup.id} cancelled. Nothing was downloaded or uploaded."
|
|
own = self.db.tasks_with_status(*ACTIVE_TASK_STATUSES, admin_user_id=user_id)
|
|
if own:
|
|
running = [t for t in own if t.status == TaskStatus.RUNNING]
|
|
target = running[0] if running else own[-1]
|
|
return self._cancel_reply(target.id, await self.queue.cancel_task(target.id))
|
|
others = self.db.tasks_with_status(*ACTIVE_TASK_STATUSES)
|
|
if others:
|
|
ids = ", ".join(f"#{t.id}" for t in others)
|
|
return f"You have nothing to cancel. Active task(s) {ids} belong to another administrator; use /cancel ID to cancel one explicitly."
|
|
return "Nothing to cancel."
|
|
|
|
@staticmethod
|
|
def _cancel_reply(task_id: int, outcome: str) -> str:
|
|
return {
|
|
"unknown": f"Task #{task_id} does not exist.",
|
|
"finished": f"Task #{task_id} has already finished.",
|
|
"setup_cancelled": f"Upload setup #{task_id} cancelled.",
|
|
"cancelled": (
|
|
f"🛑 Task #{task_id} cancelled at a safe checkpoint. Completed uploads are kept; "
|
|
"the remaining videos were not processed and their temporary files were removed."
|
|
),
|
|
}[outcome]
|
|
|
|
async def cmd_taskstatus(self, arg: str, user_id: int) -> str:
|
|
if arg:
|
|
if not arg.isdigit():
|
|
return "Usage: /taskstatus or /taskstatus TASK_ID"
|
|
task = self.db.get_task(int(arg))
|
|
return self._describe_task(task) if task else f"Task #{arg} does not exist."
|
|
parts = []
|
|
setup, expired = self._active_setup(user_id)
|
|
if setup is not None:
|
|
parts.append(
|
|
f"Your setup: Task #{setup.id}, {_STEP[setup.status]}"
|
|
+ (f" (start {setup.start_message_id})" if setup.start_message_id else "")
|
|
)
|
|
elif expired:
|
|
parts.append("Your previous setup expired.")
|
|
active = self.db.tasks_with_status(*ACTIVE_TASK_STATUSES)
|
|
parts += [self._describe_task(t) for t in active]
|
|
if not active:
|
|
recent = self.db.recent_tasks(1)
|
|
parts.append("No active upload tasks." + (f"\n\nLast task:\n{self._describe_task(recent[0])}" if recent else ""))
|
|
return HtmlReply("\n\n".join(parts))
|
|
|
|
def _describe_task(self, task: UploadTask) -> HtmlReply:
|
|
lines = [
|
|
f"<b>Task #{task.id}</b> — {esc(task.status.value)}",
|
|
f"Range: {task.range_text}",
|
|
f"Channel: {esc(self._channel_label(task)) if task.channel_id else '-'}",
|
|
f"Requested by: {task.admin_user_id}",
|
|
]
|
|
if task.status not in SETUP_TASK_STATUSES:
|
|
lines += [
|
|
f"Eligible videos: {task.eligible_count} · Uploaded: {task.uploaded_count} · "
|
|
f"Duplicates: {task.duplicate_count} · Failed: {task.failed_count}",
|
|
f"Non-video skipped: {task.non_video_count} · Other skipped: {task.skipped_count} · "
|
|
f"Missing: {task.missing_count}",
|
|
f"Temporary files deleted: {task.deleted_files_count}",
|
|
]
|
|
if task.current_message_id and task.status == TaskStatus.RUNNING:
|
|
lines.append(f"Current message: {task.current_message_id}")
|
|
board = self.queue.reporter.board if self.queue.reporter else None
|
|
if board and board.task and board.task.get("id") == task.id:
|
|
lines.append(f"Video: {esc(board.title)} · {esc(board.status)}")
|
|
if board.upload:
|
|
lines.append(f"Upload: {esc(board.upload.describe())}")
|
|
elif board.download:
|
|
lines.append(f"Download: {esc(board.download.describe())}")
|
|
for label, value in (("Created", task.created_at), ("Started", task.started_at), ("Finished", task.completed_at)):
|
|
if value:
|
|
lines.append(f"{label}: {value}")
|
|
if task.last_error:
|
|
lines.append(f"Note: {esc(_short(task.last_error))}")
|
|
return HtmlReply("\n".join(lines))
|
|
|
|
async def cmd_uploadhistory(self, arg: str, user_id: int) -> str:
|
|
tasks = self.db.recent_tasks(10)
|
|
if not tasks:
|
|
return "No upload tasks yet. Start one with /upload."
|
|
lines = ["<b>Recent upload tasks</b>"]
|
|
for t in tasks:
|
|
lines.append(
|
|
f"#{t.id} {t.range_text} {esc(t.status.value)} — uploaded {t.uploaded_count}, "
|
|
f"duplicates {t.duplicate_count}, failed {t.failed_count}, non-video {t.non_video_count} "
|
|
f"(by {t.admin_user_id})"
|
|
)
|
|
return HtmlReply("\n".join(lines))
|
|
|
|
# -- general commands ---------------------------------------------------------------------
|
|
|
|
async def cmd_start(self, arg: str, user_id: int) -> str:
|
|
return (
|
|
"Telegram -> Google Drive video organizer.\n"
|
|
"Videos are uploaded into Drive folders named after the caption Topic, "
|
|
"using the caption Title as the file name.\n\n"
|
|
"Process a message range: /upload, send the start and end links, then /confirm.\n"
|
|
"Process the whole channel: /sync then /run.\n\n" + HELP
|
|
)
|
|
|
|
async def cmd_help(self, arg: str, user_id: int) -> str:
|
|
return HELP
|
|
|
|
async def cmd_status(self, arg: str, user_id: int) -> str:
|
|
counts = self.db.counts()
|
|
lines = [f"Queue: {self.queue.status}"]
|
|
if self.queue.halt_reason:
|
|
lines.append(f"Halted because: {self.queue.halt_reason}")
|
|
active = self.db.tasks_with_status(*ACTIVE_TASK_STATUSES)
|
|
if active:
|
|
lines.append("Upload tasks: " + ", ".join(f"#{t.id} {t.status.value} ({t.range_text})" for t in active))
|
|
current = self.queue.current
|
|
if current and self.queue.reporter:
|
|
lines.append("")
|
|
lines.append(re.sub(r"</?b>", "", self.queue.reporter.board.render()))
|
|
elif current:
|
|
lines.append(f"Current: message {current.message_id} ({current.title or 'untitled'})")
|
|
lines.append("")
|
|
lines.append(
|
|
f"Completed {counts['COMPLETED']} · Pending {counts['PENDING'] + counts['RETRY_WAIT']} · "
|
|
f"Failed {counts['FAILED']} · Skipped {counts['SKIPPED']}"
|
|
)
|
|
lines.append(f"Whole-channel processing (/run): {'on' if self.queue.global_enabled else 'off'}")
|
|
lines.append(f"Live channel listener: {'on' if self.queue.listening else 'off'}")
|
|
return "\n".join(lines)
|
|
|
|
async def cmd_progress(self, arg: str, user_id: int) -> str:
|
|
c = self.db.counts()
|
|
total = sum(c.values())
|
|
processed = c["COMPLETED"]
|
|
remaining = self.queue.remaining()
|
|
considered = total - c["SKIPPED"]
|
|
pct = f"{100 * processed / considered:.1f}%" if considered else "n/a"
|
|
return (
|
|
f"Total discovered: {total}\n"
|
|
f"Processed (uploaded & verified): {processed} ({pct})\n"
|
|
f"Pending: {c['PENDING']}\n"
|
|
f"Waiting to retry: {c['RETRY_WAIT']}\n"
|
|
f"In progress: {c['DOWNLOADING'] + c['DOWNLOADED'] + c['UPLOADING']}\n"
|
|
f"Failed: {c['FAILED']}\n"
|
|
f"Skipped: {c['SKIPPED']}\n"
|
|
f"Remaining: {remaining}"
|
|
)
|
|
|
|
async def cmd_run(self, arg: str, user_id: int) -> str:
|
|
if self.queue.start():
|
|
pending = self.queue.remaining()
|
|
hint = "" if pending else "\nThe queue is empty - use /sync to discover channel videos."
|
|
return f"Processing started ({pending} item(s) waiting).{hint}"
|
|
return f"Processing is already {self.queue.status}."
|
|
|
|
async def cmd_pause(self, arg: str, user_id: int) -> str:
|
|
if self.queue.pause():
|
|
return "Pausing: the current transfer stops at the next resumable checkpoint. Use /resume to continue."
|
|
return f"Nothing to pause (queue is {self.queue.status})."
|
|
|
|
async def cmd_resume(self, arg: str, user_id: int) -> str:
|
|
if self.queue.resume():
|
|
return "Processing resumed."
|
|
return f"Nothing to resume (queue is {self.queue.status})."
|
|
|
|
async def cmd_stop(self, arg: str, user_id: int) -> str:
|
|
await self.queue.stop(timeout=60)
|
|
return (
|
|
"Stopped. Interrupted work was checkpointed; a running upload task was paused. "
|
|
"Use /resume to continue upload tasks or /run for whole-channel processing."
|
|
)
|
|
|
|
async def cmd_retry(self, arg: str, user_id: int) -> str:
|
|
count = self.queue.retry_failed()
|
|
if not count:
|
|
return "No failed items to retry."
|
|
started = self.queue.start()
|
|
return f"Re-queued {count} failed item(s)." + (" Processing started." if started else "")
|
|
|
|
async def cmd_failed(self, arg: str, user_id: int) -> str:
|
|
items = self.db.list_by_state(State.FAILED, limit=20)
|
|
if not items:
|
|
return "No failed items."
|
|
lines = [f"Failed items (showing {len(items)}):"]
|
|
for rec in items:
|
|
lines.append(f"• #{rec.message_id} {_short(rec.title, 60)} — {_short(rec.last_error)}")
|
|
lines.append("Use /retry to re-queue them, /check ID for details.")
|
|
return "\n".join(lines)
|
|
|
|
async def cmd_folders(self, arg: str, user_id: int) -> str:
|
|
topics = self.db.topic_counts()
|
|
counts = self.db.counts()
|
|
lines = [f"Drive folders created/reused: {self.db.folder_count()}", f"Files uploaded: {counts['COMPLETED']}"]
|
|
if topics:
|
|
lines.append("")
|
|
lines += [f"• {topic or self.config.fallback_topic}: {n}" for topic, n in topics]
|
|
return "\n".join(lines)
|
|
|
|
async def cmd_check(self, arg: str, user_id: int) -> str:
|
|
if not arg.lstrip("-").isdigit():
|
|
return "Usage: /check MESSAGE_ID"
|
|
records = self.db.find_by_message_id(int(arg))
|
|
if not records:
|
|
return f"Message {arg} is not in the database. Use /sync or /upload to discover messages."
|
|
return "\n\n".join(self._describe(r) for r in records)
|
|
|
|
async def cmd_sync(self, arg: str, user_id: int) -> str:
|
|
result = await self.queue.sync(rescan=arg.lower() == "all")
|
|
if self.queue.running:
|
|
self.queue.wake()
|
|
return "Sync complete.\n" + result.summary()
|
|
|
|
def _describe(self, rec: VideoRecord) -> str:
|
|
lines = [
|
|
f"Message {rec.message_id} — {rec.state}",
|
|
f"Title: {rec.title or '-'}",
|
|
f"Topic: {rec.topic or '- (uses ' + self.config.fallback_topic + ')'}",
|
|
f"Batch: {rec.batch or '-'}",
|
|
f"Index: {', '.join(rec.indexes) or '-'}",
|
|
f"Extracted by: {rec.extracted_by or '-'}",
|
|
f"Download: {rec.download_status or '-'} · Upload: {rec.upload_status or '-'}",
|
|
f"Size: {rec.expected_size or rec.local_size or '?'} bytes",
|
|
f"Attempts: {rec.attempts}",
|
|
]
|
|
if rec.task_id:
|
|
lines.append(f"Upload task: #{rec.task_id}")
|
|
if rec.final_name:
|
|
lines.append(f"Drive name: {rec.final_name}")
|
|
if rec.drive_file_id:
|
|
lines.append(f"Drive file ID: {rec.drive_file_id}")
|
|
if rec.next_retry_at and rec.state == State.RETRY_WAIT:
|
|
lines.append(f"Next retry: {rec.next_retry_at}")
|
|
if rec.skip_reason:
|
|
lines.append(f"Skip reason: {_short(rec.skip_reason)}")
|
|
if rec.last_error:
|
|
lines.append(f"Last error: {_short(rec.last_error)}")
|
|
if rec.warnings:
|
|
lines.append(f"Warnings: {_short('; '.join(rec.warnings), 300)}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _parse_mode(text: str) -> str | None:
|
|
return "html" if isinstance(text, HtmlReply) else None
|
|
|
|
|
|
class TelethonNotifier:
|
|
def __init__(self, client):
|
|
self.client = client
|
|
|
|
async def send(self, chat_id: int, text: str) -> int | None:
|
|
message = await self.client.send_message(chat_id, str(text), parse_mode=_parse_mode(text), link_preview=False)
|
|
return message.id
|
|
|
|
async def edit(self, chat_id: int, message_id: int, text: str) -> None:
|
|
from telethon import errors
|
|
|
|
try:
|
|
await self.client.edit_message(chat_id, message_id, str(text), parse_mode=_parse_mode(text), link_preview=False)
|
|
except errors.MessageNotModifiedError:
|
|
pass
|
|
|
|
|
|
def register_command_handlers(client, router: CommandRouter) -> None:
|
|
from telethon import events
|
|
|
|
@client.on(events.NewMessage(incoming=True, func=lambda e: e.is_private))
|
|
async def _on_private_message(event) -> None: # pragma: no cover - needs a live Telegram connection
|
|
reply = await router.handle(event.sender_id, event.raw_text)
|
|
if not reply:
|
|
return
|
|
if isinstance(reply, HtmlReply):
|
|
await event.respond(str(reply), parse_mode="html", link_preview=False)
|
|
return
|
|
for start in range(0, len(reply), 4000):
|
|
await event.respond(reply[start : start + 4000], parse_mode=None, link_preview=False)
|