From 54a8dcb28380f87cec51b46bdb3231ba433e01a3 Mon Sep 17 00:00:00 2001 From: alisaza Date: Sun, 13 Sep 2026 13:38:36 +0330 Subject: [PATCH] =?UTF-8?q?Add=20initial=20Telegram=20relay=20service=20fo?= =?UTF-8?q?r=20Iran=E2=86=92foreign=20delivery.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin Hono API so the Iran-hosted backend can POST ops alerts here and this VPS calls Telegram Bot API. --- .env.example | 17 ++ .gitignore | 7 + Dockerfile | 25 ++ README.md | 99 ++++++++ docker-compose.yml | 20 ++ package-lock.json | 604 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 27 ++ src/app.test.ts | 46 ++++ src/app.ts | 93 +++++++ src/config.ts | 68 +++++ src/index.ts | 19 ++ src/telegram.ts | 74 ++++++ tsconfig.json | 19 ++ 13 files changed, 1118 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/app.test.ts create mode 100644 src/app.ts create mode 100644 src/config.ts create mode 100644 src/index.ts create mode 100644 src/telegram.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dd9c748 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# HTTP +PORT=3100 +HOST=0.0.0.0 + +# Shared secret between Iran backend and this relay (≥32 chars). +# Backend must send header: X-Ghabilee-Telegram-Relay-Secret +RELAY_SECRET=change-me-to-a-long-random-secret-at-least-32 + +# Telegram Bot API (same bot used by Ghabilee ops alerts) +TELEGRAM_BOT_TOKEN= + +# Default destination: prefer group; private chat is fallback +TELEGRAM_GROUP_CHAT_ID= +TELEGRAM_CHAT_ID= + +# Optional default forum topic (ops). Callers may override per request. +TELEGRAM_GROUP_THREAD_ID= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96a0b60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.env +.env.local +*.log +.DS_Store +coverage/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..72ca952 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1 + +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev + +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +RUN addgroup -S relay && adduser -S relay -G relay +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY package.json ./ +USER relay +EXPOSE 3100 +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bdcaa4 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# Ghabilee Telegram Relay + +Thin HTTP service that lives on a **foreign** VPS and forwards ops alerts to +Telegram Bot API. The Iran-hosted Nest backend calls this relay instead of +`api.telegram.org` directly (which is often unreachable from inside Iran). + +Repo: + +## Why + +- Core product (Jibit, Kavenegar, Postgres) stays in Iran. +- Only Telegram delivery needs outbound access to `api.telegram.org`. +- Failures here must not break user flows (backend already treats most alerts as best-effort). + +## API + +### `GET /health` + +Liveness for Docker/load balancers. No auth. + +### `POST /v1/send` + +Auth header (required): + +```http +X-Ghabilee-Telegram-Relay-Secret: +``` + +Body (JSON): + +| Field | Type | Required | Notes | +| ----- | ---- | -------- | ----- | +| `text` | string | yes | 1–4096 chars (Telegram limit) | +| `chatId` | string | no | Override default chat from env | +| `messageThreadId` | number \| null | no | Forum topic; `null` = no topic; omit = env default | +| `disableWebPagePreview` | boolean | no | Default `true` | + +Success: + +```json +{ "ok": true, "messageId": 123 } +``` + +Errors: `401` unauthorized, `400` validation, `502` Telegram API failure. + +### Example + +```bash +curl -sS -X POST "https://relay.example.com/v1/send" \ + -H "Content-Type: application/json" \ + -H "X-Ghabilee-Telegram-Relay-Secret: $RELAY_SECRET" \ + -d '{"text":"hello from relay","messageThreadId":8}' +``` + +## Local run + +```bash +cp .env.example .env +# fill TELEGRAM_BOT_TOKEN, chat ids, RELAY_SECRET (≥32 chars) + +npm install +npm run dev +``` + +Tests / typecheck: + +```bash +npm test +npm run typecheck +``` + +## Docker + +```bash +cp .env.example .env +docker compose up -d --build +``` + +## Env + +See [`.env.example`](.env.example). Production needs: + +- `RELAY_SECRET` (≥32) +- `TELEGRAM_BOT_TOKEN` +- `TELEGRAM_GROUP_CHAT_ID` (preferred) or `TELEGRAM_CHAT_ID` +- optional `TELEGRAM_GROUP_THREAD_ID` (ops forum topic) + +## Backend integration (next step) + +In `ghabilee-backend` `OpsAlertsService.sendTelegram`, when +`TELEGRAM_RELAY_URL` is set, POST to `{TELEGRAM_RELAY_URL}/v1/send` with the +shared secret instead of calling Telegram directly. Keep a direct-Telegram +fallback for local/dev if useful. + +## Security notes + +- Do **not** put the secret in query strings (access logs). +- Expose only HTTPS on the foreign VPS (Caddy/Nginx + Let’s Encrypt). +- Firewall: ideally allow only the Iran VPS egress IP to hit `/v1/send`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d1c3bda --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + telegram-relay: + build: . + ports: + - '${PORT:-3100}:3100' + env_file: + - .env + restart: unless-stopped + healthcheck: + test: + [ + 'CMD', + 'wget', + '-qO-', + 'http://127.0.0.1:3100/health', + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ebcd264 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,604 @@ +{ + "name": "ghabilee-telegram-relay", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ghabilee-telegram-relay", + "version": "0.1.0", + "dependencies": { + "@hono/node-server": "^1.14.4", + "hono": "^4.7.11", + "zod": "^3.25.67" + }, + "devDependencies": { + "@types/node": "^22.15.32", + "tsx": "^4.20.3", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hono": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d6e405c --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "ghabilee-telegram-relay", + "version": "0.1.0", + "private": true, + "description": "External Telegram sendMessage relay for Ghabilee ops alerts (Iran backend → foreign VPS).", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "tsx --test src/**/*.test.ts" + }, + "dependencies": { + "@hono/node-server": "^1.14.4", + "hono": "^4.7.11", + "zod": "^3.25.67" + }, + "devDependencies": { + "@types/node": "^22.15.32", + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} diff --git a/src/app.test.ts b/src/app.test.ts new file mode 100644 index 0000000..be391ba --- /dev/null +++ b/src/app.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { createApp, RELAY_SECRET_HEADER } from './app.js' +import type { RelayConfig } from './config.js' + +const baseConfig: RelayConfig = { + port: 3100, + host: '127.0.0.1', + relaySecret: 'a'.repeat(32), + telegramBotToken: 'test-token', + defaultChatId: '-100123', + defaultThreadId: 8, +} + +describe('POST /v1/send', () => { + it('rejects missing secret', async () => { + const app = createApp(baseConfig) + const res = await app.request('/v1/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'hello' }), + }) + assert.equal(res.status, 401) + }) + + it('rejects short/invalid body', async () => { + const app = createApp(baseConfig) + const res = await app.request('/v1/send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [RELAY_SECRET_HEADER]: baseConfig.relaySecret, + }, + body: JSON.stringify({ text: '' }), + }) + assert.equal(res.status, 400) + }) + + it('returns health', async () => { + const app = createApp(baseConfig) + const res = await app.request('/health') + assert.equal(res.status, 200) + const body = (await res.json()) as { ok: boolean } + assert.equal(body.ok, true) + }) +}) diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..85283b7 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,93 @@ +import { Hono } from 'hono' +import { timingSafeEqual } from 'node:crypto' +import type { RelayConfig } from './config.js' +import { + SendMessageBodySchema, + TelegramApiError, + sendTelegramMessage, +} from './telegram.js' + +export const RELAY_SECRET_HEADER = 'x-ghabilee-telegram-relay-secret' + +function secretsEqual(expected: string, provided: string): boolean { + const a = Buffer.from(expected) + const b = Buffer.from(provided) + if (a.length !== b.length) return false + return timingSafeEqual(a, b) +} + +export function createApp(config: RelayConfig) { + const app = new Hono() + + app.get('/health', (c) => + c.json({ + ok: true, + service: 'ghabilee-telegram-relay', + }), + ) + + app.post('/v1/send', async (c) => { + const provided = c.req.header(RELAY_SECRET_HEADER)?.trim() ?? '' + if (!provided || !secretsEqual(config.relaySecret, provided)) { + return c.json({ ok: false, error: 'unauthorized' }, 401) + } + + let json: unknown + try { + json = await c.req.json() + } catch { + return c.json({ ok: false, error: 'invalid_json' }, 400) + } + + const parsed = SendMessageBodySchema.safeParse(json) + if (!parsed.success) { + return c.json( + { + ok: false, + error: 'validation_failed', + details: parsed.error.flatten(), + }, + 400, + ) + } + + const body = parsed.data + const chatId = body.chatId ?? config.defaultChatId + + let messageThreadId: number | undefined + if (body.messageThreadId === null) { + messageThreadId = undefined + } else if (body.messageThreadId !== undefined) { + messageThreadId = body.messageThreadId + } else { + messageThreadId = config.defaultThreadId + } + + try { + const result = await sendTelegramMessage({ + botToken: config.telegramBotToken, + chatId, + text: body.text, + ...(messageThreadId !== undefined ? { messageThreadId } : {}), + disableWebPagePreview: body.disableWebPagePreview, + }) + return c.json({ ok: true, messageId: result.messageId }) + } catch (error) { + if (error instanceof TelegramApiError) { + console.error( + `Telegram sendMessage failed: status=${error.status} ${error.detail}`, + ) + return c.json( + { ok: false, error: 'telegram_delivery_failed' }, + 502, + ) + } + console.error('Unexpected send failure', error) + return c.json({ ok: false, error: 'internal_error' }, 500) + } + }) + + app.notFound((c) => c.json({ ok: false, error: 'not_found' }, 404)) + + return app +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..cff8681 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,68 @@ +export interface RelayConfig { + port: number + host: string + relaySecret: string + telegramBotToken: string + defaultChatId: string + defaultThreadId: number | undefined +} + +function required(name: string, value: string | undefined): string { + const trimmed = value?.trim() + if (!trimmed) { + throw new Error(`Missing required env: ${name}`) + } + return trimmed +} + +function optional(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed && trimmed.length > 0 ? trimmed : undefined +} + +function parseThreadId(raw: string | undefined): number | undefined { + const value = optional(raw) + if (!value) return undefined + // Tolerate "8 # اوتومیشن" if someone pastes an inline comment. + const withoutComment = value.split('#')[0]?.trim() ?? '' + const threadId = Number(withoutComment) + if (!Number.isInteger(threadId) || threadId < 1) { + throw new Error('TELEGRAM_GROUP_THREAD_ID must be a positive integer') + } + return threadId +} + +/** + * Load and validate process env. Call once at boot. + * Telegram token/chat may be empty only in health-only smoke; production requires them. + */ +export function loadConfig(env: NodeJS.ProcessEnv = process.env): RelayConfig { + const relaySecret = required('RELAY_SECRET', env.RELAY_SECRET) + if (relaySecret.length < 32) { + throw new Error('RELAY_SECRET must be at least 32 characters') + } + + const groupChatId = optional(env.TELEGRAM_GROUP_CHAT_ID) + const privateChatId = optional(env.TELEGRAM_CHAT_ID) + const defaultChatId = groupChatId ?? privateChatId + if (!defaultChatId) { + throw new Error( + 'Set TELEGRAM_GROUP_CHAT_ID or TELEGRAM_CHAT_ID as the default destination', + ) + } + + const portRaw = optional(env.PORT) ?? '3100' + const port = Number(portRaw) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('PORT must be an integer between 1 and 65535') + } + + return { + port, + host: optional(env.HOST) ?? '0.0.0.0', + relaySecret, + telegramBotToken: required('TELEGRAM_BOT_TOKEN', env.TELEGRAM_BOT_TOKEN), + defaultChatId, + defaultThreadId: parseThreadId(env.TELEGRAM_GROUP_THREAD_ID), + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..345e221 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,19 @@ +import { serve } from '@hono/node-server' +import { createApp } from './app.js' +import { loadConfig } from './config.js' + +const config = loadConfig() +const app = createApp(config) + +serve( + { + fetch: app.fetch, + port: config.port, + hostname: config.host, + }, + (info) => { + console.log( + `ghabilee-telegram-relay listening on http://${info.address}:${info.port}`, + ) + }, +) diff --git a/src/telegram.ts b/src/telegram.ts new file mode 100644 index 0000000..d81eab4 --- /dev/null +++ b/src/telegram.ts @@ -0,0 +1,74 @@ +import { z } from 'zod' + +/** Telegram Bot API sendMessage text limit. */ +export const TELEGRAM_TEXT_MAX = 4096 + +export const SendMessageBodySchema = z.object({ + text: z.string().trim().min(1).max(TELEGRAM_TEXT_MAX), + /** Override default chat from env (group or private). */ + chatId: z.string().trim().min(1).optional(), + /** Forum topic id; omit to use env default (if any). Pass null to force no topic. */ + messageThreadId: z.number().int().positive().nullable().optional(), + disableWebPagePreview: z.boolean().optional().default(true), +}) + +export type SendMessageBody = z.infer + +export interface TelegramSendResult { + ok: true + messageId: number +} + +export class TelegramApiError extends Error { + constructor( + readonly status: number, + readonly detail: string, + ) { + super(`Telegram sendMessage failed: status=${status}`) + this.name = 'TelegramApiError' + } +} + +export async function sendTelegramMessage(input: { + botToken: string + chatId: string + text: string + messageThreadId?: number + disableWebPagePreview: boolean + fetchImpl?: typeof fetch +}): Promise { + const fetchImpl = input.fetchImpl ?? fetch + const payload: Record = { + chat_id: input.chatId, + text: input.text, + disable_web_page_preview: input.disableWebPagePreview, + } + if (input.messageThreadId !== undefined) { + payload.message_thread_id = input.messageThreadId + } + + const response = await fetchImpl( + `https://api.telegram.org/bot${input.botToken}/sendMessage`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + ) + + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new TelegramApiError(response.status, detail.slice(0, 300)) + } + + const json = (await response.json()) as { + ok?: boolean + result?: { message_id?: number } + } + const messageId = json.result?.message_id + if (!json.ok || typeof messageId !== 'number') { + throw new TelegramApiError(502, 'Unexpected Telegram API response') + } + + return { ok: true, messageId } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3ebbe1c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +}