Spaces:
Running
Running
Add an authenticated free-tier Telegram relay instead of paid hosting
Browse files- .dockerignore +5 -0
- .gitignore +5 -0
- Dockerfile +0 -1
- README.md +15 -22
- diagnose_telegram.py +65 -0
- receiver.py +26 -6
- render.yaml +0 -25
- telegram-relay/README.md +32 -0
- telegram-relay/worker.mjs +101 -0
- telegram-relay/wrangler.toml +8 -0
.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 |
-
##
|
| 57 |
|
| 58 |
-
The
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 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 |
-
|
| 71 |
-
|
| 72 |
-
|
| 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 =
|
| 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 =
|
| 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
|