Harden account import/continuous behavior and finish multi-account UI polish
This commit is contained in:
1 parent
59cdad220a
commit
881e7ddfca
8 files changed
+572
-35
No files matched your search
+252
-16
@@ -194,6 +194,7 @@ def load_messages(
|
||||
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():
|
||||
@@ -207,9 +208,15 @@ def load_messages(
|
||||
"FROM messages "
|
||||
)
|
||||
params: List[Any] = []
|
||||
where: List[str] = []
|
||||
if before_message_id is not None:
|
||||
query += "WHERE message_id < ? "
|
||||
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()
|
||||
@@ -323,16 +330,29 @@ class JobRunner:
|
||||
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")
|
||||
job_id = f"job-{int(time.time() * 1000)}"
|
||||
account_id = payload.get("account_id")
|
||||
job = Job(
|
||||
job_id=job_id,
|
||||
job_type=job_type,
|
||||
title=title,
|
||||
payload=payload,
|
||||
account_id=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]
|
||||
@@ -346,6 +366,15 @@ class JobRunner:
|
||||
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)
|
||||
@@ -811,6 +840,20 @@ class PerAccountContinuousScrapeManager:
|
||||
|
||||
def _run_loop(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
# 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)
|
||||
@@ -904,10 +947,10 @@ class ContinuousScrapeOrchestrator:
|
||||
mgr = self._get_or_create(account_id)
|
||||
return mgr.update(enabled, interval_minutes, channels, run_all_tracked)
|
||||
|
||||
def add_account(self, account_id: str) -> None:
|
||||
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 cfg.get("enabled", True) and START_CONTINUOUS:
|
||||
if auto_start and cfg.get("enabled", False) and START_CONTINUOUS:
|
||||
self.start_account(account_id)
|
||||
|
||||
def remove_account(self, account_id: str) -> None:
|
||||
@@ -924,7 +967,7 @@ class ContinuousScrapeOrchestrator:
|
||||
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", True):
|
||||
if cfg.get("enabled", False) and START_CONTINUOUS:
|
||||
self.start_account(account_id)
|
||||
|
||||
|
||||
@@ -973,6 +1016,27 @@ def auth_status_for(account_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
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)
|
||||
|
||||
@@ -1166,6 +1230,12 @@ def openapi_payload() -> Dict[str, Any]:
|
||||
"in": "query",
|
||||
"schema": {"type": "integer"},
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"in": "query",
|
||||
"schema": {"type": "string"},
|
||||
"description": "Full-text search in message text",
|
||||
},
|
||||
],
|
||||
"responses": json_response,
|
||||
}
|
||||
@@ -1229,6 +1299,31 @@ def openapi_payload() -> Dict[str, Any]:
|
||||
},
|
||||
}
|
||||
},
|
||||
"/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",
|
||||
@@ -1318,6 +1413,23 @@ def openapi_payload() -> Dict[str, Any]:
|
||||
"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",
|
||||
@@ -1344,6 +1456,34 @@ def openapi_payload() -> Dict[str, Any]:
|
||||
"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",
|
||||
@@ -1452,6 +1592,12 @@ def openapi_payload() -> Dict[str, Any]:
|
||||
"in": "query",
|
||||
"schema": {"type": "integer"},
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"in": "query",
|
||||
"schema": {"type": "string"},
|
||||
"description": "Full-text search in message text",
|
||||
},
|
||||
],
|
||||
"responses": json_response,
|
||||
}
|
||||
@@ -1634,6 +1780,9 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
)
|
||||
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)
|
||||
@@ -1703,6 +1852,7 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
"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})
|
||||
|
||||
@@ -1719,6 +1869,15 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
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":
|
||||
@@ -1744,9 +1903,37 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
"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))
|
||||
|
||||
@@ -1756,10 +1943,12 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
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(
|
||||
(
|
||||
@@ -1997,6 +2186,8 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
# ── /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)
|
||||
|
||||
@@ -2034,14 +2225,52 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
"scrape_media": True,
|
||||
"forwarding_rules": [],
|
||||
"continuous_scraping": {
|
||||
"enabled": True,
|
||||
"enabled": False,
|
||||
"interval_minutes": 1,
|
||||
"channels": [],
|
||||
"run_all_tracked": True,
|
||||
},
|
||||
}
|
||||
store.save(init_state)
|
||||
self.app.continuous_orchestrator.add_account(account_id)
|
||||
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)
|
||||
return self.send_json({"ok": True, "account_id": account_id})
|
||||
|
||||
def _handle_post_account(self, path: str, body: Dict[str, Any]) -> None:
|
||||
@@ -2300,9 +2529,16 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
if acc_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(str(acc_dir), ignore_errors=True)
|
||||
if session_file.exists():
|
||||
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_file.unlink()
|
||||
session_sidecar.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
Reference in new issue
Block a user