Spaces:
Running
Running
Fix Telegram delivery with an authenticated free-tier relay (#1)
Browse files- Expose delivery failures and prepare continuous hosting (4b3530b1d3a883c4c3387c8734b3ccba6f2ee2aa)
- Add an authenticated free-tier Telegram relay instead of paid hosting (92cad08eb80704a5543a7af015088c1adecfeca5)
- Verify Cloudflare runtime compatibility and add read-only relay diagnostics (e91560b997e4bb7bed78408a056790ef2d12fbe3)
- .dockerignore +13 -0
- .gitignore +12 -0
- README.md +40 -2
- diagnose_telegram.py +65 -0
- receiver.py +83 -23
- telegram-relay/README.md +32 -0
- telegram-relay/worker.mjs +111 -0
- telegram-relay/wrangler.toml +8 -0
.dockerignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.cache
|
| 2 |
+
.git
|
| 3 |
+
__pycache__
|
| 4 |
+
*.pyc
|
| 5 |
+
.env
|
| 6 |
+
.env.*
|
| 7 |
+
*.sqlite3
|
| 8 |
+
*.sqlite3-*
|
| 9 |
+
.local-*
|
| 10 |
+
.wrangler
|
| 11 |
+
node_modules
|
| 12 |
+
.dev.vars
|
| 13 |
+
.dev.vars.*
|
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.cache/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.env
|
| 5 |
+
.env.*
|
| 6 |
+
*.sqlite3
|
| 7 |
+
*.sqlite3-*
|
| 8 |
+
.local-*
|
| 9 |
+
.wrangler/
|
| 10 |
+
node_modules/
|
| 11 |
+
.dev.vars
|
| 12 |
+
.dev.vars.*
|
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,40 @@ 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 |
+
## Optional free Telegram relay
|
| 57 |
+
|
| 58 |
+
The `telegram-relay/` directory contains a Cloudflare Worker for installations
|
| 59 |
+
where the Space cannot reach Telegram directly. It supports the Workers Free
|
| 60 |
+
plan within its quotas. Configure `TELEGRAM_RELAY_URL` and
|
| 61 |
+
`TELEGRAM_RELAY_SECRET` together to use it. Otherwise requests go directly to
|
| 62 |
+
Telegram as before. The Worker holds its own bot token and allows only this
|
| 63 |
+
monitor's five Bot API methods for one numeric chat ID. Both normal delivery and
|
| 64 |
+
authenticated diagnostics use the configured transport.
|
| 65 |
+
|
| 66 |
+
When the relay is configured, read-only `/diagnostics` and `/status` also accept
|
| 67 |
+
`Authorization: Bearer TELEGRAM_RELAY_SECRET`. The existing webhook-secret
|
| 68 |
+
routes continue to work. This does not authorize incoming publication webhooks.
|
| 69 |
+
|
| 70 |
+
See `telegram-relay/README.md` for deployment and secret configuration. Verify
|
| 71 |
+
the relay's read-only getMe, getChat and getChatMember checks before rebuilding
|
| 72 |
+
the Space, then verify an actual publication arrives after deployment. Neither
|
| 73 |
+
the bot token nor the shared secret belongs in source control.
|
| 74 |
+
|
| 75 |
+
The Worker does not fix HF sleeping or the temporary queue. Do not rebuild the
|
| 76 |
+
running Space until pending publications have been recovered or their loss and
|
| 77 |
+
replay from Medialogia have been explicitly handled. Keep only one app instance.
|
diagnose_telegram.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import getpass
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import re
|
| 6 |
+
import urllib.error
|
| 7 |
+
import urllib.request
|
| 8 |
+
|
| 9 |
+
from receiver import Clients, Config, network_error_category
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def summarize(report):
|
| 13 |
+
fields = {"method", "ok", "error", "seconds", "chat_type", "permissions",
|
| 14 |
+
"membership", "is_member", "can_send_messages", "can_post_messages"}
|
| 15 |
+
return {"status": report.get("status"),
|
| 16 |
+
"checks": [{key: value for key, value in check.items() if key in fields}
|
| 17 |
+
for check in report.get("checks", [])]}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def compare(token, chat_id, webhook_secret):
|
| 21 |
+
config = Config(secret=webhook_secret or "a" * 32,
|
| 22 |
+
telegram_token=token, chat_id=chat_id)
|
| 23 |
+
report = {"mac": summarize(Clients(config).diagnose_telegram())}
|
| 24 |
+
if webhook_secret:
|
| 25 |
+
try:
|
| 26 |
+
request = urllib.request.Request(
|
| 27 |
+
"https://pranalytics-prtooltest.hf.space/diagnostics/" + webhook_secret)
|
| 28 |
+
with urllib.request.urlopen(request, timeout=40) as response:
|
| 29 |
+
report["space"] = summarize(json.loads(response.read(65536)))
|
| 30 |
+
except urllib.error.HTTPError as exc:
|
| 31 |
+
report["space"] = {"error": "diagnostics_http_" + str(exc.code)}
|
| 32 |
+
exc.close()
|
| 33 |
+
except OSError as exc:
|
| 34 |
+
report["space"] = {"error": "diagnostics_" + network_error_category(exc)}
|
| 35 |
+
except (ValueError, TypeError, AttributeError):
|
| 36 |
+
report["space"] = {"error": "diagnostics_invalid_response"}
|
| 37 |
+
return report
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def main():
|
| 41 |
+
print("Проверка только читает настройки. Сообщения не отправляются.")
|
| 42 |
+
print("Значения вводятся скрыто и не сохраняются. Используйте значения, заданные при настройке HF.")
|
| 43 |
+
token = getpass.getpass("TELEGRAM_BOT_TOKEN: ").strip()
|
| 44 |
+
chat_id = getpass.getpass("TELEGRAM_CHAT_ID: ").strip()
|
| 45 |
+
webhook_secret = getpass.getpass("WEBHOOK_SECRET (Enter — пропустить проверку Space): ").strip()
|
| 46 |
+
if not re.fullmatch(r"\d+:[A-Za-z0-9_-]+", token):
|
| 47 |
+
raise SystemExit("Неверный формат TELEGRAM_BOT_TOKEN.")
|
| 48 |
+
if not re.fullmatch(r"-?\d+|@[A-Za-z0-9_]+", chat_id):
|
| 49 |
+
raise SystemExit("Неверный формат TELEGRAM_CHAT_ID.")
|
| 50 |
+
if webhook_secret and not re.fullmatch(r"[A-Za-z0-9_-]{24,}", webhook_secret):
|
| 51 |
+
raise SystemExit("Неверный формат WEBHOOK_SECRET.")
|
| 52 |
+
result = json.dumps(compare(token, chat_id, webhook_secret), ensure_ascii=False, indent=2)
|
| 53 |
+
destination = Path(__file__).with_name(".local-diagnostics.json")
|
| 54 |
+
descriptor = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
|
| 55 |
+
with os.fdopen(descriptor, "w") as report_file:
|
| 56 |
+
report_file.write(result + "\n")
|
| 57 |
+
print(result)
|
| 58 |
+
print("Обезличенный отчёт сохранён. Вернитесь в чат и напишите: готово.")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
if __name__ == "__main__":
|
| 62 |
+
try:
|
| 63 |
+
main()
|
| 64 |
+
except (KeyboardInterrupt, EOFError):
|
| 65 |
+
raise SystemExit("Проверка отменена.") from None
|
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 = """Ты классификатор публикаций для мониторинга нового внешнего вида
|
|
@@ -156,6 +156,8 @@ class Config:
|
|
| 156 |
database_url: str = ""
|
| 157 |
db_path: str = "/tmp/medialogia-monitor/queue.sqlite3"
|
| 158 |
thread_id: str = ""
|
|
|
|
|
|
|
| 159 |
|
| 160 |
@classmethod
|
| 161 |
def from_env(cls):
|
|
@@ -168,9 +170,21 @@ class Config:
|
|
| 168 |
model=os.getenv("DEEPSEEK_MODEL", "deepseek-flash").strip(),
|
| 169 |
database_url=os.getenv("DATABASE_URL", "").strip(),
|
| 170 |
db_path=os.getenv("MONITOR_DB_PATH", "/tmp/medialogia-monitor/queue.sqlite3"),
|
| 171 |
-
thread_id=os.getenv("TELEGRAM_THREAD_ID", "").strip()
|
|
|
|
|
|
|
| 172 |
|
| 173 |
def validate(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
if not re.fullmatch(r"[A-Za-z0-9_-]{24,}", self.secret):
|
| 175 |
raise ValueError("WEBHOOK_SECRET: use at least 24 ASCII letters/digits/-/_")
|
| 176 |
if self.chat_id and not re.fullmatch(r"-?\d+|@[A-Za-z0-9_]+", self.chat_id):
|
|
@@ -189,8 +203,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:
|
|
@@ -318,7 +331,9 @@ def network_error_category(exc):
|
|
| 318 |
|
| 319 |
def request_json(url, payload, headers=None, service="api", timeout=45):
|
| 320 |
request = urllib.request.Request(url, data=json.dumps(payload, ensure_ascii=False).encode(),
|
| 321 |
-
headers={"Content-Type": "application/json",
|
|
|
|
|
|
|
| 322 |
try:
|
| 323 |
with urllib.request.urlopen(request, timeout=timeout) as response:
|
| 324 |
result = json.loads(response.read(2 * MAX_BYTES))
|
|
@@ -353,6 +368,16 @@ 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."""
|
|
@@ -369,9 +394,7 @@ class Clients:
|
|
| 369 |
payload["user_id"] = bot_id
|
| 370 |
started = time.monotonic()
|
| 371 |
try:
|
| 372 |
-
result =
|
| 373 |
-
"https://api.telegram.org/bot" + self.config.telegram_token + "/" + method,
|
| 374 |
-
payload, service="telegram", timeout=10)
|
| 375 |
if not result.get("ok") or not isinstance(result.get("result"), dict):
|
| 376 |
raise ServiceError("telegram_invalid_response")
|
| 377 |
data = result["result"]
|
|
@@ -423,10 +446,14 @@ 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 +536,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 |
|
|
@@ -546,14 +598,21 @@ def make_server(monitor, host="0.0.0.0", port=7860):
|
|
| 546 |
def authorized(self, prefix):
|
| 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 |
-
|
| 554 |
-
if self.authorized("/diagnostics/"):
|
| 555 |
return self.reply(200, monitor.clients.diagnose_telegram())
|
| 556 |
-
if self.authorized("/status/") or self.authorized("/samples/")
|
|
|
|
| 557 |
try:
|
| 558 |
return self.reply(200, monitor.status())
|
| 559 |
except Exception:
|
|
@@ -563,8 +622,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 +655,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 +667,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.3-telegram-relay"
|
| 32 |
MAX_BYTES = 1024 * 1024
|
| 33 |
AI_CHUNK = 12000
|
| 34 |
DEFAULT_PROMPT = """Ты классификатор публикаций для мониторинга нового внешнего вида
|
|
|
|
| 156 |
database_url: str = ""
|
| 157 |
db_path: str = "/tmp/medialogia-monitor/queue.sqlite3"
|
| 158 |
thread_id: str = ""
|
| 159 |
+
telegram_relay_url: str = ""
|
| 160 |
+
telegram_relay_secret: str = ""
|
| 161 |
|
| 162 |
@classmethod
|
| 163 |
def from_env(cls):
|
|
|
|
| 170 |
model=os.getenv("DEEPSEEK_MODEL", "deepseek-flash").strip(),
|
| 171 |
database_url=os.getenv("DATABASE_URL", "").strip(),
|
| 172 |
db_path=os.getenv("MONITOR_DB_PATH", "/tmp/medialogia-monitor/queue.sqlite3"),
|
| 173 |
+
thread_id=os.getenv("TELEGRAM_THREAD_ID", "").strip(),
|
| 174 |
+
telegram_relay_url=os.getenv("TELEGRAM_RELAY_URL", "").strip(),
|
| 175 |
+
telegram_relay_secret=os.getenv("TELEGRAM_RELAY_SECRET", "").strip())
|
| 176 |
|
| 177 |
def validate(self):
|
| 178 |
+
if bool(self.telegram_relay_url) != bool(self.telegram_relay_secret):
|
| 179 |
+
raise ValueError("TELEGRAM_RELAY_URL and TELEGRAM_RELAY_SECRET must be set together")
|
| 180 |
+
if self.telegram_relay_url:
|
| 181 |
+
relay = urlsplit(self.telegram_relay_url)
|
| 182 |
+
if (relay.scheme != "https" or not relay.hostname or relay.username or relay.password
|
| 183 |
+
or relay.query or relay.fragment or relay.path != "/telegram"
|
| 184 |
+
or any(ord(char) < 33 for char in self.telegram_relay_url)):
|
| 185 |
+
raise ValueError("TELEGRAM_RELAY_URL: expected an HTTPS /telegram endpoint")
|
| 186 |
+
if not re.fullmatch(r"[A-Za-z0-9_-]{32,}", self.telegram_relay_secret):
|
| 187 |
+
raise ValueError("TELEGRAM_RELAY_SECRET: use at least 32 ASCII letters/digits/-/_")
|
| 188 |
if not re.fullmatch(r"[A-Za-z0-9_-]{24,}", self.secret):
|
| 189 |
raise ValueError("WEBHOOK_SECRET: use at least 24 ASCII letters/digits/-/_")
|
| 190 |
if self.chat_id and not re.fullmatch(r"-?\d+|@[A-Za-z0-9_]+", self.chat_id):
|
|
|
|
| 203 |
|
| 204 |
def missing(self):
|
| 205 |
return [name for name, value in (("TELEGRAM_BOT_TOKEN", self.telegram_token),
|
| 206 |
+
("TELEGRAM_CHAT_ID", self.chat_id), ("DEEPSEEK_API_KEY", self.deepseek_key)) if not value]
|
|
|
|
| 207 |
|
| 208 |
|
| 209 |
class Store:
|
|
|
|
| 331 |
|
| 332 |
def request_json(url, payload, headers=None, service="api", timeout=45):
|
| 333 |
request = urllib.request.Request(url, data=json.dumps(payload, ensure_ascii=False).encode(),
|
| 334 |
+
headers={"Content-Type": "application/json", "Accept": "application/json",
|
| 335 |
+
"User-Agent": "MedialogiaMonitor/" + VERSION,
|
| 336 |
+
**(headers or {})}, method="POST")
|
| 337 |
try:
|
| 338 |
with urllib.request.urlopen(request, timeout=timeout) as response:
|
| 339 |
result = json.loads(response.read(2 * MAX_BYTES))
|
|
|
|
| 368 |
def __init__(self, config):
|
| 369 |
self.config, self.last_tg = config, 0.0
|
| 370 |
self.diagnostics_lock = threading.Lock()
|
| 371 |
+
self.telegram_health = {"status": "not_checked"}
|
| 372 |
+
|
| 373 |
+
def telegram_request(self, method, payload, timeout):
|
| 374 |
+
if self.config.telegram_relay_url:
|
| 375 |
+
return request_json(self.config.telegram_relay_url,
|
| 376 |
+
{"method": method, "payload": payload},
|
| 377 |
+
{"Authorization": "Bearer " + self.config.telegram_relay_secret},
|
| 378 |
+
service="telegram", timeout=timeout)
|
| 379 |
+
return request_json("https://api.telegram.org/bot" + self.config.telegram_token + "/" + method,
|
| 380 |
+
payload, service="telegram", timeout=timeout)
|
| 381 |
|
| 382 |
def diagnose_telegram(self):
|
| 383 |
"""Read-only Bot API checks; no publication, queue changes or AI calls."""
|
|
|
|
| 394 |
payload["user_id"] = bot_id
|
| 395 |
started = time.monotonic()
|
| 396 |
try:
|
| 397 |
+
result = self.telegram_request(method, payload, timeout=10)
|
|
|
|
|
|
|
| 398 |
if not result.get("ok") or not isinstance(result.get("result"), dict):
|
| 399 |
raise ServiceError("telegram_invalid_response")
|
| 400 |
data = result["result"]
|
|
|
|
| 446 |
data = {"chat_id": self.config.chat_id, **payload}
|
| 447 |
if method == "sendMessage" and self.config.thread_id:
|
| 448 |
data["message_thread_id"] = int(self.config.thread_id)
|
| 449 |
+
try:
|
| 450 |
+
result = self.telegram_request(method, data, timeout=35)
|
| 451 |
+
if not isinstance(result, dict) or not result.get("ok"):
|
| 452 |
+
raise ServiceError("telegram_invalid_response")
|
| 453 |
+
except ServiceError as exc:
|
| 454 |
+
self.telegram_health = {"status": "error", "error": exc.code}
|
| 455 |
+
raise
|
| 456 |
+
self.telegram_health = {"status": "ok"}
|
| 457 |
return result.get("result")
|
| 458 |
|
| 459 |
|
|
|
|
| 536 |
self.stop.wait(0.25 if row else 1)
|
| 537 |
|
| 538 |
def start(self):
|
| 539 |
+
for lane, enabled in (("ai", bool(self.config.deepseek_key)),
|
| 540 |
+
("tg", bool(self.config.telegram_token and self.config.chat_id))):
|
| 541 |
+
if enabled:
|
| 542 |
thread = threading.Thread(target=self.worker, args=(lane,), daemon=True, name=lane)
|
| 543 |
self.threads.append(thread)
|
| 544 |
thread.start()
|
| 545 |
|
| 546 |
+
def health(self):
|
| 547 |
+
missing = self.config.missing()
|
| 548 |
+
telegram = dict(self.clients.telegram_health)
|
| 549 |
+
try:
|
| 550 |
+
queue = self.store.stats()
|
| 551 |
+
except Exception:
|
| 552 |
+
return {"status": "storage_unavailable", "version": VERSION}
|
| 553 |
+
workers = {thread.name: thread.is_alive() for thread in self.threads}
|
| 554 |
+
expected = {lane for lane, enabled in (("ai", bool(self.config.deepseek_key)),
|
| 555 |
+
("tg", bool(self.config.telegram_token and self.config.chat_id))) if enabled}
|
| 556 |
+
if missing:
|
| 557 |
+
state = "configuration_required"
|
| 558 |
+
elif not all(workers.get(lane, False) for lane in expected):
|
| 559 |
+
state = "worker_unavailable"
|
| 560 |
+
elif queue["telegram_errors"] or queue["ai_errors"] or telegram["status"] == "error":
|
| 561 |
+
state = "monitor_degraded"
|
| 562 |
+
elif telegram["status"] == "not_checked":
|
| 563 |
+
state = "monitor_waiting_for_delivery"
|
| 564 |
+
else:
|
| 565 |
+
state = "monitor_ready"
|
| 566 |
+
return {"status": state, "version": VERSION, "missing": missing,
|
| 567 |
+
"storage": "postgresql" if self.config.database_url else "sqlite",
|
| 568 |
+
"telegram": telegram, "workers": workers}
|
| 569 |
+
|
| 570 |
def status(self):
|
| 571 |
return {"mode": "monitor", "version": VERSION, "missing": self.config.missing(), "model": self.config.model,
|
| 572 |
+
"storage": "postgresql" if self.config.database_url else "sqlite",
|
| 573 |
"queue": self.store.stats(), "workers": {t.name: t.is_alive() for t in self.threads}}
|
| 574 |
|
| 575 |
|
|
|
|
| 598 |
def authorized(self, prefix):
|
| 599 |
return secrets.compare_digest(urlsplit(self.path).path.encode(), (prefix + monitor.config.secret).encode())
|
| 600 |
|
| 601 |
+
def relay_authorized(self):
|
| 602 |
+
secret = monitor.config.telegram_relay_secret
|
| 603 |
+
return bool(secret) and secrets.compare_digest(
|
| 604 |
+
self.headers.get("Authorization", "").encode(), ("Bearer " + secret).encode())
|
| 605 |
+
|
| 606 |
def do_GET(self):
|
| 607 |
+
if urlsplit(self.path).path == "/live":
|
| 608 |
+
return self.reply(200, {"status": "alive", "version": VERSION})
|
| 609 |
if urlsplit(self.path).path in ("/", "/health"):
|
| 610 |
+
health = monitor.health()
|
| 611 |
+
return self.reply(503 if health["status"] in ("storage_unavailable", "worker_unavailable") else 200, health)
|
| 612 |
+
if self.authorized("/diagnostics/") or (urlsplit(self.path).path == "/diagnostics" and self.relay_authorized()):
|
|
|
|
| 613 |
return self.reply(200, monitor.clients.diagnose_telegram())
|
| 614 |
+
if (self.authorized("/status/") or self.authorized("/samples/")
|
| 615 |
+
or (urlsplit(self.path).path == "/status" and self.relay_authorized())):
|
| 616 |
try:
|
| 617 |
return self.reply(200, monitor.status())
|
| 618 |
except Exception:
|
|
|
|
| 622 |
def do_POST(self):
|
| 623 |
if not self.authorized("/medialogia/"):
|
| 624 |
return self.reply(404, {"error": "not_found"})
|
|
|
|
|
|
|
| 625 |
try:
|
| 626 |
size = int(self.headers["Content-Length"])
|
| 627 |
except (ValueError, TypeError):
|
|
|
|
| 655 |
app = Monitor(Config.from_env())
|
| 656 |
server = make_server(app, port=int(os.getenv("PORT", "7860")))
|
| 657 |
app.start()
|
| 658 |
+
LOG.info("Monitor started; storage=%s; missing=%s", "postgresql" if app.config.database_url else "sqlite", app.config.missing())
|
| 659 |
|
| 660 |
def shutdown(*_args):
|
| 661 |
app.stop.set()
|
|
|
|
| 667 |
server.serve_forever()
|
| 668 |
finally:
|
| 669 |
app.stop.set()
|
| 670 |
+
deadline = time.monotonic() + 75
|
| 671 |
+
for thread in app.threads:
|
| 672 |
+
thread.join(timeout=max(0, deadline - time.monotonic()))
|
| 673 |
server.server_close()
|
| 674 |
|
telegram-relay/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Telegram delivery relay
|
| 2 |
+
|
| 3 |
+
This optional Worker forwards the monitor's five Bot API methods to Telegram.
|
| 4 |
+
It requires its own shared secret and accepts chat operations only for one
|
| 5 |
+
numeric chat ID. It does not accept arbitrary destination URLs or bot tokens.
|
| 6 |
+
Request logging is disabled. Do not enable logging of headers or request bodies.
|
| 7 |
+
|
| 8 |
+
Use the Cloudflare Workers Free plan. Its quotas apply; this is not a guarantee
|
| 9 |
+
of continuous delivery or permanent availability from Hugging Face. HF sleeping
|
| 10 |
+
and temporary SQLite storage remain separate limitations.
|
| 11 |
+
|
| 12 |
+
After signing into a free Cloudflare account:
|
| 13 |
+
|
| 14 |
+
```
|
| 15 |
+
npx wrangler login
|
| 16 |
+
npx wrangler deploy
|
| 17 |
+
npx wrangler secret put TELEGRAM_BOT_TOKEN
|
| 18 |
+
npx wrangler secret put TELEGRAM_CHAT_ID
|
| 19 |
+
npx wrangler secret put RELAY_SECRET
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
Run these commands from this directory. Enter values interactively, never as
|
| 23 |
+
command-line arguments. Use the same bot token and numeric chat ID as the
|
| 24 |
+
monitor. Generate a random shared secret with at least 32 URL-safe characters.
|
| 25 |
+
Store the same secret in HF as `TELEGRAM_RELAY_SECRET` and the deployed HTTPS
|
| 26 |
+
Worker URL ending in `/telegram` as `TELEGRAM_RELAY_URL`. Existing Telegram
|
| 27 |
+
settings remain present in the Space. Never use an anonymous third-party proxy.
|
| 28 |
+
|
| 29 |
+
Before changing the running Space, verify getMe, getChat and getChatMember via
|
| 30 |
+
the deployed Worker. Preserve or explicitly handle the pending temporary queue
|
| 31 |
+
before rebuilding HF. After updating, rerun the Space's authenticated diagnostics
|
| 32 |
+
and confirm an actual publication arrives in Telegram.
|
telegram-relay/worker.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const allowedMethods = new Set(["getMe", "getChat", "getChatMember", "sendMessage", "editMessageText"]);
|
| 2 |
+
const maxBytes = 65536;
|
| 3 |
+
|
| 4 |
+
function reply(status, description) {
|
| 5 |
+
return Response.json({ ok: false, description }, {
|
| 6 |
+
status,
|
| 7 |
+
headers: { "Cache-Control": "no-store" },
|
| 8 |
+
});
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
async function authorized(actual, expected) {
|
| 12 |
+
const encoder = new TextEncoder();
|
| 13 |
+
const [actualHash, expectedHash] = await Promise.all([
|
| 14 |
+
crypto.subtle.digest("SHA-256", encoder.encode(actual)),
|
| 15 |
+
crypto.subtle.digest("SHA-256", encoder.encode(expected)),
|
| 16 |
+
]);
|
| 17 |
+
const actualBytes = new Uint8Array(actualHash);
|
| 18 |
+
const expectedBytes = new Uint8Array(expectedHash);
|
| 19 |
+
let difference = 0;
|
| 20 |
+
for (let index = 0; index < actualBytes.length; index += 1) {
|
| 21 |
+
difference |= actualBytes[index] ^ expectedBytes[index];
|
| 22 |
+
}
|
| 23 |
+
return difference === 0;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
async function readBody(request) {
|
| 27 |
+
if (!request.body) throw new Error("empty_body");
|
| 28 |
+
const reader = request.body.getReader();
|
| 29 |
+
const chunks = [];
|
| 30 |
+
let size = 0;
|
| 31 |
+
while (true) {
|
| 32 |
+
const { value, done } = await reader.read();
|
| 33 |
+
if (done) break;
|
| 34 |
+
size += value.byteLength;
|
| 35 |
+
if (size > maxBytes) {
|
| 36 |
+
await reader.cancel();
|
| 37 |
+
throw new Error("payload_too_large");
|
| 38 |
+
}
|
| 39 |
+
chunks.push(value);
|
| 40 |
+
}
|
| 41 |
+
const body = new Uint8Array(size);
|
| 42 |
+
let offset = 0;
|
| 43 |
+
for (const chunk of chunks) {
|
| 44 |
+
body.set(chunk, offset);
|
| 45 |
+
offset += chunk.byteLength;
|
| 46 |
+
}
|
| 47 |
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export default {
|
| 51 |
+
async fetch(request, environment) {
|
| 52 |
+
const url = new URL(request.url);
|
| 53 |
+
if (url.pathname !== "/telegram" || url.search || request.method !== "POST") {
|
| 54 |
+
return reply(404, "not_found");
|
| 55 |
+
}
|
| 56 |
+
if (!/^\d+:[A-Za-z0-9_-]+$/.test(environment.TELEGRAM_BOT_TOKEN || "")
|
| 57 |
+
|| !/^[A-Za-z0-9_-]{32,}$/.test(environment.RELAY_SECRET || "")
|
| 58 |
+
|| !/^-?\d+$/.test(environment.TELEGRAM_CHAT_ID || "")) {
|
| 59 |
+
return reply(503, "configuration_required");
|
| 60 |
+
}
|
| 61 |
+
if (!await authorized(request.headers.get("Authorization") || "", "Bearer " + environment.RELAY_SECRET)) {
|
| 62 |
+
return reply(401, "unauthorized");
|
| 63 |
+
}
|
| 64 |
+
let input;
|
| 65 |
+
try {
|
| 66 |
+
input = await readBody(request);
|
| 67 |
+
} catch (error) {
|
| 68 |
+
return reply(error.message === "payload_too_large" ? 413 : 400, "invalid_payload");
|
| 69 |
+
}
|
| 70 |
+
if (!input || typeof input !== "object" || !allowedMethods.has(input.method)
|
| 71 |
+
|| !input.payload || typeof input.payload !== "object" || Array.isArray(input.payload)) {
|
| 72 |
+
return reply(400, "invalid_method_or_payload");
|
| 73 |
+
}
|
| 74 |
+
if (input.method === "getMe") {
|
| 75 |
+
input.payload = {};
|
| 76 |
+
} else if (String(input.payload.chat_id) !== environment.TELEGRAM_CHAT_ID) {
|
| 77 |
+
return reply(403, "chat_not_allowed");
|
| 78 |
+
}
|
| 79 |
+
try {
|
| 80 |
+
const response = await fetch(
|
| 81 |
+
"https://api.telegram.org/bot" + environment.TELEGRAM_BOT_TOKEN + "/" + input.method,
|
| 82 |
+
{
|
| 83 |
+
method: "POST",
|
| 84 |
+
headers: { "Content-Type": "application/json" },
|
| 85 |
+
body: JSON.stringify(input.payload),
|
| 86 |
+
redirect: "manual",
|
| 87 |
+
signal: AbortSignal.timeout(8000),
|
| 88 |
+
},
|
| 89 |
+
);
|
| 90 |
+
if (response.status >= 300 && response.status < 400) return reply(502, "telegram_unexpected_redirect");
|
| 91 |
+
const body = await response.text();
|
| 92 |
+
let data;
|
| 93 |
+
try {
|
| 94 |
+
data = JSON.parse(body);
|
| 95 |
+
} catch {
|
| 96 |
+
return reply(502, "telegram_upstream_non_json_http_" + response.status);
|
| 97 |
+
}
|
| 98 |
+
if (!data || typeof data.ok !== "boolean") return reply(502, "telegram_invalid_response");
|
| 99 |
+
return new Response(body, {
|
| 100 |
+
status: response.status,
|
| 101 |
+
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
| 102 |
+
});
|
| 103 |
+
} catch (error) {
|
| 104 |
+
if (typeof AbortSignal.timeout !== "function") return reply(502, "telegram_timeout_api_unavailable");
|
| 105 |
+
if (String(error.message).includes("Network connection lost")) return reply(502, "telegram_network_connection_lost");
|
| 106 |
+
if (String(error.message).includes("resolve")) return reply(502, "telegram_dns_error");
|
| 107 |
+
const category = ["TimeoutError", "AbortError", "TypeError"].includes(error.name) ? error.name : "unknown";
|
| 108 |
+
return reply(502, "telegram_upstream_" + category);
|
| 109 |
+
}
|
| 110 |
+
},
|
| 111 |
+
};
|
telegram-relay/wrangler.toml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name = "pranalytics-telegram-relay"
|
| 2 |
+
main = "worker.mjs"
|
| 3 |
+
compatibility_date = "2026-10-23"
|
| 4 |
+
workers_dev = true
|
| 5 |
+
preview_urls = false
|
| 6 |
+
|
| 7 |
+
[observability]
|
| 8 |
+
enabled = false
|