2765 lines
112 KiB
Python
2765 lines
112 KiB
Python
import asyncio
|
|
import base64
|
|
import contextlib
|
|
import io
|
|
import json
|
|
import logging
|
|
import mimetypes
|
|
import os
|
|
import posixpath
|
|
import queue
|
|
import signal
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
import traceback
|
|
import urllib.parse
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import qrcode
|
|
import qrcode.image.svg
|
|
from app_state import (
|
|
StateStore,
|
|
account_exists,
|
|
account_data_dir,
|
|
account_session_path,
|
|
get_account_store,
|
|
get_global_store,
|
|
load_account,
|
|
list_accounts,
|
|
)
|
|
from health import health_payload
|
|
from scraper_jobs import ScraperJobService
|
|
from telethon import TelegramClient
|
|
from telethon.errors import SessionPasswordNeededError
|
|
from telegram_scraper_with_forwarding import _ensure_session_wal
|
|
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
DATA_DIR = BASE_DIR / "data"
|
|
WEBUI_DIR = BASE_DIR / "webui"
|
|
STATE_FILE = DATA_DIR / "state.json"
|
|
DEFAULT_HOST = os.environ.get("TELEGRAM_SCRAPER_HOST", "0.0.0.0")
|
|
DEFAULT_PORT = int(os.environ.get("TELEGRAM_SCRAPER_PORT", "8080"))
|
|
START_CONTINUOUS = os.environ.get("TELEGRAM_SCRAPER_START_CONTINUOUS", "1") != "0"
|
|
SESSION_DIR = BASE_DIR / "session"
|
|
SESSION_DIR.mkdir(exist_ok=True)
|
|
STATE_STORE = StateStore(STATE_FILE)
|
|
SCRAPER_JOBS = ScraperJobService(STATE_STORE)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def utc_now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def load_state() -> Dict[str, Any]:
|
|
return STATE_STORE.load()
|
|
|
|
|
|
def save_state(state: Dict[str, Any]) -> None:
|
|
STATE_STORE.save(state)
|
|
|
|
|
|
def guess_media_kind(media_path: Optional[str], media_type: Optional[str]) -> Optional[str]:
|
|
if not media_path:
|
|
return None
|
|
suffix = Path(media_path).suffix.lower()
|
|
if suffix in {".jpg", ".jpeg", ".png", ".webp", ".gif"}:
|
|
return "image"
|
|
if suffix in {".mp4", ".webm", ".mov", ".m4v"}:
|
|
return "video"
|
|
if suffix in {".mp3", ".ogg", ".wav", ".m4a"}:
|
|
return "audio"
|
|
if media_type == "MessageMediaPhoto":
|
|
return "image"
|
|
return "file"
|
|
|
|
|
|
def normalize_media_url(media_path: Optional[str]) -> Optional[str]:
|
|
if not media_path:
|
|
return None
|
|
path = Path(media_path)
|
|
if path.is_absolute():
|
|
try:
|
|
relative = path.resolve().relative_to(DATA_DIR.resolve())
|
|
except ValueError:
|
|
return None
|
|
else:
|
|
normalized = Path(media_path)
|
|
if normalized.parts and normalized.parts[0] == "data":
|
|
normalized = Path(*normalized.parts[1:])
|
|
relative = normalized
|
|
return "/media/" + urllib.parse.quote(str(relative).replace("\\", "/"))
|
|
|
|
|
|
def channel_db_path(account_id: Optional[str], channel_id: str) -> Path:
|
|
if account_id:
|
|
return account_data_dir(DATA_DIR, account_id) / channel_id / f"{channel_id}.db"
|
|
return DATA_DIR / channel_id / f"{channel_id}.db"
|
|
|
|
|
|
def database_summary(account_id: Optional[str], channel_id: str) -> Dict[str, Any]:
|
|
db_path = channel_db_path(account_id, channel_id)
|
|
summary: Dict[str, Any] = {
|
|
"message_count": 0,
|
|
"last_date": None,
|
|
"first_date": None,
|
|
"media_count": 0,
|
|
"has_database": db_path.exists(),
|
|
"last_message_preview": "",
|
|
"last_sender": "",
|
|
}
|
|
if not db_path.exists():
|
|
return summary
|
|
conn = sqlite3.connect(str(db_path))
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT COUNT(*), MIN(date), MAX(date), "
|
|
"SUM(CASE WHEN media_type IS NOT NULL AND media_type != 'MessageMediaWebPage' THEN 1 ELSE 0 END) "
|
|
"FROM messages"
|
|
)
|
|
row = cursor.fetchone() or (0, None, None, 0)
|
|
summary.update(
|
|
{
|
|
"message_count": row[0] or 0,
|
|
"first_date": row[1],
|
|
"last_date": row[2],
|
|
"media_count": row[3] or 0,
|
|
}
|
|
)
|
|
try:
|
|
cursor.execute(
|
|
"SELECT message, media_type, first_name, last_name, username, post_author "
|
|
"FROM messages ORDER BY message_id DESC LIMIT 1"
|
|
)
|
|
latest = cursor.fetchone()
|
|
except sqlite3.OperationalError:
|
|
latest = None
|
|
if latest:
|
|
text = (latest[0] or "").strip()
|
|
if not text and latest[1]:
|
|
text = f"[{latest[1]}]"
|
|
sender = " ".join(part for part in [latest[2], latest[3]] if part).strip()
|
|
summary["last_message_preview"] = text[:180]
|
|
summary["last_sender"] = sender or latest[4] or latest[5] or ""
|
|
return summary
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def channel_display_name(state: Dict[str, Any], channel_id: str) -> str:
|
|
return state.get("channel_names", {}).get(channel_id) or channel_id
|
|
|
|
|
|
def list_channels_snapshot(account_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
if account_id:
|
|
state = load_account(DATA_DIR, account_id)
|
|
else:
|
|
state = load_state()
|
|
channels: List[Dict[str, Any]] = []
|
|
for channel_id, last_message_id in state.get("channels", {}).items():
|
|
summary = database_summary(account_id, channel_id)
|
|
channels.append(
|
|
{
|
|
"channel_id": channel_id,
|
|
"name": channel_display_name(state, channel_id),
|
|
"last_message_id": last_message_id,
|
|
**summary,
|
|
}
|
|
)
|
|
channels.sort(
|
|
key=lambda item: (item["last_date"] or "", item["message_count"]),
|
|
reverse=True,
|
|
)
|
|
return channels
|
|
|
|
|
|
def account_state_for_legacy() -> Optional[Dict[str, Any]]:
|
|
legacy = load_state()
|
|
if legacy.get("api_id") and legacy.get("api_hash"):
|
|
return legacy
|
|
return None
|
|
|
|
|
|
def load_messages(
|
|
account_id: Optional[str],
|
|
channel_id: str,
|
|
limit: int = 120,
|
|
before_message_id: Optional[int] = None,
|
|
search: Optional[str] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
db_path = channel_db_path(account_id, channel_id)
|
|
if not db_path.exists():
|
|
return []
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
query = (
|
|
"SELECT message_id, date, sender_id, first_name, last_name, username, message, "
|
|
"media_type, media_path, reply_to, post_author, views, forwards, reactions "
|
|
"FROM messages "
|
|
)
|
|
params: List[Any] = []
|
|
where: List[str] = []
|
|
if before_message_id is not None:
|
|
where.append("message_id < ?")
|
|
params.append(before_message_id)
|
|
if search:
|
|
where.append("message LIKE ?")
|
|
params.append(f"%{search}%")
|
|
if where:
|
|
query += "WHERE " + " AND ".join(where) + " "
|
|
query += "ORDER BY message_id DESC LIMIT ?"
|
|
params.append(limit)
|
|
rows = conn.execute(query, params).fetchall()
|
|
finally:
|
|
conn.close()
|
|
|
|
messages: List[Dict[str, Any]] = []
|
|
for row in reversed(rows):
|
|
sender_name = " ".join(
|
|
part for part in [row["first_name"], row["last_name"]] if part
|
|
).strip()
|
|
if not sender_name:
|
|
sender_name = row["username"] or row["post_author"] or channel_id
|
|
messages.append(
|
|
{
|
|
"message_id": row["message_id"],
|
|
"date": row["date"],
|
|
"sender_id": row["sender_id"],
|
|
"sender_name": sender_name,
|
|
"username": row["username"],
|
|
"text": row["message"] or "",
|
|
"media_type": row["media_type"],
|
|
"media_path": row["media_path"],
|
|
"media_url": normalize_media_url(row["media_path"]),
|
|
"media_kind": guess_media_kind(row["media_path"], row["media_type"]),
|
|
"reply_to": row["reply_to"],
|
|
"post_author": row["post_author"],
|
|
"views": row["views"],
|
|
"forwards": row["forwards"],
|
|
"reactions": row["reactions"],
|
|
}
|
|
)
|
|
|
|
reply_ids = list({m["reply_to"] for m in messages if m["reply_to"]})
|
|
if reply_ids:
|
|
placeholders = ",".join("?" for _ in reply_ids)
|
|
try:
|
|
conn2 = sqlite3.connect(str(db_path))
|
|
conn2.row_factory = sqlite3.Row
|
|
try:
|
|
rows2 = conn2.execute(
|
|
f"SELECT message_id, message, first_name, last_name, username "
|
|
f"FROM messages WHERE message_id IN ({placeholders})",
|
|
reply_ids,
|
|
).fetchall()
|
|
reply_map: Dict[int, Dict[str, str]] = {}
|
|
for r in rows2:
|
|
r_sender = " ".join(
|
|
part for part in [r["first_name"], r["last_name"]] if part
|
|
).strip() or (r["username"] or "")
|
|
reply_map[r["message_id"]] = {
|
|
"text": (r["message"] or "")[:300],
|
|
"sender_name": r_sender,
|
|
}
|
|
for m in messages:
|
|
rt = m.get("reply_to")
|
|
if rt and rt in reply_map:
|
|
m["reply_to_message"] = reply_map[rt]
|
|
finally:
|
|
conn2.close()
|
|
except sqlite3.Error:
|
|
pass
|
|
|
|
return messages
|
|
|
|
|
|
# ── Job ──────────────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class Job:
|
|
job_id: str
|
|
job_type: str
|
|
title: str
|
|
payload: Dict[str, Any]
|
|
status: str = "queued"
|
|
created_at: str = field(default_factory=utc_now_iso)
|
|
started_at: Optional[str] = None
|
|
finished_at: Optional[str] = None
|
|
logs: str = ""
|
|
error: Optional[str] = None
|
|
account_id: Optional[str] = None
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"job_id": self.job_id,
|
|
"job_type": self.job_type,
|
|
"title": self.title,
|
|
"payload": self.payload,
|
|
"status": self.status,
|
|
"created_at": self.created_at,
|
|
"started_at": self.started_at,
|
|
"finished_at": self.finished_at,
|
|
"logs": self.logs,
|
|
"error": self.error,
|
|
"account_id": self.account_id,
|
|
}
|
|
|
|
|
|
# ── JobRunner ────────────────────────────────────────────────────────────
|
|
|
|
class JobRunner:
|
|
def __init__(self) -> None:
|
|
self.jobs: Dict[str, Job] = {}
|
|
self.job_order: List[str] = []
|
|
self.queue: "queue.Queue[Job]" = queue.Queue()
|
|
self.lock = threading.Lock()
|
|
self._shutdown_flag = False
|
|
self.worker = threading.Thread(target=self._run, daemon=True)
|
|
self.worker.start()
|
|
|
|
def create_job(self, job_type: str, title: str, payload: Dict[str, Any]) -> Job:
|
|
if self._shutdown_flag:
|
|
raise RuntimeError("Server is shutting down, cannot create new jobs")
|
|
account_id = payload.get("account_id")
|
|
with self.lock:
|
|
if account_id:
|
|
for job_id in self.job_order:
|
|
existing = self.jobs[job_id]
|
|
if (
|
|
existing.account_id == account_id
|
|
and existing.status in {"queued", "running"}
|
|
):
|
|
existing.logs = (
|
|
existing.logs
|
|
+ "\n"
|
|
+ f"[{datetime.now().strftime('%H:%M:%S')}] Reused existing active job for this account."
|
|
).strip()
|
|
return existing
|
|
job_id = f"job-{int(time.time() * 1000)}"
|
|
job = Job(
|
|
job_id=job_id,
|
|
job_type=job_type,
|
|
title=title,
|
|
payload=payload,
|
|
account_id=account_id,
|
|
)
|
|
self.jobs[job_id] = job
|
|
self.job_order.insert(0, job_id)
|
|
self.job_order = self.job_order[:20]
|
|
self.queue.put(job)
|
|
return job
|
|
|
|
def recent_jobs(self, account_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
with self.lock:
|
|
result = [self.jobs[job_id].to_dict() for job_id in self.job_order]
|
|
if account_id is not None:
|
|
result = [j for j in result if j.get("account_id") == account_id]
|
|
return result
|
|
|
|
def active_jobs_by_account(self) -> Dict[str, Dict[str, Any]]:
|
|
with self.lock:
|
|
active = {}
|
|
for job_id in self.job_order:
|
|
job = self.jobs[job_id]
|
|
if job.account_id and job.status in {"queued", "running"}:
|
|
active[job.account_id] = job.to_dict()
|
|
return active
|
|
|
|
def get_job(self, job_id: str) -> Optional[Dict[str, Any]]:
|
|
with self.lock:
|
|
job = self.jobs.get(job_id)
|
|
return job.to_dict() if job else None
|
|
|
|
def shutdown(self, timeout: float = 10.0) -> None:
|
|
self._shutdown_flag = True
|
|
running_jobs = []
|
|
with self.lock:
|
|
for job_id, job in list(self.jobs.items()):
|
|
if job.status == "running":
|
|
running_jobs.append(job)
|
|
if running_jobs:
|
|
logger.warning("Waiting for %d running job(s) to finish...", len(running_jobs))
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
with self.lock:
|
|
still_running = [j for j in running_jobs if j.status == "running"]
|
|
if not still_running:
|
|
break
|
|
time.sleep(0.2)
|
|
still_running = [j for j in running_jobs if j.status == "running"]
|
|
if still_running:
|
|
logger.warning(
|
|
"%d job(s) did not finish within %ss timeout",
|
|
len(still_running),
|
|
timeout,
|
|
)
|
|
|
|
def _run(self) -> None:
|
|
while not self._shutdown_flag:
|
|
try:
|
|
job = self.queue.get(timeout=1)
|
|
except queue.Empty:
|
|
continue
|
|
self._execute(job)
|
|
self.queue.task_done()
|
|
|
|
def _execute(self, job: Job) -> None:
|
|
with self.lock:
|
|
job.status = "running"
|
|
job.started_at = utc_now_iso()
|
|
buffer = io.StringIO()
|
|
try:
|
|
with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer):
|
|
run_job(job.job_type, job.payload)
|
|
with self.lock:
|
|
job.status = "done"
|
|
except Exception as exc:
|
|
with self.lock:
|
|
job.status = "failed"
|
|
job.error = str(exc)
|
|
traceback.print_exc(file=buffer)
|
|
finally:
|
|
with self.lock:
|
|
job.logs = buffer.getvalue()
|
|
job.finished_at = utc_now_iso()
|
|
|
|
|
|
# ── TelegramAuthManager (per-account) ────────────────────────────────────
|
|
|
|
class TelegramAuthManager:
|
|
def __init__(self) -> None:
|
|
self.lock = threading.Lock()
|
|
self.loop = asyncio.new_event_loop()
|
|
self.thread = threading.Thread(target=self._run_loop, daemon=True)
|
|
self.thread.start()
|
|
self.clients: Dict[str, TelegramClient] = {}
|
|
self.auth_data: Dict[str, Dict[str, Any]] = {}
|
|
|
|
def _run_loop(self) -> None:
|
|
asyncio.set_event_loop(self.loop)
|
|
self.loop.run_forever()
|
|
|
|
def _run(self, coro):
|
|
future = asyncio.run_coroutine_threadsafe(coro, self.loop)
|
|
return future.result()
|
|
|
|
def _get_auth_data(self, account_id: str) -> Dict[str, Any]:
|
|
if account_id not in self.auth_data:
|
|
self.auth_data[account_id] = {
|
|
"phase": "idle",
|
|
"status": "unknown",
|
|
"details": "",
|
|
"qr_url": None,
|
|
"qr_image": None,
|
|
"phone": None,
|
|
"phone_code_hash": None,
|
|
"qr_login": None,
|
|
"qr_wait_task": None,
|
|
"user_id": None,
|
|
"updated_at": utc_now_iso(),
|
|
}
|
|
return self.auth_data[account_id]
|
|
|
|
def _set_state(self, account_id: str, **updates: Any) -> None:
|
|
data = self._get_auth_data(account_id)
|
|
data.update(updates)
|
|
data["updated_at"] = utc_now_iso()
|
|
|
|
def _make_qr_image(self, qr_url: str) -> str:
|
|
qr = qrcode.QRCode(border=1, box_size=8)
|
|
qr.add_data(qr_url)
|
|
qr.make(fit=True)
|
|
image = qr.make_image(image_factory=qrcode.image.svg.SvgPathImage)
|
|
buffer = io.BytesIO()
|
|
image.save(buffer)
|
|
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
return f"data:image/svg+xml;base64,{encoded}"
|
|
|
|
async def _get_client(self, account_id: str) -> TelegramClient:
|
|
acc_state = load_account(DATA_DIR, account_id)
|
|
api_id = acc_state.get("api_id")
|
|
api_hash = acc_state.get("api_hash")
|
|
if not api_id or not api_hash:
|
|
raise RuntimeError("Save api_id and api_hash first for this account.")
|
|
if account_id not in self.clients or self.clients[account_id] is None:
|
|
_ensure_session_wal(account_session_path(SESSION_DIR, account_id))
|
|
self.clients[account_id] = TelegramClient(
|
|
account_session_path(SESSION_DIR, account_id),
|
|
api_id,
|
|
api_hash,
|
|
)
|
|
client = self.clients[account_id]
|
|
if not client.is_connected():
|
|
await client.connect()
|
|
data = self._get_auth_data(account_id)
|
|
if data.get("user_id") is None and await client.is_user_authorized():
|
|
try:
|
|
me = await client.get_me()
|
|
data["user_id"] = me.id
|
|
except Exception:
|
|
pass
|
|
return client
|
|
|
|
def auth_state(self, account_id: str) -> Dict[str, Any]:
|
|
data = self._get_auth_data(account_id)
|
|
acc_state = load_account(DATA_DIR, account_id)
|
|
snapshot = dict(data)
|
|
snapshot.pop("qr_login", None)
|
|
snapshot.pop("qr_wait_task", None)
|
|
snapshot.pop("phone_code_hash", None)
|
|
snapshot["saved_credentials"] = {
|
|
"api_id": acc_state.get("api_id"),
|
|
"api_hash_present": bool(acc_state.get("api_hash")),
|
|
}
|
|
snapshot["auth_status"] = auth_status_for(account_id)
|
|
return snapshot
|
|
|
|
def all_auth_states(self) -> Dict[str, Dict[str, Any]]:
|
|
result: Dict[str, Dict[str, Any]] = {}
|
|
for account_id in list_accounts(DATA_DIR):
|
|
result[account_id] = self.auth_state(account_id)
|
|
return result
|
|
|
|
def save_credentials(self, account_id: str, api_id: int, api_hash: str) -> Dict[str, Any]:
|
|
def mutate(state: Dict[str, Any]) -> None:
|
|
state["api_id"] = int(api_id)
|
|
state["api_hash"] = api_hash.strip()
|
|
|
|
store = get_account_store(DATA_DIR, account_id)
|
|
store.update(mutate)
|
|
self._set_state(
|
|
account_id,
|
|
phase="credentials_saved",
|
|
status="ready_for_auth",
|
|
details="Credentials saved. You can login with QR or phone code.",
|
|
)
|
|
return self.auth_state(account_id)
|
|
|
|
async def _start_qr_login(self, account_id: str) -> Dict[str, Any]:
|
|
client = await self._get_client(account_id)
|
|
data = self._get_auth_data(account_id)
|
|
if await client.is_user_authorized():
|
|
self._set_state(
|
|
account_id,
|
|
phase="authorized",
|
|
status="authorized",
|
|
details="Telegram session is already authorized.",
|
|
)
|
|
return self.auth_state(account_id)
|
|
qr_login = await client.qr_login()
|
|
qr_url = qr_login.url
|
|
data["qr_login"] = qr_login
|
|
self._set_state(
|
|
account_id,
|
|
phase="qr_waiting",
|
|
status="qr_waiting",
|
|
details="Scan the QR code in Telegram: Settings -> Devices -> Scan QR.",
|
|
qr_url=qr_url,
|
|
qr_image=self._make_qr_image(qr_url),
|
|
)
|
|
data["qr_wait_task"] = self.loop.create_task(
|
|
self._wait_for_qr_login(account_id)
|
|
)
|
|
return self.auth_state(account_id)
|
|
|
|
async def _wait_for_qr_login(self, account_id: str) -> None:
|
|
data = self._get_auth_data(account_id)
|
|
qr_login = data.get("qr_login")
|
|
if not qr_login:
|
|
return
|
|
try:
|
|
await qr_login.wait()
|
|
self._set_state(
|
|
account_id,
|
|
phase="authorized",
|
|
status="authorized",
|
|
details="Telegram session is authorized.",
|
|
qr_url=None,
|
|
qr_image=None,
|
|
phone=None,
|
|
)
|
|
except SessionPasswordNeededError:
|
|
self._set_state(
|
|
account_id,
|
|
phase="password_required",
|
|
status="password_required",
|
|
details="Two-factor authentication is enabled. Enter your Telegram password.",
|
|
)
|
|
except Exception as exc:
|
|
self._set_state(
|
|
account_id,
|
|
phase="error",
|
|
status="error",
|
|
details=f"QR login failed: {exc}",
|
|
qr_url=None,
|
|
qr_image=None,
|
|
)
|
|
|
|
def start_qr_login(self, account_id: str) -> Dict[str, Any]:
|
|
return self._run(self._start_qr_login(account_id))
|
|
|
|
async def _request_phone_code(self, account_id: str, phone: str) -> Dict[str, Any]:
|
|
client = await self._get_client(account_id)
|
|
data = self._get_auth_data(account_id)
|
|
if await client.is_user_authorized():
|
|
self._set_state(
|
|
account_id,
|
|
phase="authorized",
|
|
status="authorized",
|
|
details="Telegram session is already authorized.",
|
|
)
|
|
return self.auth_state(account_id)
|
|
sent = await client.send_code_request(phone)
|
|
data["phone"] = phone
|
|
data["phone_code_hash"] = sent.phone_code_hash
|
|
self._set_state(
|
|
account_id,
|
|
phase="code_required",
|
|
status="code_required",
|
|
details=f"Code sent to {phone}. Enter it below.",
|
|
phone=phone,
|
|
)
|
|
return self.auth_state(account_id)
|
|
|
|
def request_phone_code(self, account_id: str, phone: str) -> Dict[str, Any]:
|
|
return self._run(self._request_phone_code(account_id, phone))
|
|
|
|
async def _submit_phone_code(self, account_id: str, code: str) -> Dict[str, Any]:
|
|
client = await self._get_client(account_id)
|
|
data = self._get_auth_data(account_id)
|
|
if not data.get("phone") or not data.get("phone_code_hash"):
|
|
raise RuntimeError("Request a phone code first.")
|
|
try:
|
|
await client.sign_in(
|
|
phone=data["phone"],
|
|
code=code,
|
|
phone_code_hash=data["phone_code_hash"],
|
|
)
|
|
self._set_state(
|
|
account_id,
|
|
phase="authorized",
|
|
status="authorized",
|
|
details="Telegram session is authorized.",
|
|
qr_url=None,
|
|
qr_image=None,
|
|
)
|
|
except SessionPasswordNeededError:
|
|
self._set_state(
|
|
account_id,
|
|
phase="password_required",
|
|
status="password_required",
|
|
details="Two-factor authentication is enabled. Enter your Telegram password.",
|
|
)
|
|
return self.auth_state(account_id)
|
|
|
|
def submit_phone_code(self, account_id: str, code: str) -> Dict[str, Any]:
|
|
return self._run(self._submit_phone_code(account_id, code))
|
|
|
|
async def _submit_password(self, account_id: str, password: str) -> Dict[str, Any]:
|
|
client = await self._get_client(account_id)
|
|
await client.sign_in(password=password)
|
|
self._set_state(
|
|
account_id,
|
|
phase="authorized",
|
|
status="authorized",
|
|
details="Telegram session is authorized.",
|
|
qr_url=None,
|
|
qr_image=None,
|
|
)
|
|
return self.auth_state(account_id)
|
|
|
|
def submit_password(self, account_id: str, password: str) -> Dict[str, Any]:
|
|
return self._run(self._submit_password(account_id, password))
|
|
|
|
def delete_account(self, account_id: str) -> None:
|
|
if account_id in self.clients:
|
|
client = self.clients[account_id]
|
|
try:
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
self._disconnect_client(client), self.loop
|
|
)
|
|
future.result(timeout=5)
|
|
except Exception:
|
|
pass
|
|
del self.clients[account_id]
|
|
self.auth_data.pop(account_id, None)
|
|
|
|
async def _disconnect_client(self, client: TelegramClient) -> None:
|
|
if client and client.is_connected():
|
|
await client.disconnect()
|
|
|
|
def shutdown(self, timeout: float = 5.0) -> None:
|
|
async def _disconnect_all():
|
|
for client in self.clients.values():
|
|
try:
|
|
if client and client.is_connected():
|
|
await client.disconnect()
|
|
except Exception:
|
|
pass
|
|
|
|
if self.clients:
|
|
try:
|
|
future = asyncio.run_coroutine_threadsafe(_disconnect_all(), self.loop)
|
|
future.result(timeout=timeout)
|
|
except Exception:
|
|
pass
|
|
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
self.thread.join(timeout=timeout)
|
|
|
|
|
|
# ── PerAccountContinuousScrapeManager ────────────────────────────────────
|
|
|
|
class PerAccountContinuousScrapeManager:
|
|
def __init__(self, account_id: str) -> None:
|
|
self.account_id = account_id
|
|
self.lock = threading.RLock()
|
|
self.thread: Optional[threading.Thread] = None
|
|
self.stop_event = threading.Event()
|
|
self.config: Dict[str, Any] = self._load_config()
|
|
self.status: Dict[str, Any] = {
|
|
"running": False,
|
|
"last_started_at": None,
|
|
"last_finished_at": None,
|
|
"last_iteration_at": None,
|
|
"last_error": None,
|
|
"logs": [],
|
|
"log_entries": [],
|
|
}
|
|
|
|
def _load_config(self) -> Dict[str, Any]:
|
|
acc_state = load_account(DATA_DIR, self.account_id)
|
|
return dict(acc_state.get("continuous_scraping", {
|
|
"enabled": True,
|
|
"interval_minutes": 1,
|
|
"channels": [],
|
|
"run_all_tracked": True,
|
|
}))
|
|
|
|
def _save_config(self) -> None:
|
|
store = get_account_store(DATA_DIR, self.account_id)
|
|
def mutate(state: Dict[str, Any]) -> None:
|
|
state["continuous_scraping"] = {
|
|
"enabled": bool(self.config.get("enabled", True)),
|
|
"interval_minutes": max(1, int(self.config.get("interval_minutes", 1) or 1)),
|
|
"channels": [
|
|
str(item).strip()
|
|
for item in self.config.get("channels", [])
|
|
if str(item).strip()
|
|
],
|
|
"run_all_tracked": bool(self.config.get("run_all_tracked", True)),
|
|
}
|
|
store.update(mutate)
|
|
|
|
def refresh_config(self) -> None:
|
|
"""
|
|
Re-read account state from disk and sync the in-memory config.
|
|
Call this after external state changes (import, channel add/remove)
|
|
so the manager picks up new channels or config updates without a restart.
|
|
"""
|
|
disk_state = load_account(DATA_DIR, self.account_id)
|
|
disk_cfg = disk_state.get("continuous_scraping") or {}
|
|
with self.lock:
|
|
# Merge disk config into memory, preserving our current running state
|
|
self.config["enabled"] = bool(disk_cfg.get("enabled", False))
|
|
self.config["interval_minutes"] = max(1, int(disk_cfg.get("interval_minutes", 1) or 1))
|
|
self.config["channels"] = [
|
|
str(item).strip()
|
|
for item in disk_cfg.get("channels", [])
|
|
if str(item).strip()
|
|
]
|
|
self.config["run_all_tracked"] = bool(disk_cfg.get("run_all_tracked", True))
|
|
# Sync running state with desired enabled state
|
|
if self.config["enabled"] and not self.status["running"]:
|
|
pass # don't auto-start — user must call start()
|
|
elif not self.config["enabled"] and self.status["running"]:
|
|
self._log("Continuous disabled via external state change, stopping.", "warn")
|
|
self.stop_event.set()
|
|
self.status["running"] = False
|
|
self._log("Config refreshed from disk.", "debug")
|
|
|
|
def _log(self, message: str, level: str = "debug") -> None:
|
|
timestamp = utc_now_iso()
|
|
entry = {"timestamp": timestamp, "level": level, "message": message}
|
|
with self.lock:
|
|
logs = self.status.setdefault("logs", [])
|
|
log_entries = self.status.setdefault("log_entries", [])
|
|
logs.append(f"[{datetime.now().strftime('%H:%M:%S')}] {message}")
|
|
log_entries.append(entry)
|
|
if len(logs) > 300:
|
|
del logs[:-300]
|
|
if len(log_entries) > 300:
|
|
del log_entries[:-300]
|
|
|
|
def snapshot(self) -> Dict[str, Any]:
|
|
with self.lock:
|
|
return {
|
|
"config": dict(self.config),
|
|
"status": {
|
|
**self.status,
|
|
"logs": list(self.status.get("logs", [])),
|
|
"log_entries": list(self.status.get("log_entries", [])),
|
|
},
|
|
}
|
|
|
|
def update(
|
|
self,
|
|
enabled: bool,
|
|
interval_minutes: int,
|
|
channels: List[str],
|
|
run_all_tracked: bool,
|
|
) -> Dict[str, Any]:
|
|
interval_minutes = max(1, int(interval_minutes))
|
|
with self.lock:
|
|
self.config = {
|
|
"enabled": bool(enabled),
|
|
"interval_minutes": interval_minutes,
|
|
"channels": channels,
|
|
"run_all_tracked": bool(run_all_tracked),
|
|
}
|
|
self._save_config()
|
|
self.config = self._load_config()
|
|
if enabled:
|
|
self.start()
|
|
else:
|
|
self.stop()
|
|
return self.snapshot()
|
|
|
|
def start(self) -> None:
|
|
with self.lock:
|
|
if self.status["running"]:
|
|
self._log("Continuous scraping is already running.", "warn")
|
|
return
|
|
self.stop_event.clear()
|
|
self.status["running"] = True
|
|
self.status["last_started_at"] = utc_now_iso()
|
|
self.status["last_error"] = None
|
|
self.thread = threading.Thread(target=self._run_loop, daemon=True)
|
|
self.thread.start()
|
|
self._log("Continuous scraping started.", "info")
|
|
|
|
def stop(self) -> None:
|
|
self.stop_event.set()
|
|
with self.lock:
|
|
was_running = self.status["running"]
|
|
self.status["running"] = False
|
|
if was_running:
|
|
self._log("Continuous scraping stop requested.", "warn")
|
|
|
|
def _resolve_channels(self) -> List[str]:
|
|
acc_state = load_account(DATA_DIR, self.account_id)
|
|
with self.lock:
|
|
run_all_tracked = self.config.get("run_all_tracked", True)
|
|
configured = list(self.config.get("channels", []))
|
|
if run_all_tracked:
|
|
return list(acc_state.get("channels", {}).keys())
|
|
tracked = set(acc_state.get("channels", {}).keys())
|
|
return [channel for channel in configured if channel in tracked]
|
|
|
|
def _run_loop(self) -> None:
|
|
while not self.stop_event.is_set():
|
|
# Refresh config from disk so channel / setting changes take effect
|
|
self.refresh_config()
|
|
|
|
# Check auth — don't iterate if account isn't authorized
|
|
auth_info = auth_status_for(self.account_id)
|
|
if auth_info.get("status") not in ("ready", "authorized"):
|
|
self._log(
|
|
f"Account not authorized (status={auth_info.get('status')}), "
|
|
"skipping iteration",
|
|
"warn",
|
|
)
|
|
sleep_seconds = max(5, 60)
|
|
interrupted = self.stop_event.wait(timeout=sleep_seconds)
|
|
if interrupted:
|
|
break
|
|
continue
|
|
|
|
channels = self._resolve_channels()
|
|
cfg = self.snapshot()["config"]
|
|
interval_minutes = cfg.get("interval_minutes", 1)
|
|
|
|
if not channels:
|
|
self._log("No channels configured for continuous scraping.", "warn")
|
|
else:
|
|
self._log(f"Starting iteration for {len(channels)} channel(s).", "info")
|
|
buffer = io.StringIO()
|
|
try:
|
|
with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer):
|
|
run_job("scrape_selected", {
|
|
"channels": channels,
|
|
"account_id": self.account_id,
|
|
})
|
|
output = buffer.getvalue().strip()
|
|
if output:
|
|
for line in output.splitlines():
|
|
self._log(line)
|
|
with self.lock:
|
|
self.status["last_iteration_at"] = utc_now_iso()
|
|
self.status["last_finished_at"] = utc_now_iso()
|
|
self.status["last_error"] = None
|
|
self._log("Iteration finished.", "success")
|
|
except Exception as exc:
|
|
output = buffer.getvalue().strip()
|
|
if output:
|
|
for line in output.splitlines():
|
|
self._log(line)
|
|
self._log(f"Iteration failed: {exc}", "error")
|
|
with self.lock:
|
|
self.status["last_error"] = str(exc)
|
|
|
|
sleep_seconds = max(5, interval_minutes * 60)
|
|
self._log(f"Sleeping for {interval_minutes} minute(s).", "debug")
|
|
interrupted = self.stop_event.wait(timeout=sleep_seconds)
|
|
if interrupted:
|
|
break
|
|
|
|
with self.lock:
|
|
self.status["running"] = False
|
|
self._log("Continuous scraping stopped.", "warn")
|
|
|
|
|
|
# ── ContinuousScrapeOrchestrator ─────────────────────────────────────────
|
|
|
|
class ContinuousScrapeOrchestrator:
|
|
def __init__(self) -> None:
|
|
self.lock = threading.Lock()
|
|
self.managers: Dict[str, PerAccountContinuousScrapeManager] = {}
|
|
|
|
def _get_or_create(self, account_id: str) -> PerAccountContinuousScrapeManager:
|
|
with self.lock:
|
|
if account_id not in self.managers:
|
|
self.managers[account_id] = PerAccountContinuousScrapeManager(account_id)
|
|
return self.managers[account_id]
|
|
|
|
def start_account(self, account_id: str) -> None:
|
|
mgr = self._get_or_create(account_id)
|
|
mgr.start()
|
|
|
|
def stop_account(self, account_id: str) -> None:
|
|
with self.lock:
|
|
mgr = self.managers.get(account_id)
|
|
if mgr:
|
|
mgr.stop()
|
|
|
|
def snapshot_for(self, account_id: str) -> Dict[str, Any]:
|
|
mgr = self._get_or_create(account_id)
|
|
return mgr.snapshot()
|
|
|
|
def snapshot(self) -> Dict[str, Dict[str, Any]]:
|
|
result: Dict[str, Dict[str, Any]] = {}
|
|
with self.lock:
|
|
for account_id, mgr in self.managers.items():
|
|
result[account_id] = dict(mgr.snapshot())
|
|
missing = set(list_accounts(DATA_DIR)) - set(result.keys())
|
|
for account_id in missing:
|
|
mgr = self._get_or_create(account_id)
|
|
result[account_id] = dict(mgr.snapshot())
|
|
return result
|
|
|
|
def refresh_account(self, account_id: str) -> None:
|
|
"""Re-read account state from disk and sync the continuous manager config."""
|
|
mgr = self._get_or_create(account_id)
|
|
mgr.refresh_config()
|
|
|
|
def update_for(
|
|
self,
|
|
account_id: str,
|
|
enabled: bool,
|
|
interval_minutes: int,
|
|
channels: List[str],
|
|
run_all_tracked: bool,
|
|
) -> Dict[str, Any]:
|
|
mgr = self._get_or_create(account_id)
|
|
result = mgr.update(enabled, interval_minutes, channels, run_all_tracked)
|
|
# After explicit update, sync state to disk is already done by mgr.update
|
|
return result
|
|
|
|
def add_account(self, account_id: str, auto_start: bool = True) -> None:
|
|
acc_state = load_account(DATA_DIR, account_id)
|
|
cfg = acc_state.get("continuous_scraping", {})
|
|
if auto_start and cfg.get("enabled", False) and START_CONTINUOUS:
|
|
self.start_account(account_id)
|
|
|
|
def remove_account(self, account_id: str) -> None:
|
|
self.stop_account(account_id)
|
|
with self.lock:
|
|
self.managers.pop(account_id, None)
|
|
|
|
def stop_all(self) -> None:
|
|
with self.lock:
|
|
for account_id in list(self.managers.keys()):
|
|
self.managers[account_id].stop()
|
|
|
|
def start_all(self) -> None:
|
|
for account_id in list_accounts(DATA_DIR):
|
|
acc_state = load_account(DATA_DIR, account_id)
|
|
cfg = acc_state.get("continuous_scraping", {})
|
|
if cfg.get("enabled", False) and START_CONTINUOUS:
|
|
self.start_account(account_id)
|
|
|
|
|
|
# ── Legacy helpers ───────────────────────────────────────────────────────
|
|
|
|
def import_scraper_class():
|
|
from telegram_scraper_with_forwarding import OptimizedTelegramScraper
|
|
return OptimizedTelegramScraper
|
|
|
|
|
|
def run_job(job_type: str, payload: Dict[str, Any]) -> None:
|
|
SCRAPER_JOBS.run(job_type, payload)
|
|
|
|
|
|
def auth_status_for(account_id: Optional[str] = None) -> Dict[str, Any]:
|
|
if account_id:
|
|
acc_state = load_account(DATA_DIR, account_id)
|
|
session_file = account_session_path(SESSION_DIR, account_id)
|
|
else:
|
|
acc_state = load_state()
|
|
session_file = str(SESSION_DIR / "session.session")
|
|
status: Dict[str, Any] = {
|
|
"has_api_credentials": bool(acc_state.get("api_id") and acc_state.get("api_hash")),
|
|
"telethon_available": False,
|
|
"session_ready": False,
|
|
"status": "read_only",
|
|
"details": "",
|
|
}
|
|
try:
|
|
import_scraper_class()
|
|
status["telethon_available"] = True
|
|
except Exception as exc:
|
|
status["details"] = f"Python deps are missing: {exc}"
|
|
return status
|
|
|
|
status["session_ready"] = Path(session_file).exists()
|
|
if status["has_api_credentials"] and status["session_ready"]:
|
|
status["status"] = "ready"
|
|
status["details"] = "Scraping actions should be available after dependencies are installed."
|
|
elif status["has_api_credentials"]:
|
|
status["status"] = "needs_auth"
|
|
status["details"] = "API keys found, but Telegram session file is missing."
|
|
else:
|
|
status["status"] = "needs_credentials"
|
|
status["details"] = "api_id/api_hash are missing."
|
|
return status
|
|
|
|
|
|
def account_health_summary(account_id: str, job_runner: Optional[JobRunner] = None) -> Dict[str, Any]:
|
|
acc_state = load_account(DATA_DIR, account_id)
|
|
acc_dir = account_data_dir(DATA_DIR, account_id)
|
|
session_file = Path(account_session_path(SESSION_DIR, account_id))
|
|
channels = list_channels_snapshot(account_id)
|
|
active_job = (job_runner.active_jobs_by_account().get(account_id) if job_runner else None)
|
|
last_scrape = next((item.get("last_date") for item in channels if item.get("last_date")), None)
|
|
return {
|
|
"account_id": account_id,
|
|
"label": acc_state.get("label", ""),
|
|
"data_dir_exists": acc_dir.exists(),
|
|
"session_ready": session_file.exists(),
|
|
"api_credentials": bool(acc_state.get("api_id") and acc_state.get("api_hash")),
|
|
"channel_count": len(channels),
|
|
"message_count": sum(int(item.get("message_count") or 0) for item in channels),
|
|
"media_count": sum(int(item.get("media_count") or 0) for item in channels),
|
|
"last_scrape": last_scrape,
|
|
"active_job": active_job,
|
|
}
|
|
|
|
|
|
def auth_status() -> Dict[str, Any]:
|
|
return auth_status_for(account_id=None)
|
|
|
|
|
|
def dashboard_payload(job_runner: JobRunner) -> Dict[str, Any]:
|
|
state = load_state()
|
|
channels = list_channels_snapshot()
|
|
return {
|
|
"state": {
|
|
"scrape_media": bool(state.get("scrape_media", True)),
|
|
"channel_count": len(state.get("channels", {})),
|
|
"forwarding_rules": state.get("forwarding_rules", []),
|
|
},
|
|
"auth": auth_status(),
|
|
"channels": channels,
|
|
"jobs": job_runner.recent_jobs(),
|
|
}
|
|
|
|
|
|
def openapi_payload() -> Dict[str, Any]:
|
|
json_response = {
|
|
"200": {
|
|
"description": "JSON response",
|
|
"content": {"application/json": {"schema": {"type": "object"}}},
|
|
}
|
|
}
|
|
accepted_response = {
|
|
"202": {
|
|
"description": "Job accepted",
|
|
"content": {"application/json": {"schema": {"type": "object"}}},
|
|
}
|
|
}
|
|
error_response = {"400": {"description": "Bad request"}}
|
|
|
|
def json_body(properties: Dict[str, Any], required: Optional[List[str]] = None):
|
|
return {
|
|
"required": True,
|
|
"content": {
|
|
"application/json": {
|
|
"schema": {
|
|
"type": "object",
|
|
"properties": properties,
|
|
"required": required or [],
|
|
"example": {
|
|
key: value.get("example")
|
|
for key, value in properties.items()
|
|
if "example" in value
|
|
},
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
return {
|
|
"openapi": "3.0.3",
|
|
"info": {
|
|
"title": "Telegram Scraper Web UI API",
|
|
"version": "0.1.0",
|
|
"description": "Local endpoints used by the Telegram Scraper web interface.",
|
|
},
|
|
"servers": [{"url": "/"}],
|
|
"paths": {
|
|
"/api/dashboard": {
|
|
"get": {
|
|
"summary": "Dashboard snapshot",
|
|
"description": "Returns state summary, channels, auth status, and recent jobs.",
|
|
"responses": json_response,
|
|
}
|
|
},
|
|
"/api/auth": {
|
|
"get": {
|
|
"summary": "Authentication status",
|
|
"description": "Returns saved credential flags, login phase, and QR login data.",
|
|
"responses": json_response,
|
|
}
|
|
},
|
|
"/api/auth/credentials": {
|
|
"post": {
|
|
"summary": "Save Telegram API credentials",
|
|
"requestBody": json_body(
|
|
{
|
|
"api_id": {"type": "integer", "example": 123456},
|
|
"api_hash": {
|
|
"type": "string",
|
|
"example": "0123456789abcdef",
|
|
},
|
|
},
|
|
["api_id", "api_hash"],
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/auth/qr/start": {
|
|
"post": {
|
|
"summary": "Start QR login",
|
|
"requestBody": json_body({}),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/auth/phone/request": {
|
|
"post": {
|
|
"summary": "Request phone login code",
|
|
"requestBody": json_body(
|
|
{"phone": {"type": "string", "example": "+1234567890"}},
|
|
["phone"],
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/auth/phone/submit": {
|
|
"post": {
|
|
"summary": "Submit phone login code",
|
|
"requestBody": json_body(
|
|
{"code": {"type": "string", "example": "12345"}},
|
|
["code"],
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/auth/password": {
|
|
"post": {
|
|
"summary": "Submit 2FA password",
|
|
"requestBody": json_body(
|
|
{"password": {"type": "string", "example": "password"}},
|
|
["password"],
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/continuous": {
|
|
"get": {
|
|
"summary": "Continuous scraping status",
|
|
"responses": json_response,
|
|
},
|
|
"post": {
|
|
"summary": "Update continuous scraping",
|
|
"requestBody": json_body(
|
|
{
|
|
"enabled": {"type": "boolean", "example": True},
|
|
"interval_minutes": {"type": "integer", "example": 5},
|
|
"run_all_tracked": {"type": "boolean", "example": False},
|
|
"channels": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"example": ["-1001234567890"],
|
|
},
|
|
}
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
},
|
|
},
|
|
"/health/continuous": {
|
|
"get": {
|
|
"summary": "Continuous scraping health",
|
|
"responses": json_response,
|
|
}
|
|
},
|
|
"/health": {
|
|
"get": {
|
|
"summary": "Application health",
|
|
"responses": json_response,
|
|
}
|
|
},
|
|
"/api/channels": {
|
|
"get": {
|
|
"summary": "List tracked channels",
|
|
"responses": json_response,
|
|
}
|
|
},
|
|
"/api/channels/{channel_id}/messages": {
|
|
"get": {
|
|
"summary": "List channel messages",
|
|
"parameters": [
|
|
{
|
|
"name": "channel_id",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
},
|
|
{
|
|
"name": "limit",
|
|
"in": "query",
|
|
"schema": {
|
|
"type": "integer",
|
|
"default": 120,
|
|
"maximum": 300,
|
|
},
|
|
},
|
|
{
|
|
"name": "before",
|
|
"in": "query",
|
|
"schema": {"type": "integer"},
|
|
},
|
|
{
|
|
"name": "search",
|
|
"in": "query",
|
|
"schema": {"type": "string"},
|
|
"description": "Full-text search in message text",
|
|
},
|
|
],
|
|
"responses": json_response,
|
|
}
|
|
},
|
|
"/api/channels/add": {
|
|
"post": {
|
|
"summary": "Track a channel",
|
|
"requestBody": json_body(
|
|
{
|
|
"channel_id": {
|
|
"type": "string",
|
|
"example": "-1001234567890",
|
|
},
|
|
"name": {"type": "string", "example": "Research feed"},
|
|
},
|
|
["channel_id"],
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/channels/remove": {
|
|
"post": {
|
|
"summary": "Remove a tracked channel",
|
|
"requestBody": json_body(
|
|
{"channel_id": {"type": "string", "example": "-1001234567890"}},
|
|
["channel_id"],
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/settings/media": {
|
|
"post": {
|
|
"summary": "Toggle media scraping",
|
|
"requestBody": json_body(
|
|
{"value": {"type": "boolean", "example": True}},
|
|
["value"],
|
|
),
|
|
"responses": {**accepted_response, **error_response},
|
|
}
|
|
},
|
|
"/api/jobs": {
|
|
"get": {
|
|
"summary": "Recent jobs",
|
|
"responses": json_response,
|
|
}
|
|
},
|
|
"/api/jobs/{job_id}": {
|
|
"get": {
|
|
"summary": "Job details",
|
|
"parameters": [
|
|
{
|
|
"name": "job_id",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
],
|
|
"responses": {
|
|
**json_response,
|
|
"404": {"description": "Job not found"},
|
|
},
|
|
}
|
|
},
|
|
"/api/jobs/{job_id}/events": {
|
|
"get": {
|
|
"summary": "Job SSE event stream",
|
|
"description": "Server-Sent Events stream of job status updates. Closes when job completes or fails.",
|
|
"parameters": [
|
|
{
|
|
"name": "job_id",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
],
|
|
"responses": {
|
|
"200": {
|
|
"description": "SSE stream of job status objects",
|
|
"content": {
|
|
"text/event-stream": {
|
|
"schema": {"type": "string"}
|
|
}
|
|
},
|
|
},
|
|
"404": {"description": "Job not found"},
|
|
},
|
|
}
|
|
},
|
|
"/api/jobs/scrape": {
|
|
"post": {
|
|
"summary": "Scrape one channel or all tracked channels",
|
|
"requestBody": json_body(
|
|
{"channel_id": {"type": "string", "example": "-1001234567890"}}
|
|
),
|
|
"responses": accepted_response,
|
|
}
|
|
},
|
|
"/api/jobs/export": {
|
|
"post": {
|
|
"summary": "Export all tracked channels",
|
|
"requestBody": json_body({}),
|
|
"responses": accepted_response,
|
|
}
|
|
},
|
|
"/api/jobs/export-channel": {
|
|
"post": {
|
|
"summary": "Export one channel",
|
|
"requestBody": json_body(
|
|
{"channel_id": {"type": "string", "example": "-1001234567890"}},
|
|
["channel_id"],
|
|
),
|
|
"responses": {**accepted_response, **error_response},
|
|
}
|
|
},
|
|
"/api/jobs/rescrape-media": {
|
|
"post": {
|
|
"summary": "Rescrape media for a channel",
|
|
"requestBody": json_body(
|
|
{"channel_id": {"type": "string", "example": "-1001234567890"}},
|
|
["channel_id"],
|
|
),
|
|
"responses": {**accepted_response, **error_response},
|
|
}
|
|
},
|
|
"/api/jobs/fix-media": {
|
|
"post": {
|
|
"summary": "Fix missing media for a channel",
|
|
"requestBody": json_body(
|
|
{"channel_id": {"type": "string", "example": "-1001234567890"}},
|
|
["channel_id"],
|
|
),
|
|
"responses": {**accepted_response, **error_response},
|
|
}
|
|
},
|
|
"/api/jobs/refresh-dialogs": {
|
|
"post": {
|
|
"summary": "Refresh Telegram dialogs",
|
|
"requestBody": json_body({}),
|
|
"responses": accepted_response,
|
|
}
|
|
},
|
|
"/openapi.json": {
|
|
"get": {
|
|
"summary": "OpenAPI document",
|
|
"responses": json_response,
|
|
}
|
|
},
|
|
"/api/accounts": {
|
|
"get": {
|
|
"summary": "List all accounts",
|
|
"responses": json_response,
|
|
},
|
|
"post": {
|
|
"summary": "Create a new account",
|
|
"requestBody": json_body(
|
|
{
|
|
"account_id": {
|
|
"type": "string",
|
|
"example": "work",
|
|
},
|
|
"label": {
|
|
"type": "string",
|
|
"example": "Work Account",
|
|
},
|
|
"api_id": {
|
|
"type": "integer",
|
|
"example": 123456,
|
|
},
|
|
"api_hash": {
|
|
"type": "string",
|
|
"example": "0123456789abcdef",
|
|
},
|
|
}
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
},
|
|
},
|
|
"/api/accounts/import": {
|
|
"post": {
|
|
"summary": "Import account from exported JSON",
|
|
"requestBody": json_body(
|
|
{
|
|
"account_id": {"type": "string", "example": "work"},
|
|
"state": {
|
|
"type": "object",
|
|
"description": "Exported account state object",
|
|
"example": {"label": "Work", "api_id": 123456, "api_hash": "abc", "channels": {}, "continuous_scraping": {"enabled": False}},
|
|
},
|
|
},
|
|
["account_id", "state"],
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/accounts/{id}": {
|
|
"get": {
|
|
"summary": "Account dashboard",
|
|
"parameters": [
|
|
{
|
|
"name": "id",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
],
|
|
"responses": json_response,
|
|
},
|
|
"delete": {
|
|
"summary": "Delete account",
|
|
"parameters": [
|
|
{
|
|
"name": "id",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
],
|
|
"responses": json_response,
|
|
},
|
|
},
|
|
"/api/accounts/{id}/export": {
|
|
"get": {
|
|
"summary": "Export account settings as JSON",
|
|
"parameters": [
|
|
{
|
|
"name": "id",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
],
|
|
"responses": json_response,
|
|
}
|
|
},
|
|
"/api/accounts/{id}/health": {
|
|
"get": {
|
|
"summary": "Account health summary",
|
|
"parameters": [
|
|
{
|
|
"name": "id",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
],
|
|
"responses": json_response,
|
|
}
|
|
},
|
|
"/api/accounts/{id}/auth": {
|
|
"get": {
|
|
"summary": "Account auth snapshot",
|
|
"parameters": [
|
|
{
|
|
"name": "id",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
],
|
|
"responses": json_response,
|
|
},
|
|
},
|
|
"/api/accounts/{id}/auth/credentials": {
|
|
"post": {
|
|
"summary": "Save credentials for account",
|
|
"requestBody": json_body(
|
|
{
|
|
"api_id": {"type": "integer", "example": 123456},
|
|
"api_hash": {"type": "string", "example": "0123456789abcdef"},
|
|
},
|
|
["api_id", "api_hash"],
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/accounts/{id}/auth/qr/start": {
|
|
"post": {
|
|
"summary": "Start QR login for account",
|
|
"requestBody": json_body({}),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/accounts/{id}/auth/phone/request": {
|
|
"post": {
|
|
"summary": "Request phone code for account",
|
|
"requestBody": json_body(
|
|
{"phone": {"type": "string", "example": "+1234567890"}},
|
|
["phone"],
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/accounts/{id}/auth/phone/submit": {
|
|
"post": {
|
|
"summary": "Submit phone code for account",
|
|
"requestBody": json_body(
|
|
{"code": {"type": "string", "example": "12345"}},
|
|
["code"],
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/accounts/{id}/auth/password": {
|
|
"post": {
|
|
"summary": "Submit 2FA password for account",
|
|
"requestBody": json_body(
|
|
{"password": {"type": "string", "example": "password"}},
|
|
["password"],
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/accounts/{id}/channels": {
|
|
"get": {
|
|
"summary": "List channels for account",
|
|
"parameters": [
|
|
{
|
|
"name": "id",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
],
|
|
"responses": json_response,
|
|
}
|
|
},
|
|
"/api/accounts/{id}/channels/{channel_id}/messages": {
|
|
"get": {
|
|
"summary": "List messages for account channel",
|
|
"parameters": [
|
|
{
|
|
"name": "id",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
},
|
|
{
|
|
"name": "channel_id",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
},
|
|
{
|
|
"name": "limit",
|
|
"in": "query",
|
|
"schema": {
|
|
"type": "integer",
|
|
"default": 120,
|
|
"maximum": 300,
|
|
},
|
|
},
|
|
{
|
|
"name": "before",
|
|
"in": "query",
|
|
"schema": {"type": "integer"},
|
|
},
|
|
{
|
|
"name": "search",
|
|
"in": "query",
|
|
"schema": {"type": "string"},
|
|
"description": "Full-text search in message text",
|
|
},
|
|
],
|
|
"responses": json_response,
|
|
}
|
|
},
|
|
"/api/accounts/{id}/channels/add": {
|
|
"post": {
|
|
"summary": "Add channel to account",
|
|
"requestBody": json_body(
|
|
{
|
|
"channel_id": {"type": "string", "example": "-1001234567890"},
|
|
"name": {"type": "string", "example": "Research feed"},
|
|
},
|
|
["channel_id"],
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/accounts/{id}/channels/remove": {
|
|
"post": {
|
|
"summary": "Remove channel from account",
|
|
"requestBody": json_body(
|
|
{"channel_id": {"type": "string", "example": "-1001234567890"}},
|
|
["channel_id"],
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
}
|
|
},
|
|
"/api/accounts/{id}/jobs/scrape": {
|
|
"post": {
|
|
"summary": "Scrape channels for account",
|
|
"requestBody": json_body(
|
|
{"channel_id": {"type": "string", "example": "-1001234567890"}}
|
|
),
|
|
"responses": accepted_response,
|
|
}
|
|
},
|
|
"/api/accounts/{id}/jobs/export": {
|
|
"post": {
|
|
"summary": "Export all channels for account",
|
|
"requestBody": json_body({}),
|
|
"responses": accepted_response,
|
|
}
|
|
},
|
|
"/api/accounts/{id}/jobs/export-channel": {
|
|
"post": {
|
|
"summary": "Export one channel for account",
|
|
"requestBody": json_body(
|
|
{"channel_id": {"type": "string", "example": "-1001234567890"}},
|
|
["channel_id"],
|
|
),
|
|
"responses": {**accepted_response, **error_response},
|
|
}
|
|
},
|
|
"/api/accounts/{id}/jobs/rescrape-media": {
|
|
"post": {
|
|
"summary": "Rescrape media for account channel",
|
|
"requestBody": json_body(
|
|
{"channel_id": {"type": "string", "example": "-1001234567890"}},
|
|
["channel_id"],
|
|
),
|
|
"responses": {**accepted_response, **error_response},
|
|
}
|
|
},
|
|
"/api/accounts/{id}/jobs/fix-media": {
|
|
"post": {
|
|
"summary": "Fix missing media for account channel",
|
|
"requestBody": json_body(
|
|
{"channel_id": {"type": "string", "example": "-1001234567890"}},
|
|
["channel_id"],
|
|
),
|
|
"responses": {**accepted_response, **error_response},
|
|
}
|
|
},
|
|
"/api/accounts/{id}/jobs/refresh-dialogs": {
|
|
"post": {
|
|
"summary": "Refresh dialogs for account",
|
|
"requestBody": json_body({}),
|
|
"responses": accepted_response,
|
|
}
|
|
},
|
|
"/api/accounts/{id}/settings/media": {
|
|
"post": {
|
|
"summary": "Toggle media scraping for account",
|
|
"requestBody": json_body(
|
|
{"value": {"type": "boolean", "example": True}},
|
|
["value"],
|
|
),
|
|
"responses": {**accepted_response, **error_response},
|
|
}
|
|
},
|
|
"/api/accounts/{id}/continuous": {
|
|
"get": {
|
|
"summary": "Continuous scraping status for account",
|
|
"parameters": [
|
|
{
|
|
"name": "id",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
],
|
|
"responses": json_response,
|
|
},
|
|
"post": {
|
|
"summary": "Update continuous scraping for account",
|
|
"requestBody": json_body(
|
|
{
|
|
"enabled": {"type": "boolean", "example": True},
|
|
"interval_minutes": {"type": "integer", "example": 5},
|
|
"run_all_tracked": {"type": "boolean", "example": False},
|
|
"channels": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"example": ["-1001234567890"],
|
|
},
|
|
}
|
|
),
|
|
"responses": {**json_response, **error_response},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
# ── Request Handler ──────────────────────────────────────────────────────
|
|
|
|
class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|
server_version = "TelegramScraperWebUI/0.1"
|
|
|
|
@property
|
|
def app(self) -> "TelegramScraperWebServer":
|
|
return self.server # type: ignore[return-value]
|
|
|
|
# ── GET ──────────────────────────────────────────────────────────────
|
|
|
|
def do_GET(self) -> None:
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
path = parsed.path
|
|
query = urllib.parse.parse_qs(parsed.query)
|
|
|
|
if path == "/":
|
|
return self.serve_file(WEBUI_DIR / "index.html", "text/html; charset=utf-8")
|
|
if path == "/viewer":
|
|
return self.serve_file(WEBUI_DIR / "viewer.html", "text/html; charset=utf-8")
|
|
if path in {"/swagger", "/swigger", "/docs"}:
|
|
return self.serve_file(WEBUI_DIR / "swagger.html", "text/html; charset=utf-8")
|
|
if path.startswith("/static/"):
|
|
relative = path.removeprefix("/static/")
|
|
return self.serve_static(relative)
|
|
if path.startswith("/media/"):
|
|
relative = path.removeprefix("/media/")
|
|
return self.serve_media(relative)
|
|
if path == "/openapi.json":
|
|
return self.send_json(openapi_payload())
|
|
if path == "/api/dashboard":
|
|
return self.send_json(dashboard_payload(self.app.job_runner))
|
|
if path == "/api/auth":
|
|
if self.app.legacy_account_id:
|
|
return self.send_json(self.app.auth_manager.auth_state(self.app.legacy_account_id))
|
|
return self.send_json(self._legacy_auth_snapshot())
|
|
if path == "/api/continuous":
|
|
if self.app.legacy_account_id:
|
|
return self.send_json(self.app.continuous_orchestrator.snapshot_for(self.app.legacy_account_id))
|
|
return self.send_json({"config": {}, "status": {"running": False, "logs": [], "log_entries": []}})
|
|
if path == "/health/continuous":
|
|
if self.app.legacy_account_id:
|
|
return self.send_json(self.app.continuous_orchestrator.snapshot_for(self.app.legacy_account_id))
|
|
return self.send_json({"config": {}, "status": {"running": False, "logs": [], "log_entries": []}})
|
|
if path == "/health":
|
|
cs_snapshot = self.app.continuous_orchestrator.snapshot()
|
|
return self.send_json(
|
|
health_payload(
|
|
DATA_DIR,
|
|
SESSION_DIR,
|
|
STATE_STORE,
|
|
cs_snapshot,
|
|
self.app.job_runner.queue.qsize(),
|
|
list_accounts(DATA_DIR),
|
|
)
|
|
)
|
|
if path == "/api/jobs":
|
|
return self.send_json(self.app.job_runner.recent_jobs())
|
|
if path.startswith("/api/jobs/") and path.endswith("/events"):
|
|
job_id = path.split("/")[-2]
|
|
return self.stream_job_events(job_id)
|
|
if path.startswith("/api/jobs/"):
|
|
job_id = path.rsplit("/", 1)[-1]
|
|
job = self.app.job_runner.get_job(job_id)
|
|
if not job:
|
|
return self.send_error_json(HTTPStatus.NOT_FOUND, "Job not found")
|
|
return self.send_json(job)
|
|
if path == "/api/channels":
|
|
return self.send_json(list_channels_snapshot())
|
|
if path.startswith("/api/channels/") and path.endswith("/messages"):
|
|
parts = path.split("/")
|
|
channel_id = urllib.parse.unquote(parts[3]).lstrip("@")
|
|
limit = max(1, min(int(query.get("limit", ["120"])[0]), 300))
|
|
before = query.get("before")
|
|
before_message_id = int(before[0]) if before else None
|
|
search = (query.get("search") or query.get("q") or [""])[0].strip()
|
|
payload = {
|
|
"channel_id": channel_id,
|
|
"messages": load_messages(
|
|
None, channel_id, limit=limit, before_message_id=before_message_id,
|
|
search=search or None
|
|
),
|
|
"channel": next(
|
|
(
|
|
item
|
|
for item in list_channels_snapshot()
|
|
if item["channel_id"] == channel_id
|
|
),
|
|
None,
|
|
),
|
|
}
|
|
return self.send_json(payload)
|
|
|
|
# ── /api/accounts/* routes ──────────────────────────────────────
|
|
if path == "/api/accounts":
|
|
return self._handle_get_accounts_list()
|
|
if path.startswith("/api/accounts/"):
|
|
return self._handle_get_account(path, query)
|
|
|
|
return self.send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
|
|
def _legacy_auth_snapshot(self) -> Dict[str, Any]:
|
|
state = load_state()
|
|
return {
|
|
"phase": "unknown",
|
|
"status": "unknown",
|
|
"details": "",
|
|
"qr_url": None,
|
|
"qr_image": None,
|
|
"phone": None,
|
|
"user_id": None,
|
|
"saved_credentials": {
|
|
"api_id": state.get("api_id"),
|
|
"api_hash_present": bool(state.get("api_hash")),
|
|
},
|
|
"auth_status": auth_status(),
|
|
}
|
|
|
|
def _handle_get_accounts_list(self) -> None:
|
|
ids = list_accounts(DATA_DIR)
|
|
accounts = []
|
|
for account_id in ids:
|
|
acc_state = load_account(DATA_DIR, account_id)
|
|
auth_info = auth_status_for(account_id)
|
|
cs_info = self.app.continuous_orchestrator.snapshot_for(account_id)
|
|
accounts.append({
|
|
"id": account_id,
|
|
"label": acc_state.get("label", ""),
|
|
"auth": auth_info,
|
|
"status": auth_info.get("status", "unknown"),
|
|
"continuous_running": cs_info.get("status", {}).get("running", False),
|
|
"health": account_health_summary(account_id, self.app.job_runner),
|
|
})
|
|
return self.send_json({"accounts": accounts})
|
|
|
|
def _handle_get_account(self, path: str, query: Dict[str, List[str]]) -> None:
|
|
rest = path[len("/api/accounts/"):]
|
|
parts = rest.split("/")
|
|
account_id = urllib.parse.unquote(parts[0])
|
|
sub = parts[1:]
|
|
|
|
if not self._account_exists_or_404(account_id):
|
|
return
|
|
|
|
if not sub:
|
|
return self._handle_get_account_dashboard(account_id)
|
|
if sub == ["auth"]:
|
|
return self.send_json(self.app.auth_manager.auth_state(account_id))
|
|
if sub == ["health"]:
|
|
return self.send_json(account_health_summary(account_id, self.app.job_runner))
|
|
if sub == ["export"]:
|
|
payload = {
|
|
"version": 1,
|
|
"account_id": account_id,
|
|
"state": load_account(DATA_DIR, account_id),
|
|
}
|
|
return self.send_json(payload)
|
|
if sub == ["channels"]:
|
|
return self._handle_get_account_channels(account_id)
|
|
if len(sub) >= 3 and sub[0] == "channels" and sub[-1] == "messages":
|
|
channel_id = urllib.parse.unquote(sub[1]).lstrip("@")
|
|
return self._handle_get_account_channel_messages(account_id, channel_id, query)
|
|
if sub == ["continuous"]:
|
|
return self.send_json(
|
|
self.app.continuous_orchestrator.snapshot_for(account_id)
|
|
)
|
|
return self.send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
|
|
def _handle_get_account_dashboard(self, account_id: str) -> None:
|
|
acc_state = load_account(DATA_DIR, account_id)
|
|
channels = list_channels_snapshot(account_id)
|
|
auth_info = auth_status_for(account_id)
|
|
cs_info = self.app.continuous_orchestrator.snapshot_for(account_id)
|
|
payload = {
|
|
"account_id": account_id,
|
|
"label": acc_state.get("label", ""),
|
|
"auth": auth_info,
|
|
"auth_detail": self.app.auth_manager.auth_state(account_id),
|
|
"channels": channels,
|
|
"jobs": self.app.job_runner.recent_jobs(account_id=account_id),
|
|
"continuous": cs_info,
|
|
"scrape_media": bool(acc_state.get("scrape_media", True)),
|
|
"health": account_health_summary(account_id, self.app.job_runner),
|
|
}
|
|
return self.send_json(payload)
|
|
|
|
def stream_job_events(self, job_id: str) -> None:
|
|
if not self.app.job_runner.get_job(job_id):
|
|
return self.send_error_json(HTTPStatus.NOT_FOUND, "Job not found")
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.send_header("Connection", "keep-alive")
|
|
self.end_headers()
|
|
|
|
last_payload = None
|
|
deadline = time.time() + 60 * 30
|
|
while time.time() < deadline:
|
|
job = self.app.job_runner.get_job(job_id)
|
|
if not job:
|
|
break
|
|
payload = json.dumps(job, ensure_ascii=False)
|
|
if payload != last_payload:
|
|
try:
|
|
self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
|
|
self.wfile.flush()
|
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
break
|
|
last_payload = payload
|
|
if job.get("status") in {"completed", "failed"}:
|
|
break
|
|
time.sleep(1)
|
|
|
|
def _handle_get_account_channels(self, account_id: str) -> None:
|
|
return self.send_json(list_channels_snapshot(account_id))
|
|
|
|
def _handle_get_account_channel_messages(
|
|
self, account_id: str, channel_id: str, query: Dict[str, List[str]]
|
|
) -> None:
|
|
limit = max(1, min(int(query.get("limit", ["120"])[0]), 300))
|
|
before = query.get("before")
|
|
before_message_id = int(before[0]) if before else None
|
|
search = (query.get("search") or query.get("q") or [""])[0].strip()
|
|
payload = {
|
|
"channel_id": channel_id,
|
|
"messages": load_messages(
|
|
account_id, channel_id, limit=limit, before_message_id=before_message_id
|
|
, search=search or None
|
|
),
|
|
"channel": next(
|
|
(
|
|
item
|
|
for item in list_channels_snapshot(account_id)
|
|
if item["channel_id"] == channel_id
|
|
),
|
|
None,
|
|
),
|
|
}
|
|
return self.send_json(payload)
|
|
|
|
# ── HEAD ─────────────────────────────────────────────────────────────
|
|
|
|
def do_HEAD(self) -> None:
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
path = parsed.path
|
|
|
|
if path == "/":
|
|
return self.serve_file(WEBUI_DIR / "index.html", "text/html; charset=utf-8", head_only=True)
|
|
if path == "/viewer":
|
|
return self.serve_file(WEBUI_DIR / "viewer.html", "text/html; charset=utf-8", head_only=True)
|
|
if path in {"/swagger", "/swigger", "/docs"}:
|
|
return self.serve_file(WEBUI_DIR / "swagger.html", "text/html; charset=utf-8", head_only=True)
|
|
if path.startswith("/static/"):
|
|
relative = path.removeprefix("/static/")
|
|
return self.serve_static(relative, head_only=True)
|
|
if path.startswith("/media/"):
|
|
relative = path.removeprefix("/media/")
|
|
return self.serve_media(relative, head_only=True)
|
|
if (
|
|
path
|
|
in {
|
|
"/api/dashboard",
|
|
"/api/auth",
|
|
"/api/continuous",
|
|
"/api/jobs",
|
|
"/api/channels",
|
|
"/api/accounts",
|
|
"/health",
|
|
"/health/continuous",
|
|
"/openapi.json",
|
|
}
|
|
or path.startswith("/api/jobs/")
|
|
or (path.startswith("/api/channels/") and path.endswith("/messages"))
|
|
or path.startswith("/api/accounts/")
|
|
):
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.end_headers()
|
|
return
|
|
return self.send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
|
|
# ── POST ─────────────────────────────────────────────────────────────
|
|
|
|
def do_POST(self) -> None:
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
path = parsed.path
|
|
body = self.read_json_body()
|
|
if body is None:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "Expected JSON body")
|
|
|
|
# ── Legacy endpoints ────────────────────────────────────────────
|
|
if path == "/api/settings/media":
|
|
value = bool(body.get("value"))
|
|
if self.app.legacy_account_id:
|
|
return self._handle_account_settings_media(self.app.legacy_account_id, value)
|
|
job = self.app.job_runner.create_job(
|
|
"set_scrape_media",
|
|
"Update media scraping setting",
|
|
{"value": value},
|
|
)
|
|
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
|
|
|
|
if path == "/api/continuous":
|
|
try:
|
|
enabled = bool(body.get("enabled"))
|
|
interval_minutes = int(body.get("interval_minutes", 1))
|
|
channels = [
|
|
str(item).strip()
|
|
for item in body.get("channels", [])
|
|
if str(item).strip()
|
|
]
|
|
run_all_tracked = bool(body.get("run_all_tracked", True))
|
|
if self.app.legacy_account_id:
|
|
payload = self.app.continuous_orchestrator.update_for(
|
|
account_id=self.app.legacy_account_id,
|
|
enabled=enabled,
|
|
interval_minutes=interval_minutes,
|
|
channels=channels,
|
|
run_all_tracked=run_all_tracked,
|
|
)
|
|
else:
|
|
payload = {"config": {}, "status": {"running": False, "logs": [], "log_entries": []}}
|
|
except Exception as exc:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
|
return self.send_json(payload)
|
|
|
|
if path == "/api/auth/credentials":
|
|
api_id = body.get("api_id")
|
|
api_hash = str(body.get("api_hash", "")).strip()
|
|
if not api_id or not api_hash:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "api_id and api_hash are required")
|
|
try:
|
|
state = load_state()
|
|
state["api_id"] = int(api_id)
|
|
state["api_hash"] = api_hash
|
|
save_state(state)
|
|
payload = self._legacy_auth_snapshot()
|
|
except Exception as exc:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
|
return self.send_json(payload)
|
|
|
|
if path == "/api/auth/qr/start":
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "Legacy auth is not available in multi-account mode. Use /api/accounts/{id}/auth/qr/start instead.")
|
|
|
|
if path == "/api/auth/phone/request":
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "Legacy auth is not available in multi-account mode. Use /api/accounts/{id}/auth/phone/request instead.")
|
|
|
|
if path == "/api/auth/phone/submit":
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "Legacy auth is not available in multi-account mode. Use /api/accounts/{id}/auth/phone/submit instead.")
|
|
|
|
if path == "/api/auth/password":
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "Legacy auth is not available in multi-account mode. Use /api/accounts/{id}/auth/password instead.")
|
|
|
|
if path == "/api/channels/add":
|
|
channel_id = str(body.get("channel_id", "")).strip()
|
|
if not channel_id:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
|
|
if self.app.legacy_account_id:
|
|
return self._handle_account_channel_add(self.app.legacy_account_id, channel_id, body.get("name"))
|
|
state = load_state()
|
|
if channel_id not in state["channels"]:
|
|
state["channels"][channel_id] = 0
|
|
if body.get("name"):
|
|
state.setdefault("channel_names", {})[channel_id] = str(body["name"]).strip()
|
|
save_state(state)
|
|
return self.send_json({"ok": True, "channel_id": channel_id})
|
|
|
|
if path == "/api/channels/remove":
|
|
channel_id = str(body.get("channel_id", "")).strip()
|
|
if self.app.legacy_account_id:
|
|
return self._handle_account_channel_remove(self.app.legacy_account_id, channel_id)
|
|
state = load_state()
|
|
existed = channel_id in state.get("channels", {})
|
|
state.get("channels", {}).pop(channel_id, None)
|
|
save_state(state)
|
|
return self.send_json({"ok": existed, "channel_id": channel_id})
|
|
|
|
if path == "/api/jobs/scrape":
|
|
channel_id = body.get("channel_id")
|
|
payload = {}
|
|
account_id = self.app.legacy_account_id
|
|
if account_id:
|
|
payload["account_id"] = account_id
|
|
if channel_id:
|
|
payload["channel_id"] = str(channel_id)
|
|
job = self.app.job_runner.create_job(
|
|
"scrape_channel",
|
|
f"Scrape channel {channel_id}" + (f" [{account_id}]" if account_id else ""),
|
|
payload,
|
|
)
|
|
else:
|
|
job = self.app.job_runner.create_job(
|
|
"scrape_all",
|
|
"Scrape all tracked channels" + (f" [{account_id}]" if account_id else ""),
|
|
payload,
|
|
)
|
|
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
|
|
|
|
if path == "/api/jobs/export":
|
|
payload = {}
|
|
if self.app.legacy_account_id:
|
|
payload["account_id"] = self.app.legacy_account_id
|
|
job = self.app.job_runner.create_job(
|
|
"export_all",
|
|
"Export all tracked channels",
|
|
payload,
|
|
)
|
|
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
|
|
|
|
if path == "/api/jobs/export-channel":
|
|
channel_id = str(body.get("channel_id", "")).strip()
|
|
if not channel_id:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
|
|
payload = {"channel_id": channel_id}
|
|
if self.app.legacy_account_id:
|
|
payload["account_id"] = self.app.legacy_account_id
|
|
job = self.app.job_runner.create_job(
|
|
"export_channel",
|
|
f"Export channel {channel_id}",
|
|
payload,
|
|
)
|
|
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
|
|
|
|
if path == "/api/jobs/rescrape-media":
|
|
channel_id = str(body.get("channel_id", "")).strip()
|
|
if not channel_id:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
|
|
payload = {"channel_id": channel_id}
|
|
if self.app.legacy_account_id:
|
|
payload["account_id"] = self.app.legacy_account_id
|
|
job = self.app.job_runner.create_job(
|
|
"rescrape_media",
|
|
f"Rescrape media for {channel_id}",
|
|
payload,
|
|
)
|
|
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
|
|
|
|
if path == "/api/jobs/fix-media":
|
|
channel_id = str(body.get("channel_id", "")).strip()
|
|
if not channel_id:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
|
|
payload = {"channel_id": channel_id}
|
|
if self.app.legacy_account_id:
|
|
payload["account_id"] = self.app.legacy_account_id
|
|
job = self.app.job_runner.create_job(
|
|
"fix_missing_media",
|
|
f"Fix missing media for {channel_id}",
|
|
payload,
|
|
)
|
|
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
|
|
|
|
if path == "/api/jobs/refresh-dialogs":
|
|
payload = {}
|
|
if self.app.legacy_account_id:
|
|
payload["account_id"] = self.app.legacy_account_id
|
|
job = self.app.job_runner.create_job(
|
|
"refresh_dialogs",
|
|
"Refresh Telegram dialogs list",
|
|
payload,
|
|
)
|
|
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
|
|
|
|
# ── /api/accounts/* POST routes ─────────────────────────────────
|
|
if path == "/api/accounts":
|
|
return self._handle_post_accounts_create(body)
|
|
if path == "/api/accounts/import":
|
|
return self._handle_post_accounts_import(body)
|
|
if path.startswith("/api/accounts/"):
|
|
return self._handle_post_account(path, body)
|
|
|
|
return self.send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
|
|
def _handle_post_accounts_create(self, body: Dict[str, Any]) -> None:
|
|
account_id = str(body.get("account_id", "")).strip()
|
|
if not account_id:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "account_id is required")
|
|
if not account_id.replace("-", "").replace("_", "").isalnum():
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "account_id must be alphanumeric (dashes and underscores allowed)")
|
|
|
|
existing = list_accounts(DATA_DIR)
|
|
if account_id in existing:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, f"Account '{account_id}' already exists")
|
|
|
|
label = str(body.get("label", account_id)).strip()
|
|
api_id = body.get("api_id")
|
|
api_hash = str(body.get("api_hash", "")).strip()
|
|
|
|
def mutate_global(state: Dict[str, Any]) -> None:
|
|
accounts = state.setdefault("accounts", [])
|
|
if account_id not in accounts:
|
|
accounts.append(account_id)
|
|
|
|
get_global_store(DATA_DIR).update(mutate_global)
|
|
|
|
store = get_account_store(DATA_DIR, account_id)
|
|
init_state = {
|
|
"label": label,
|
|
"api_id": int(api_id) if api_id else None,
|
|
"api_hash": api_hash if api_id else None,
|
|
"channels": {},
|
|
"channel_names": {},
|
|
"scrape_media": True,
|
|
"forwarding_rules": [],
|
|
"continuous_scraping": {
|
|
"enabled": False,
|
|
"interval_minutes": 1,
|
|
"channels": [],
|
|
"run_all_tracked": True,
|
|
},
|
|
}
|
|
store.save(init_state)
|
|
self.app.continuous_orchestrator.add_account(account_id, auto_start=False)
|
|
return self.send_json({"ok": True, "account_id": account_id})
|
|
|
|
def _handle_post_accounts_import(self, body: Dict[str, Any]) -> None:
|
|
account_id = str(body.get("account_id") or body.get("id") or "").strip()
|
|
state = body.get("state")
|
|
if not account_id:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "account_id is required")
|
|
if not account_id.replace("-", "").replace("_", "").isalnum():
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "account_id must be alphanumeric (dashes and underscores allowed)")
|
|
if account_exists(DATA_DIR, account_id):
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, f"Account '{account_id}' already exists")
|
|
if not isinstance(state, dict):
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "state object is required")
|
|
|
|
imported_state = {
|
|
"label": str(body.get("label") or state.get("label") or account_id).strip(),
|
|
"api_id": state.get("api_id"),
|
|
"api_hash": state.get("api_hash"),
|
|
"channels": state.get("channels") if isinstance(state.get("channels"), dict) else {},
|
|
"channel_names": state.get("channel_names") if isinstance(state.get("channel_names"), dict) else {},
|
|
"scrape_media": bool(state.get("scrape_media", True)),
|
|
"forwarding_rules": state.get("forwarding_rules") if isinstance(state.get("forwarding_rules"), list) else [],
|
|
"continuous_scraping": state.get("continuous_scraping") if isinstance(state.get("continuous_scraping"), dict) else {
|
|
"enabled": False,
|
|
"interval_minutes": 1,
|
|
"channels": [],
|
|
"run_all_tracked": True,
|
|
},
|
|
}
|
|
|
|
def mutate_global(global_state: Dict[str, Any]) -> None:
|
|
accounts = global_state.setdefault("accounts", [])
|
|
if account_id not in accounts:
|
|
accounts.append(account_id)
|
|
|
|
get_global_store(DATA_DIR).update(mutate_global)
|
|
get_account_store(DATA_DIR, account_id).save(imported_state)
|
|
self.app.continuous_orchestrator.add_account(account_id, auto_start=False)
|
|
self.app.continuous_orchestrator.refresh_account(account_id)
|
|
return self.send_json({"ok": True, "account_id": account_id})
|
|
|
|
def _handle_post_account(self, path: str, body: Dict[str, Any]) -> None:
|
|
rest = path[len("/api/accounts/"):]
|
|
parts = rest.split("/")
|
|
account_id = urllib.parse.unquote(parts[0])
|
|
sub = parts[1:]
|
|
|
|
if not sub:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "Missing endpoint")
|
|
if not self._account_exists_or_404(account_id):
|
|
return
|
|
if sub == ["auth", "credentials"]:
|
|
return self._handle_post_account_auth_credentials(account_id, body)
|
|
if sub == ["auth", "qr", "start"]:
|
|
return self._handle_post_account_auth_qr_start(account_id)
|
|
if sub == ["auth", "phone", "request"]:
|
|
return self._handle_post_account_auth_phone_request(account_id, body)
|
|
if sub == ["auth", "phone", "submit"]:
|
|
return self._handle_post_account_auth_phone_submit(account_id, body)
|
|
if sub == ["auth", "password"]:
|
|
return self._handle_post_account_auth_password(account_id, body)
|
|
if sub == ["channels", "add"]:
|
|
return self._handle_account_channel_add(account_id, str(body.get("channel_id", "")).strip(), body.get("name"))
|
|
if sub == ["channels", "remove"]:
|
|
return self._handle_account_channel_remove(account_id, str(body.get("channel_id", "")).strip())
|
|
if sub == ["jobs", "scrape"]:
|
|
return self._handle_account_job_scrape(account_id, body)
|
|
if sub == ["jobs", "export"]:
|
|
return self._handle_account_job_export(account_id)
|
|
if sub == ["jobs", "export-channel"]:
|
|
return self._handle_account_job_export_channel(account_id, body)
|
|
if sub == ["jobs", "rescrape-media"]:
|
|
return self._handle_account_job_rescrape_media(account_id, body)
|
|
if sub == ["jobs", "fix-media"]:
|
|
return self._handle_account_job_fix_media(account_id, body)
|
|
if sub == ["jobs", "refresh-dialogs"]:
|
|
return self._handle_account_job_refresh_dialogs(account_id)
|
|
if sub == ["settings", "media"]:
|
|
return self._handle_account_settings_media(account_id, bool(body.get("value")))
|
|
if sub == ["continuous"]:
|
|
return self._handle_account_continuous(account_id, body)
|
|
return self.send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
|
|
def _account_exists_or_404(self, account_id: str) -> bool:
|
|
if not account_id or not account_exists(DATA_DIR, account_id):
|
|
self.send_error_json(HTTPStatus.NOT_FOUND, f"Account '{account_id}' not found")
|
|
return False
|
|
return True
|
|
|
|
def _handle_post_account_auth_credentials(self, account_id: str, body: Dict[str, Any]) -> None:
|
|
api_id = body.get("api_id")
|
|
api_hash = str(body.get("api_hash", "")).strip()
|
|
if not api_id or not api_hash:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "api_id and api_hash are required")
|
|
try:
|
|
payload = self.app.auth_manager.save_credentials(account_id, int(api_id), api_hash)
|
|
except Exception as exc:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
|
return self.send_json(payload)
|
|
|
|
def _handle_post_account_auth_qr_start(self, account_id: str) -> None:
|
|
try:
|
|
payload = self.app.auth_manager.start_qr_login(account_id)
|
|
except Exception as exc:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
|
return self.send_json(payload)
|
|
|
|
def _handle_post_account_auth_phone_request(self, account_id: str, body: Dict[str, Any]) -> None:
|
|
phone = str(body.get("phone", "")).strip()
|
|
if not phone:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "phone is required")
|
|
try:
|
|
payload = self.app.auth_manager.request_phone_code(account_id, phone)
|
|
except Exception as exc:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
|
return self.send_json(payload)
|
|
|
|
def _handle_post_account_auth_phone_submit(self, account_id: str, body: Dict[str, Any]) -> None:
|
|
code = str(body.get("code", "")).strip()
|
|
if not code:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "code is required")
|
|
try:
|
|
payload = self.app.auth_manager.submit_phone_code(account_id, code)
|
|
except Exception as exc:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
|
return self.send_json(payload)
|
|
|
|
def _handle_post_account_auth_password(self, account_id: str, body: Dict[str, Any]) -> None:
|
|
password = str(body.get("password", "")).strip()
|
|
if not password:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "password is required")
|
|
try:
|
|
payload = self.app.auth_manager.submit_password(account_id, password)
|
|
except Exception as exc:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
|
return self.send_json(payload)
|
|
|
|
def _handle_account_channel_add(self, account_id: str, channel_id: str, name: Optional[str]) -> None:
|
|
channel_id = channel_id.lstrip("@")
|
|
if not channel_id:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
|
|
store = get_account_store(DATA_DIR, account_id)
|
|
def mutate(state: Dict[str, Any]) -> None:
|
|
if channel_id not in state.setdefault("channels", {}):
|
|
state["channels"][channel_id] = 0
|
|
if name:
|
|
state.setdefault("channel_names", {})[channel_id] = str(name).strip()
|
|
store.update(mutate)
|
|
self.app.continuous_orchestrator.refresh_account(account_id)
|
|
return self.send_json({"ok": True, "channel_id": channel_id})
|
|
|
|
def _handle_account_channel_remove(self, account_id: str, channel_id: str) -> None:
|
|
if not channel_id:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
|
|
store = get_account_store(DATA_DIR, account_id)
|
|
existed = [False]
|
|
def mutate(state: Dict[str, Any]) -> None:
|
|
chans = state.get("channels", {})
|
|
if channel_id in chans:
|
|
existed[0] = True
|
|
del chans[channel_id]
|
|
state.get("channel_names", {}).pop(channel_id, None)
|
|
store.update(mutate)
|
|
self.app.continuous_orchestrator.refresh_account(account_id)
|
|
return self.send_json({"ok": existed[0], "channel_id": channel_id})
|
|
|
|
def _handle_account_job_scrape(self, account_id: str, body: Dict[str, Any]) -> None:
|
|
channel_id = body.get("channel_id")
|
|
if channel_id:
|
|
job = self.app.job_runner.create_job(
|
|
"scrape_channel",
|
|
f"Scrape channel {channel_id} [{account_id}]",
|
|
{"channel_id": str(channel_id), "account_id": account_id},
|
|
)
|
|
else:
|
|
job = self.app.job_runner.create_job(
|
|
"scrape_all",
|
|
f"Scrape all tracked channels [{account_id}]",
|
|
{"account_id": account_id},
|
|
)
|
|
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
|
|
|
|
def _handle_account_job_export(self, account_id: str) -> None:
|
|
job = self.app.job_runner.create_job(
|
|
"export_all",
|
|
f"Export all tracked channels [{account_id}]",
|
|
{"account_id": account_id},
|
|
)
|
|
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
|
|
|
|
def _handle_account_job_export_channel(self, account_id: str, body: Dict[str, Any]) -> None:
|
|
channel_id = str(body.get("channel_id", "")).strip()
|
|
if not channel_id:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
|
|
job = self.app.job_runner.create_job(
|
|
"export_channel",
|
|
f"Export channel {channel_id} [{account_id}]",
|
|
{"channel_id": channel_id, "account_id": account_id},
|
|
)
|
|
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
|
|
|
|
def _handle_account_job_rescrape_media(self, account_id: str, body: Dict[str, Any]) -> None:
|
|
channel_id = str(body.get("channel_id", "")).strip()
|
|
if not channel_id:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
|
|
job = self.app.job_runner.create_job(
|
|
"rescrape_media",
|
|
f"Rescrape media for {channel_id} [{account_id}]",
|
|
{"channel_id": channel_id, "account_id": account_id},
|
|
)
|
|
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
|
|
|
|
def _handle_account_job_fix_media(self, account_id: str, body: Dict[str, Any]) -> None:
|
|
channel_id = str(body.get("channel_id", "")).strip()
|
|
if not channel_id:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
|
|
job = self.app.job_runner.create_job(
|
|
"fix_missing_media",
|
|
f"Fix missing media for {channel_id} [{account_id}]",
|
|
{"channel_id": channel_id, "account_id": account_id},
|
|
)
|
|
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
|
|
|
|
def _handle_account_job_refresh_dialogs(self, account_id: str) -> None:
|
|
job = self.app.job_runner.create_job(
|
|
"refresh_dialogs",
|
|
f"Refresh Telegram dialogs [{account_id}]",
|
|
{"account_id": account_id},
|
|
)
|
|
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
|
|
|
|
def _handle_account_settings_media(self, account_id: str, value: bool) -> None:
|
|
store = get_account_store(DATA_DIR, account_id)
|
|
def mutate(state: Dict[str, Any]) -> None:
|
|
state["scrape_media"] = value
|
|
store.update(mutate)
|
|
job = self.app.job_runner.create_job(
|
|
"set_scrape_media",
|
|
f"Update media scraping setting [{account_id}]",
|
|
{"value": value, "account_id": account_id},
|
|
)
|
|
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
|
|
|
|
def _handle_account_continuous(self, account_id: str, body: Dict[str, Any]) -> None:
|
|
try:
|
|
enabled = bool(body.get("enabled"))
|
|
interval_minutes = int(body.get("interval_minutes", 1))
|
|
channels = [
|
|
str(item).strip()
|
|
for item in body.get("channels", [])
|
|
if str(item).strip()
|
|
]
|
|
run_all_tracked = bool(body.get("run_all_tracked", True))
|
|
payload = self.app.continuous_orchestrator.update_for(
|
|
account_id=account_id,
|
|
enabled=enabled,
|
|
interval_minutes=interval_minutes,
|
|
channels=channels,
|
|
run_all_tracked=run_all_tracked,
|
|
)
|
|
except Exception as exc:
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
|
return self.send_json(payload)
|
|
|
|
# ── DELETE ───────────────────────────────────────────────────────────
|
|
|
|
def do_DELETE(self) -> None:
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
path = parsed.path
|
|
|
|
if path.startswith("/api/accounts/"):
|
|
rest = path[len("/api/accounts/"):]
|
|
parts = rest.split("/")
|
|
account_id = urllib.parse.unquote(parts[0])
|
|
if len(parts) == 1:
|
|
return self._handle_delete_account(account_id)
|
|
return self.send_error_json(HTTPStatus.BAD_REQUEST, "Invalid account path")
|
|
|
|
return self.send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
|
|
def _handle_delete_account(self, account_id: str) -> None:
|
|
ids = list_accounts(DATA_DIR)
|
|
if account_id not in ids:
|
|
return self.send_error_json(HTTPStatus.NOT_FOUND, f"Account '{account_id}' not found")
|
|
|
|
self.app.continuous_orchestrator.remove_account(account_id)
|
|
self.app.auth_manager.delete_account(account_id)
|
|
|
|
def mutate_global(state: Dict[str, Any]) -> None:
|
|
accounts = state.setdefault("accounts", [])
|
|
if account_id in accounts:
|
|
accounts.remove(account_id)
|
|
|
|
get_global_store(DATA_DIR).update(mutate_global)
|
|
|
|
acc_dir = account_data_dir(DATA_DIR, account_id)
|
|
session_file = Path(account_session_path(SESSION_DIR, account_id))
|
|
if acc_dir.exists():
|
|
import shutil
|
|
shutil.rmtree(str(acc_dir), ignore_errors=True)
|
|
for session_sidecar in (
|
|
session_file,
|
|
Path(str(session_file) + "-wal"),
|
|
Path(str(session_file) + "-shm"),
|
|
session_file.with_suffix(session_file.suffix + "-journal"),
|
|
):
|
|
if not session_sidecar.exists():
|
|
continue
|
|
try:
|
|
session_sidecar.unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
return self.send_json({"ok": True, "account_id": account_id})
|
|
|
|
# ── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
def read_json_body(self) -> Optional[Dict[str, Any]]:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
if length <= 0:
|
|
return {}
|
|
raw = self.rfile.read(length)
|
|
try:
|
|
return json.loads(raw.decode("utf-8"))
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
def serve_static(self, relative_path: str, head_only: bool = False) -> None:
|
|
file_path = (WEBUI_DIR / relative_path).resolve()
|
|
try:
|
|
file_path.relative_to(WEBUI_DIR.resolve())
|
|
except ValueError:
|
|
return self.send_error_json(HTTPStatus.FORBIDDEN, "Forbidden")
|
|
if not file_path.exists() or not file_path.is_file():
|
|
return self.send_error_json(HTTPStatus.NOT_FOUND, "File not found")
|
|
mime_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
|
return self.serve_file(file_path, mime_type, head_only=head_only)
|
|
|
|
def serve_media(self, relative_path: str, head_only: bool = False) -> None:
|
|
clean = Path(posixpath.normpath(urllib.parse.unquote(relative_path)))
|
|
file_path = (DATA_DIR / clean).resolve()
|
|
try:
|
|
file_path.relative_to(DATA_DIR.resolve())
|
|
except ValueError:
|
|
return self.send_error_json(HTTPStatus.FORBIDDEN, "Forbidden")
|
|
if not file_path.exists() or not file_path.is_file():
|
|
return self.send_error_json(HTTPStatus.NOT_FOUND, "Media file not found")
|
|
mime_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
|
return self.serve_file(file_path, mime_type, head_only=head_only)
|
|
|
|
def serve_file(self, file_path: Path, content_type: str, head_only: bool = False) -> None:
|
|
stat = file_path.stat()
|
|
file_size = stat.st_size
|
|
last_modified = stat.st_mtime
|
|
|
|
range_header = self.headers.get("Range", "").strip()
|
|
if range_header.startswith("bytes="):
|
|
try:
|
|
start, end = self._parse_range(range_header, file_size)
|
|
except ValueError:
|
|
self.send_error_json(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE, "Invalid range")
|
|
return
|
|
content_length = end - start + 1
|
|
self.send_response(HTTPStatus.PARTIAL_CONTENT)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
self.send_header(
|
|
"Last-Modified",
|
|
time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last_modified)),
|
|
)
|
|
self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
|
|
self.send_header("Content-Length", str(content_length))
|
|
self.end_headers()
|
|
if not head_only:
|
|
self._write_file_range(file_path, start, content_length)
|
|
else:
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
self.send_header(
|
|
"Last-Modified",
|
|
time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last_modified)),
|
|
)
|
|
self.send_header("Content-Length", str(file_size))
|
|
self.end_headers()
|
|
if not head_only:
|
|
self._write_file_range(file_path, 0, file_size)
|
|
|
|
def _parse_range(self, range_header: str, file_size: int) -> tuple[int, int]:
|
|
range_val = range_header.removeprefix("bytes=").strip()
|
|
if "-" not in range_val:
|
|
raise ValueError("missing dash")
|
|
parts = range_val.split("-", 1)
|
|
if parts[0] == "":
|
|
end = int(parts[1])
|
|
start = max(0, file_size - end)
|
|
else:
|
|
start = int(parts[0])
|
|
end = int(parts[1]) if parts[1] else file_size - 1
|
|
if start < 0 or start >= file_size or end >= file_size or start > end:
|
|
raise ValueError("out of bounds")
|
|
return start, end
|
|
|
|
def _write_file_range(self, file_path: Path, offset: int, length: int) -> None:
|
|
chunk_size = 65536
|
|
with open(file_path, "rb") as f:
|
|
f.seek(offset)
|
|
remaining = length
|
|
while remaining > 0:
|
|
chunk = f.read(min(chunk_size, remaining))
|
|
if not chunk:
|
|
break
|
|
self.wfile.write(chunk)
|
|
remaining -= len(chunk)
|
|
|
|
def send_json(self, payload: Any, status: HTTPStatus = HTTPStatus.OK) -> None:
|
|
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(raw)))
|
|
self.end_headers()
|
|
self.wfile.write(raw)
|
|
|
|
def send_error_json(self, status: HTTPStatus, message: str) -> None:
|
|
self.send_json({"error": message, "status": int(status)}, status=status)
|
|
|
|
def log_message(self, format: str, *args: Any) -> None:
|
|
logger.info(
|
|
"%s %s — %s",
|
|
self.command,
|
|
self.path,
|
|
args[1] if len(args) > 1 else "-",
|
|
)
|
|
|
|
|
|
# ── Server ───────────────────────────────────────────────────────────────
|
|
|
|
class TelegramScraperWebServer(ThreadingHTTPServer):
|
|
def __init__(self, server_address: tuple[str, int]):
|
|
super().__init__(server_address, TelegramScraperRequestHandler)
|
|
self.job_runner = JobRunner()
|
|
self.auth_manager = TelegramAuthManager()
|
|
self.continuous_orchestrator = ContinuousScrapeOrchestrator()
|
|
self.legacy_account_id: Optional[str] = None
|
|
self._detect_legacy_account()
|
|
|
|
if START_CONTINUOUS:
|
|
self.continuous_orchestrator.start_all()
|
|
|
|
def _detect_legacy_account(self) -> None:
|
|
ids = list_accounts(DATA_DIR)
|
|
if not ids:
|
|
legacy = load_state()
|
|
if legacy.get("api_id") and legacy.get("api_hash"):
|
|
import app_state as as_mod
|
|
as_mod.migrate_legacy_state(DATA_DIR, SESSION_DIR)
|
|
ids = list_accounts(DATA_DIR)
|
|
if ids and len(ids) == 1:
|
|
self.legacy_account_id = ids[0]
|
|
|
|
|
|
def run_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
|
|
server = TelegramScraperWebServer((host, port))
|
|
shutdown_event = threading.Event()
|
|
|
|
def _shutdown(signum=None, frame=None):
|
|
if shutdown_event.is_set():
|
|
return
|
|
logger.warning("Shutdown signal received, shutting down gracefully...")
|
|
shutdown_event.set()
|
|
server.continuous_orchestrator.stop_all()
|
|
server.auth_manager.shutdown()
|
|
server.job_runner.shutdown()
|
|
|
|
signal.signal(signal.SIGTERM, _shutdown)
|
|
signal.signal(signal.SIGINT, _shutdown)
|
|
|
|
logger.info("Telegram Scraper Web UI listening on http://%s:%s", host, port)
|
|
logger.info("Press Ctrl+C to stop.")
|
|
try:
|
|
while not shutdown_event.is_set():
|
|
server.handle_request()
|
|
except KeyboardInterrupt:
|
|
_shutdown()
|
|
finally:
|
|
server.server_close()
|
|
logger.info("Server stopped.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run_server()
|