Add initial Telegram relay service for Iran→foreign delivery.

Thin Hono API so the Iran-hosted backend can POST ops alerts here and this VPS calls Telegram Bot API.
This commit is contained in:
alisaza 2026-09-13 13:38:36 +03:30
commit 54a8dcb283
13 changed files with 1118 additions and 0 deletions

17
.env.example Normal file
View File

@ -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=

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules/
dist/
.env
.env.local
*.log
.DS_Store
coverage/

25
Dockerfile Normal file
View File

@ -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"]

99
README.md Normal file
View File

@ -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: <https://git.ghabilee.ir/AliSaZa/telegrambot.git>
## 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: <RELAY_SECRET>
```
Body (JSON):
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| `text` | string | yes | 14096 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 + Lets Encrypt).
- Firewall: ideally allow only the Iran VPS egress IP to hit `/v1/send`.

20
docker-compose.yml Normal file
View File

@ -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

604
package-lock.json generated Normal file
View File

@ -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"
}
}
}
}

27
package.json Normal file
View File

@ -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"
}
}

46
src/app.test.ts Normal file
View File

@ -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)
})
})

93
src/app.ts Normal file
View File

@ -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
}

68
src/config.ts Normal file
View File

@ -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),
}
}

19
src/index.ts Normal file
View File

@ -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}`,
)
},
)

74
src/telegram.ts Normal file
View File

@ -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<typeof SendMessageBodySchema>
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<TelegramSendResult> {
const fetchImpl = input.fetchImpl ?? fetch
const payload: Record<string, unknown> = {
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 }
}

19
tsconfig.json Normal file
View File

@ -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"]
}