Fix Telegram delivery with an authenticated free-tier relay

#1
by Dink0 - opened
.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
- DEEPSEEK_API_KEY and ALERT_MENTIONS. CLASSIFIER_PROMPT overrides the generic
 
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.1-diagnostics"
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", **(headers or {})}, method="POST")
 
 
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 = request_json(
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
- result = request_json("https://api.telegram.org/bot" + self.config.telegram_token + "/" + method,
427
- data, service="telegram", timeout=35)
428
- if not isinstance(result, dict) or not result.get("ok"):
429
- raise ServiceError("telegram_invalid_response")
 
 
 
 
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
- if not self.config.missing():
513
- for lane in ("ai", "tg"):
 
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 "temporary_sqlite",
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
- missing = monitor.config.missing()
552
- return self.reply(200, {"status": "configuration_required" if missing else "monitor_ready",
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/"):
 
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 "temporary_sqlite", app.config.missing())
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