Dink0 commited on
Commit
92cad08
·
verified ·
1 Parent(s): 4b3530b

Add an authenticated free-tier Telegram relay instead of paid hosting

Browse files
.dockerignore CHANGED
@@ -6,3 +6,8 @@ __pycache__
6
  .env.*
7
  *.sqlite3
8
  *.sqlite3-*
 
 
 
 
 
 
6
  .env.*
7
  *.sqlite3
8
  *.sqlite3-*
9
+ .local-*
10
+ .wrangler
11
+ node_modules
12
+ .dev.vars
13
+ .dev.vars.*
.gitignore CHANGED
@@ -5,3 +5,8 @@ __pycache__/
5
  .env.*
6
  *.sqlite3
7
  *.sqlite3-*
 
 
 
 
 
 
5
  .env.*
6
  *.sqlite3
7
  *.sqlite3-*
8
+ .local-*
9
+ .wrangler/
10
+ node_modules/
11
+ .dev.vars
12
+ .dev.vars.*
Dockerfile CHANGED
@@ -1,7 +1,6 @@
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
 
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
README.md CHANGED
@@ -53,28 +53,21 @@ is available. `ALERT_MENTIONS` is optional. Valid authenticated webhooks are sto
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.
 
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
+ See `telegram-relay/README.md` for deployment and secret configuration. Verify
67
+ the relay's read-only getMe, getChat and getChatMember checks before rebuilding
68
+ the Space, then verify an actual publication arrives after deployment. Neither
69
+ the bot token nor the shared secret belongs in source control.
 
 
70
 
71
+ The Worker does not fix HF sleeping or the temporary queue. Do not rebuild the
72
+ running Space until pending publications have been recovered or their loss and
73
+ 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
@@ -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):
@@ -354,6 +368,15 @@ class Clients:
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."""
359
  if not self.diagnostics_lock.acquire(blocking=False):
@@ -369,9 +392,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"]
@@ -424,8 +445,7 @@ class Clients:
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:
 
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):
 
368
  self.diagnostics_lock = threading.Lock()
369
  self.telegram_health = {"status": "not_checked"}
370
 
371
+ def telegram_request(self, method, payload, timeout):
372
+ if self.config.telegram_relay_url:
373
+ return request_json(self.config.telegram_relay_url,
374
+ {"method": method, "payload": payload},
375
+ {"Authorization": "Bearer " + self.config.telegram_relay_secret},
376
+ service="telegram", timeout=timeout)
377
+ return request_json("https://api.telegram.org/bot" + self.config.telegram_token + "/" + method,
378
+ payload, service="telegram", timeout=timeout)
379
+
380
  def diagnose_telegram(self):
381
  """Read-only Bot API checks; no publication, queue changes or AI calls."""
382
  if not self.diagnostics_lock.acquire(blocking=False):
 
392
  payload["user_id"] = bot_id
393
  started = time.monotonic()
394
  try:
395
+ result = self.telegram_request(method, payload, timeout=10)
 
 
396
  if not result.get("ok") or not isinstance(result.get("result"), dict):
397
  raise ServiceError("telegram_invalid_response")
398
  data = result["result"]
 
445
  if method == "sendMessage" and self.config.thread_id:
446
  data["message_thread_id"] = int(self.config.thread_id)
447
  try:
448
+ result = self.telegram_request(method, data, timeout=35)
 
449
  if not isinstance(result, dict) or not result.get("ok"):
450
  raise ServiceError("telegram_invalid_response")
451
  except ServiceError as exc:
render.yaml DELETED
@@ -1,25 +0,0 @@
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: "error",
87
+ signal: AbortSignal.timeout(8000),
88
+ },
89
+ );
90
+ const body = await response.text();
91
+ const data = JSON.parse(body);
92
+ if (!data || typeof data.ok !== "boolean") return reply(502, "telegram_invalid_response");
93
+ return new Response(body, {
94
+ status: response.status,
95
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
96
+ });
97
+ } catch {
98
+ return reply(502, "telegram_upstream_unavailable");
99
+ }
100
+ },
101
+ };
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