Spaces:
Running
Running
Expose delivery failures and prepare continuous hosting
Browse filesProduction logs show Medialogia ingestion and DeepSeek classification succeeding while every Telegram delivery times out. This proposal makes delivery health observable, starts AI and Telegram workers independently, retains valid webhooks during incomplete downstream configuration, waits for in-flight workers on shutdown, and provides a Render deployment with a persistent SQLite disk. Eight local regression checks passed. It does not resolve or bypass the Space network timeout; actual Telegram delivery must be verified on the target host. Kept as a proposal to avoid restarting the existing Space and losing its temporary pending queue. Existing secrets are not readable through the Hugging Face API and must be supplied on the new host.
- .dockerignore +8 -0
- .gitignore +7 -0
- Dockerfile +1 -0
- README.md +43 -2
- receiver.py +48 -16
- render.yaml +25 -0
.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.cache
|
| 2 |
+
.git
|
| 3 |
+
__pycache__
|
| 4 |
+
*.pyc
|
| 5 |
+
.env
|
| 6 |
+
.env.*
|
| 7 |
+
*.sqlite3
|
| 8 |
+
*.sqlite3-*
|
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.cache/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.env
|
| 5 |
+
.env.*
|
| 6 |
+
*.sqlite3
|
| 7 |
+
*.sqlite3-*
|
Dockerfile
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
FROM python:3.12-slim
|
| 2 |
RUN useradd --create-home --uid 1000 appuser
|
| 3 |
WORKDIR /app
|
|
|
|
| 4 |
COPY requirements-monitor.txt /app/requirements-monitor.txt
|
| 5 |
RUN pip install --no-cache-dir -r /app/requirements-monitor.txt
|
| 6 |
COPY --chown=appuser:appuser receiver.py /app/receiver.py
|
|
|
|
| 1 |
FROM python:3.12-slim
|
| 2 |
RUN useradd --create-home --uid 1000 appuser
|
| 3 |
WORKDIR /app
|
| 4 |
+
RUN mkdir -p /app/data && chown appuser:appuser /app/data
|
| 5 |
COPY requirements-monitor.txt /app/requirements-monitor.txt
|
| 6 |
RUN pip install --no-cache-dir -r /app/requirements-monitor.txt
|
| 7 |
COPY --chown=appuser:appuser receiver.py /app/receiver.py
|
README.md
CHANGED
|
@@ -14,8 +14,9 @@ independent DeepSeek classifier for potential topic alerts. Text only; no image,
|
|
| 14 |
OCR, subtitle, or original-page fetching. The default model is deepseek-flash
|
| 15 |
with thinking disabled. A positive result is a signal for a person to assess.
|
| 16 |
|
| 17 |
-
Set these Space Secrets: WEBHOOK_SECRET, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
|
| 18 |
-
|
|
|
|
| 19 |
default instructions and belongs in a Secret when it contains internal context.
|
| 20 |
Never commit credentials or confidential classifier instructions to this repo.
|
| 21 |
|
|
@@ -37,3 +38,43 @@ or an alert. AI failures remain pending and are retried, never recorded as NO.
|
|
| 37 |
Normal posts are sent silently; alerts request a notification, which still
|
| 38 |
depends on recipients' Telegram settings. Mentions are explicit @usernames.
|
| 39 |
Post text is split across messages when necessary; its complete text is checked.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
OCR, subtitle, or original-page fetching. The default model is deepseek-flash
|
| 15 |
with thinking disabled. A positive result is a signal for a person to assess.
|
| 16 |
|
| 17 |
+
Set these Space Secrets: WEBHOOK_SECRET, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
|
| 18 |
+
and DEEPSEEK_API_KEY. ALERT_MENTIONS optionally adds user mentions to alerts.
|
| 19 |
+
CLASSIFIER_PROMPT overrides the generic
|
| 20 |
default instructions and belongs in a Secret when it contains internal context.
|
| 21 |
Never commit credentials or confidential classifier instructions to this repo.
|
| 22 |
|
|
|
|
| 38 |
Normal posts are sent silently; alerts request a notification, which still
|
| 39 |
depends on recipients' Telegram settings. Mentions are explicit @usernames.
|
| 40 |
Post text is split across messages when necessary; its complete text is checked.
|
| 41 |
+
|
| 42 |
+
## Delivery health and independent workers
|
| 43 |
+
|
| 44 |
+
`/health` now distinguishes successful delivery, an untested Telegram connection,
|
| 45 |
+
delivery errors, missing configuration, and stopped workers. The public response
|
| 46 |
+
contains fixed error categories, never tokens, chat identifiers or post content.
|
| 47 |
+
It is not an end-to-end delivery guarantee. `/live` is a separate process liveness
|
| 48 |
+
check, so a Telegram outage does not restart the process and discard its queue.
|
| 49 |
+
|
| 50 |
+
Telegram forwarding runs whenever its token and chat ID are configured, even if
|
| 51 |
+
DeepSeek is not configured yet. AI processing starts independently when its key
|
| 52 |
+
is available. `ALERT_MENTIONS` is optional. Valid authenticated webhooks are stored
|
| 53 |
+
even while downstream configuration is incomplete, and workers resume delivery
|
| 54 |
+
after configuration is corrected and the service restarted with the same database.
|
| 55 |
+
|
| 56 |
+
## Continuous hosting on Render
|
| 57 |
+
|
| 58 |
+
The included `render.yaml` describes one paid Docker web service with a 1 GB
|
| 59 |
+
persistent disk at `/app/data`. Check the actual compute and disk prices before
|
| 60 |
+
creating the service. Free services are unsuitable for continuous monitoring.
|
| 61 |
+
Keep exactly one instance. The existing Dockerfile runs as an unprivileged user.
|
| 62 |
+
|
| 63 |
+
Deploy this directory as the repository root. Enter the existing values for
|
| 64 |
+
`WEBHOOK_SECRET`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` and `DEEPSEEK_API_KEY`
|
| 65 |
+
as secret environment variables. Hugging Face does not return stored secret
|
| 66 |
+
values through its API; copy them from your original secure source. Preserve
|
| 67 |
+
`DEEPSEEK_MODEL`, `CLASSIFIER_PROMPT`, `ALERT_MENTIONS` and `TELEGRAM_THREAD_ID`
|
| 68 |
+
if customized. Do not put secrets in source control or Blueprint YAML.
|
| 69 |
+
|
| 70 |
+
Before switching Medialogia, run the authenticated `/diagnostics/WEBHOOK_SECRET`
|
| 71 |
+
checks on the new service and confirm bot membership and chat permissions. Then
|
| 72 |
+
send a test publication through Medialogia and verify its arrival in Telegram.
|
| 73 |
+
The diagnostic itself sends no messages. Update Medialogia to the new HTTPS
|
| 74 |
+
`/medialogia/WEBHOOK_SECRET` URL only when ready to switch. Do not run two active
|
| 75 |
+
webhook destinations for the same monitoring feed unless duplicates are desired.
|
| 76 |
+
|
| 77 |
+
The old Hugging Face queue is in temporary storage and is not automatically
|
| 78 |
+
migrated. Do not rebuild or delete that Space until any required pending
|
| 79 |
+
publications have been recovered or replayed from Medialogia. A persistent disk
|
| 80 |
+
retains the new service's queue across deploys; maintain separate backups.
|
receiver.py
CHANGED
|
@@ -28,7 +28,7 @@ from urllib.parse import urlsplit
|
|
| 28 |
from zoneinfo import ZoneInfo
|
| 29 |
|
| 30 |
LOG = logging.getLogger("monitor")
|
| 31 |
-
VERSION = "1.
|
| 32 |
MAX_BYTES = 1024 * 1024
|
| 33 |
AI_CHUNK = 12000
|
| 34 |
DEFAULT_PROMPT = """Ты классификатор публикаций для мониторинга нового внешнего вида
|
|
@@ -189,8 +189,7 @@ class Config:
|
|
| 189 |
|
| 190 |
def missing(self):
|
| 191 |
return [name for name, value in (("TELEGRAM_BOT_TOKEN", self.telegram_token),
|
| 192 |
-
("TELEGRAM_CHAT_ID", self.chat_id), ("DEEPSEEK_API_KEY", self.deepseek_key)
|
| 193 |
-
("ALERT_MENTIONS", self.mentions)) if not value]
|
| 194 |
|
| 195 |
|
| 196 |
class Store:
|
|
@@ -353,6 +352,7 @@ class Clients:
|
|
| 353 |
def __init__(self, config):
|
| 354 |
self.config, self.last_tg = config, 0.0
|
| 355 |
self.diagnostics_lock = threading.Lock()
|
|
|
|
| 356 |
|
| 357 |
def diagnose_telegram(self):
|
| 358 |
"""Read-only Bot API checks; no publication, queue changes or AI calls."""
|
|
@@ -423,10 +423,15 @@ class Clients:
|
|
| 423 |
data = {"chat_id": self.config.chat_id, **payload}
|
| 424 |
if method == "sendMessage" and self.config.thread_id:
|
| 425 |
data["message_thread_id"] = int(self.config.thread_id)
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
return result.get("result")
|
| 431 |
|
| 432 |
|
|
@@ -509,15 +514,40 @@ class Monitor:
|
|
| 509 |
self.stop.wait(0.25 if row else 1)
|
| 510 |
|
| 511 |
def start(self):
|
| 512 |
-
|
| 513 |
-
|
|
|
|
| 514 |
thread = threading.Thread(target=self.worker, args=(lane,), daemon=True, name=lane)
|
| 515 |
self.threads.append(thread)
|
| 516 |
thread.start()
|
| 517 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
def status(self):
|
| 519 |
return {"mode": "monitor", "version": VERSION, "missing": self.config.missing(), "model": self.config.model,
|
| 520 |
-
"storage": "postgresql" if self.config.database_url else "
|
| 521 |
"queue": self.store.stats(), "workers": {t.name: t.is_alive() for t in self.threads}}
|
| 522 |
|
| 523 |
|
|
@@ -547,10 +577,11 @@ def make_server(monitor, host="0.0.0.0", port=7860):
|
|
| 547 |
return secrets.compare_digest(urlsplit(self.path).path.encode(), (prefix + monitor.config.secret).encode())
|
| 548 |
|
| 549 |
def do_GET(self):
|
|
|
|
|
|
|
| 550 |
if urlsplit(self.path).path in ("/", "/health"):
|
| 551 |
-
|
| 552 |
-
return self.reply(
|
| 553 |
-
"version": VERSION, "missing": missing, "storage": "postgresql" if monitor.config.database_url else "temporary_sqlite"})
|
| 554 |
if self.authorized("/diagnostics/"):
|
| 555 |
return self.reply(200, monitor.clients.diagnose_telegram())
|
| 556 |
if self.authorized("/status/") or self.authorized("/samples/"):
|
|
@@ -563,8 +594,6 @@ def make_server(monitor, host="0.0.0.0", port=7860):
|
|
| 563 |
def do_POST(self):
|
| 564 |
if not self.authorized("/medialogia/"):
|
| 565 |
return self.reply(404, {"error": "not_found"})
|
| 566 |
-
if monitor.config.missing():
|
| 567 |
-
return self.reply(503, {"error": "configuration_required"})
|
| 568 |
try:
|
| 569 |
size = int(self.headers["Content-Length"])
|
| 570 |
except (ValueError, TypeError):
|
|
@@ -598,7 +627,7 @@ if __name__ == "__main__":
|
|
| 598 |
app = Monitor(Config.from_env())
|
| 599 |
server = make_server(app, port=int(os.getenv("PORT", "7860")))
|
| 600 |
app.start()
|
| 601 |
-
LOG.info("Monitor started; storage=%s; missing=%s", "postgresql" if app.config.database_url else "
|
| 602 |
|
| 603 |
def shutdown(*_args):
|
| 604 |
app.stop.set()
|
|
@@ -610,5 +639,8 @@ if __name__ == "__main__":
|
|
| 610 |
server.serve_forever()
|
| 611 |
finally:
|
| 612 |
app.stop.set()
|
|
|
|
|
|
|
|
|
|
| 613 |
server.server_close()
|
| 614 |
|
|
|
|
| 28 |
from zoneinfo import ZoneInfo
|
| 29 |
|
| 30 |
LOG = logging.getLogger("monitor")
|
| 31 |
+
VERSION = "1.2-delivery-health"
|
| 32 |
MAX_BYTES = 1024 * 1024
|
| 33 |
AI_CHUNK = 12000
|
| 34 |
DEFAULT_PROMPT = """Ты классификатор публикаций для мониторинга нового внешнего вида
|
|
|
|
| 189 |
|
| 190 |
def missing(self):
|
| 191 |
return [name for name, value in (("TELEGRAM_BOT_TOKEN", self.telegram_token),
|
| 192 |
+
("TELEGRAM_CHAT_ID", self.chat_id), ("DEEPSEEK_API_KEY", self.deepseek_key)) if not value]
|
|
|
|
| 193 |
|
| 194 |
|
| 195 |
class Store:
|
|
|
|
| 352 |
def __init__(self, config):
|
| 353 |
self.config, self.last_tg = config, 0.0
|
| 354 |
self.diagnostics_lock = threading.Lock()
|
| 355 |
+
self.telegram_health = {"status": "not_checked"}
|
| 356 |
|
| 357 |
def diagnose_telegram(self):
|
| 358 |
"""Read-only Bot API checks; no publication, queue changes or AI calls."""
|
|
|
|
| 423 |
data = {"chat_id": self.config.chat_id, **payload}
|
| 424 |
if method == "sendMessage" and self.config.thread_id:
|
| 425 |
data["message_thread_id"] = int(self.config.thread_id)
|
| 426 |
+
try:
|
| 427 |
+
result = request_json("https://api.telegram.org/bot" + self.config.telegram_token + "/" + method,
|
| 428 |
+
data, service="telegram", timeout=35)
|
| 429 |
+
if not isinstance(result, dict) or not result.get("ok"):
|
| 430 |
+
raise ServiceError("telegram_invalid_response")
|
| 431 |
+
except ServiceError as exc:
|
| 432 |
+
self.telegram_health = {"status": "error", "error": exc.code}
|
| 433 |
+
raise
|
| 434 |
+
self.telegram_health = {"status": "ok"}
|
| 435 |
return result.get("result")
|
| 436 |
|
| 437 |
|
|
|
|
| 514 |
self.stop.wait(0.25 if row else 1)
|
| 515 |
|
| 516 |
def start(self):
|
| 517 |
+
for lane, enabled in (("ai", bool(self.config.deepseek_key)),
|
| 518 |
+
("tg", bool(self.config.telegram_token and self.config.chat_id))):
|
| 519 |
+
if enabled:
|
| 520 |
thread = threading.Thread(target=self.worker, args=(lane,), daemon=True, name=lane)
|
| 521 |
self.threads.append(thread)
|
| 522 |
thread.start()
|
| 523 |
|
| 524 |
+
def health(self):
|
| 525 |
+
missing = self.config.missing()
|
| 526 |
+
telegram = dict(self.clients.telegram_health)
|
| 527 |
+
try:
|
| 528 |
+
queue = self.store.stats()
|
| 529 |
+
except Exception:
|
| 530 |
+
return {"status": "storage_unavailable", "version": VERSION}
|
| 531 |
+
workers = {thread.name: thread.is_alive() for thread in self.threads}
|
| 532 |
+
expected = {lane for lane, enabled in (("ai", bool(self.config.deepseek_key)),
|
| 533 |
+
("tg", bool(self.config.telegram_token and self.config.chat_id))) if enabled}
|
| 534 |
+
if missing:
|
| 535 |
+
state = "configuration_required"
|
| 536 |
+
elif not all(workers.get(lane, False) for lane in expected):
|
| 537 |
+
state = "worker_unavailable"
|
| 538 |
+
elif queue["telegram_errors"] or queue["ai_errors"] or telegram["status"] == "error":
|
| 539 |
+
state = "monitor_degraded"
|
| 540 |
+
elif telegram["status"] == "not_checked":
|
| 541 |
+
state = "monitor_waiting_for_delivery"
|
| 542 |
+
else:
|
| 543 |
+
state = "monitor_ready"
|
| 544 |
+
return {"status": state, "version": VERSION, "missing": missing,
|
| 545 |
+
"storage": "postgresql" if self.config.database_url else "sqlite",
|
| 546 |
+
"telegram": telegram, "workers": workers}
|
| 547 |
+
|
| 548 |
def status(self):
|
| 549 |
return {"mode": "monitor", "version": VERSION, "missing": self.config.missing(), "model": self.config.model,
|
| 550 |
+
"storage": "postgresql" if self.config.database_url else "sqlite",
|
| 551 |
"queue": self.store.stats(), "workers": {t.name: t.is_alive() for t in self.threads}}
|
| 552 |
|
| 553 |
|
|
|
|
| 577 |
return secrets.compare_digest(urlsplit(self.path).path.encode(), (prefix + monitor.config.secret).encode())
|
| 578 |
|
| 579 |
def do_GET(self):
|
| 580 |
+
if urlsplit(self.path).path == "/live":
|
| 581 |
+
return self.reply(200, {"status": "alive", "version": VERSION})
|
| 582 |
if urlsplit(self.path).path in ("/", "/health"):
|
| 583 |
+
health = monitor.health()
|
| 584 |
+
return self.reply(503 if health["status"] in ("storage_unavailable", "worker_unavailable") else 200, health)
|
|
|
|
| 585 |
if self.authorized("/diagnostics/"):
|
| 586 |
return self.reply(200, monitor.clients.diagnose_telegram())
|
| 587 |
if self.authorized("/status/") or self.authorized("/samples/"):
|
|
|
|
| 594 |
def do_POST(self):
|
| 595 |
if not self.authorized("/medialogia/"):
|
| 596 |
return self.reply(404, {"error": "not_found"})
|
|
|
|
|
|
|
| 597 |
try:
|
| 598 |
size = int(self.headers["Content-Length"])
|
| 599 |
except (ValueError, TypeError):
|
|
|
|
| 627 |
app = Monitor(Config.from_env())
|
| 628 |
server = make_server(app, port=int(os.getenv("PORT", "7860")))
|
| 629 |
app.start()
|
| 630 |
+
LOG.info("Monitor started; storage=%s; missing=%s", "postgresql" if app.config.database_url else "sqlite", app.config.missing())
|
| 631 |
|
| 632 |
def shutdown(*_args):
|
| 633 |
app.stop.set()
|
|
|
|
| 639 |
server.serve_forever()
|
| 640 |
finally:
|
| 641 |
app.stop.set()
|
| 642 |
+
deadline = time.monotonic() + 75
|
| 643 |
+
for thread in app.threads:
|
| 644 |
+
thread.join(timeout=max(0, deadline - time.monotonic()))
|
| 645 |
server.server_close()
|
| 646 |
|
render.yaml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
- type: web
|
| 3 |
+
name: medialogia-monitor
|
| 4 |
+
runtime: docker
|
| 5 |
+
plan: starter
|
| 6 |
+
numInstances: 1
|
| 7 |
+
healthCheckPath: /live
|
| 8 |
+
maxShutdownDelaySeconds: 90
|
| 9 |
+
disk:
|
| 10 |
+
name: monitor-queue
|
| 11 |
+
mountPath: /app/data
|
| 12 |
+
sizeGB: 1
|
| 13 |
+
envVars:
|
| 14 |
+
- key: MONITOR_DB_PATH
|
| 15 |
+
value: /app/data/queue.sqlite3
|
| 16 |
+
- key: WEBHOOK_SECRET
|
| 17 |
+
sync: false
|
| 18 |
+
- key: TELEGRAM_BOT_TOKEN
|
| 19 |
+
sync: false
|
| 20 |
+
- key: TELEGRAM_CHAT_ID
|
| 21 |
+
sync: false
|
| 22 |
+
- key: DEEPSEEK_API_KEY
|
| 23 |
+
sync: false
|
| 24 |
+
- key: DEEPSEEK_MODEL
|
| 25 |
+
value: deepseek-flash
|