feat(gateway): include production Telegram user gateway

Version the NestJS TDLib gateway with Docker deployment, migrations, automated tests and platform integration documentation while excluding all account sessions, credentials, runtime databases and backups.
This commit is contained in:
wmq
2026-08-01 18:38:20 +08:00
parent af49d22ca7
commit 7f53406cd6
142 changed files with 23107 additions and 7 deletions
+2
View File
@@ -28,6 +28,8 @@ storage/
*.sqlite3
*.dump
*.sql
*.rdb
*.binlog
# Test and diagnostic output
playwright-report/
+13
View File
@@ -2,6 +2,19 @@
本项目遵循语义化版本。早期版本由经过脱敏的本地源码快照恢复,因此使用带日期的 `snapshot-*` 标签;自 `v1.0.0` 起按正式发布流程维护。
## [1.1.0] - 2026-08-01
### 新增
- 将完整 Telegram User Gateway 源码纳入 `telegram-gateway/`,平台与网关可以按同一 Git 版本追踪。
- 提交 NestJS、TDLib、PostgreSQL、Redis、BullMQ、Webhook、限流、审计、代理、会话、消息及群组相关实现。
- 增加 Telegram Gateway 独立 Docker 部署、平台连接、健康检查、凭据隔离和验证说明。
### 安全
- 排除 `.env`、TDLib 登录态、数据库、Redis 备份、上传文件、运行日志与真实账号调试脚本。
- 强化仓库忽略规则,防止后续误提交 `.binlog`、`.rdb` 和会话数据。
## [1.0.0] - 2026-07-30
### 新增
+9 -2
View File
@@ -2,7 +2,7 @@
灵犀跨境是一套面向跨境业务团队的多渠道客户经营 SaaS。系统将 Telegram、普通 WhatsApp 账号和企业邮箱接入统一消息中心,并提供上下文翻译、逐句意图识别、情绪与风险分析、客户画像、商机、任务、知识库和经营分析能力。
当前仓库包含 Web 前端、业务 API 和 WhatsApp Gateway;Telegram 通过独立部署的 Telegram Gateway 接入。开放功能均连接真实数据库和真实网关,不使用前端 Mock 作为生产数据源。
当前仓库包含 Web 前端、业务 API、Telegram Gateway 和 WhatsApp Gateway。Telegram Gateway 源码位于 `telegram-gateway/`,运行时仍作为独立服务部署。开放功能均连接真实数据库和真实网关,不使用前端 Mock 作为生产数据源。
## 核心能力
@@ -16,7 +16,7 @@
## 快速启动
要求:Docker Engine 24+、Docker Compose v2,以及一个可访问的 Telegram Gateway。
要求:Docker Engine 24+、Docker Compose v2。首次部署应先按 [Telegram Gateway 接入指南](docs/TELEGRAM-GATEWAY.md) 启动网关,再启动平台。
```powershell
Copy-Item .env.example .env
@@ -51,6 +51,12 @@ npm ci
npm test
Pop-Location
Push-Location telegram-gateway
npm ci
npm run build
npm test
Pop-Location
npm run test:e2e
```
@@ -63,6 +69,7 @@ E2E 默认访问 `http://127.0.0.1:8088`,可通过 `E2E_BASE_URL` 修改。
- [配置说明](docs/CONFIGURATION.md)
- [部署与回滚](docs/DEPLOYMENT.md)
- [接口目录](docs/API.md)
- [Telegram Gateway 接入](docs/TELEGRAM-GATEWAY.md)
- [测试策略](docs/TESTING.md)
- [运维手册](docs/OPERATIONS.md)
- [备份恢复](docs/BACKUP-RESTORE.md)
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.1.0
+2 -1
View File
@@ -25,6 +25,7 @@ Nginx (web:80)
| --- | --- |
| `src/` | React 页面、组件、状态、API 客户端、国际化与类型 |
| `server/src/index.js` | 认证、租户业务、消息、AI、渠道、Webhook、WebSocket 与数据初始化 |
| `telegram-gateway/` | NestJS + TDLib 多租户 Telegram 普通账号网关,独立部署 |
| `whatsapp-gateway/` | 普通 WhatsApp 多账号连接、消息/媒体/群组/联系人操作 |
| `tests/e2e/` | Playwright 桌面与移动端验收 |
| `docker/` | Nginx 与邮件协议测试辅助镜像 |
@@ -74,6 +75,6 @@ Nginx (web:80)
## 6. 当前约束
- Telegram Gateway 为独立仓库和独立部署,本仓库只保存适配与配置。
- Telegram Gateway 源码随本仓库版本化,但使用独立 Compose、数据库、Redis 和 TDLib 数据卷部署。
- 普通 WhatsApp 接入依赖非官方 Web 多设备协议,生产使用前必须评估账号风控和平台条款。
- API 目前集中在单入口文件,业务增长后应按领域拆分路由、服务、仓储和迁移文件。
+10
View File
@@ -12,6 +12,14 @@
```bash
git clone ssh://git@git.ohkeji.cn:23/wmq/lingxi-web.git
cd lingxi-web
# 先配置并启动 Telegram Gateway
cp telegram-gateway/.env.example telegram-gateway/.env
# 编辑 telegram-gateway/.env,配置 TG_API_ID、TG_API_HASH 和强随机密钥
docker compose --env-file telegram-gateway/.env \
-f telegram-gateway/docker/docker-compose.yml up -d --build
# 再启动灵犀跨境平台
cp .env.example .env
# 编辑 .env 并替换全部占位值
docker compose --env-file .env build
@@ -27,6 +35,8 @@ docker compose exec api node -e "fetch('http://127.0.0.1:3100/health/ready').the
docker compose logs --tail=100 api whatsapp-gateway web
```
平台的 `TELEGRAM_GATEWAY_URL` 指向网关 `/api` 地址。生产环境应通过私有网络连接,不把 Gateway 管理接口直接暴露公网。详细配置见 [Telegram Gateway 接入指南](TELEGRAM-GATEWAY.md)。
## 3. 生产入口
在 Compose 外层部署 Caddy、Nginx 或云负载均衡:
+72
View File
@@ -0,0 +1,72 @@
# Telegram Gateway 接入指南
## 定位
`telegram-gateway/` 使用 Telegram TDLib 登录普通用户账号,不是 Bot API。它负责账号授权、历史同步、消息与媒体、联系人、群组/频道、代理、会话设备、Webhook、审计、限流和异步任务;灵犀跨境 API 通过服务凭据调用它。
网关源码与平台在同一 Git 仓库版本化,但运行时保持独立:它拥有自己的 PostgreSQL、Redis 和 TDLib 持久卷,故障与扩缩容不会直接占用平台数据库。
## 配置
```powershell
Copy-Item telegram-gateway/.env.example telegram-gateway/.env
```
至少替换:
- `TG_API_ID`、`TG_API_HASH`:从 Telegram 官方开发者后台申请,不能使用 Bot Token。
- `JWT_SECRET`、`ADMIN_API_KEY`:分别生成强随机值,不得复用。
- `DB_PASSWORD`:生产数据库强密码。
- `CORS_ORIGINS`:仅填写受信任的管理入口;平台服务端调用不依赖浏览器跨域。
生产环境不要使用示例中的 `postgres` 密码。`.env` 不得提交 Git。
## 启动
```powershell
docker compose --env-file telegram-gateway/.env `
-f telegram-gateway/docker/docker-compose.yml up -d --build
docker compose -f telegram-gateway/docker/docker-compose.yml ps
Invoke-RestMethod http://127.0.0.1:3000/api/admin/health
Invoke-RestMethod http://127.0.0.1:3000/api/admin/ready
```
Swagger 默认位于 `http://127.0.0.1:3000/api/docs`。生产环境应限制 Swagger 和管理接口的访问来源。
## 连接灵犀跨境
在平台根目录 `.env` 配置:
```dotenv
TELEGRAM_GATEWAY_URL=http://host.docker.internal:3000/api
TELEGRAM_GATEWAY_TOKEN=replace-with-gateway-jwt
TELEGRAM_GATEWAY_TENANT_ID=replace-with-gateway-tenant-id
TELEGRAM_GATEWAY_SERVICE_EMAIL=replace-with-service-account
TELEGRAM_GATEWAY_SERVICE_PASSWORD=replace-with-service-password
TELEGRAM_WEBHOOK_SECRET=replace-with-independent-webhook-secret
```
Linux 生产部署建议把两个 Compose 接入同一私有 Docker 网络,并把 URL 改为网关服务名。不要通过公网传递服务密码或长期 JWT。
## 数据边界
以下内容只存在部署环境,不能进入 Git 或普通源码备份:
- `telegram-gateway/.env`
- TDLib `db.sqlite`、`td.binlog`、WAL 文件及账号目录
- PostgreSQL/Redis 导出
- 下载媒体、上传文件和业务日志
- 验证码、二步密码、手机号及授权会话
平台数据库保存渠道引用和业务消息;网关数据库保存 Telegram 操作状态。备份与恢复必须同时记录两个系统的版本和时间点。
## 验证
```powershell
npm ci --prefix telegram-gateway
npm run build --prefix telegram-gateway
npm test --prefix telegram-gateway
```
自动化测试通过后,仍需使用无重要数据的测试账号验证手机号、验证码、二步密码、历史同步、双向收发、媒体、Webhook、代理、断网重连和 FloodWait 退避。Mock/内存数据库测试不能证明真实 Telegram 可用。
+1
View File
@@ -10,6 +10,7 @@
| 邮件协议 | `npm run test:email-protocol --prefix server` | IMAP/SMTP 协议 |
| 邮件 API E2E | `npm run test:email-api --prefix server` | 真实测试邮件服务链路 |
| WhatsApp 网关 | `npm test --prefix whatsapp-gateway` | 路由、状态、消息与可靠性 |
| Telegram 网关 | `npm run build --prefix telegram-gateway`、`npm test --prefix telegram-gateway` | NestJS 编译、业务服务、路由、迁移与网关逻辑 |
| 浏览器 E2E | `npm run test:e2e` | 桌面/移动端主要用户路径 |
## 提交前最低要求
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "lingxi-crossborder-saas",
"version": "1.0.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lingxi-crossborder-saas",
"version": "1.0.0",
"version": "1.1.0",
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@tanstack/react-query": "^5.62.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "lingxi-crossborder-saas",
"private": true,
"version": "1.0.0",
"version": "1.1.0",
"type": "module",
"description": "灵犀跨境 - Telegram、WhatsApp 与企业邮箱 AI 客户经营平台",
"scripts": {
+13
View File
@@ -0,0 +1,13 @@
node_modules
dist
coverage
.env
.env.*
!.env.example
tdlib_data
uploads
data
test
*.log
.git
.claude
+46
View File
@@ -0,0 +1,46 @@
# Application
NODE_ENV=development
PORT=3000
APP_NAME=TelegramUserGateway
CORS_ORIGINS=
# PostgreSQL
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE=telegram_gateway
DB_SYNCHRONIZE=false
DB_MIGRATIONS_RUN=true
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_ENABLED=true
# JWT
JWT_SECRET=change-me-to-a-secure-random-string
JWT_EXPIRATION=24h
# TDLib
TDLIB_PATH=/usr/local/lib/libtdjson.so
TG_API_ID=0
TG_API_HASH=your_api_hash_here
# Rate Limiting
RATE_LIMIT_DEFAULT_TTL=60000
RATE_LIMIT_DEFAULT_LIMIT=30
# Webhook
WEBHOOK_MAX_RETRIES=8
WEBHOOK_RETRY_BACKOFF_BASE=5000
WEBHOOK_TIMEOUT=10000
# File Storage
FILE_STORAGE_PATH=./uploads
FILE_MAX_SIZE=209715200
# Admin
ADMIN_API_KEY=change-me-admin-key
+28
View File
@@ -0,0 +1,28 @@
node_modules/
dist/
coverage/
.env
.env.*
!.env.example
*.local
tdlib_data/
uploads/
data/
backups/
*.db
*.sqlite
*.sqlite3
*.rdb
*.binlog
*.dump
*.tar.gz
*.log
.claude/
.idea/
.vscode/
.DS_Store
Thumbs.db
test-report.html
+90
View File
@@ -0,0 +1,90 @@
# Production deployment
## Prerequisites
- Docker Desktop with the WSL 2 engine
- Telegram `TG_API_ID` and `TG_API_HASH`
- Strong, unique `DB_PASSWORD` and `JWT_SECRET`
Never commit `.env` or `tdlib_data/`. Existing Telegram API credentials that
have previously appeared in scripts should be rotated in Telegram's developer
portal before exposing this service publicly.
## Windows setup
Run the following once in an **Administrator PowerShell**, then restart Windows:
```powershell
wsl --install --no-distribution
```
After restart, open Docker Desktop and wait until the Linux engine reports that
it is running.
## Configure
Copy `.env.example` to `.env` and set at least:
```dotenv
NODE_ENV=production
DB_USERNAME=postgres
DB_PASSWORD=<strong-random-password>
DB_DATABASE=telegram_gateway
JWT_SECRET=<at-least-32-random-bytes>
TG_API_ID=<telegram-api-id>
TG_API_HASH=<telegram-api-hash>
CORS_ORIGINS=https://your-control-plane.example
```
Production uses migrations and keeps TypeORM synchronization disabled.
## Start
From the project root:
```powershell
docker compose --env-file .env -f docker/docker-compose.yml up -d --build
docker compose --env-file .env -f docker/docker-compose.yml ps
```
Readiness and API documentation:
- `GET http://127.0.0.1:3000/api/admin/ready`
- `GET http://127.0.0.1:3000/api/admin/health`
- `http://127.0.0.1:3000/api/docs`
## Verify
```powershell
npm run build
npm test -- --runInBand
npm run test:e2e -- --runInBand
node test/real-readonly-smoke.js
```
The real smoke test uses the session selected by `TG_SESSION_DIR` and performs
read-only calls only: authorization state, connection, current identity, public
chat lookup, and chat listing.
## Persistent data
Docker volumes preserve:
- PostgreSQL data
- Redis data
- uploaded files
- TDLib authorization/session databases
Back up PostgreSQL and the `tdlibdata` volume together. Losing `tdlibdata`
requires Telegram account authorization again.
## Operational behavior
- `/health` is a liveness check.
- `/ready` returns failure if PostgreSQL or Redis is unavailable.
- Telegram workers wait for native TDLib initialization before an account is
marked started.
- Active account sessions restart automatically after application restart.
- Incoming TDLib updates are stored, mapped to webhook events, and delivered
through the retry queue.
- Webhook, proxy, password, audit, message-job, and admin data are tenant scoped.
+112
View File
@@ -0,0 +1,112 @@
# Telegram User Account Gateway
A production-grade REST API + Webhook gateway for managing Telegram user accounts via TDLib (not Bot API). Supports multi-tenant, multi-account management with independent proxies, message queue, rate limiting, and audit logging.
## Architecture
- **Runtime**: Node.js 20+ with TypeScript
- **Framework**: NestJS 11.x
- **Database**: PostgreSQL 16 (TypeORM)
- **Cache/Queue**: Redis 7 + BullMQ
- **TDLib**: tdjson via worker_threads
- **Auth**: JWT (Bearer tokens)
- **API Docs**: Swagger at `/api/docs`
## Quick Start
```bash
# Copy environment config
cp .env.example .env
# Start infrastructure
docker-compose -f docker/docker-compose.yml up -d postgres redis
# Install dependencies
npm install
# Run migrations
npm run migration:run
# Start development server
npm run start:dev
```
## Docker Compose
```bash
# Start all services
docker compose --env-file .env -f docker/docker-compose.yml up -d --build
```
See [DEPLOYMENT.md](./DEPLOYMENT.md) for production setup, readiness checks,
persistent data, and Windows WSL requirements.
When used by 灵犀跨境, also read the repository-level
[integration guide](../docs/TELEGRAM-GATEWAY.md).
## API Endpoints
### Admin
- `GET /api/admin/health` - Health check (public)
- `GET /api/admin/metrics` - Application metrics
- `GET /api/admin/status` - TDLib client status
### Auth
- `POST /api/auth/register` - Register user (public)
- `POST /api/auth/login` - Login (public)
### Tenants
- `POST /api/tenants` - Create tenant
- `GET /api/tenants` - List tenants
- `GET /api/tenants/:id` - Get tenant
- `PUT /api/tenants/:id` - Update tenant
- `DELETE /api/tenants/:id` - Delete tenant
### TG Accounts
- `POST /api/tg-accounts` - Create Telegram account
- `GET /api/tg-accounts` - List accounts
- `GET /api/tg-accounts/:id` - Get account
- `DELETE /api/tg-accounts/:id` - Remove account
### TG Account Authorization
- `POST /api/tg-accounts/:id/auth/phone` - Set phone number
- `POST /api/tg-accounts/:id/auth/code` - Submit verification code
- `POST /api/tg-accounts/:id/auth/password` - Submit 2FA password
- `POST /api/tg-accounts/:id/auth/logout` - Logout
### Chats & Messages
- `GET /api/tg-accounts/:id/chats` - List chats
- `GET /api/tg-accounts/:id/chats/:chatId/history` - Chat history
- `POST /api/tg-accounts/:id/messages/send` - Send message (queued)
- `POST /api/tg-accounts/:id/messages/send-direct` - Send message (direct)
- `POST /api/tg-accounts/:id/messages/pin` - Pin a message
- `POST /api/tg-accounts/:id/messages/unpin` - Unpin a message
- `POST /api/tg-accounts/:id/messages/action` - Send typing/chat action
- `GET /api/tg-accounts/:id/messages/:chatId/:messageId/link` - Get message link
### Contacts, Sessions & Groups
- `GET /api/tg-accounts/:id/contacts/search?q=...` - Search by phone, username, or general query
- `GET /api/tg-accounts/:id/dialogs/sessions` - List active Telegram sessions
- `POST /api/tg-accounts/:id/dialogs/sessions/:sessionId/terminate` - Terminate session
- `POST /api/tg-accounts/:id/dialogs/users/:userId/block` - Block user
- `POST /api/tg-accounts/:id/dialogs/users/:userId/unblock` - Unblock user
- `POST /api/tg-accounts/:id/groups/join-by-invite-link` - Join via invite link
- `PUT /api/tg-accounts/:id/groups/:chatId/title` - Change group title
- `PUT /api/tg-accounts/:id/groups/:chatId/photo` - Change/remove group photo
### Webhooks
- `POST /api/tg-accounts/:id/webhooks` - Create webhook
- `GET /api/tg-accounts/:id/webhooks` - List webhooks
### Operations
- `GET /api/admin/audit-logs` - Query tenant-scoped audit logs
- `GET /api/admin/audit-logs/accounts/:accountId` - Query account audit logs
- `GET /api/admin/rate-limits/:accountId` - Get account rate-limit statistics
## Environment Variables
See `.env.example` for all available configuration options.
## License
MIT
+43
View File
@@ -0,0 +1,43 @@
FROM node:20-bookworm-slim AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends g++ make python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
npm ci \
--prefer-offline \
--fetch-retries=8 \
--fetch-retry-mintimeout=2000 \
--fetch-retry-maxtimeout=30000 \
--fetch-timeout=600000
COPY tsconfig.json nest-cli.json ./
COPY src/ ./src/
RUN npm run build
RUN npm prune --omit=dev
FROM node:20-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --chown=node:node --from=builder /app/dist ./dist
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
RUN mkdir -p /app/uploads /app/tdlib_data \
&& chown node:node /app/uploads /app/tdlib_data
USER node
EXPOSE 3000
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/main.js"]
+107
View File
@@ -0,0 +1,107 @@
services:
postgres:
image: postgres:16-alpine
container_name: tgw-postgres
environment:
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_DATABASE:-telegram_gateway}
volumes:
- pgdata:/var/lib/postgresql/data
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_DATABASE:-telegram_gateway}']
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: tgw-redis
volumes:
- redisdata:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 5
app:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: tgw-app
ports:
- '3000:3000'
env_file:
- ../.env
environment:
NODE_ENV: production
DB_TYPE: postgres
DB_SYNCHRONIZE: 'false'
DB_MIGRATIONS_RUN: 'true'
DB_HOST: postgres
DB_PORT: 5432
DB_USERNAME: ${DB_USERNAME:-postgres}
DB_PASSWORD: ${DB_PASSWORD}
DB_DATABASE: ${DB_DATABASE:-telegram_gateway}
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_ENABLED: 'true'
JWT_SECRET: ${JWT_SECRET}
TDLIB_PATH: /app/node_modules/@prebuilt-tdlib/linux-x64-glibc/libtdjson.so
TG_API_ID: ${TG_API_ID}
TG_API_HASH: ${TG_API_HASH}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- uploads:/app/uploads
- tdlibdata:/app/tdlib_data
restart: unless-stopped
healthcheck:
test:
[
'CMD',
'node',
'-e',
"fetch('http://127.0.0.1:3000/api/admin/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
webhook-receiver:
profiles: ['test']
image: node:20-bookworm-slim
container_name: tgw-webhook-receiver
command: ['node', '/receiver/webhook-receiver.js']
environment:
WEBHOOK_TEST_SECRET: ${WEBHOOK_TEST_SECRET:-gateway-test-secret}
WEBHOOK_FAIL_COUNT: ${WEBHOOK_FAIL_COUNT:-3}
volumes:
- ./scripts/webhook-receiver.js:/receiver/webhook-receiver.js:ro
ports:
- '18080:8080'
restart: unless-stopped
socks5-proxy:
profiles: ['test']
image: node:20-bookworm-slim
container_name: tgw-socks5-proxy
command: ['node', '/proxy/socks5-proxy.js']
volumes:
- ./scripts/socks5-proxy.js:/proxy/socks5-proxy.js:ro
ports:
- '11080:1080'
- '11081:1081'
restart: unless-stopped
volumes:
pgdata:
redisdata:
uploads:
tdlibdata:
@@ -0,0 +1,50 @@
param(
[Parameter(Mandatory = $true)]
[string]$Destination
)
$ErrorActionPreference = 'Stop'
$resolved = [System.IO.Path]::GetFullPath($Destination)
New-Item -ItemType Directory -Force -Path $resolved | Out-Null
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$backupDir = Join-Path $resolved "telegram-gateway-$timestamp"
New-Item -ItemType Directory -Force -Path $backupDir | Out-Null
docker exec tgw-postgres pg_dump -U postgres -d telegram_gateway -Fc -f /tmp/gateway.dump
docker cp tgw-postgres:/tmp/gateway.dump (Join-Path $backupDir 'postgres.dump')
docker exec tgw-redis redis-cli SAVE | Out-Null
docker cp tgw-redis:/data/dump.rdb (Join-Path $backupDir 'redis.rdb')
docker run --rm --volumes-from tgw-app -v "${backupDir}:/backup" `
node:20-bookworm-slim `
tar -czf /backup/tdlib-data.tar.gz -C /app tdlib_data uploads
$publicTables = [int](docker exec tgw-postgres psql -U postgres -d telegram_gateway -Atc `
"select count(*) from information_schema.tables where table_schema='public';")
$tenantRows = [int](docker exec tgw-postgres psql -U postgres -d telegram_gateway -Atc `
'select count(*) from tenants;')
$accountRows = [int](docker exec tgw-postgres psql -U postgres -d telegram_gateway -Atc `
'select count(*) from tg_accounts;')
$redisDbSize = [int](docker exec tgw-redis redis-cli DBSIZE)
$tdlibFileCount = [int](docker exec tgw-app sh -c `
"find /app/tdlib_data /app/uploads -type f 2>/dev/null | wc -l")
$manifest = @{
createdAt = (Get-Date).ToUniversalTime().ToString('o')
postgres = 'postgres.dump'
redis = 'redis.rdb'
tdlib = 'tdlib-data.tar.gz'
sha256 = @{}
expected = @{
publicTables = $publicTables
tenantRows = $tenantRows
accountRows = $accountRows
redisDbSize = $redisDbSize
tdlibFileCount = $tdlibFileCount
}
}
foreach ($file in @('postgres.dump', 'redis.rdb', 'tdlib-data.tar.gz')) {
$manifest.sha256[$file] = (Get-FileHash -Algorithm SHA256 (Join-Path $backupDir $file)).Hash
}
$manifest | ConvertTo-Json -Depth 4 | Set-Content -Encoding utf8 (Join-Path $backupDir 'manifest.json')
Write-Output $backupDir
@@ -0,0 +1,35 @@
#!/bin/sh
# Install TDLib shared library for the gateway
# This script downloads and installs pre-built tdlib
set -e
TDLIB_VERSION="1.8.0"
TDLIB_URL="https://github.com/tdlib/td/archive/refs/tags/v${TDLIB_VERSION}.tar.gz"
echo "Installing TDLib v${TDLIB_VERSION}..."
# Install build dependencies
apk add --no-cache \
g++ \
make \
cmake \
openssl-dev \
zlib-dev \
linux-headers \
git
# Build TDLib
cd /tmp
wget -q "${TDLIB_URL}" -O tdlib.tar.gz
tar xzf tdlib.tar.gz
cd "td-${TDLIB_VERSION}"
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release ..
cmake --build . --target tdjson -j$(nproc)
# Install
cp libtdjson.so /usr/local/lib/
cp libtdjson.so.${TDLIB_VERSION} /usr/local/lib/
echo "TDLib installed successfully to /usr/local/lib/libtdjson.so"
@@ -0,0 +1,112 @@
param(
[Parameter(Mandatory = $true)]
[string]$BackupDirectory
)
$ErrorActionPreference = 'Stop'
$source = [System.IO.Path]::GetFullPath($BackupDirectory)
$manifest = Get-Content (Join-Path $source 'manifest.json') | ConvertFrom-Json
foreach ($file in @('postgres.dump', 'redis.rdb', 'tdlib-data.tar.gz')) {
$actual = (Get-FileHash -Algorithm SHA256 (Join-Path $source $file)).Hash
if ($actual -ne $manifest.sha256.$file) {
throw "Checksum mismatch: $file"
}
}
$postgresContainer = 'tgw-restore-postgres'
$redisContainer = 'tgw-restore-redis'
$filesVolume = 'tgw_restore_files'
function Remove-TestResources {
$containerNames = @(docker ps -a --format '{{.Names}}')
if ($containerNames -contains $postgresContainer) {
docker rm -f $postgresContainer | Out-Null
}
if ($containerNames -contains $redisContainer) {
docker rm -f $redisContainer | Out-Null
}
$volumeNames = @(docker volume ls --format '{{.Name}}')
if ($volumeNames -contains $filesVolume) {
docker volume rm $filesVolume | Out-Null
}
}
try {
Remove-TestResources
docker run -d --name $postgresContainer -e POSTGRES_PASSWORD=restore-test `
-e POSTGRES_DB=telegram_gateway_restore postgres:16-alpine | Out-Null
$postgresReady = $false
for ($attempt = 0; $attempt -lt 30; $attempt++) {
docker exec $postgresContainer pg_isready -U postgres -d telegram_gateway_restore 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
$postgresReady = $true
break
}
Start-Sleep -Seconds 1
}
if (-not $postgresReady) {
throw 'Isolated PostgreSQL restore container did not become ready'
}
docker cp (Join-Path $source 'postgres.dump') "${postgresContainer}:/tmp/gateway.dump"
docker exec $postgresContainer pg_restore -U postgres -d telegram_gateway_restore /tmp/gateway.dump
$tables = [int](docker exec $postgresContainer psql -U postgres -d telegram_gateway_restore -Atc `
"select count(*) from information_schema.tables where table_schema='public';")
$tenants = [int](docker exec $postgresContainer psql -U postgres -d telegram_gateway_restore -Atc `
'select count(*) from tenants;')
$accounts = [int](docker exec $postgresContainer psql -U postgres -d telegram_gateway_restore -Atc `
'select count(*) from tg_accounts;')
if ($tables -ne [int]$manifest.expected.publicTables -or
$tenants -ne [int]$manifest.expected.tenantRows -or
$accounts -ne [int]$manifest.expected.accountRows) {
throw "PostgreSQL restore count mismatch"
}
docker create --name $redisContainer redis:7-alpine | Out-Null
docker cp (Join-Path $source 'redis.rdb') "${redisContainer}:/data/dump.rdb"
docker run --rm -v "${source}:/backup:ro" redis:7-alpine `
redis-check-rdb /backup/redis.rdb | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Redis RDB integrity check failed'
}
docker start $redisContainer | Out-Null
$redisReady = $false
for ($attempt = 0; $attempt -lt 20; $attempt++) {
$ping = docker exec $redisContainer redis-cli PING 2>$null
if ($ping -eq 'PONG') {
$redisReady = $true
break
}
Start-Sleep -Milliseconds 500
}
if (-not $redisReady) {
throw 'Isolated Redis restore container did not become ready'
}
$redisSize = [int](docker exec $redisContainer redis-cli DBSIZE)
# BullMQ keys have TTLs and may legitimately expire between SAVE and this
# isolated restore. The RDB checksum/load must succeed and the restored key
# count must never exceed the snapshot-time upper bound.
if ($redisSize -gt [int]$manifest.expected.redisDbSize) {
throw "Redis restore produced more keys than the snapshot"
}
docker volume create $filesVolume | Out-Null
docker run --rm -v "${filesVolume}:/restore" -v "${source}:/backup:ro" `
node:20-bookworm-slim `
tar -xzf /backup/tdlib-data.tar.gz -C /restore
$fileCount = [int](docker run --rm -v "${filesVolume}:/restore:ro" `
node:20-bookworm-slim sh -c `
"find /restore/tdlib_data /restore/uploads -type f 2>/dev/null | wc -l")
if ($fileCount -ne [int]$manifest.expected.tdlibFileCount) {
throw "TDLib/uploads restore file count mismatch"
}
Write-Output (
"restore_verified tables=$tables tenants=$tenants accounts=$accounts " +
"redis_keys=$redisSize redis_snapshot_max=$($manifest.expected.redisDbSize) " +
"files=$fileCount checksums=ok"
)
} finally {
Remove-TestResources
}
@@ -0,0 +1,38 @@
param(
[Parameter(Mandatory = $true)]
[string]$EnvironmentFile
)
$ErrorActionPreference = 'Stop'
$envPath = [System.IO.Path]::GetFullPath($EnvironmentFile)
if (-not (Test-Path -LiteralPath $envPath -PathType Leaf)) {
throw "Environment file not found"
}
$bytes = New-Object byte[] 48
$generator = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$generator.GetBytes($bytes)
} finally {
$generator.Dispose()
}
$newSecret = [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
$found = $false
$updated = foreach ($line in [System.IO.File]::ReadAllLines($envPath)) {
if ($line -match '^JWT_SECRET=') {
$found = $true
"JWT_SECRET=$newSecret"
} else {
$line
}
}
if (-not $found) {
$updated += "JWT_SECRET=$newSecret"
}
$temporaryPath = "$envPath.rotate.tmp"
$utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllLines($temporaryPath, $updated, $utf8WithoutBom)
Move-Item -LiteralPath $temporaryPath -Destination $envPath -Force
Write-Output 'JWT_SECRET rotated'
@@ -0,0 +1,123 @@
const net = require('net');
const http = require('http');
const listenPort = Number(process.env.SOCKS5_PORT || 1080);
const metricsPort = Number(process.env.SOCKS5_METRICS_PORT || 1081);
const stats = { accepted: 0, connected: 0, failed: 0, active: 0 };
function reply(socket, code, addressType = 1) {
const address = addressType === 4 ? Buffer.alloc(16) : Buffer.alloc(4);
socket.write(Buffer.concat([
Buffer.from([5, code, 0, addressType]),
address,
Buffer.from([0, 0]),
]));
}
function consumeRequest(socket, state) {
if (state.phase === 'greeting') {
if (state.buffer.length < 2) return;
const methodsLength = state.buffer[1];
if (state.buffer.length < 2 + methodsLength) return;
const methods = state.buffer.subarray(2, 2 + methodsLength);
state.buffer = state.buffer.subarray(2 + methodsLength);
if (state.buffer[0] !== undefined && state.buffer[0] !== 5) {
socket.destroy();
return;
}
if (!methods.includes(0)) {
socket.end(Buffer.from([5, 255]));
return;
}
socket.write(Buffer.from([5, 0]));
state.phase = 'request';
}
if (state.phase !== 'request' || state.buffer.length < 4) return;
const [version, command, , addressType] = state.buffer;
if (version !== 5 || command !== 1) {
reply(socket, command === 1 ? 1 : 7);
socket.end();
return;
}
let offset = 4;
let host;
if (addressType === 1) {
if (state.buffer.length < offset + 4 + 2) return;
host = Array.from(state.buffer.subarray(offset, offset + 4)).join('.');
offset += 4;
} else if (addressType === 3) {
if (state.buffer.length < offset + 1) return;
const length = state.buffer[offset++];
if (state.buffer.length < offset + length + 2) return;
host = state.buffer.subarray(offset, offset + length).toString('utf8');
offset += length;
} else if (addressType === 4) {
if (state.buffer.length < offset + 16 + 2) return;
const words = [];
for (let index = 0; index < 16; index += 2) {
words.push(state.buffer.readUInt16BE(offset + index).toString(16));
}
host = words.join(':');
offset += 16;
} else {
reply(socket, 8);
socket.end();
return;
}
const port = state.buffer.readUInt16BE(offset);
state.buffer = state.buffer.subarray(offset + 2);
state.phase = 'connecting';
const upstream = net.createConnection({ host, port });
upstream.setTimeout(30000);
upstream.once('connect', () => {
stats.connected += 1;
stats.active += 1;
console.log(JSON.stringify({ event: 'connect', host, port, addressType }));
reply(socket, 0, addressType === 4 ? 4 : 1);
if (state.buffer.length) upstream.write(state.buffer);
socket.pipe(upstream);
upstream.pipe(socket);
});
upstream.once('error', (error) => {
stats.failed += 1;
console.error(JSON.stringify({ event: 'connect_error', host, port, code: error.code }));
if (!socket.destroyed) {
reply(socket, error.code === 'ECONNREFUSED' ? 5 : 4);
socket.end();
}
});
upstream.once('close', () => {
if (stats.active > 0) stats.active -= 1;
});
socket.once('close', () => upstream.destroy());
}
const server = net.createServer((socket) => {
stats.accepted += 1;
const state = { phase: 'greeting', buffer: Buffer.alloc(0) };
socket.setTimeout(30000);
socket.on('data', (chunk) => {
if (state.phase === 'connecting') return;
state.buffer = Buffer.concat([state.buffer, chunk]);
consumeRequest(socket, state);
});
socket.on('timeout', () => socket.destroy());
socket.on('error', () => {});
});
server.listen(listenPort, '0.0.0.0', () => {
console.log(`SOCKS5 test proxy listening on ${listenPort}`);
});
http.createServer((request, response) => {
if (request.url !== '/stats') {
response.writeHead(404).end();
return;
}
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify(stats));
}).listen(metricsPort, '0.0.0.0');
@@ -0,0 +1,41 @@
const http = require('http');
const crypto = require('crypto');
const secret = process.env.WEBHOOK_TEST_SECRET || '';
const received = [];
let failuresRemaining = Number(process.env.WEBHOOK_FAIL_COUNT || 0);
http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/received') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify(received));
}
if (req.method === 'POST' && (req.url === '/events' || req.url === '/fail')) {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const body = Buffer.concat(chunks).toString();
const expected = `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
const signatureValid = !secret || req.headers['x-webhook-signature'] === expected;
received.push({
path: req.url,
signatureValid,
body: JSON.parse(body),
receivedAt: new Date().toISOString(),
});
if (!signatureValid) {
res.writeHead(401);
return res.end('invalid signature');
}
if (req.url === '/fail' && failuresRemaining-- > 0) {
res.writeHead(503);
return res.end('intentional failure');
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ accepted: true }));
});
return;
}
res.writeHead(404);
res.end('not found');
}).listen(8080, '0.0.0.0');
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": ["**/*.js"],
"watchAssets": true
}
}
+11937
View File
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
{
"name": "telegram-user-gateway",
"version": "1.0.0",
"description": "Production-grade Telegram User Account Gateway - REST API + Webhook system using TDLib",
"private": true,
"license": "MIT",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "typeorm-ts-node-commonjs",
"migration:generate": "npm run typeorm -- migration:generate -d src/database/data-source.ts",
"migration:run": "npm run typeorm -- migration:run -d src/database/data-source.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/database/data-source.ts"
},
"dependencies": {
"@nestjs/bull": "^11.0.4",
"@nestjs/common": "^11.1.28",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.28",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.28",
"@nestjs/swagger": "^11.4.5",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.3",
"@types/multer": "^2.1.0",
"@types/supertest": "^7.2.0",
"bcryptjs": "^2.4.3",
"bullmq": "^5.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.3.1",
"helmet": "^8.3.0",
"ioredis": "^5.3.2",
"koffi": "^3.0.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.11.3",
"pg-mem": "^3.0.14",
"prebuilt-tdlib": "^0.1008064.0",
"qs": "^6.15.3",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",
"sql.js": "^1.14.1",
"supertest": "^7.2.2",
"swagger-ui-express": "^5.0.0",
"typeorm": "^0.3.31",
"uuid": "^11.1.1",
"winston": "^3.11.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.1.28",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.0",
"@types/passport-jwt": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.0",
"jest": "^29.7.0",
"prettier": "^3.1.1",
"source-map-support": "^0.5.21",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": ".",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"src/**/*.(t|j)s"
],
"coverageDirectory": "./coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
},
"testPathIgnorePatterns": [
"<rootDir>/test/endpoints.spec.ts"
]
}
}
+907
View File
@@ -0,0 +1,907 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Telegram User Gateway - 项目架构文档</title>
<style>
:root {
--bg: #0d1117;
--card: #161b22;
--border: #30363d;
--text: #c9d1d9;
--heading: #f0f6fc;
--accent: #58a6ff;
--accent2: #3fb950;
--accent3: #d2a8ff;
--accent4: #f78166;
--accent5: #79c0ff;
--code-bg: #0d1117;
--tag-bg: #1f6feb33;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.7;
padding: 0;
}
.hero {
background: linear-gradient(135deg, #1a1e2e 0%, #0d1117 50%, #161b22 100%);
border-bottom: 1px solid var(--border);
padding: 60px 40px;
text-align: center;
}
.hero h1 {
font-size: 2.8em;
color: var(--heading);
margin-bottom: 12px;
letter-spacing: -1px;
}
.hero h1 span { color: var(--accent); }
.hero p {
font-size: 1.15em;
color: #8b949e;
max-width: 700px;
margin: 0 auto;
}
.badge-row {
margin-top: 20px;
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.badge {
display: inline-block;
padding: 4px 14px;
border-radius: 20px;
font-size: 0.82em;
font-weight: 600;
border: 1px solid var(--border);
background: var(--card);
color: var(--accent5);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 40px 24px;
}
.toc {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px 32px;
margin-bottom: 40px;
}
.toc h2 {
color: var(--heading);
font-size: 1.3em;
margin-bottom: 16px;
}
.toc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 8px;
}
.toc a {
color: var(--accent);
text-decoration: none;
font-size: 0.95em;
padding: 4px 0;
transition: color 0.2s;
}
.toc a:hover { color: var(--accent3); text-decoration: underline; }
.section {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
margin-bottom: 28px;
}
.section h2 {
color: var(--heading);
font-size: 1.6em;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.section h2 .num {
background: var(--tag-bg);
color: var(--accent);
padding: 2px 12px;
border-radius: 8px;
font-size: 0.7em;
}
.section h3 {
color: var(--accent3);
font-size: 1.15em;
margin: 24px 0 12px;
}
.section p, .section li {
color: var(--text);
margin-bottom: 8px;
}
.section ul, .section ol {
padding-left: 24px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 0.92em;
}
th {
background: #21262d;
color: var(--accent);
text-align: left;
padding: 10px 14px;
border: 1px solid var(--border);
font-weight: 600;
}
td {
padding: 9px 14px;
border: 1px solid var(--border);
vertical-align: top;
}
tr:nth-child(even) td { background: #161b22; }
tr:nth-child(odd) td { background: #0d1117; }
code {
background: #21262d;
color: var(--accent5);
padding: 2px 7px;
border-radius: 5px;
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
font-size: 0.88em;
}
pre {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
overflow-x: auto;
margin: 16px 0;
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
font-size: 0.85em;
line-height: 1.6;
color: var(--text);
}
.arch-box {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 24px;
margin: 16px 0;
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
font-size: 0.82em;
line-height: 1.5;
color: var(--accent2);
white-space: pre;
overflow-x: auto;
}
.highlight { color: var(--accent); font-weight: 600; }
.highlight2 { color: var(--accent3); font-weight: 600; }
.highlight3 { color: var(--accent4); font-weight: 600; }
.tag {
display: inline-block;
padding: 2px 10px;
border-radius: 6px;
font-size: 0.78em;
font-weight: 600;
margin: 2px;
}
.tag-green { background: #238636; color: #fff; }
.tag-blue { background: #1f6feb; color: #fff; }
.tag-purple { background: #8957e5; color: #fff; }
.tag-orange { background: #9e6a03; color: #fff; }
.tag-red { background: #da3633; color: #fff; }
.flow-card {
background: #21262d;
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 20px;
margin: 8px 0;
display: flex;
align-items: flex-start;
gap: 14px;
}
.flow-card .step {
background: var(--tag-bg);
color: var(--accent);
padding: 4px 10px;
border-radius: 6px;
font-weight: 700;
font-size: 0.8em;
white-space: nowrap;
min-width: 50px;
text-align: center;
}
.flow-card .content { flex: 1; }
.flow-card .content strong { color: var(--heading); }
.flow-arrow {
text-align: center;
color: var(--border);
font-size: 1.2em;
margin: 2px 0;
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 768px) {
.two-col { grid-template-columns: 1fr; }
.hero h1 { font-size: 1.8em; }
.container { padding: 20px 12px; }
.section { padding: 20px; }
}
.file-tree {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 0.82em;
line-height: 1.8;
color: var(--text);
overflow-x: auto;
}
.file-tree .dir { color: var(--accent); font-weight: 600; }
.file-tree .file { color: var(--text); }
.file-tree .comment { color: #8b949e; }
.footer {
text-align: center;
padding: 40px;
color: #484f58;
font-size: 0.85em;
border-top: 1px solid var(--border);
margin-top: 40px;
}
.state-machine {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin: 16px 0;
font-size: 0.88em;
}
.state-node {
background: #21262d;
border: 2px solid var(--accent);
color: var(--accent);
padding: 6px 14px;
border-radius: 8px;
font-weight: 600;
font-family: monospace;
}
.state-arrow { color: var(--accent4); font-weight: bold; font-size: 1.2em; }
.info-box {
background: #161b22;
border-left: 4px solid var(--accent);
padding: 16px 20px;
margin: 16px 0;
border-radius: 0 8px 8px 0;
}
.info-box.warning { border-left-color: var(--accent4); }
.info-box.success { border-left-color: var(--accent2); }
</style>
</head>
<body>
<div class="hero">
<h1>Telegram User <span>Gateway</span></h1>
<p>基于 TDLib 的生产级 Telegram 用户账号网关,通过 REST API + Webhook 方式管理多租户多账号的 Telegram 操作</p>
<div class="badge-row">
<span class="badge">Node.js 20+</span>
<span class="badge">NestJS 10</span>
<span class="badge">TypeScript 5.3</span>
<span class="badge">TDLib 1.8.64+</span>
<span class="badge">PostgreSQL / SQLite</span>
<span class="badge">Redis + BullMQ</span>
<span class="badge">Docker</span>
</div>
</div>
<div class="container">
<!-- TOC -->
<div class="toc">
<h2>目录</h2>
<div class="toc-grid">
<a href="#overview">1. 项目概述</a>
<a href="#tech-stack">2. 技术栈</a>
<a href="#architecture">3. 核心架构</a>
<a href="#structure">4. 目录结构</a>
<a href="#modules">5. 核心模块</a>
<a href="#entities">6. 数据库实体</a>
<a href="#state-machine">7. 账号状态机</a>
<a href="#api">8. API 端点</a>
<a href="#security">9. 安全与多租户</a>
<a href="#config">10. 配置模式</a>
<a href="#workflow">11. 完整调用流程</a>
<a href="#deploy">12. 构建与部署</a>
</div>
</div>
<!-- 1. 项目概述 -->
<div class="section" id="overview">
<h2><span class="num">01</span> 项目概述</h2>
<p>这是一个 <strong>生产级 Telegram 用户账号网关</strong>。它不是 Telegram Bot API 的封装,而是通过 <strong>TDLib</strong>(Telegram 的底层 C 语言库)以<strong>用户账号</strong>身份操作 Telegram。</p>
<p>对外暴露标准 REST API,让上层业务系统可以通过 HTTP 请求操控 Telegram 账号,支持多租户、多账号管理。</p>
<h3>典型使用场景</h3>
<div class="two-col">
<div>
<ul>
<li>自动化消息发送与群发</li>
<li>群组/频道管理自动化</li>
<li>用户数据采集与分析</li>
</ul>
</div>
<div>
<ul>
<li>消息监控与实时推送</li>
<li>客户服务自动化</li>
<li>跨平台消息桥接</li>
</ul>
</div>
</div>
</div>
<!-- 2. 技术栈 -->
<div class="section" id="tech-stack">
<h2><span class="num">02</span> 技术栈</h2>
<table>
<thead>
<tr><th>层次</th><th>技术选型</th><th>说明</th></tr>
</thead>
<tbody>
<tr><td>运行时</td><td><code>Node.js 20+</code> / <code>TypeScript 5.3</code></td><td>高性能异步 I/O</td></tr>
<tr><td>Web 框架</td><td><code>NestJS 10.x</code></td><td>模块化架构 + 依赖注入</td></tr>
<tr><td>数据库</td><td><code>PostgreSQL 16</code> / <code>SQLite (sql.js)</code></td><td>生产用 PG,开发用 SQLite</td></tr>
<tr><td>队列</td><td><code>Redis 7</code> + <code>BullMQ</code></td><td>可选,通过 .env 关闭</td></tr>
<tr><td>TDLib 绑定</td><td><code>prebuilt-tdlib</code> + <code>koffi</code></td><td>FFI 调用原生 DLL/SO</td></tr>
<tr><td>线程模型</td><td><code>worker_threads</code></td><td>每账号一个独立线程</td></tr>
<tr><td>认证</td><td><code>JWT</code> + <code>Passport</code></td><td>Bearer Token 认证</td></tr>
<tr><td>API 文档</td><td><code>Swagger</code></td><td>自动生成 /api/docs</td></tr>
<tr><td>日志</td><td><code>Winston</code></td><td>结构化日志</td></tr>
<tr><td>容器化</td><td><code>Docker</code> + <code>Docker Compose</code></td><td>多阶段构建</td></tr>
</tbody>
</table>
</div>
<!-- 3. 核心架构 -->
<div class="section" id="architecture">
<h2><span class="num">03</span> 核心架构</h2>
<p>系统采用 <strong>三层架构</strong>:应用层(NestJS 主线程)、Worker 层(每账号独立线程)、TDLib 层(C 原生库)。</p>
<div class="arch-box"><span class="highlight">┌─────────────────────────────────────────────────────────┐
│ 外部业务系统 │
│ (通过 REST API 调用) │
└──────────────────────┬──────────────────────────────────┘
│ HTTP
<span class="highlight2">┌──────────────────────▼──────────────────────────────────┐
│ NestJS 应用层 (主线程) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ 认证模块 │ │ 账号模块 │ │ 消息模块 │ │Webhook │ │
│ │ Auth │ │ Accounts │ │ Messages │ │ 模块 │ │
│ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │
│ │ │
│ TdlibClientManagerService │
│ (Worker 线程管理器) │
└────────────────┬─────────────────────────────────────────┘
│ worker_threads (postMessage)
<span class="highlight3">┌────────────────▼─────────────────────────────────────────┐
│ Worker 线程层 (每 TG 账号一个) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Worker #1 │ │ Worker #2 │ │ Worker #3 │ │
│ │ (tdjson │ │ (tdjson │ │ (tdjson │ │
│ │ FFI 调用) │ │ FFI 调用) │ │ FFI 调用) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└────────────────┬─────────────────────────────────────────┘
│
┌────────────────▼─────────────────────────────────────────┐
│ TDLib (C 原生库) │
│ tdjson.dll / libtdjson.so │
└──────────────────────────────────────────────────────────┘</span></span></div>
<h3>请求-响应匹配机制</h3>
<div class="info-box">
<p>每个请求通过 <code>UUID @extra</code> 标记,存入 <code>pendingRequests</code> Map。Worker 处理完成后通过相同的 <code>@extra</code> 回传,主线程匹配后 resolve/reject 对应的 Promise。超时 30 秒自动 reject。</p>
</div>
</div>
<!-- 4. 目录结构 -->
<div class="section" id="structure">
<h2><span class="num">04</span> 目录结构</h2>
<h3>项目根目录</h3>
<div class="file-tree">
<span class="dir">telegram-getway/</span>
├── <span class="file">.env</span> <span class="comment"># 运行时配置</span>
├── <span class="file">.env.example</span> <span class="comment"># 配置模板</span>
├── <span class="file">package.json</span> <span class="comment"># 依赖与脚本</span>
├── <span class="file">tsconfig.json</span> <span class="comment"># TypeScript 配置</span>
├── <span class="file">nest-cli.json</span> <span class="comment"># NestJS CLI 配置</span>
├── <span class="dir">src/</span> <span class="comment"># 源代码</span>
├── <span class="dir">data/</span> <span class="comment"># SQLite 数据库</span>
├── <span class="dir">tdlib_data/</span> <span class="comment"># TDLib 数据(每账号子目录)</span>
├── <span class="dir">uploads/</span> <span class="comment"># 文件上传存储</span>
├── <span class="dir">docker/</span> <span class="comment"># Docker 配置</span>
│ ├── <span class="file">Dockerfile</span> <span class="comment"># 多阶段构建</span>
│ └── <span class="file">docker-compose.yml</span> <span class="comment"># PG + Redis + App</span>
└── <span class="dir">test/</span> <span class="comment"># 测试文件</span>
</div>
<h3>源代码目录 (src/)</h3>
<div class="file-tree">
<span class="dir">src/</span>
├── <span class="file">main.ts</span> <span class="comment"># 应用入口</span>
├── <span class="file">app.module.ts</span> <span class="comment"># 根模块(注册 15 个子模块)</span>
├── <span class="dir">config/</span> <span class="comment"># 配置模块</span>
│ ├── <span class="file">app.config.ts</span> <span class="comment"># 应用配置</span>
│ ├── <span class="file">database.config.ts</span> <span class="comment"># 数据库配置(PG/SQLite)</span>
│ ├── <span class="file">redis.config.ts</span> <span class="comment"># Redis 配置</span>
│ └── <span class="file">rate-limit.config.ts</span> <span class="comment"># 速率限制配置</span>
├── <span class="dir">common/</span> <span class="comment"># 通用组件</span>
│ ├── <span class="dir">decorators/</span> <span class="comment"># 装饰器(@CurrentUser, @Tenant)</span>
│ ├── <span class="dir">errors/</span> <span class="comment"># 异常类 + 60+ 错误码</span>
│ ├── <span class="dir">filters/</span> <span class="comment"># 全局异常过滤器</span>
│ ├── <span class="dir">guards/</span> <span class="comment"># JWT 守卫 + 租户守卫</span>
│ ├── <span class="dir">interceptors/</span> <span class="comment"># 响应格式 + 审计日志</span>
│ └── <span class="dir">pipes/</span> <span class="comment"># 参数验证管道</span>
├── <span class="dir">database/</span> <span class="comment"># 数据库层</span>
│ ├── <span class="dir">entities/</span> <span class="comment"># 16 个 TypeORM 实体</span>
│ └── <span class="dir">migrations/</span> <span class="comment"># 数据库迁移</span>
├── <span class="dir">modules/</span> <span class="comment"># 15 个业务模块</span>
│ ├── <span class="dir">auth/</span> <span class="comment"># 认证(注册/登录/JWT)</span>
│ ├── <span class="dir">tenants/</span> <span class="comment"># 租户管理</span>
│ ├── <span class="dir">tg-accounts/</span> <span class="comment"># TG 账号 CRUD</span>
│ ├── <span class="dir">tg-auth/</span> <span class="comment"># TG 授权流程</span>
│ ├── <span class="dir">tdlib/</span> <span class="comment"># TDLib 核心管理器</span>
│ ├── <span class="dir">tg-proxies/</span> <span class="comment"># 代理管理</span>
│ ├── <span class="dir">tg-chats/</span> <span class="comment"># 聊天模块</span>
│ ├── <span class="dir">tg-dialogs/</span> <span class="comment"># 对话模块</span>
│ ├── <span class="dir">tg-contacts/</span> <span class="comment"># 联系人模块</span>
│ ├── <span class="dir">tg-messages/</span> <span class="comment"># 消息发送/编辑/搜索</span>
│ ├── <span class="dir">tg-groups/</span> <span class="comment"># 群组管理</span>
│ ├── <span class="dir">tg-channels/</span> <span class="comment"># 频道管理</span>
│ ├── <span class="dir">tg-files/</span> <span class="comment"># 文件上传/下载</span>
│ ├── <span class="dir">tg-webhooks/</span> <span class="comment"># Webhook 订阅与投递</span>
│ ├── <span class="dir">tg-rate-limit/</span> <span class="comment"># 速率限制</span>
│ ├── <span class="dir">tg-audit/</span> <span class="comment"># 审计日志</span>
│ └── <span class="dir">admin/</span> <span class="comment"># 管理(健康检查/指标)</span>
└── <span class="dir">workers/</span> <span class="comment"># Worker 线程</span>
├── <span class="file">tdlib-client.worker.ts</span> <span class="comment"># TDLib 客户端(核心)</span>
├── <span class="file">send-message.worker.ts</span> <span class="comment"># 消息发送 BullMQ</span>
├── <span class="file">file-download.worker.ts</span><span class="comment"># 文件下载</span>
└── <span class="file">webhook-delivery.worker.ts</span> <span class="comment"># Webhook 投递</span>
</div>
</div>
<!-- 5. 核心模块 -->
<div class="section" id="modules">
<h2><span class="num">05</span> 核心模块详解</h2>
<h3>5.1 TDLib 客户端管理器</h3>
<p><code>src/modules/tdlib/tdlib-client-manager.service.ts</code> — 整个系统的<strong>心脏</strong></p>
<table>
<thead><tr><th>属性</th><th>类型</th><th>作用</th></tr></thead>
<tbody>
<tr><td><code>workers</code></td><td><code>Map&lt;string, Worker&gt;</code></td><td>accountId → Worker 线程映射</td></tr>
<tr><td><code>pendingRequests</code></td><td><code>Map&lt;string, PendingRequest&gt;</code></td><td>UUID → Promise 映射(请求-响应匹配)</td></tr>
<tr><td><code>accountAuthState</code></td><td><code>Map&lt;string, string&gt;</code></td><td>追踪每个账号的授权状态</td></tr>
<tr><td><code>clientIdToAccountId</code></td><td><code>Map&lt;number, string&gt;</code></td><td>clientId → accountId 反向查找</td></tr>
</tbody>
</table>
<p><strong>核心方法:</strong></p>
<ul>
<li><code>startClient(accountId)</code> — 创建 Worker 线程,启动 TDLib 实例</li>
<li><code>stopClient(accountId)</code> — 发送 stop 消息,清理 Worker</li>
<li><code>sendRequest(accountId, request)</code> — 发送请求并返回 Promise(30s 超时)</li>
<li><code>handleTdlibResponse(msg)</code> — 处理 Worker 回传的响应,匹配 pending 请求</li>
<li><code>handleUnsolicitedUpdate(msg)</code> — 处理非请求触发的 TG 更新(如状态变化)</li>
</ul>
<h3>5.2 TDLib Worker</h3>
<p><code>src/workers/tdlib-client.worker.ts</code> — 每个 TG 账号的独立线程</p>
<div class="info-box warning">
<p><strong>关键兼容性</strong>:TDLib 1.8.64+ 使用 <strong>FLAT 格式</strong>,<code>setTdlibParameters</code> 参数不再包裹在 <code>parameters</code> 对象里。</p>
</div>
<p><strong>启动流程:</strong></p>
<div class="flow-card">
<span class="step">1</span>
<div class="content"><strong>loadTdlib()</strong> — 通过 koffi 加载原生 tdjson DLL/SO(支持自动路径检测)</div>
</div>
<div class="flow-arrow">↓</div>
<div class="flow-card">
<span class="step">2</span>
<div class="content"><strong>td_json_client_create()</strong> — 创建 TDLib 客户端实例</div>
</div>
<div class="flow-arrow">↓</div>
<div class="flow-card">
<span class="step">3</span>
<div class="content"><strong>配置代理</strong> — 支持 SOCKS5 / HTTP / MTProto 代理</div>
</div>
<div class="flow-arrow">↓</div>
<div class="flow-card">
<span class="step">4</span>
<div class="content"><strong>mainLoop()</strong> — setImmediate 递归事件循环,非阻塞轮询 TDLib</div>
</div>
<p><strong>事件循环逻辑:</strong></p>
<ol>
<li>处理主线程消息队列(发送请求到 TDLib)</li>
<li><code>td_json_client_receive(timeout=0.1)</code> — 非阻塞轮询响应</li>
<li><code>processResponse()</code> — 处理 TDLib 响应,自动处理授权状态机</li>
<li><code>setImmediate(mainLoop)</code> — 让出事件循环,继续下一轮</li>
</ol>
<h3>5.3 账号授权模块</h3>
<p><code>src/modules/tg-auth/tg-auth.service.ts</code> — 处理 Telegram 完整授权流程</p>
<table>
<thead><tr><th>API 端点</th><th>方法</th><th>TDLib 调用</th><th>状态转换</th></tr></thead>
<tbody>
<tr><td><code>POST /auth/phone</code></td><td>setPhoneNumber()</td><td>setAuthenticationPhoneNumber</td><td>→ WAITING_CODE</td></tr>
<tr><td><code>POST /auth/code</code></td><td>submitCode()</td><td>checkAuthenticationCode</td><td>→ READY / WAITING_PASSWORD</td></tr>
<tr><td><code>POST /auth/password</code></td><td>submitPassword()</td><td>checkAuthenticationPassword</td><td>→ READY</td></tr>
<tr><td><code>POST /auth/logout</code></td><td>logout()</td><td>logOut</td><td>→ CREATED</td></tr>
<tr><td><code>POST /auth/resend</code></td><td>resendCode()</td><td>resendAuthenticationCode</td><td>—</td></tr>
</tbody>
</table>
<h3>5.4 消息模块</h3>
<p><code>src/modules/tg-messages/tg-messages.service.ts</code> — 双模式消息发送</p>
<div class="two-col">
<div class="section" style="margin-bottom:0">
<h3 style="margin-top:0">队列模式(Redis 启用)</h3>
<ul>
<li>BullMQ 异步发送</li>
<li>3 次指数退避重试</li>
<li>TgSendJob 持久化追踪</li>
<li>失败回退到直接模式</li>
</ul>
</div>
<div class="section" style="margin-bottom:0">
<h3 style="margin-top:0">直接模式(Redis 禁用)</h3>
<ul>
<li>同步调用 TDLib</li>
<li>无重试机制</li>
<li>简单直接</li>
<li>适合开发/轻量场景</li>
</ul>
</div>
</div>
<p><strong>支持的操作:</strong></p>
<table>
<thead><tr><th>操作</th><th>TDLib 方法</th><th>说明</th></tr></thead>
<tbody>
<tr><td>sendMessage</td><td>sendMessage</td><td>发送文本/媒体消息</td></tr>
<tr><td>editMessage</td><td>editMessageText</td><td>编辑已发消息</td></tr>
<tr><td>forwardMessage</td><td>forwardMessages</td><td>转发消息</td></tr>
<tr><td>deleteMessage</td><td>deleteMessages</td><td>删除消息(可选 revoke)</td></tr>
<tr><td>searchMessages</td><td>searchChatMessages</td><td>搜索聊天记录</td></tr>
</tbody>
</table>
<h3>5.5 Webhook 模块</h3>
<p><code>src/modules/tg-webhooks/tg-webhooks.service.ts</code> — 事件推送机制</p>
<ul>
<li>为每个 TG 账号创建 Webhook 订阅,指定 URL 和事件类型</li>
<li>支持通配符 <code>*</code> 匹配所有事件</li>
<li><strong>HMAC-SHA256</strong> 签名验证(<code>X-Webhook-Signature</code> header)</li>
<li>指数退避重试(默认最多 8 次)</li>
<li>投递记录持久化到数据库</li>
</ul>
</div>
<!-- 6. 数据库实体 -->
<div class="section" id="entities">
<h2><span class="num">06</span> 数据库实体关系</h2>
<p>共 <strong>16 个实体</strong>,覆盖 Telegram 操作的所有数据面。</p>
<div class="arch-box"><span class="highlight">Tenant (租户)</span>
├── 1:N → <span class="highlight2">User</span> (系统用户: email + password)
└── 1:N → <span class="highlight2">TgAccount</span> (TG账号)
├── 1:N → <span class="highlight3">TgAccountProxyBinding</span> → <span class="highlight3">TgProxy</span> (代理绑定)
├── 1:N → <span class="highlight3">TgChat</span> (聊天)
├── 1:N → <span class="highlight3">TgMessage</span> (消息)
├── 1:N → <span class="highlight3">TgContact</span> (联系人)
├── 1:N → <span class="highlight3">TgGroup</span> (群组)
├── 1:N → <span class="highlight3">TgSendJob</span> (发送任务)
├── 1:N → <span class="highlight3">TgFile</span> (文件)
├── 1:N → <span class="highlight3">TgWebhookSubscription</span> (Webhook订阅)
│ └── 1:N → <span class="highlight3">TgWebhookDelivery</span> (投递记录)
└── 1:N → <span class="highlight3">TgAuditLog</span> (审计日志)
独立实体: <span class="highlight">TgRawUpdate</span> (原始更新), <span class="highlight">TgRateLimitEvent</span> (速率事件)</div>
<table>
<thead><tr><th>实体</th><th>表名</th><th>关键字段</th></tr></thead>
<tbody>
<tr><td><code>Tenant</code></td><td>tenants</td><td>name, maxAccounts, isActive</td></tr>
<tr><td><code>User</code></td><td>users</td><td>email, passwordHash, tenantId</td></tr>
<tr><td><code>TgAccount</code></td><td>tg_accounts</td><td>phoneNumber, status, tenantId, tdlibClientId</td></tr>
<tr><td><code>TgProxy</code></td><td>tg_proxies</td><td>type, host, port, username, password</td></tr>
<tr><td><code>TgChat</code></td><td>tg_chats</td><td>tgChatId, title, type, accountId</td></tr>
<tr><td><code>TgMessage</code></td><td>tg_messages</td><td>tgMessageId, content, chatId, accountId</td></tr>
<tr><td><code>TgSendJob</code></td><td>tg_send_jobs</td><td>bullJobId, status, payload, accountId</td></tr>
<tr><td><code>TgWebhookSubscription</code></td><td>tg_webhook_subscriptions</td><td>url, events, secret, isActive</td></tr>
<tr><td><code>TgWebhookDelivery</code></td><td>tg_webhook_deliveries</td><td>url, payload, status, attempt</td></tr>
</tbody>
</table>
</div>
<!-- 7. 状态机 -->
<div class="section" id="state-machine">
<h2><span class="num">07</span> 账号状态机</h2>
<p>TG 账号的生命周期通过状态机管理:</p>
<div class="state-machine">
<span class="state-node">CREATED</span>
<span class="state-arrow">→</span>
<span class="state-node">WAITING_TDLIB</span>
<span class="state-arrow">→</span>
<span class="state-node">WAITING_PHONE</span>
<span class="state-arrow">→</span>
<span class="state-node">WAITING_CODE</span>
<span class="state-arrow">→</span>
<span class="state-node">WAITING_PASSWORD</span>
<span class="state-arrow">→</span>
<span class="state-node" style="border-color: var(--accent2); color: var(--accent2);">READY</span>
</div>
<div style="text-align:center; margin-top:8px;">
<span class="state-arrow">↓ 异常</span>
</div>
<div class="state-machine" style="justify-content:center;">
<span class="state-node" style="border-color: var(--accent4); color: var(--accent4);">ERROR</span>
<span style="margin: 0 20px; color: #8b949e;">/</span>
<span class="state-node" style="border-color: var(--accent4); color: var(--accent4);">DISABLED</span>
</div>
<table>
<thead><tr><th>状态</th><th>含义</th><th>触发条件</th></tr></thead>
<tbody>
<tr><td><code>CREATED</code></td><td>已创建,未启动</td><td>创建 TG 账号记录</td></tr>
<tr><td><code>WAITING_TDLIB</code></td><td>TDLib 初始化中</td><td>调用 startClient()</td></tr>
<tr><td><code>WAITING_PHONE</code></td><td>等待提交手机号</td><td>TDLib 就绪</td></tr>
<tr><td><code>WAITING_CODE</code></td><td>等待验证码</td><td>提交手机号成功</td></tr>
<tr><td><code>WAITING_PASSWORD</code></td><td>等待 2FA 密码</td><td>提交验证码后检测到 2FA</td></tr>
<tr><td><code>READY</code></td><td>已授权,可用</td><td>授权流程完成</td></tr>
<tr><td><code>ERROR</code></td><td>出错</td><td>任何步骤失败</td></tr>
<tr><td><code>DISABLED</code></td><td>已禁用</td><td>手动禁用或登出</td></tr>
</tbody>
</table>
</div>
<!-- 8. API 端点 -->
<div class="section" id="api">
<h2><span class="num">08</span> API 端点总览</h2>
<table>
<thead><tr><th>路径</th><th>方法</th><th>功能</th><th>认证</th></tr></thead>
<tbody>
<tr><td><code>/api/auth/register</code></td><td>POST</td><td>用户注册</td><td><span class="tag tag-green">公开</span></td></tr>
<tr><td><code>/api/auth/login</code></td><td>POST</td><td>用户登录(返回 JWT)</td><td><span class="tag tag-green">公开</span></td></tr>
<tr><td><code>/api/admin/health</code></td><td>GET</td><td>健康检查</td><td><span class="tag tag-green">公开</span></td></tr>
<tr><td><code>/api/admin/metrics</code></td><td>GET</td><td>应用指标</td><td><span class="tag tag-blue">JWT</span></td></tr>
<tr><td><code>/api/admin/status</code></td><td>GET</td><td>TDLib 状态</td><td><span class="tag tag-blue">JWT</span></td></tr>
<tr><td><code>/api/tenants</code></td><td>CRUD</td><td>租户管理</td><td><span class="tag tag-blue">JWT</span></td></tr>
<tr><td><code>/api/tg-accounts</code></td><td>CRUD</td><td>TG 账号管理</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
<tr><td><code>/api/tg-accounts/:id/start</code></td><td>POST</td><td>启动 TDLib 客户端</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
<tr><td><code>/api/tg-accounts/:id/stop</code></td><td>POST</td><td>停止 TDLib 客户端</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
<tr><td><code>/api/tg-accounts/:id/auth/phone</code></td><td>POST</td><td>提交手机号</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
<tr><td><code>/api/tg-accounts/:id/auth/code</code></td><td>POST</td><td>提交验证码</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
<tr><td><code>/api/tg-accounts/:id/auth/password</code></td><td>POST</td><td>提交 2FA 密码</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
<tr><td><code>/api/tg-accounts/:id/chats</code></td><td>GET</td><td>获取聊天列表</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
<tr><td><code>/api/tg-accounts/:id/messages</code></td><td>POST</td><td>发送消息</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
<tr><td><code>/api/tg-accounts/:id/messages/search</code></td><td>POST</td><td>搜索消息</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
<tr><td><code>/api/tg-accounts/:id/webhooks</code></td><td>CRUD</td><td>Webhook 订阅管理</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
<tr><td><code>/api/docs</code></td><td>GET</td><td>Swagger API 文档</td><td><span class="tag tag-green">公开</span></td></tr>
</tbody>
</table>
</div>
<!-- 9. 安全 -->
<div class="section" id="security">
<h2><span class="num">09</span> 安全与多租户</h2>
<div class="two-col">
<div>
<h3>认证机制</h3>
<ul>
<li><strong>JWT 全局守卫</strong>:<code>APP_GUARD</code> 注册 <code>JwtAuthGuard</code></li>
<li>所有路由默认需要认证</li>
<li>公开路由通过 <code>@Public()</code> 装饰器标记</li>
<li>密码使用 <strong>bcrypt</strong> 哈希存储</li>
</ul>
</div>
<div>
<h3>租户隔离</h3>
<ul>
<li><code>x-tenant-id</code> header + <code>TenantGuard</code></li>
<li>每个请求都绑定到特定租户</li>
<li>租户有 <code>maxAccounts</code> 限制(默认 5)</li>
<li>数据完全隔离,不可跨租户访问</li>
</ul>
</div>
</div>
<h3>审计日志</h3>
<ul>
<li><code>AuditLogInterceptor</code> 自动记录关键操作</li>
<li>记录操作人、操作类型、请求参数、响应结果</li>
<li>持久化到 <code>tg_audit_logs</code> 表</li>
</ul>
<h3>错误码体系</h3>
<p>定义了 <strong>60+ 错误码</strong>,覆盖所有异常场景:</p>
<div class="two-col">
<div>
<ul>
<li><code>GENERAL</code>: INTERNAL_ERROR, VALIDATION_ERROR, NOT_FOUND...</li>
<li><code>TENANT</code>: TENANT_NOT_FOUND, TENANT_DISABLED</li>
<li><code>AUTH</code>: INVALID_CREDENTIALS, TOKEN_EXPIRED</li>
</ul>
</div>
<div>
<ul>
<li><code>TG_ACCOUNT</code>: TG_ACCOUNT_NOT_FOUND, TG_ACCOUNT_LIMIT_REACHED</li>
<li><code>TDLib</code>: TDLIB_CLIENT_NOT_FOUND, TDLIB_ERROR</li>
<li><code>WEBHOOK</code>: WEBHOOK_NOT_FOUND, WEBHOOK_DELIVERY_FAILED</li>
</ul>
</div>
</div>
</div>
<!-- 10. 配置 -->
<div class="section" id="config">
<h2><span class="num">10</span> 配置模式</h2>
<div class="two-col">
<div class="section" style="margin-bottom:0">
<h3 style="margin-top:0">开发模式(当前)</h3>
<table>
<tbody>
<tr><td><code>DB_TYPE</code></td><td>sqlite</td></tr>
<tr><td><code>REDIS_ENABLED</code></td><td>false</td></tr>
<tr><td><code>TDLIB_PATH</code></td><td>自动检测</td></tr>
<tr><td>消息发送</td><td>直接模式</td></tr>
</tbody>
</table>
<div class="info-box success">
<p>无需外部依赖,开箱即用</p>
</div>
</div>
<div class="section" style="margin-bottom:0">
<h3 style="margin-top:0">生产模式</h3>
<table>
<tbody>
<tr><td><code>DB_TYPE</code></td><td>postgres</td></tr>
<tr><td><code>REDIS_ENABLED</code></td><td>true</td></tr>
<tr><td><code>TDLIB_PATH</code></td><td>指定路径</td></tr>
<tr><td>消息发送</td><td>队列模式</td></tr>
</tbody>
</table>
<div class="info-box warning">
<p>需要 PostgreSQL + Redis 服务</p>
</div>
</div>
</div>
<h3>关键环境变量</h3>
<table>
<thead><tr><th>变量</th><th>说明</th><th>默认值</th></tr></thead>
<tbody>
<tr><td><code>PORT</code></td><td>服务端口</td><td>3000</td></tr>
<tr><td><code>DB_TYPE</code></td><td>数据库类型 (postgres/sqlite)</td><td>postgres</td></tr>
<tr><td><code>TG_API_ID</code></td><td>Telegram API ID</td><td>—</td></tr>
<tr><td><code>TG_API_HASH</code></td><td>Telegram API Hash</td><td>—</td></tr>
<tr><td><code>JWT_SECRET</code></td><td>JWT 签名密钥</td><td>—</td></tr>
<tr><td><code>REDIS_ENABLED</code></td><td>是否启用 Redis</td><td>true</td></tr>
<tr><td><code>TDLIB_PATH</code></td><td>TDLib 库路径</td><td>自动检测</td></tr>
<tr><td><code>MAX_FILE_SIZE</code></td><td>最大上传文件大小</td><td>200MB</td></tr>
</tbody>
</table>
</div>
<!-- 11. 完整流程 -->
<div class="section" id="workflow">
<h2><span class="num">11</span> 完整调用流程示例</h2>
<div class="flow-card">
<span class="step">1</span>
<div class="content"><strong>POST /api/auth/register</strong> — 注册系统用户<br><code>{"email": "admin@example.com", "password": "***"}</code></div>
</div>
<div class="flow-arrow">↓</div>
<div class="flow-card">
<span class="step">2</span>
<div class="content"><strong>POST /api/auth/login</strong> — 登录获取 JWT Token<br><code>{"token": "eyJhbGciOiJIUzI1NiIs..."}</code></div>
</div>
<div class="flow-arrow">↓</div>
<div class="flow-card">
<span class="step">3</span>
<div class="content"><strong>POST /api/tenants</strong> — 创建租户<br>Header: <code>x-tenant-id: xxx</code></div>
</div>
<div class="flow-arrow">↓</div>
<div class="flow-card">
<span class="step">4</span>
<div class="content"><strong>POST /api/tg-accounts</strong> — 创建 TG 账号(自动启动 TDLib Worker)</div>
</div>
<div class="flow-arrow">↓</div>
<div class="flow-card">
<span class="step">5</span>
<div class="content"><strong>POST /api/tg-accounts/:id/auth/phone</strong> — 提交手机号<br><code>{"phoneNumber": "+8613800138000"}</code></div>
</div>
<div class="flow-arrow">↓</div>
<div class="flow-card">
<span class="step">6</span>
<div class="content"><strong>POST /api/tg-accounts/:id/auth/code</strong> — 提交验证码<br><code>{"code": "12345"}</code></div>
</div>
<div class="flow-arrow">↓</div>
<div class="flow-card">
<span class="step">7</span>
<div class="content"><strong>POST /api/tg-accounts/:id/messages</strong> — 发送消息<br><code>{"chatId": 123456, "text": "Hello!"}</code></div>
</div>
<div class="flow-arrow">↓</div>
<div class="flow-card">
<span class="step">8</span>
<div class="content"><strong>POST /api/tg-accounts/:id/webhooks</strong> — 创建 Webhook 订阅<br><code>{"url": "https://example.com/hook", "events": ["newMessage"]}</code></div>
</div>
</div>
<!-- 12. 构建部署 -->
<div class="section" id="deploy">
<h2><span class="num">12</span> 构建与部署</h2>
<h3>本地开发</h3>
<pre><span class="highlight"># 安装依赖</span>
npm install
<span class="highlight"># 开发模式(热重载)</span>
npm run start:dev
<span class="highlight"># 访问 Swagger 文档</span>
http://localhost:3000/api/docs</pre>
<h3>生产构建</h3>
<pre><span class="highlight"># 编译 TypeScript</span>
npm run build
<span class="highlight"># 启动生产服务</span>
npm run start:prod
<span class="highlight"># 运行数据库迁移</span>
npm run migration:run</pre>
<h3>Docker 部署</h3>
<pre><span class="highlight"># 一键启动(PostgreSQL + Redis + App)</span>
docker-compose -f docker/docker-compose.yml up -d
<span class="highlight"># 查看日志</span>
docker-compose -f docker/docker-compose.yml logs -f app</pre>
<h3>测试</h3>
<pre><span class="highlight"># 单元测试</span>
npm run test
<span class="highlight"># 端到端测试</span>
npm run test:e2e</pre>
</div>
</div>
<div class="footer">
<p>Telegram User Gateway v1.0.0 &nbsp;|&nbsp; 生成于 2026-06-24 &nbsp;|&nbsp; 基于源码自动分析</p>
</div>
</body>
</html>
+165
View File
@@ -0,0 +1,165 @@
// Preload .env before any module imports
import * as dotenv from 'dotenv';
dotenv.config({ path: ['.env', '.env.local'] });
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { appConfig } from './config/app.config';
import { databaseConfig } from './config/database.config';
import { redisConfig } from './config/redis.config';
import { rateLimitConfig } from './config/rate-limit.config';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { TenantsModule } from './modules/tenants/tenants.module';
import { AuthModule } from './modules/auth/auth.module';
import { TgAccountsModule } from './modules/tg-accounts/tg-accounts.module';
import { TgAuthModule } from './modules/tg-auth/tg-auth.module';
import { TgProxiesModule } from './modules/tg-proxies/tg-proxies.module';
import { TdlibModule } from './modules/tdlib/tdlib.module';
import { TgChatsModule } from './modules/tg-chats/tg-chats.module';
import { TgDialogsModule } from './modules/tg-dialogs/tg-dialogs.module';
import { TgContactsModule } from './modules/tg-contacts/tg-contacts.module';
import { TgMessagesModule } from './modules/tg-messages/tg-messages.module';
import { TgGroupsModule } from './modules/tg-groups/tg-groups.module';
import { TgChannelsModule } from './modules/tg-channels/tg-channels.module';
import { TgFilesModule } from './modules/tg-files/tg-files.module';
import { TgWebhooksModule } from './modules/tg-webhooks/tg-webhooks.module';
import { TgRateLimitModule } from './modules/tg-rate-limit/tg-rate-limit.module';
import { TgAuditModule } from './modules/tg-audit/tg-audit.module';
import { AdminModule } from './modules/admin/admin.module';
import { TgUpdatesModule } from './modules/tg-updates/tg-updates.module';
import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor';
// Conditionally import BullMQ
const redisEnabled = process.env.REDIS_ENABLED !== 'false';
const bullImports: any[] = [];
const bullProviders: any[] = [];
if (redisEnabled) {
const { BullModule } = require('@nestjs/bull');
bullImports.push(
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
redis: {
host: config.get('redis.host'),
port: config.get('redis.port'),
password: config.get('redis.password'),
db: config.get('redis.db'),
},
}),
}),
);
} else {
// Provide mock Bull queue tokens so injectors don't crash
const { getQueueToken } = require('@nestjs/bull');
const queueNames = ['send-message', 'file-download', 'webhook-delivery'];
for (const name of queueNames) {
bullProviders.push({
provide: getQueueToken(name),
useValue: { add: () => Promise.reject(new Error('Redis/BullMQ not available')), process: () => {} },
});
bullProviders.push({
provide: 'BullQueue_' + name,
useValue: { add: () => Promise.reject(new Error('Redis/BullMQ not available')), process: () => {} },
});
}
}
const imports: any[] = [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, redisConfig, rateLimitConfig],
envFilePath: ['.env'],
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const dbType = config.get('database.type') || 'postgres';
if (dbType === 'sqlite') {
const sqlitePath = config.get('database.sqlitePath') || 'data/gateway.sqlite';
const inMemory = sqlitePath === ':memory:';
return {
type: 'sqljs',
...(inMemory ? {} : { location: sqlitePath }),
autoSave: !inMemory,
entities: [__dirname + '/database/entities/**/*.entity.{ts,js}'],
synchronize: true,
};
}
return {
type: 'postgres',
host: config.get('database.host'),
port: config.get('database.port'),
username: config.get('database.username'),
password: config.get('database.password'),
database: config.get('database.database'),
entities: [__dirname + '/database/entities/**/*.entity.{ts,js}'],
synchronize:
config.get('database.synchronize') ||
config.get('app.nodeEnv') === 'development',
migrationsRun: config.get('database.migrationsRun') || false,
migrations: [__dirname + '/database/migrations/*.{ts,js}'],
logging: config.get('app.nodeEnv') === 'development',
};
},
}),
...bullImports,
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
throttlers: [
{
ttl: config.get('rateLimit.defaultTtl') || 60000,
limit: config.get('rateLimit.defaultLimit') || 30,
},
],
}),
}),
TenantsModule,
AuthModule,
TgAccountsModule,
TgAuthModule,
TgProxiesModule,
TdlibModule,
TgChatsModule,
TgDialogsModule,
TgContactsModule,
TgMessagesModule,
TgGroupsModule,
TgChannelsModule,
TgFilesModule,
TgWebhooksModule,
TgRateLimitModule,
TgAuditModule,
AdminModule,
TgUpdatesModule,
];
@Module({
imports,
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: AuditLogInterceptor,
},
...bullProviders,
],
})
export class AppModule {}
@@ -0,0 +1,17 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty, ApiResponse } from '@nestjs/swagger';
export class OkResponse<T> {
@ApiProperty()
success: boolean;
@ApiProperty()
data: T;
@ApiProperty()
timestamp: string;
}
export function ApiOkResponse(description: string) {
return applyDecorators(ApiResponse({ status: 200, description, type: OkResponse }));
}
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);
@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const Tenant = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.tenant;
},
);
@@ -0,0 +1,18 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { ErrorCode } from './error-codes';
export class AppException extends HttpException {
public readonly errorCode: ErrorCode;
public readonly details?: Record<string, unknown>;
constructor(
errorCode: ErrorCode,
message: string,
status: HttpStatus = HttpStatus.BAD_REQUEST,
details?: Record<string, unknown>,
) {
super({ errorCode, message, details }, status);
this.errorCode = errorCode;
this.details = details;
}
}
@@ -0,0 +1,63 @@
export const ErrorCodes = {
// General
INTERNAL_ERROR: 'INTERNAL_ERROR',
VALIDATION_ERROR: 'VALIDATION_ERROR',
NOT_FOUND: 'NOT_FOUND',
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
CONFLICT: 'CONFLICT',
RATE_LIMITED: 'RATE_LIMITED',
// Tenant
TENANT_NOT_FOUND: 'TENANT_NOT_FOUND',
TENANT_DISABLED: 'TENANT_DISABLED',
// Auth
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
TOKEN_INVALID: 'TOKEN_INVALID',
// TG Account
TG_ACCOUNT_NOT_FOUND: 'TG_ACCOUNT_NOT_FOUND',
TG_ACCOUNT_DISABLED: 'TG_ACCOUNT_DISABLED',
TG_ACCOUNT_LIMIT_REACHED: 'TG_ACCOUNT_LIMIT_REACHED',
TG_ACCOUNT_START_FAILED: 'TG_ACCOUNT_START_FAILED',
// TG Auth
TG_AUTH_ALREADY_AUTHORIZED: 'TG_AUTH_ALREADY_AUTHORIZED',
TG_AUTH_PHONE_INVALID: 'TG_AUTH_PHONE_INVALID',
TG_AUTH_CODE_INVALID: 'TG_AUTH_CODE_INVALID',
TG_AUTH_PASSWORD_INVALID: 'TG_AUTH_PASSWORD_INVALID',
TG_AUTH_CODE_EXPIRED: 'TG_AUTH_CODE_EXPIRED',
// TG Proxy
TG_PROXY_NOT_FOUND: 'TG_PROXY_NOT_FOUND',
TG_PROXY_CONNECTION_FAILED: 'TG_PROXY_CONNECTION_FAILED',
// TDLib
TDLIB_CLIENT_NOT_FOUND: 'TDLIB_CLIENT_NOT_FOUND',
TDLIB_CLIENT_ALREADY_RUNNING: 'TDLIB_CLIENT_ALREADY_RUNNING',
TDLIB_ERROR: 'TDLIB_ERROR',
// Chats
CHAT_NOT_FOUND: 'CHAT_NOT_FOUND',
INVALID_CHAT_ID: 'INVALID_CHAT_ID',
// Messages
MESSAGE_NOT_FOUND: 'MESSAGE_NOT_FOUND',
MESSAGE_SEND_FAILED: 'MESSAGE_SEND_FAILED',
MESSAGE_EDIT_FAILED: 'MESSAGE_EDIT_FAILED',
MESSAGE_DELETE_FAILED: 'MESSAGE_DELETE_FAILED',
// Files
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
FILE_TOO_LARGE: 'FILE_TOO_LARGE',
FILE_UPLOAD_FAILED: 'FILE_UPLOAD_FAILED',
FILE_DOWNLOAD_FAILED: 'FILE_DOWNLOAD_FAILED',
// Webhooks
WEBHOOK_NOT_FOUND: 'WEBHOOK_NOT_FOUND',
WEBHOOK_DELIVERY_FAILED: 'WEBHOOK_DELIVERY_FAILED',
} as const;
export type ErrorCode = string;
@@ -0,0 +1,57 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { AppException } from '../errors/app-exception';
import { ErrorCodes } from '../errors/error-codes';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let errorCode: string = ErrorCodes.INTERNAL_ERROR;
let message = 'Internal server error';
let details: Record<string, unknown> | undefined;
if (exception instanceof AppException) {
status = exception.getStatus();
errorCode = exception.errorCode;
message = exception.message;
details = exception.details;
} else if (exception instanceof HttpException) {
status = exception.getStatus();
message = exception.message;
const res = exception.getResponse();
if (typeof res === 'object' && res !== null) {
const r = res as Record<string, unknown>;
if (Array.isArray(r.message)) {
errorCode = ErrorCodes.VALIDATION_ERROR;
details = { errors: r.message };
message = 'Validation failed';
}
}
} else if (exception instanceof Error) {
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
}
response.status(status).json({
success: false,
errorCode,
message,
details,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
@@ -0,0 +1,23 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
export const IS_PUBLIC_KEY = 'isPublic';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
@@ -0,0 +1,44 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AppException } from '../errors/app-exception';
import { ErrorCodes } from '../errors/error-codes';
import { HttpStatus } from '@nestjs/common';
import { IS_PUBLIC_KEY } from './jwt-auth.guard';
@Injectable()
export class TenantGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// Skip if the route is marked as public
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const tokenTenantId = request.user?.tenantId;
const requestedTenantId = request.headers['x-tenant-id'] || request.query.tenantId;
const tenantId = requestedTenantId || tokenTenantId;
if (!tenantId) {
throw new AppException(
ErrorCodes.TENANT_NOT_FOUND,
'Tenant ID is required (x-tenant-id header)',
HttpStatus.BAD_REQUEST,
);
}
if (tokenTenantId && requestedTenantId && tokenTenantId !== requestedTenantId) {
throw new AppException(
ErrorCodes.FORBIDDEN,
'Tenant ID does not match the authenticated user',
HttpStatus.FORBIDDEN,
);
}
request.tenantId = tenantId;
return true;
}
}
@@ -0,0 +1,90 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { from, Observable, throwError } from 'rxjs';
import { catchError, mergeMap } from 'rxjs/operators';
import { Reflector } from '@nestjs/core';
import { TgAuditService } from '../../modules/tg-audit/tg-audit.service';
export const AUDIT_LOG_KEY = 'auditLog';
export interface AuditLogMetadata {
action: string;
resource: string;
}
@Injectable()
export class AuditLogInterceptor implements NestInterceptor {
constructor(
private reflector: Reflector,
private auditService: TgAuditService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const metadata = this.reflector.get<AuditLogMetadata>(
AUDIT_LOG_KEY,
context.getHandler(),
);
const request = context.switchToHttp().getRequest();
if (!metadata && ['GET', 'HEAD', 'OPTIONS'].includes(request.method)) {
return next.handle();
}
const startTime = Date.now();
const action = metadata?.action || request.method.toLowerCase();
const resource = metadata?.resource || request.route?.path || request.path;
return next.handle().pipe(
mergeMap((result) =>
from(
this.writeAudit(request, action, resource, context, startTime),
).pipe(mergeMap(() => [result])),
),
catchError((error) =>
from(
this.writeAudit(
request,
action,
resource,
context,
startTime,
error?.status || 500,
),
).pipe(mergeMap(() => throwError(() => error))),
),
);
}
private async writeAudit(
request: any,
action: string,
resource: string,
context: ExecutionContext,
startTime: number,
statusCode?: number,
) {
const tenantId = request.user?.tenantId || request.tenantId;
if (!tenantId) return;
try {
const response = context.switchToHttp().getResponse();
await this.auditService.log({
action,
resource,
resourceId: request.params?.id || request.params?.accountId,
tenantId,
userId: request.user?.id,
accountId: request.params?.accountId,
ipAddress: request.ip,
statusCode: statusCode || response.statusCode,
duration: Date.now() - startTime,
details: { requestId: request.requestId },
});
} catch {
// Auditing must not break the primary business request.
}
}
}
@@ -0,0 +1,22 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class RequestIdInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
const requestId = request.headers['x-request-id'] || uuidv4();
request.requestId = requestId;
const response = context.switchToHttp().getResponse();
response.setHeader('x-request-id', requestId);
return next.handle();
}
}
@@ -0,0 +1,30 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface WrappedResponse<T> {
success: boolean;
data: T;
timestamp: string;
}
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, WrappedResponse<T>> {
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<WrappedResponse<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}
@@ -0,0 +1,48 @@
import {
PipeTransform,
Injectable,
ArgumentMetadata,
HttpStatus,
} from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { AppException } from '../errors/app-exception';
import { ErrorCodes } from '../errors/error-codes';
@Injectable()
export class ValidationPipe implements PipeTransform<unknown> {
async transform(value: unknown, metadata: ArgumentMetadata) {
const { metatype } = metadata;
if (!metatype || !this.toValidate(metatype)) {
return value;
}
const object = plainToInstance(metatype, value);
const errors = await validate(object);
if (errors.length > 0) {
const messages = errors.flatMap((err) =>
Object.values(err.constraints || {}),
);
throw new AppException(
ErrorCodes.VALIDATION_ERROR,
'Validation failed',
HttpStatus.BAD_REQUEST,
{ errors: messages },
);
}
return object;
}
private toValidate(metatype: new (...args: unknown[]) => unknown): boolean {
const types: (new (...args: unknown[]) => unknown)[] = [
String,
Boolean,
Number,
Array,
Object,
];
return !types.includes(metatype);
}
}
@@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export const appConfig = registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3000', 10),
name: process.env.APP_NAME || 'TelegramUserGateway',
}));
@@ -0,0 +1,13 @@
import { registerAs } from '@nestjs/config';
export const databaseConfig = registerAs('database', () => ({
type: (process.env.DB_TYPE as 'postgres' | 'sqlite') || 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_DATABASE || 'telegram_gateway',
sqlitePath: process.env.DB_SQLITE_PATH || 'data/gateway.sqlite',
synchronize: process.env.DB_SYNCHRONIZE === 'true',
migrationsRun: process.env.DB_MIGRATIONS_RUN === 'true',
}));
@@ -0,0 +1,6 @@
import { registerAs } from '@nestjs/config';
export const rateLimitConfig = registerAs('rateLimit', () => ({
defaultTtl: parseInt(process.env.RATE_LIMIT_DEFAULT_TTL || '60000', 10),
defaultLimit: parseInt(process.env.RATE_LIMIT_DEFAULT_LIMIT || '30', 10),
}));
@@ -0,0 +1,8 @@
import { registerAs } from '@nestjs/config';
export const redisConfig = registerAs('redis', () => ({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB || '0', 10),
}));
@@ -0,0 +1,18 @@
import { DataSource } from 'typeorm';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config();
export const AppDataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_DATABASE || 'telegram_gateway',
entities: [path.join(__dirname, 'entities', '*.entity.{ts,js}')],
migrations: [path.join(__dirname, 'migrations', '*.{ts,js}')],
synchronize: process.env.NODE_ENV === 'development',
logging: process.env.NODE_ENV === 'development',
});
@@ -0,0 +1,17 @@
export { Tenant } from './tenant.entity';
export { User } from './user.entity';
export { TgAccount, TgAccountStatus } from './tg-account.entity';
export { TgProxy, ProxyType } from './tg-proxy.entity';
export { TgAccountProxyBinding } from './tg-account-proxy-binding.entity';
export { TgChat, ChatType } from './tg-chat.entity';
export { TgUser } from './tg-user.entity';
export { TgContact } from './tg-contact.entity';
export { TgGroup } from './tg-group.entity';
export { TgMessage } from './tg-message.entity';
export { TgSendJob, SendJobStatus } from './tg-send-job.entity';
export { TgFile, FileType, FileStorageBackend } from './tg-file.entity';
export { TgWebhookSubscription } from './tg-webhook-subscription.entity';
export { TgWebhookDelivery, DeliveryStatus } from './tg-webhook-delivery.entity';
export { TgAuditLog } from './tg-audit-log.entity';
export { TgRawUpdate } from './tg-raw-update.entity';
export { TgRateLimitEvent } from './tg-rate-limit-event.entity';
@@ -0,0 +1,40 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { User } from './user.entity';
import { TgAccount } from './tg-account.entity';
@Entity('tenants')
export class Tenant {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true, length: 100 })
name: string;
@Column({ default: true })
isActive: boolean;
@Column({ type: 'simple-json', nullable: true })
config: Record<string, unknown>;
@Column({ type: 'int', default: 5 })
maxAccounts: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@OneToMany(() => User, (user) => user.tenant)
users: User[];
@OneToMany(() => TgAccount, (account) => account.tenant)
tgAccounts: TgAccount[];
}
@@ -0,0 +1,36 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { TgAccount } from './tg-account.entity';
import { TgProxy } from './tg-proxy.entity';
@Entity('tg_account_proxy_bindings')
export class TgAccountProxyBinding {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
accountId: string;
@ManyToOne(() => TgAccount, (account) => account.proxyBindings)
@JoinColumn({ name: 'accountId' })
account: TgAccount;
@Column()
proxyId: string;
@ManyToOne(() => TgProxy, (proxy) => proxy.accountBindings)
@JoinColumn({ name: 'proxyId' })
proxy: TgProxy;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,82 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { Tenant } from './tenant.entity';
import { TgAccountProxyBinding } from './tg-account-proxy-binding.entity';
import { TgChat } from './tg-chat.entity';
export enum TgAccountStatus {
CREATED = 'created',
WAITING_TDLIB = 'waiting_tdlib',
WAITING_PHONE = 'waiting_phone',
WAITING_CODE = 'waiting_code',
WAITING_PASSWORD = 'waiting_password',
READY = 'ready',
DISABLED = 'disabled',
ERROR = 'error',
}
@Entity('tg_accounts')
export class TgAccount {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
tenantId: string;
@ManyToOne(() => Tenant, (tenant) => tenant.tgAccounts)
@JoinColumn({ name: 'tenantId' })
tenant: Tenant;
@Column({ length: 50, nullable: true })
phoneNumber: string;
@Column({ length: 255, nullable: true })
firstName: string;
@Column({ length: 255, nullable: true })
lastName: string;
@Column({ length: 255, nullable: true })
username: string;
@Column({ default: 0 })
tgUserId: number;
@Column({
type: 'varchar', length: 50,
default: TgAccountStatus.CREATED,
})
status: TgAccountStatus;
@Column({ nullable: true })
tdlibClientId: number;
@Column({ type: 'text', nullable: true })
tdlibState: string;
@Column({ type: 'simple-json', nullable: true })
metadata: Record<string, unknown>;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@OneToMany(() => TgAccountProxyBinding, (binding) => binding.account)
proxyBindings: TgAccountProxyBinding[];
@OneToMany(() => TgChat, (chat) => chat.account)
chats: TgChat[];
}
@@ -0,0 +1,47 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity('tg_audit_logs')
export class TgAuditLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column()
tenantId: string;
@Column({ nullable: true })
userId: string;
@Column({ nullable: true })
accountId: string;
@Column({ length: 100 })
action: string;
@Column({ length: 100 })
resource: string;
@Column({ nullable: true })
resourceId: string;
@Column({ length: 45, nullable: true })
ipAddress: string;
@Column({ type: 'simple-json', nullable: true })
details: Record<string, unknown>;
@Column({ type: 'int', nullable: true })
statusCode: number;
@Column({ type: 'int', nullable: true })
duration: number;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,64 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { TgAccount } from './tg-account.entity';
export enum ChatType {
PRIVATE = 'private',
GROUP = 'group',
SUPERGROUP = 'supergroup',
CHANNEL = 'channel',
SECRET = 'secret',
}
@Entity('tg_chats')
export class TgChat {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
accountId: string;
@ManyToOne(() => TgAccount, (account) => account.chats)
@JoinColumn({ name: 'accountId' })
account: TgAccount;
@Column({ type: 'bigint' })
tgChatId: number;
@Column({ type: 'varchar', length: 50 })
type: ChatType;
@Column({ length: 500 })
title: string;
@Column({ type: 'text', nullable: true })
lastMessage: string;
@Column({ type: 'bigint', default: 0 })
lastMessageId: number;
@Column({ nullable: true })
lastMessageDate: Date;
@Column({ type: 'int', default: 0 })
unreadCount: number;
@Column({ type: 'text', nullable: true, transformer: { to: (v) => v ? JSON.stringify(v) : null, from: (v) => v ? JSON.parse(v) : null } })
photo: Record<string, unknown>;
@Column({ type: 'text', nullable: true, transformer: { to: (v) => v ? JSON.stringify(v) : null, from: (v) => v ? JSON.parse(v) : null } })
raw: Record<string, unknown>;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,43 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('tg_contacts')
export class TgContact {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
accountId: string;
@Column({ type: 'bigint' })
tgUserId: number;
@Column({ length: 255, nullable: true })
firstName: string;
@Column({ length: 255, nullable: true })
lastName: string;
@Column({ length: 255, nullable: true })
username: string;
@Column({ length: 20, nullable: true })
phoneNumber: string;
@Column({ default: false })
isMutual: boolean;
@Column({ type: 'simple-json', nullable: true })
raw: Record<string, unknown>;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,70 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum FileType {
PHOTO = 'photo',
VIDEO = 'video',
DOCUMENT = 'document',
AUDIO = 'audio',
VOICE = 'voice',
STICKER = 'sticker',
ANIMATION = 'animation',
OTHER = 'other',
}
export enum FileStorageBackend {
LOCAL = 'local',
S3 = 's3',
}
@Entity('tg_files')
export class TgFile {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column()
accountId: string;
@Column({ type: 'bigint' })
tgFileId: number;
@Column({ type: 'varchar', length: 50, default: FileType.OTHER })
type: FileType;
@Column({ length: 500 })
fileName: string;
@Column({ length: 100 })
mimeType: string;
@Column({ type: 'bigint', default: 0 })
size: number;
@Column({
type: 'varchar', length: 50,
default: FileStorageBackend.LOCAL,
})
storageBackend: FileStorageBackend;
@Column({ length: 1000, nullable: true })
storagePath: string;
@Column({ length: 1000, nullable: true })
storageUrl: string;
@Column({ type: 'simple-json', nullable: true })
metadata: Record<string, unknown>;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('tg_groups')
export class TgGroup {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
accountId: string;
@Column({ type: 'bigint' })
tgChatId: number;
@Column({ length: 500 })
title: string;
@Column({ type: 'int', default: 0 })
memberCount: number;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ length: 255, nullable: true })
inviteLink: string;
@Column({ type: 'simple-json', nullable: true })
photo: Record<string, unknown>;
@Column({ type: 'simple-json', nullable: true })
permissions: Record<string, unknown>;
@Column({ type: 'simple-json', nullable: true })
raw: Record<string, unknown>;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,47 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity('tg_messages')
export class TgMessage {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column()
accountId: string;
@Column({ type: 'bigint' })
tgChatId: number;
@Column({ type: 'bigint' })
tgMessageId: number;
@Column({ type: 'bigint', nullable: true })
senderTgUserId: number;
@Column({ type: 'text', nullable: true })
text: string;
@Column({ type: 'text', nullable: true, transformer: { to: (v) => v ? JSON.stringify(v) : null, from: (v) => v ? JSON.parse(v) : null } })
content: Record<string, unknown>;
@Column({ default: false })
isOutgoing: boolean;
@Column({ nullable: true })
tgDate: Date;
@Column({ nullable: true })
editDate: Date;
@Column({ type: 'text', nullable: true, transformer: { to: (v) => v ? JSON.stringify(v) : null, from: (v) => v ? JSON.parse(v) : null } })
raw: Record<string, unknown>;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,64 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { Tenant } from './tenant.entity';
import { TgAccountProxyBinding } from './tg-account-proxy-binding.entity';
export enum ProxyType {
SOCKS5 = 'socks5',
HTTP = 'http',
MT_PROTO = 'mtproto',
}
@Entity('tg_proxies')
export class TgProxy {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
tenantId: string;
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenantId' })
tenant: Tenant;
@Column({ length: 255 })
name: string;
@Column({ type: 'varchar', length: 50 })
type: ProxyType;
@Column({ length: 255 })
host: string;
@Column({ type: 'int' })
port: number;
@Column({ length: 255, nullable: true })
username: string;
@Column({ length: 255, nullable: true, select: false })
password: string;
@Column({ type: 'text', nullable: true, select: false })
secret: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@OneToMany(() => TgAccountProxyBinding, (binding) => binding.proxy)
accountBindings: TgAccountProxyBinding[];
}
@@ -0,0 +1,35 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity('tg_rate_limit_events')
export class TgRateLimitEvent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column()
accountId: string;
@Column({ length: 50 })
action: string;
@Column({ length: 100 })
window: string;
@Column({ type: 'int' })
count: number;
@Column({ type: 'int' })
limit: number;
@Column()
blocked: boolean;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,29 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity('tg_raw_updates')
export class TgRawUpdate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column()
accountId: string;
@Column({ length: 100 })
updateType: string;
@Column({ type: 'simple-json' })
payload: Record<string, unknown>;
@Column({ default: false })
processed: boolean;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,59 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum SendJobStatus {
PENDING = 'pending',
PROCESSING = 'processing',
COMPLETED = 'completed',
FAILED = 'failed',
}
@Entity('tg_send_jobs')
@Index(['accountId', 'idempotencyKey'], { unique: true })
export class TgSendJob {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column()
accountId: string;
@Column({ type: 'bigint' })
tgChatId: number;
@Column()
bullJobId: string;
@Column({ type: 'varchar', length: 128, nullable: true })
idempotencyKey: string | null;
@Column({
type: 'varchar', length: 50,
default: SendJobStatus.PENDING,
})
status: SendJobStatus;
@Column({ type: 'simple-json' })
payload: Record<string, unknown>;
@Column({ type: 'simple-json', nullable: true })
result: Record<string, unknown>;
@Column({ type: 'text', nullable: true })
errorMessage: string;
@Column({ type: 'int', default: 0 })
attempt: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,43 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('tg_users')
export class TgUser {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'bigint', unique: true })
tgUserId: number;
@Column({ length: 255, nullable: true })
firstName: string;
@Column({ length: 255, nullable: true })
lastName: string;
@Column({ length: 255, nullable: true })
username: string;
@Column({ length: 20, nullable: true })
phoneNumber: string;
@Column({ type: 'simple-json', nullable: true })
photo: Record<string, unknown>;
@Column({ default: 'user' })
type: string;
@Column({ type: 'simple-json', nullable: true })
raw: Record<string, unknown>;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,56 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum DeliveryStatus {
PENDING = 'pending',
SUCCESS = 'success',
FAILED = 'failed',
RETRYING = 'retrying',
DEAD_LETTER = 'dead_letter',
}
@Entity('tg_webhook_deliveries')
export class TgWebhookDelivery {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column()
subscriptionId: string;
@Column({ length: 1000 })
url: string;
@Column({ type: 'simple-json' })
payload: Record<string, unknown>;
@Column({
type: 'varchar', length: 50,
default: DeliveryStatus.PENDING,
})
status: DeliveryStatus;
@Column({ type: 'int', nullable: true })
responseCode: number;
@Column({ type: 'text', nullable: true })
responseBody: string;
@Column({ type: 'int', default: 0 })
attempt: number;
@Column({ type: 'text', nullable: true })
errorMessage: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,52 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export type WebhookEvent =
| 'message.received'
| 'message.sent'
| 'message.edited'
| 'message.deleted'
| 'update.new_chat'
| 'update.user_status'
| 'update.callback_query'
| '*';
@Entity('tg_webhook_subscriptions')
export class TgWebhookSubscription {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column()
accountId: string;
@Column({ length: 1000 })
url: string;
@Column({ length: 255, nullable: true, select: false })
secret: string;
@Column({ type: 'simple-array' })
events: string[];
@Column({ default: true })
isActive: boolean;
@Column({ type: 'int', default: 8 })
maxRetries: number;
@Column({ nullable: true })
lastDeliveryAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,44 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Tenant } from './tenant.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 255 })
email: string;
@Column({ length: 255, select: false })
passwordHash: string;
@Column({ length: 255, nullable: true })
name: string;
@Column({ default: 'user' })
role: string;
@Column({ default: true })
isActive: boolean;
@Column()
tenantId: string;
@ManyToOne(() => Tenant, (tenant) => tenant.users)
@JoinColumn({ name: 'tenantId' })
tenant: Tenant;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialSchema1700000000000 implements MigrationInterface {
name = 'InitialSchema1700000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
if (queryRunner.connection.options.type === 'postgres') {
await queryRunner.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
}
// Capture the entity-to-database delta once, then execute it as this
// initial migration. This keeps the initial schema aligned with all
// registered entities while production still runs with synchronize=false.
const schemaBuilder = (
queryRunner.connection.driver as unknown as {
createSchemaBuilder: () => {
log: () => Promise<{
upQueries: Array<{ query: string; parameters?: unknown[] }>;
}>;
};
}
).createSchemaBuilder();
const schema = await schemaBuilder.log();
for (const query of schema.upQueries) {
await queryRunner.query(query.query, query.parameters);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tables = [
'tg_webhook_deliveries',
'tg_webhook_subscriptions',
'tg_rate_limit_events',
'tg_raw_updates',
'tg_audit_logs',
'tg_files',
'tg_send_jobs',
'tg_messages',
'tg_groups',
'tg_contacts',
'tg_users',
'tg_chats',
'tg_account_proxy_bindings',
'tg_proxies',
'tg_accounts',
'users',
'tenants',
];
for (const table of tables) {
await queryRunner.dropTable(table, true, true, true);
}
}
}
@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from 'typeorm';
export class SendJobIdempotency1700000001000 implements MigrationInterface {
name = 'SendJobIdempotency1700000001000';
public async up(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('tg_send_jobs');
if (!table?.findColumnByName('idempotencyKey')) {
await queryRunner.addColumn(
'tg_send_jobs',
new TableColumn({
name: 'idempotencyKey',
type: 'varchar',
length: '128',
isNullable: true,
}),
);
}
const refreshed = await queryRunner.getTable('tg_send_jobs');
if (!refreshed?.indices.some((index) => index.name === 'IDX_send_job_idempotency')) {
await queryRunner.createIndex(
'tg_send_jobs',
new TableIndex({
name: 'IDX_send_job_idempotency',
columnNames: ['accountId', 'idempotencyKey'],
isUnique: true,
}),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('tg_send_jobs');
if (table?.indices.some((index) => index.name === 'IDX_send_job_idempotency')) {
await queryRunner.dropIndex('tg_send_jobs', 'IDX_send_job_idempotency');
}
if (table?.findColumnByName('idempotencyKey')) {
await queryRunner.dropColumn('tg_send_jobs', 'idempotencyKey');
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { RequestIdInterceptor } from './common/interceptors/request-id.interceptor';
import { ValidationPipe } from './common/pipes/validation.pipe';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { Logger } from '@nestjs/common';
import helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const logger = new Logger('Bootstrap');
app.use(helmet());
const configuredOrigins = process.env.CORS_ORIGINS
?.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
app.enableCors({
origin:
configuredOrigins?.length
? configuredOrigins
: process.env.NODE_ENV === 'production'
? false
: true,
credentials: true,
});
app.enableShutdownHooks();
app.setGlobalPrefix('api');
app.useGlobalFilters(new AllExceptionsFilter());
app.useGlobalInterceptors(new RequestIdInterceptor(), new ResponseInterceptor());
app.useGlobalPipes(new ValidationPipe());
// Swagger
const config = new DocumentBuilder()
.setTitle('Telegram User Gateway API')
.setDescription('REST API for managing Telegram user accounts via TDLib')
.setVersion('1.0')
.addBearerAuth()
.addApiKey({ type: 'apiKey', name: 'x-tenant-id', in: 'header' }, 'tenant-id')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const port = process.env.PORT || 3000;
await app.listen(port);
logger.log(`Application running on http://localhost:${port}`);
logger.log(`Swagger docs at http://localhost:${port}/api/docs`);
}
bootstrap();
@@ -0,0 +1,48 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { SetMetadata } from '@nestjs/common';
import { AdminService } from './admin.service';
import { IS_PUBLIC_KEY } from '../../common/guards/jwt-auth.guard';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('Admin')
@Controller('admin')
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get('health')
@SetMetadata(IS_PUBLIC_KEY, true)
@ApiOperation({ summary: 'Health check endpoint' })
health() {
return this.adminService.getHealth();
}
@Get('ready')
@SetMetadata(IS_PUBLIC_KEY, true)
@ApiOperation({ summary: 'Database and Redis readiness check' })
ready() {
return this.adminService.getReadiness();
}
@Get('metrics')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Application metrics' })
metrics(@CurrentUser('tenantId') tenantId: string) {
return this.adminService.getMetrics(tenantId);
}
@Get('status')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'TDLib client status' })
status(@CurrentUser('tenantId') tenantId: string) {
return this.adminService.getClientStatus(tenantId);
}
@Get('alerts')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Evaluate active operational alerts' })
alerts(@CurrentUser('tenantId') tenantId: string) {
return this.adminService.getAlerts(tenantId);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { TdlibModule } from '../tdlib/tdlib.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TgAccount } from '../../database/entities/tg-account.entity';
import { TgWebhookDelivery } from '../../database/entities/tg-webhook-delivery.entity';
@Module({
imports: [TdlibModule, TypeOrmModule.forFeature([TgAccount, TgWebhookDelivery])],
providers: [AdminService],
controllers: [AdminController],
})
export class AdminModule {}
@@ -0,0 +1,177 @@
import {
Injectable,
OnModuleDestroy,
ServiceUnavailableException,
} from '@nestjs/common';
import { TdlibClientManagerService } from '../tdlib/tdlib-client-manager.service';
import { DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TgAccount } from '../../database/entities/tg-account.entity';
import {
DeliveryStatus,
TgWebhookDelivery,
} from '../../database/entities/tg-webhook-delivery.entity';
import { In } from 'typeorm';
import { getHeapStatistics } from 'v8';
@Injectable()
export class AdminService implements OnModuleDestroy {
private readonly redis?: Redis;
constructor(
private tdlibClientManager: TdlibClientManagerService,
private dataSource: DataSource,
config: ConfigService,
@InjectRepository(TgAccount)
private readonly accountRepo: Repository<TgAccount>,
@InjectRepository(TgWebhookDelivery)
private readonly deliveryRepo: Repository<TgWebhookDelivery>,
) {
if (process.env.REDIS_ENABLED !== 'false') {
this.redis = new Redis({
host: config.get('redis.host'),
port: config.get('redis.port'),
password: config.get('redis.password'),
db: config.get('redis.db'),
lazyConnect: true,
enableOfflineQueue: false,
maxRetriesPerRequest: 1,
});
this.redis.on('error', () => undefined);
}
}
getHealth() {
return {
status: 'ok',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
memory: process.memoryUsage(),
nodeVersion: process.version,
};
}
async getMetrics(tenantId: string) {
const clientIds = await this.getTenantClientIds(tenantId);
return {
activeTdlibClients: clientIds.length,
clients: clientIds,
memoryUsage: process.memoryUsage(),
cpuUsage: process.cpuUsage(),
};
}
async getClientStatus(tenantId: string) {
return {
activeClients: await this.getTenantClientIds(tenantId),
};
}
async getAlerts(tenantId: string) {
const accounts = await this.accountRepo.find({
where: { tenantId },
select: ['id', 'status'],
});
const accountIds = accounts.map((account) => account.id);
const alerts: Array<{
code: string;
severity: 'warning' | 'critical';
value: number | string;
threshold?: number;
}> = [];
const memory = process.memoryUsage();
const heapLimit = getHeapStatistics().heap_size_limit;
const heapRatio = heapLimit ? memory.heapUsed / heapLimit : 0;
const memoryThreshold = Number(process.env.ALERT_HEAP_RATIO || 0.9);
if (heapRatio >= memoryThreshold) {
alerts.push({
code: 'HIGH_HEAP_USAGE',
severity: 'warning',
value: Number(heapRatio.toFixed(4)),
threshold: memoryThreshold,
});
}
const errorAccounts = accounts.filter((account) => account.status === 'error').length;
if (errorAccounts) {
alerts.push({
code: 'TDLIB_ACCOUNT_ERRORS',
severity: 'critical',
value: errorAccounts,
});
}
if (accountIds.length) {
const deadLetters = await this.deliveryRepo.count({
where: {
status: DeliveryStatus.DEAD_LETTER,
subscriptionId: In(
(await this.dataSource.query(
'SELECT id FROM tg_webhook_subscriptions WHERE "accountId" = ANY($1)',
[accountIds],
)).map((row: { id: string }) => row.id),
),
},
});
if (deadLetters) {
alerts.push({
code: 'WEBHOOK_DEAD_LETTERS',
severity: 'warning',
value: deadLetters,
});
}
}
return {
status: alerts.length ? 'active' : 'clear',
alerts,
evaluatedAt: new Date().toISOString(),
};
}
async getReadiness() {
const checks: Record<string, string> = {};
try {
await this.dataSource.query('SELECT 1');
checks.database = 'up';
} catch {
checks.database = 'down';
}
if (this.redis) {
try {
if (this.redis.status === 'wait') await this.redis.connect();
checks.redis = await this.redis.ping() === 'PONG' ? 'up' : 'down';
} catch {
checks.redis = 'down';
}
} else {
checks.redis = 'disabled';
}
if (checks.database !== 'up' || checks.redis === 'down') {
throw new ServiceUnavailableException({
status: 'not_ready',
checks,
});
}
return { status: 'ready', checks };
}
async onModuleDestroy() {
if (this.redis && this.redis.status !== 'end') {
await this.redis.quit().catch(() => undefined);
}
}
private async getTenantClientIds(tenantId: string): Promise<string[]> {
const accounts = await this.accountRepo.find({
where: { tenantId },
select: ['id'],
});
const allowed = new Set(accounts.map((account) => account.id));
return this.tdlibClientManager
.getClientIds()
.filter((accountId) => allowed.has(accountId));
}
}
@@ -0,0 +1,24 @@
import { Controller, Post, Body } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto } from './dto/auth.dto';
import { SetMetadata } from '@nestjs/common';
import { IS_PUBLIC_KEY } from '../../common/guards/jwt-auth.guard';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@SetMetadata(IS_PUBLIC_KEY, true)
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
@SetMetadata(IS_PUBLIC_KEY, true)
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
}
@@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { User } from '../../database/entities/user.entity';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { TenantsModule } from '../tenants/tenants.module';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET') || 'change-me',
signOptions: {
expiresIn: config.get('JWT_EXPIRATION') || '24h',
},
}),
}),
TenantsModule,
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
@@ -0,0 +1,96 @@
import { Injectable, HttpStatus } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { User } from '../../database/entities/user.entity';
import { RegisterDto, LoginDto } from './dto/auth.dto';
import { AppException } from '../../common/errors/app-exception';
import { ErrorCodes } from '../../common/errors/error-codes';
import { JwtPayload } from './jwt.strategy';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
private jwtService: JwtService,
) {}
async register(dto: RegisterDto): Promise<{ user: Omit<User, 'passwordHash'>; token: string }> {
const existing = await this.userRepo.findOne({
where: { email: dto.email, tenantId: dto.tenantId },
});
if (existing) {
throw new AppException(ErrorCodes.CONFLICT, 'User already exists in this tenant', HttpStatus.CONFLICT);
}
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = this.userRepo.create({
email: dto.email,
passwordHash,
tenantId: dto.tenantId,
name: dto.name,
});
const saved = await this.userRepo.save(user);
const token = this.generateToken(saved);
return { user: this.toPublicUser(saved), token };
}
async login(dto: LoginDto): Promise<{ user: Omit<User, 'passwordHash'>; token: string }> {
const user = await this.userRepo
.createQueryBuilder('user')
.addSelect('user.passwordHash')
.where('user.email = :email', { email: dto.email })
.andWhere('user.tenantId = :tenantId', { tenantId: dto.tenantId })
.getOne();
if (!user) {
throw new AppException(
ErrorCodes.INVALID_CREDENTIALS,
'Invalid email or password',
HttpStatus.UNAUTHORIZED,
);
}
if (!user.isActive) {
throw new AppException(
ErrorCodes.FORBIDDEN,
'User account is disabled',
HttpStatus.FORBIDDEN,
);
}
const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash);
if (!isPasswordValid) {
throw new AppException(
ErrorCodes.INVALID_CREDENTIALS,
'Invalid email or password',
HttpStatus.UNAUTHORIZED,
);
}
const token = this.generateToken(user);
return { user: this.toPublicUser(user), token };
}
async validateUserById(id: string): Promise<User | null> {
const user = await this.userRepo.findOne({ where: { id, isActive: true } });
return user || null;
}
private generateToken(user: User): string {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
tenantId: user.tenantId,
role: user.role,
};
return this.jwtService.sign(payload);
}
private toPublicUser(user: User): Omit<User, 'passwordHash'> {
const { passwordHash: _passwordHash, ...publicUser } = user;
return publicUser;
}
}
@@ -0,0 +1,36 @@
import { IsString, IsEmail, MinLength, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ description: 'User email' })
@IsEmail()
email: string;
@ApiProperty({ description: 'Password (min 8 chars)' })
@IsString()
@MinLength(8)
password: string;
@ApiProperty({ description: 'Tenant ID' })
@IsString()
tenantId: string;
@ApiPropertyOptional({ description: 'Display name' })
@IsOptional()
@IsString()
name?: string;
}
export class LoginDto {
@ApiProperty({ description: 'User email' })
@IsEmail()
email: string;
@ApiProperty({ description: 'Password' })
@IsString()
password: string;
@ApiProperty({ description: 'Tenant ID' })
@IsString()
tenantId: string;
}
@@ -0,0 +1,39 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
export interface JwtPayload {
sub: string;
email: string;
tenantId: string;
role: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
configService: ConfigService,
private authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET') || 'change-me',
});
}
async validate(payload: JwtPayload) {
const user = await this.authService.validateUserById(payload.sub);
if (!user) {
throw new UnauthorizedException('User not found or inactive');
}
return {
id: payload.sub,
email: payload.email,
tenantId: payload.tenantId,
role: payload.role,
};
}
}
@@ -0,0 +1,438 @@
import { Injectable, Logger, OnModuleDestroy, HttpStatus } from '@nestjs/common';
import { Worker } from 'worker_threads';
import { v4 as uuidv4 } from 'uuid';
import { ConfigService } from '@nestjs/config';
import * as path from 'path';
import * as fs from 'fs';
import { Subject, filter, firstValueFrom, map, timeout } from 'rxjs';
import { AppException } from '../../common/errors/app-exception';
import { ErrorCodes } from '../../common/errors/error-codes';
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
timer: NodeJS.Timeout;
}
interface WorkerInit {
clientId: number;
tdlibPath?: string;
databaseDirectory: string;
apiId: number;
apiHash: string;
proxy?: {
type: string;
host: string;
port: number;
username?: string;
password?: string;
secret?: string;
};
}
export interface TdlibUpdate {
accountId: string;
clientId: number;
data: Record<string, unknown>;
}
interface WorkerMessage {
clientId: number;
data: Record<string, unknown>;
}
export function parseFloodWaitSeconds(error: unknown): number | null {
const candidate = error as {
message?: string;
details?: { tdlibCode?: number; tdlibMessage?: string };
};
const text = `${candidate?.message || ''} ${candidate?.details?.tdlibMessage || ''}`;
const match = text.match(/(?:retry\s+after\s*|FLOOD_WAIT[_\s-]*)(\d+)/i);
if (match) return Math.max(1, Number(match[1]));
const code = Number(candidate?.details?.tdlibCode);
return code === 420 || code === 429 ? 1 : null;
}
// Internal @extra values used by the worker for init handshake — not user requests
const INTERNAL_EXTRAS = new Set([
'init_params', 'enc_key', 'proxy_setup', 'proxy_disable', 'log_level', 'shutdown', '__internal__',
'worker_ready',
]);
@Injectable()
export class TdlibClientManagerService implements OnModuleDestroy {
private readonly logger = new Logger(TdlibClientManagerService.name);
private workers = new Map<string, Worker>();
private pendingRequests = new Map<string, PendingRequest>();
private clientIdCounter = 0;
private readonly tdlibPath: string;
private readonly baseDbDir: string;
private readonly requestTimeout = 30000; // 30 seconds
private readonly defaultApiId: number;
private readonly defaultApiHash: string;
// Track auth state per account (updated from worker's unsolicited updates)
private accountAuthState = new Map<string, string>();
private accountConnectionState = new Map<string, string>();
// Map clientId->accountId for reverse lookup
private clientIdToAccountId = new Map<number, string>();
private readonly updateSubject = new Subject<TdlibUpdate>();
private readonly completedSends = new Map<string, Record<string, unknown>>();
readonly updates$ = this.updateSubject.asObservable();
constructor(private configService: ConfigService) {
this.tdlibPath = configService.get('TDLIB_PATH') || '';
this.baseDbDir = path.join(process.cwd(), 'tdlib_data');
this.defaultApiId = parseInt(configService.get('TG_API_ID') || '0', 10);
this.defaultApiHash = configService.get('TG_API_HASH') || '';
if (!fs.existsSync(this.baseDbDir)) {
fs.mkdirSync(this.baseDbDir, { recursive: true });
}
}
async startClient(accountId: string, config?: {
apiId?: number;
apiHash?: string;
proxy?: WorkerInit['proxy'];
}): Promise<number> {
if (this.workers.has(accountId)) {
this.logger.warn(`Client already running for account ${accountId}`);
return this.getExistingClientId(accountId);
}
const clientId = ++this.clientIdCounter;
const dbDir = path.join(this.baseDbDir, accountId);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const workerData: WorkerInit = {
clientId,
databaseDirectory: dbDir,
apiId: config?.apiId || this.defaultApiId,
apiHash: config?.apiHash || this.defaultApiHash,
proxy: config?.proxy,
};
if (this.tdlibPath) {
workerData.tdlibPath = this.tdlibPath;
}
const workerPath = path.join(__dirname, '..', '..', 'workers', 'tdlib-client.worker.js');
try {
const worker = new Worker(workerPath, { workerData });
worker.on('message', (msg: WorkerMessage) => {
this.handleTdlibResponse(msg);
});
worker.on('error', (err) => {
this.logger.error(`Worker error for account ${accountId}: ${err.message}`);
this.cleanupWorker(accountId);
});
worker.on('exit', (code) => {
this.logger.warn(`Worker exited for account ${accountId} with code ${code}`);
this.workers.delete(accountId);
});
this.workers.set(accountId, worker);
this.clientIdToAccountId.set(clientId, accountId);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error('TDLib worker initialization timed out'));
}, 10_000);
const onMessage = (msg: WorkerMessage) => {
if (
msg.clientId === clientId &&
msg.data['@extra'] === 'worker_ready'
) {
cleanup();
resolve();
}
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const cleanup = () => {
clearTimeout(timer);
worker.off('message', onMessage);
worker.off('error', onError);
};
worker.on('message', onMessage);
worker.on('error', onError);
});
this.logger.log(`TDLib client started for account ${accountId} (clientId: ${clientId})`);
return clientId;
} catch (err) {
this.cleanupWorker(accountId);
this.logger.error(`Failed to start TDLib client for ${accountId}: ${err}`);
throw err;
}
}
async stopClient(accountId: string): Promise<void> {
const worker = this.workers.get(accountId);
if (!worker) {
return;
}
const exited = new Promise<void>((resolve) => {
worker.once('exit', () => resolve());
worker.once('error', () => resolve());
});
worker.postMessage({ type: 'stop' });
const graceful = await Promise.race([
exited.then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 5000)),
]);
if (!graceful) {
await worker.terminate().catch(() => {});
}
this.cleanupWorker(accountId);
this.logger.log(`TDLib client stopped for account ${accountId}`);
}
async sendRequest(accountId: string, request: Record<string, unknown>): Promise<unknown> {
const maxRetries = Math.max(
0,
Number(this.configService.get('TDLIB_FLOOD_WAIT_RETRIES') || 3),
);
const maxWaitSeconds = Math.max(
1,
Number(this.configService.get('TDLIB_FLOOD_WAIT_MAX_SECONDS') || 60),
);
for (let retry = 0; ; retry++) {
try {
return await this.sendRequestOnce(accountId, request);
} catch (error) {
const waitSeconds = parseFloodWaitSeconds(error);
if (
waitSeconds === null ||
retry >= maxRetries ||
waitSeconds > maxWaitSeconds
) {
throw error;
}
this.logger.warn(
`Telegram FloodWait for account ${accountId}; retrying `
+ `${request['@type']} in ${waitSeconds}s (${retry + 1}/${maxRetries})`,
);
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
}
}
}
private async sendRequestOnce(
accountId: string,
request: Record<string, unknown>,
): Promise<unknown> {
const worker = this.workers.get(accountId);
if (!worker) {
throw new AppException(
ErrorCodes.TDLIB_CLIENT_NOT_FOUND,
`No TDLib client running for account ${accountId}`,
HttpStatus.SERVICE_UNAVAILABLE,
);
}
const extra = uuidv4();
const requestWithExtra = { ...request, '@extra': extra };
this.logger.log(`Sending request to worker ${accountId}: @type=${request['@type']}, @extra=${extra}`);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(extra);
this.logger.warn(`Request TIMED OUT for ${accountId}: ${extra} (type: ${request['@type']})`);
reject(new AppException(
ErrorCodes.TDLIB_ERROR,
`TDLib request timed out for account ${accountId}`,
HttpStatus.GATEWAY_TIMEOUT,
));
}, this.requestTimeout);
this.pendingRequests.set(extra, { resolve, reject, timer });
worker.postMessage({ type: 'send', request: requestWithExtra });
});
}
async execute(accountId: string, request: Record<string, unknown>): Promise<unknown> {
return this.sendRequest(accountId, request);
}
isRunning(accountId: string): boolean {
return this.workers.has(accountId);
}
getAuthState(accountId: string): string {
return this.accountAuthState.get(accountId) || 'unknown';
}
getConnectionState(accountId: string): string {
return this.accountConnectionState.get(accountId) || 'unknown';
}
getClientIds(): string[] {
return Array.from(this.workers.keys());
}
async waitForMessageSendSucceeded(
accountId: string,
temporaryMessageId: number,
timeoutMs = 30_000,
): Promise<Record<string, unknown>> {
const key = `${accountId}:${temporaryMessageId}`;
const completed = this.completedSends.get(key);
if (completed) {
this.completedSends.delete(key);
return completed;
}
return firstValueFrom(
this.updates$.pipe(
filter((update) =>
update.accountId === accountId &&
update.data['@type'] === 'updateMessageSendSucceeded' &&
Number(update.data['old_message_id']) === Number(temporaryMessageId),
),
map((update) => update.data.message as Record<string, unknown>),
timeout(timeoutMs),
),
);
}
private handleTdlibResponse(msg: WorkerMessage) {
const type = msg.data['@type'] as string;
const extra = msg.data['@extra'] as string;
// Skip internal handshake extras
if (extra && INTERNAL_EXTRAS.has(extra)) {
if (type === 'error') {
this.logger.error(
`TDLib initialization step ${extra} failed for client ${msg.clientId}: `
+ `${msg.data.code ?? 'unknown'} ${msg.data.message ?? 'Unknown error'}`,
);
}
// __internal__ is sent when authorizationStateReady is detected
if (extra === '__internal__' && type === 'authorizationStateReady') {
const accountId = this.clientIdToAccountId.get(msg.clientId);
if (accountId) {
this.accountAuthState.set(accountId, 'ready');
this.logger.log(`Account ${accountId} authorization state: ready`);
}
}
return;
}
if (!extra) {
this.handleUnsolicitedUpdate(msg);
return;
}
const pending = this.pendingRequests.get(extra);
if (pending) {
this.logger.log(`Got response for @extra=${extra}: @type=${type}`);
clearTimeout(pending.timer);
this.pendingRequests.delete(extra);
if (type === 'error') {
const tdErr = msg.data as Record<string, unknown>;
pending.reject(new AppException(
ErrorCodes.TDLIB_ERROR,
`TDLib error: ${tdErr.message || 'Unknown'} (code: ${tdErr.code})`,
(tdErr.code as number) === 404 ? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST,
{ tdlibCode: tdErr.code, tdlibMessage: tdErr.message },
));
} else {
pending.resolve(msg.data);
}
} else {
this.logger.debug(`No pending request for @extra=${extra} (type=${type}), forwarding as unsolicited`);
this.handleUnsolicitedUpdate(msg);
}
}
private handleUnsolicitedUpdate(msg: WorkerMessage) {
const updateType = msg.data['@type'] as string;
const accountId = this.clientIdToAccountId.get(msg.clientId);
if (accountId) {
if (updateType === 'updateConnectionState') {
const rawState = (msg.data.state as Record<string, unknown> | undefined)?.['@type'];
const state = String(rawState || 'unknown').replace('connectionState', '');
this.accountConnectionState.set(accountId, state.replace(/^[A-Z]/, (c) => c.toLowerCase()));
this.logger.log(`Account ${accountId} connection state: ${state}`);
}
if (updateType === 'updateMessageSendSucceeded') {
const oldMessageId = Number(msg.data['old_message_id']);
const message = msg.data.message as Record<string, unknown>;
const key = `${accountId}:${oldMessageId}`;
this.completedSends.set(key, message);
setTimeout(() => this.completedSends.delete(key), 60_000).unref();
}
this.updateSubject.next({
accountId,
clientId: msg.clientId,
data: msg.data,
});
}
// Track auth state changes
if (updateType === 'updateAuthorizationState') {
const authState = (msg.data['authorization_state'] as Record<string, unknown>)?.['@type'] as string;
if (accountId && authState) {
// Extract just the suffix (e.g., "authorizationStateReady" → "ready")
const state = authState.replace('authorizationState', '').replace(/^[A-Z]/, (c) => c.toLowerCase());
this.accountAuthState.set(accountId, state);
this.logger.log(`Account ${accountId} auth state: ${state}`);
}
}
// Suppress debug log for high-frequency update types to reduce noise
const noisyPrefixes = ['updateOption', 'updateUserStatus', 'updateChatLastMessage', 'updateUnread'];
const isNoisy = noisyPrefixes.some(p => updateType?.startsWith(p));
if (!isNoisy) {
this.logger.debug(`Unsolicited update from client ${msg.clientId}: ${updateType}`);
}
}
private getExistingClientId(accountId: string): number {
for (const [cid, aid] of this.clientIdToAccountId) {
if (aid === accountId) return cid;
}
return -1;
}
private cleanupWorker(accountId: string) {
const worker = this.workers.get(accountId);
// Find and clean up clientId mapping even if the worker's exit handler
// already removed it from the workers map.
for (const [cid, aid] of this.clientIdToAccountId) {
if (aid === accountId) {
this.clientIdToAccountId.delete(cid);
break;
}
}
this.accountAuthState.delete(accountId);
this.accountConnectionState.delete(accountId);
if (worker) {
worker.removeAllListeners();
worker.terminate().catch(() => {});
this.workers.delete(accountId);
}
}
onModuleDestroy() {
for (const [accountId] of this.workers) {
this.stopClient(accountId);
}
this.updateSubject.complete();
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TdlibClientManagerService } from './tdlib-client-manager.service';
import { TgAuditModule } from '../tg-audit/tg-audit.module';
@Module({
imports: [TgAuditModule],
providers: [TdlibClientManagerService],
exports: [TdlibClientManagerService],
})
export class TdlibModule {}
@@ -0,0 +1,42 @@
import { IsString, IsOptional, IsInt, Min, Max, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTenantDto {
@ApiProperty({ description: 'Tenant name' })
@IsString()
name: string;
@ApiPropertyOptional({ description: 'Maximum number of Telegram accounts', default: 5 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
maxAccounts?: number;
@ApiPropertyOptional({ description: 'Tenant-specific configuration' })
@IsOptional()
config?: Record<string, unknown>;
}
export class UpdateTenantDto {
@ApiPropertyOptional({ description: 'Tenant name' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ description: 'Whether the tenant is active' })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ description: 'Maximum number of Telegram accounts' })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
maxAccounts?: number;
@ApiPropertyOptional()
@IsOptional()
config?: Record<string, unknown>;
}
@@ -0,0 +1,48 @@
import {
Controller, Get, Post, Put, Delete, Body, Param, UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiSecurity } from '@nestjs/swagger';
import { TenantsService } from './tenants.service';
import { CreateTenantDto, UpdateTenantDto } from './dto/create-tenant.dto';
import { TenantGuard } from '../../common/guards/tenant.guard';
import { IS_PUBLIC_KEY } from '../../common/guards/jwt-auth.guard';
import { SetMetadata } from '@nestjs/common';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('Tenants')
@ApiBearerAuth()
@ApiSecurity('tenant-id')
@Controller('tenants')
@UseGuards(TenantGuard)
export class TenantsController {
constructor(private readonly tenantsService: TenantsService) {}
@Post()
@SetMetadata(IS_PUBLIC_KEY, true)
create(@Body() dto: CreateTenantDto) {
return this.tenantsService.create(dto);
}
@Get()
async findAll(@CurrentUser('tenantId') tenantId: string) {
return [await this.tenantsService.findById(tenantId)];
}
@Get(':id')
findOne(@CurrentUser('tenantId') tenantId: string) {
return this.tenantsService.findById(tenantId);
}
@Put(':id')
update(
@CurrentUser('tenantId') tenantId: string,
@Body() dto: UpdateTenantDto,
) {
return this.tenantsService.update(tenantId, dto);
}
@Delete(':id')
remove(@CurrentUser('tenantId') tenantId: string) {
return this.tenantsService.remove(tenantId);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tenant } from '../../database/entities/tenant.entity';
import { TenantsService } from './tenants.service';
import { TenantsController } from './tenants.controller';
@Module({
imports: [TypeOrmModule.forFeature([Tenant])],
providers: [TenantsService],
controllers: [TenantsController],
exports: [TenantsService],
})
export class TenantsModule {}
@@ -0,0 +1,53 @@
import { Injectable, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tenant } from '../../database/entities/tenant.entity';
import { CreateTenantDto, UpdateTenantDto } from './dto/create-tenant.dto';
import { AppException } from '../../common/errors/app-exception';
import { ErrorCodes } from '../../common/errors/error-codes';
@Injectable()
export class TenantsService {
constructor(
@InjectRepository(Tenant)
private tenantRepo: Repository<Tenant>,
) {}
async create(dto: CreateTenantDto): Promise<Tenant> {
const existing = await this.tenantRepo.findOne({ where: { name: dto.name } });
if (existing) {
throw new AppException(ErrorCodes.CONFLICT, 'Tenant name already exists', HttpStatus.CONFLICT);
}
const tenant = this.tenantRepo.create(dto);
return this.tenantRepo.save(tenant);
}
async findAll(): Promise<Tenant[]> {
return this.tenantRepo.find();
}
async findById(id: string): Promise<Tenant> {
const tenant = await this.tenantRepo.findOne({ where: { id } });
if (!tenant) {
throw new AppException(ErrorCodes.TENANT_NOT_FOUND, 'Tenant not found', HttpStatus.NOT_FOUND);
}
return tenant;
}
async update(id: string, dto: UpdateTenantDto): Promise<Tenant> {
const tenant = await this.findById(id);
Object.assign(tenant, dto);
return this.tenantRepo.save(tenant);
}
async remove(id: string): Promise<void> {
const tenant = await this.findById(id);
await this.tenantRepo.remove(tenant);
}
async isTenantActive(id: string): Promise<boolean> {
const tenant = await this.tenantRepo.findOne({ where: { id }, select: ['isActive'] });
return tenant?.isActive ?? false;
}
}
@@ -0,0 +1,39 @@
import { IsString, IsOptional, IsBoolean, IsInt } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTgAccountDto {
@ApiProperty({ description: 'Phone number in international format' })
@IsString()
phoneNumber: string;
@ApiPropertyOptional({ description: 'First name for the account' })
@IsOptional()
@IsString()
firstName?: string;
@ApiPropertyOptional({ description: 'Last name for the account' })
@IsOptional()
@IsString()
lastName?: string;
@ApiPropertyOptional({ description: 'Telegram API ID' })
@IsOptional()
@IsInt()
apiId?: number;
@ApiPropertyOptional({ description: 'Telegram API hash' })
@IsOptional()
@IsString()
apiHash?: string;
}
export class UpdateTgAccountDto {
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional()
@IsOptional()
metadata?: Record<string, unknown>;
}
@@ -0,0 +1,62 @@
import {
Controller, Get, Post, Put, Delete, Body, Param, UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiSecurity } from '@nestjs/swagger';
import { TgAccountsService } from './tg-accounts.service';
import { CreateTgAccountDto, UpdateTgAccountDto } from './dto/tg-account.dto';
import { TenantGuard } from '../../common/guards/tenant.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('TG Accounts')
@ApiBearerAuth()
@ApiSecurity('tenant-id')
@Controller('tg-accounts')
@UseGuards(TenantGuard)
export class TgAccountsController {
constructor(private readonly tgAccountsService: TgAccountsService) {}
@Post()
create(@CurrentUser('tenantId') tenantId: string, @Body() dto: CreateTgAccountDto) {
return this.tgAccountsService.create(tenantId, dto);
}
@Get()
findAll(@CurrentUser('tenantId') tenantId: string) {
return this.tgAccountsService.findAll(tenantId);
}
@Get(':id')
findOne(@CurrentUser('tenantId') tenantId: string, @Param('id') id: string) {
return this.tgAccountsService.findById(tenantId, id);
}
@Put(':id')
update(
@CurrentUser('tenantId') tenantId: string,
@Param('id') id: string,
@Body() dto: UpdateTgAccountDto,
) {
return this.tgAccountsService.update(tenantId, id, dto);
}
@Delete(':id')
remove(@CurrentUser('tenantId') tenantId: string, @Param('id') id: string) {
return this.tgAccountsService.remove(tenantId, id);
}
@Post(':id/start')
start(
@CurrentUser('tenantId') tenantId: string,
@Param('id') id: string,
) {
return this.tgAccountsService.startClient(tenantId, id);
}
@Post(':id/stop')
stop(
@CurrentUser('tenantId') tenantId: string,
@Param('id') id: string,
) {
return this.tgAccountsService.stopClient(tenantId, id);
}
}
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TgAccount } from '../../database/entities/tg-account.entity';
import { TgAccountsService } from './tg-accounts.service';
import { TgAccountsController } from './tg-accounts.controller';
import { TdlibModule } from '../tdlib/tdlib.module';
import { TenantsModule } from '../tenants/tenants.module';
import { TgAccountProxyBinding } from '../../database/entities/tg-account-proxy-binding.entity';
import { TgProxy } from '../../database/entities/tg-proxy.entity';
@Module({
imports: [
TypeOrmModule.forFeature([TgAccount, TgAccountProxyBinding, TgProxy]),
TdlibModule,
TenantsModule,
],
providers: [TgAccountsService],
controllers: [TgAccountsController],
exports: [TgAccountsService],
})
export class TgAccountsModule {}
@@ -0,0 +1,193 @@
import { Injectable, HttpStatus, Logger, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { TgAccount, TgAccountStatus } from '../../database/entities/tg-account.entity';
import { CreateTgAccountDto, UpdateTgAccountDto } from './dto/tg-account.dto';
import { TdlibClientManagerService } from '../tdlib/tdlib-client-manager.service';
import { AppException } from '../../common/errors/app-exception';
import { ErrorCodes } from '../../common/errors/error-codes';
import { TenantsService } from '../tenants/tenants.service';
import { TgAccountProxyBinding } from '../../database/entities/tg-account-proxy-binding.entity';
import { TgProxy } from '../../database/entities/tg-proxy.entity';
@Injectable()
export class TgAccountsService implements OnModuleInit {
private readonly logger = new Logger(TgAccountsService.name);
constructor(
@InjectRepository(TgAccount)
private accountRepo: Repository<TgAccount>,
private tdlibClientManager: TdlibClientManagerService,
private tenantsService: TenantsService,
@InjectRepository(TgAccountProxyBinding)
private bindingRepo: Repository<TgAccountProxyBinding>,
@InjectRepository(TgProxy)
private proxyRepo: Repository<TgProxy>,
) {}
async onModuleInit() {
// Auto-start TDLib workers for existing accounts on server boot
const accounts = await this.accountRepo.find({
where: {
isActive: true,
status: In([
TgAccountStatus.WAITING_TDLIB,
TgAccountStatus.WAITING_PHONE,
TgAccountStatus.WAITING_CODE,
TgAccountStatus.WAITING_PASSWORD,
TgAccountStatus.READY,
]),
},
});
for (const account of accounts) {
try {
this.logger.log(`Auto-starting TDLib client for account ${account.id}`);
const clientId = await this.tdlibClientManager.startClient(
account.id,
{ proxy: await this.getBoundProxy(account.id) },
);
account.tdlibClientId = clientId;
account.status = TgAccountStatus.WAITING_TDLIB;
await this.accountRepo.save(account);
} catch (err) {
this.logger.error(`Failed to auto-start TDLib for ${account.id}: ${err}`);
}
}
}
async create(tenantId: string, dto: CreateTgAccountDto): Promise<TgAccount> {
const count = await this.accountRepo.count({ where: { tenantId } });
const tenant = await this.tenantsService.findById(tenantId);
if (!tenant.isActive) {
throw new AppException(
ErrorCodes.TENANT_DISABLED,
'Tenant is disabled',
HttpStatus.FORBIDDEN,
);
}
if (count >= tenant.maxAccounts) {
throw new AppException(
ErrorCodes.TG_ACCOUNT_LIMIT_REACHED,
`Tenant account limit (${tenant.maxAccounts}) reached`,
HttpStatus.CONFLICT,
);
}
const account = this.accountRepo.create({
tenantId,
phoneNumber: dto.phoneNumber,
firstName: dto.firstName,
lastName: dto.lastName,
status: TgAccountStatus.CREATED,
});
const saved = await this.accountRepo.save(account);
// Start TDLib client for this account
try {
const clientId = await this.tdlibClientManager.startClient(saved.id, {
apiId: dto.apiId,
apiHash: dto.apiHash,
proxy: await this.getBoundProxy(saved.id),
});
saved.tdlibClientId = clientId;
saved.status = TgAccountStatus.WAITING_TDLIB;
await this.accountRepo.save(saved);
} catch (err) {
saved.status = TgAccountStatus.ERROR;
saved.metadata = { error: String(err) };
await this.accountRepo.save(saved);
}
return saved;
}
async findAll(tenantId: string): Promise<TgAccount[]> {
return this.accountRepo.find({ where: { tenantId } });
}
async findById(tenantId: string, id: string): Promise<TgAccount> {
const account = await this.accountRepo.findOne({ where: { id, tenantId } });
if (!account) {
throw new AppException(ErrorCodes.TG_ACCOUNT_NOT_FOUND, 'Account not found', HttpStatus.NOT_FOUND);
}
return account;
}
async update(tenantId: string, id: string, dto: UpdateTgAccountDto): Promise<TgAccount> {
const account = await this.findById(tenantId, id);
Object.assign(account, dto);
return this.accountRepo.save(account);
}
async remove(tenantId: string, id: string): Promise<void> {
const account = await this.findById(tenantId, id);
await this.tdlibClientManager.stopClient(id);
await this.accountRepo.remove(account);
}
async updateStatus(id: string, status: TgAccountStatus, metadata?: Record<string, unknown>): Promise<void> {
const updateData: Record<string, unknown> = { status };
if (metadata !== undefined) {
updateData.metadata = metadata;
}
await this.accountRepo.update(id, updateData as any);
}
async setTdlibClientId(id: string, clientId: number): Promise<void> {
await this.accountRepo.update(id, { tdlibClientId: clientId });
}
async startClient(tenantId: string, id: string): Promise<{ status: string }> {
const account = await this.findById(tenantId, id);
if (this.tdlibClientManager.isRunning(id)) {
return { status: 'already_running' };
}
try {
const clientId = await this.tdlibClientManager.startClient(id, {
proxy: await this.getBoundProxy(id),
});
account.tdlibClientId = clientId;
account.status = TgAccountStatus.WAITING_TDLIB;
await this.accountRepo.save(account);
return { status: 'started' };
} catch (err) {
account.status = TgAccountStatus.ERROR;
account.metadata = { error: String(err) };
await this.accountRepo.save(account);
throw new AppException(ErrorCodes.TG_ACCOUNT_START_FAILED, `Failed to start: ${err}`, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
async stopClient(tenantId: string, id: string): Promise<{ status: string }> {
const account = await this.findById(tenantId, id);
await this.tdlibClientManager.stopClient(id);
account.status = TgAccountStatus.DISABLED;
await this.accountRepo.save(account);
return { status: 'stopped' };
}
private async getBoundProxy(accountId: string) {
const binding = await this.bindingRepo.findOne({
where: { accountId, isActive: true },
});
if (!binding) return undefined;
const proxy = await this.proxyRepo
.createQueryBuilder('proxy')
.addSelect(['proxy.password', 'proxy.secret'])
.where('proxy.id = :id', { id: binding.proxyId })
.andWhere('proxy.isActive = :isActive', { isActive: true })
.getOne();
if (!proxy) return undefined;
return {
type: proxy.type,
host: proxy.host,
port: proxy.port,
username: proxy.username,
password: proxy.password,
secret: proxy.secret,
};
}
}
@@ -0,0 +1,51 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { TgAuditService } from './tg-audit.service';
@ApiTags('Admin Audit')
@ApiBearerAuth()
@Controller('admin/audit-logs')
@UseGuards(JwtAuthGuard)
export class TgAuditController {
constructor(private readonly auditService: TgAuditService) {}
@Get()
getLogs(
@CurrentUser('tenantId') tenantId: string,
@Query('userId') userId?: string,
@Query('accountId') accountId?: string,
@Query('action') action?: string,
@Query('resource') resource?: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.auditService.getLogs({
tenantId,
userId,
accountId,
action,
resource,
from: from ? new Date(from) : undefined,
to: to ? new Date(to) : undefined,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
});
}
@Get('accounts/:accountId')
getByAccount(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Query('limit') limit?: string,
) {
return this.auditService.getLogs({
tenantId,
accountId,
limit: limit ? Number(limit) : undefined,
});
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TgAuditLog } from '../../database/entities/tg-audit-log.entity';
import { TgAuditService } from './tg-audit.service';
import { TgAuditController } from './tg-audit.controller';
@Module({
imports: [TypeOrmModule.forFeature([TgAuditLog])],
providers: [TgAuditService],
controllers: [TgAuditController],
exports: [TgAuditService],
})
export class TgAuditModule {}
@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TgAuditLog } from '../../database/entities/tg-audit-log.entity';
export interface AuditEntry {
action: string;
resource: string;
resourceId?: string;
tenantId?: string;
userId?: string;
accountId?: string;
ipAddress?: string;
details?: Record<string, unknown>;
statusCode?: number;
duration?: number;
}
@Injectable()
export class TgAuditService {
constructor(
@InjectRepository(TgAuditLog)
private auditRepo: Repository<TgAuditLog>,
) {}
async log(entry: AuditEntry): Promise<TgAuditLog> {
const auditLog = this.auditRepo.create(entry);
return this.auditRepo.save(auditLog);
}
async getLogs(filters: {
tenantId?: string;
userId?: string;
accountId?: string;
action?: string;
resource?: string;
from?: Date;
to?: Date;
limit?: number;
offset?: number;
}): Promise<{ logs: TgAuditLog[]; total: number }> {
const query = this.auditRepo.createQueryBuilder('log');
if (filters.tenantId) query.andWhere('log.tenantId = :tenantId', { tenantId: filters.tenantId });
if (filters.userId) query.andWhere('log.userId = :userId', { userId: filters.userId });
if (filters.accountId) query.andWhere('log.accountId = :accountId', { accountId: filters.accountId });
if (filters.action) query.andWhere('log.action = :action', { action: filters.action });
if (filters.resource) query.andWhere('log.resource = :resource', { resource: filters.resource });
if (filters.from) query.andWhere('log.createdAt >= :from', { from: filters.from });
if (filters.to) query.andWhere('log.createdAt <= :to', { to: filters.to });
query.orderBy('log.createdAt', 'DESC');
const total = await query.getCount();
const logs = await query
.skip(filters.offset || 0)
.take(Math.min(Math.max(filters.limit || 100, 1), 500))
.getMany();
return { logs, total };
}
async getLogsByAccount(accountId: string, limit = 100): Promise<TgAuditLog[]> {
return this.auditRepo.find({
where: { accountId },
order: { createdAt: 'DESC' },
take: limit,
});
}
}
@@ -0,0 +1,29 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class SetPhoneNumberDto {
@ApiProperty({ description: 'Phone number in international format', example: '+1234567890' })
@IsString()
@IsNotEmpty()
phoneNumber: string;
}
export class SubmitCodeDto {
@ApiProperty({ description: 'Verification code from Telegram' })
@IsString()
@IsNotEmpty()
code: string;
}
export class SubmitPasswordDto {
@ApiProperty({ description: 'Two-factor authentication password' })
@IsString()
@IsNotEmpty()
password: string;
}
export class ResendCodeDto {
@ApiProperty({ description: 'Reason for resending code', default: 'user_request' })
@IsString()
reason: string;
}
@@ -0,0 +1,69 @@
import {
Controller, Post, Body, Param, UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiSecurity } from '@nestjs/swagger';
import { TgAuthService } from './tg-auth.service';
import {
SetPhoneNumberDto, SubmitCodeDto, SubmitPasswordDto, ResendCodeDto,
} from './dto/tg-auth.dto';
import { TenantGuard } from '../../common/guards/tenant.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('TG Auth')
@ApiBearerAuth()
@ApiSecurity('tenant-id')
@Controller('tg-accounts/:accountId/auth')
@UseGuards(TenantGuard)
export class TgAuthController {
constructor(private readonly tgAuthService: TgAuthService) {}
@Post('phone')
setPhoneNumber(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Body() dto: SetPhoneNumberDto,
) {
return this.tgAuthService.setPhoneNumber(tenantId, accountId, dto.phoneNumber);
}
@Post('code')
submitCode(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Body() dto: SubmitCodeDto,
) {
return this.tgAuthService.submitCode(tenantId, accountId, dto.code);
}
@Post('password')
submitPassword(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Body() dto: SubmitPasswordDto,
) {
return this.tgAuthService.submitPassword(tenantId, accountId, dto.password);
}
@Post('resend')
resendCode(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
) {
return this.tgAuthService.resendCode(tenantId, accountId);
}
@Post('logout')
logout(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
) {
return this.tgAuthService.logout(tenantId, accountId);
}
@Post('state')
getState(
@Param('accountId') accountId: string,
) {
return this.tgAuthService.getAuthState(accountId) || { status: 'unknown' };
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TgAccount } from '../../database/entities/tg-account.entity';
import { TgAuthService } from './tg-auth.service';
import { TgAuthController } from './tg-auth.controller';
import { TdlibModule } from '../tdlib/tdlib.module';
import { TgAccountsModule } from '../tg-accounts/tg-accounts.module';
@Module({
imports: [TypeOrmModule.forFeature([TgAccount]), TdlibModule, TgAccountsModule],
providers: [TgAuthService],
controllers: [TgAuthController],
exports: [TgAuthService],
})
export class TgAuthModule {}
@@ -0,0 +1,196 @@
import { Injectable, HttpStatus, Logger } from '@nestjs/common';
import { TdlibClientManagerService } from '../tdlib/tdlib-client-manager.service';
import { TgAccountsService } from '../tg-accounts/tg-accounts.service';
import { TgAccountStatus } from '../../database/entities/tg-account.entity';
import { AppException } from '../../common/errors/app-exception';
import { ErrorCodes } from '../../common/errors/error-codes';
export interface AuthState {
status: 'waiting_tdlib' | 'waiting_phone' | 'waiting_code' | 'waiting_password' | 'ready' | 'error';
phoneNumber?: string;
codeInfo?: Record<string, unknown>;
error?: string;
connectionState?: string;
}
@Injectable()
export class TgAuthService {
private readonly logger = new Logger(TgAuthService.name);
private authStates = new Map<string, AuthState>();
constructor(
private tdlibClientManager: TdlibClientManagerService,
private tgAccountsService: TgAccountsService,
) {}
async setPhoneNumber(tenantId: string, accountId: string, phoneNumber: string): Promise<AuthState> {
const account = await this.tgAccountsService.findById(tenantId, accountId);
if (account.status === TgAccountStatus.READY) {
throw new AppException(
ErrorCodes.TG_AUTH_ALREADY_AUTHORIZED,
'Account is already authorized',
HttpStatus.CONFLICT,
);
}
try {
const result = await this.tdlibClientManager.sendRequest(accountId, {
'@type': 'setAuthenticationPhoneNumber',
phone_number: phoneNumber,
});
const state: AuthState = {
status: 'waiting_code',
phoneNumber,
codeInfo: result as Record<string, unknown>,
};
this.authStates.set(accountId, state);
await this.tgAccountsService.updateStatus(accountId, TgAccountStatus.WAITING_CODE);
return state;
} catch (err) {
this.logger.error(`Failed to set phone number for ${accountId}: ${err}`);
await this.tgAccountsService.updateStatus(accountId, TgAccountStatus.ERROR, {
error: String(err),
});
throw new AppException(ErrorCodes.TG_AUTH_PHONE_INVALID, 'Failed to set phone number');
}
}
async submitCode(tenantId: string, accountId: string, code: string): Promise<AuthState> {
await this.verifyAccountAccess(tenantId, accountId);
try {
await this.tdlibClientManager.sendRequest(accountId, {
'@type': 'checkAuthenticationCode',
code,
});
const workerStatus = await this.waitForWorkerStatus(accountId, [
'waitPassword',
'ready',
]);
const state: AuthState = {
status: workerStatus === 'ready' ? 'ready' : 'waiting_password',
phoneNumber: this.authStates.get(accountId)?.phoneNumber,
};
this.authStates.set(accountId, state);
if (state.status === 'waiting_password') {
await this.tgAccountsService.updateStatus(accountId, TgAccountStatus.WAITING_PASSWORD);
} else {
await this.tgAccountsService.updateStatus(accountId, TgAccountStatus.READY);
}
return state;
} catch (err) {
this.logger.error(`Failed to submit code for ${accountId}: ${err}`);
await this.tgAccountsService.updateStatus(accountId, TgAccountStatus.ERROR, {
error: String(err),
});
throw new AppException(ErrorCodes.TG_AUTH_CODE_INVALID, 'Invalid verification code');
}
}
async submitPassword(tenantId: string, accountId: string, password: string): Promise<AuthState> {
await this.verifyAccountAccess(tenantId, accountId);
try {
await this.tdlibClientManager.sendRequest(accountId, {
'@type': 'checkAuthenticationPassword',
password,
});
const workerStatus = await this.waitForWorkerStatus(accountId, ['ready']);
if (workerStatus !== 'ready') {
throw new Error(`Unexpected authorization state after password: ${workerStatus}`);
}
const state: AuthState = { status: 'ready' };
this.authStates.set(accountId, state);
await this.tgAccountsService.updateStatus(accountId, TgAccountStatus.READY);
return state;
} catch (err) {
this.logger.error(`Failed to submit password for ${accountId}: ${err}`);
throw new AppException(ErrorCodes.TG_AUTH_PASSWORD_INVALID, 'Invalid 2FA password');
}
}
async resendCode(tenantId: string, accountId: string): Promise<AuthState> {
await this.verifyAccountAccess(tenantId, accountId);
try {
await this.tdlibClientManager.sendRequest(accountId, {
'@type': 'resendAuthenticationCode',
});
return this.authStates.get(accountId) || { status: 'waiting_code' };
} catch (err) {
this.logger.error(`Failed to resend code for ${accountId}: ${err}`);
throw new AppException(ErrorCodes.TG_AUTH_CODE_EXPIRED, 'Failed to resend verification code');
}
}
async logout(tenantId: string, accountId: string): Promise<void> {
await this.verifyAccountAccess(tenantId, accountId);
try {
await this.tdlibClientManager.sendRequest(accountId, {
'@type': 'logOut',
});
} catch (err) {
this.logger.warn(`Logout error for ${accountId}: ${err}`);
}
await this.tdlibClientManager.stopClient(accountId);
this.authStates.delete(accountId);
await this.tgAccountsService.updateStatus(accountId, TgAccountStatus.CREATED);
}
getAuthState(accountId: string): AuthState | undefined {
const workerState = this.tdlibClientManager.getAuthState(accountId);
if (workerState && workerState !== 'unknown') {
return {
...this.authStates.get(accountId),
status: this.normalizeWorkerStatus(workerState),
connectionState: this.tdlibClientManager.getConnectionState(accountId),
};
}
return this.authStates.get(accountId);
}
private async waitForWorkerStatus(
accountId: string,
expected: string[],
timeoutMs = 3000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
let current = this.tdlibClientManager.getAuthState(accountId);
while (!expected.includes(current) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50));
current = this.tdlibClientManager.getAuthState(accountId);
}
return current;
}
private normalizeWorkerStatus(status: string): AuthState['status'] {
const statuses: Record<string, AuthState['status']> = {
waitTdlibParameters: 'waiting_tdlib',
waitPhoneNumber: 'waiting_phone',
waitCode: 'waiting_code',
waitPassword: 'waiting_password',
ready: 'ready',
};
return statuses[status] || 'error';
}
private async verifyAccountAccess(tenantId: string, accountId: string) {
await this.tgAccountsService.findById(tenantId, accountId);
}
}
@@ -0,0 +1,136 @@
import { Controller, Delete, Get, Post, Put, Param, Query, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiSecurity } from '@nestjs/swagger';
import { TgChannelsService } from './tg-channels.service';
import { TenantGuard } from '../../common/guards/tenant.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('TG Channels')
@ApiBearerAuth()
@ApiSecurity('tenant-id')
@Controller('tg-accounts/:accountId/channels')
@UseGuards(TenantGuard)
export class TgChannelsController {
constructor(private readonly tgChannelsService: TgChannelsService) {}
@Post()
createChannel(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Body() body: { title: string; description?: string },
) {
return this.tgChannelsService.createChannel(tenantId, accountId, body);
}
@Get(':tgChatId')
getChannel(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
) {
return this.tgChannelsService.getChannel(tenantId, accountId, +tgChatId);
}
@Post('join')
joinChannel(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Body() body: { inviteLink: string },
) {
return this.tgChannelsService.joinChannel(tenantId, accountId, body.inviteLink);
}
@Get(':tgChatId/members')
getMembers(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
@Query('limit') limit?: number,
) {
return this.tgChannelsService.getChannelMembers(tenantId, accountId, +tgChatId, limit);
}
@Put(':tgChatId/title')
setTitle(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
@Body() body: { title: string },
) {
return this.tgChannelsService.setTitle(
tenantId, accountId, Number(tgChatId), body.title,
);
}
@Put(':tgChatId/description')
setDescription(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
@Body() body: { description: string },
) {
return this.tgChannelsService.setDescription(
tenantId, accountId, Number(tgChatId), body.description,
);
}
@Put(':tgChatId/photo')
setPhoto(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
@Body() body: { photo: Record<string, unknown> | null },
) {
return this.tgChannelsService.setPhoto(
tenantId, accountId, Number(tgChatId), body.photo,
);
}
@Post(':tgChatId/invite-links')
createInviteLink(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
@Body() body: {
name?: string; expirationDate?: number; memberLimit?: number;
createsJoinRequest?: boolean;
},
) {
return this.tgChannelsService.createInviteLink(
tenantId, accountId, Number(tgChatId), body,
);
}
@Delete(':tgChatId/invite-links')
revokeInviteLink(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
@Body() body: { inviteLink: string },
) {
return this.tgChannelsService.revokeInviteLink(
tenantId, accountId, Number(tgChatId), body.inviteLink,
);
}
@Post(':tgChatId/leave')
leave(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
) {
return this.tgChannelsService.leave(
tenantId, accountId, Number(tgChatId),
);
}
@Delete(':tgChatId')
deleteChannel(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
) {
return this.tgChannelsService.deleteChannel(
tenantId, accountId, Number(tgChatId),
);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TdlibModule } from '../tdlib/tdlib.module';
import { TgAccountsModule } from '../tg-accounts/tg-accounts.module';
import { TgChannelsService } from './tg-channels.service';
import { TgChannelsController } from './tg-channels.controller';
@Module({
imports: [TdlibModule, TgAccountsModule],
providers: [TgChannelsService],
controllers: [TgChannelsController],
})
export class TgChannelsModule {}
@@ -0,0 +1,171 @@
import { Injectable } from '@nestjs/common';
import { TdlibClientManagerService } from '../tdlib/tdlib-client-manager.service';
import { TgAccountsService } from '../tg-accounts/tg-accounts.service';
@Injectable()
export class TgChannelsService {
constructor(
private tdlibClientManager: TdlibClientManagerService,
private tgAccountsService: TgAccountsService,
) {}
/**
* Resolve a chat_id to its underlying supergroup_id
*/
private async resolveSupergroupId(accountId: string, chatId: number): Promise<number | undefined> {
const chat = await this.tdlibClientManager.sendRequest(accountId, {
'@type': 'getChat',
chat_id: chatId,
}) as Record<string, any>;
const chatType = chat?.type;
if (chatType?.['@type'] === 'chatTypeSupergroup') {
return chatType.supergroup_id as number;
}
return undefined;
}
async getChannel(tenantId: string, accountId: string, tgChatId: number): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
// Resolve supergroup_id from chat_id first
const supergroupId = await this.resolveSupergroupId(accountId, tgChatId);
if (supergroupId) {
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'getSupergroupFullInfo',
supergroup_id: supergroupId,
});
}
// Fallback: return the raw chat info
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'getChat',
chat_id: tgChatId,
});
}
async createChannel(tenantId: string, accountId: string, dto: {
title: string;
description?: string;
}): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'createNewSupergroupChat',
title: dto.title,
is_channel: true,
description: dto.description || '',
});
}
async joinChannel(tenantId: string, accountId: string, inviteLink: string): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
// Extract the invite hash from the URL if a full URL is provided
let link = inviteLink;
const urlMatch = inviteLink.match(/(?:t\.me|telegram\.(?:me|dog))\/\+?([a-zA-Z0-9_-]+)/);
if (urlMatch) {
link = `https://t.me/${urlMatch[1]}`;
}
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'joinChatByInviteLink',
invite_link: link,
});
}
async getChannelMembers(tenantId: string, accountId: string, tgChatId: number, limit = 50): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
// Resolve supergroup_id from chat_id
const supergroupId = await this.resolveSupergroupId(accountId, tgChatId);
if (supergroupId) {
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'getSupergroupMembers',
supergroup_id: supergroupId,
limit,
});
}
// Fallback: return chat info
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'getChat',
chat_id: tgChatId,
});
}
async setTitle(
tenantId: string, accountId: string, chatId: number, title: string,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'setChatTitle', chat_id: chatId, title,
});
}
async setDescription(
tenantId: string, accountId: string, chatId: number, description: string,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'setChatDescription', chat_id: chatId, description,
});
}
async setPhoto(
tenantId: string, accountId: string, chatId: number,
photo: Record<string, unknown> | null,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'setChatPhoto', chat_id: chatId, photo,
});
}
async createInviteLink(
tenantId: string, accountId: string, chatId: number,
options: {
name?: string; expirationDate?: number; memberLimit?: number;
createsJoinRequest?: boolean;
},
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'createChatInviteLink',
chat_id: chatId,
name: options.name || '',
expiration_date: options.expirationDate || 0,
member_limit: options.memberLimit || 0,
creates_join_request: options.createsJoinRequest ?? false,
});
}
async revokeInviteLink(
tenantId: string, accountId: string, chatId: number, inviteLink: string,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'revokeChatInviteLink',
chat_id: chatId,
invite_link: inviteLink,
});
}
async leave(
tenantId: string, accountId: string, chatId: number,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'leaveChat', chat_id: chatId,
});
}
async deleteChannel(
tenantId: string, accountId: string, chatId: number,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'deleteChat', chat_id: chatId,
});
}
}
@@ -0,0 +1,197 @@
import {
Body, Controller, Delete, Get, Post, Put, Param, Query, UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiSecurity, ApiQuery } from '@nestjs/swagger';
import { TgChatsService } from './tg-chats.service';
import { TenantGuard } from '../../common/guards/tenant.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('TG Chats')
@ApiBearerAuth()
@ApiSecurity('tenant-id')
@Controller('tg-accounts/:accountId/chats')
@UseGuards(TenantGuard)
export class TgChatsController {
constructor(private readonly tgChatsService: TgChatsService) {}
@Get()
getChats(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Query('limit') limit?: number,
@Query('offset') offset?: number,
) {
return this.tgChatsService.getChats(tenantId, accountId, limit, offset);
}
@Get('sync')
syncChats(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
) {
return this.tgChatsService.syncChatsFromTdlib(tenantId, accountId);
}
@Get('search')
@ApiQuery({ name: 'q', required: true })
searchChats(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Query('q') query: string,
@Query('limit') limit?: number,
) {
return this.tgChatsService.searchChats(tenantId, accountId, query, limit);
}
@Get('folders/all')
getFolders(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
) {
return this.tgChatsService.getFolders(tenantId, accountId);
}
@Post('folders')
createFolder(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Body() body: { folder: Record<string, unknown> },
) {
return this.tgChatsService.createFolder(tenantId, accountId, body.folder);
}
@Put('folders/:folderId')
editFolder(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('folderId') folderId: string,
@Body() body: { folder: Record<string, unknown> },
) {
return this.tgChatsService.editFolder(tenantId, accountId, Number(folderId), body.folder);
}
@Delete('folders/:folderId')
deleteFolder(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('folderId') folderId: string,
) {
return this.tgChatsService.deleteFolder(tenantId, accountId, Number(folderId));
}
@Get(':tgChatId')
getChat(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
) {
return this.tgChatsService.getChat(tenantId, accountId, +tgChatId);
}
@Get(':tgChatId/identity/profile')
getChatIdentity(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
) {
return this.tgChatsService.getChatIdentity(tenantId, accountId, +tgChatId);
}
@Get(':tgChatId/history')
getChatHistory(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
@Query('limit') limit?: number,
@Query('fromMessageId') fromMessageId?: number,
) {
return this.tgChatsService.getChatHistory(tenantId, accountId, +tgChatId, limit, fromMessageId);
}
@Post(':tgChatId/open')
openChat(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
) {
return this.tgChatsService.openChat(tenantId, accountId, +tgChatId);
}
@Post(':tgChatId/close')
closeChat(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
) {
return this.tgChatsService.closeChat(tenantId, accountId, +tgChatId);
}
@Post(':tgChatId/archive')
archive(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
) {
return this.tgChatsService.setArchived(tenantId, accountId, +tgChatId, true);
}
@Post(':tgChatId/unarchive')
unarchive(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
) {
return this.tgChatsService.setArchived(tenantId, accountId, +tgChatId, false);
}
@Post(':tgChatId/pin')
pin(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
@Body() body: { pinned: boolean; archived?: boolean },
) {
return this.tgChatsService.setPinned(
tenantId, accountId, +tgChatId, body.pinned, body.archived ?? false,
);
}
@Put(':tgChatId/mute')
mute(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
@Body() body: { seconds: number },
) {
return this.tgChatsService.setMute(tenantId, accountId, +tgChatId, body.seconds);
}
@Post(':tgChatId/marked-unread')
markedUnread(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
@Body() body: { marked: boolean },
) {
return this.tgChatsService.setMarkedUnread(tenantId, accountId, +tgChatId, body.marked);
}
@Delete(':tgChatId/history')
deleteHistory(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
@Body() body: { removeFromChatList?: boolean; revoke?: boolean },
) {
return this.tgChatsService.deleteHistory(tenantId, accountId, +tgChatId, body);
}
@Post(':tgChatId/leave')
leave(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Param('tgChatId') tgChatId: string,
) {
return this.tgChatsService.leaveChat(tenantId, accountId, +tgChatId);
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TgChat } from '../../database/entities/tg-chat.entity';
import { TgChatsService } from './tg-chats.service';
import { TgChatsController } from './tg-chats.controller';
import { TdlibModule } from '../tdlib/tdlib.module';
import { TgAccountsModule } from '../tg-accounts/tg-accounts.module';
@Module({
imports: [TypeOrmModule.forFeature([TgChat]), TdlibModule, TgAccountsModule],
providers: [TgChatsService],
controllers: [TgChatsController],
exports: [TgChatsService],
})
export class TgChatsModule {}
@@ -0,0 +1,336 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TgChat, ChatType } from '../../database/entities/tg-chat.entity';
import { TdlibClientManagerService } from '../tdlib/tdlib-client-manager.service';
import { TgAccountsService } from '../tg-accounts/tg-accounts.service';
@Injectable()
export class TgChatsService {
constructor(
@InjectRepository(TgChat)
private chatRepo: Repository<TgChat>,
private tdlibClientManager: TdlibClientManagerService,
private tgAccountsService: TgAccountsService,
) {}
async getChats(tenantId: string, accountId: string, limit = 50, offset = 0): Promise<TgChat[]> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.chatRepo.find({
where: { accountId },
order: { lastMessageDate: 'DESC', updatedAt: 'DESC' },
take: limit,
skip: offset,
});
}
async getChat(tenantId: string, accountId: string, tgChatId: number): Promise<TgChat> {
await this.tgAccountsService.findById(tenantId, accountId);
const chat = await this.chatRepo.findOne({ where: { accountId, tgChatId } });
if (chat) return chat;
const live = await this.tdlibClientManager.sendRequest(accountId, {
'@type': 'getChat',
chat_id: tgChatId,
}) as Record<string, any>;
return this.persistTdlibChat(accountId, live);
}
async getChatIdentity(
tenantId: string,
accountId: string,
tgChatId: number,
): Promise<Record<string, unknown>> {
const chat = await this.getChat(tenantId, accountId, tgChatId);
const raw = (chat.raw || {}) as Record<string, any>;
const rawType = raw.type || {};
let user: Record<string, any> | null = null;
if (rawType['@type'] === 'chatTypePrivate' && rawType.user_id) {
user = await this.tdlibClientManager.sendRequest(accountId, {
'@type': 'getUser',
user_id: Number(rawType.user_id),
}) as Record<string, any>;
}
const usernames = user?.usernames || {};
return {
chatId: String(chat.tgChatId),
title: user
? [user.first_name, user.last_name].filter(Boolean).join(' ') || chat.title
: chat.title,
username: usernames.editable_username ||
(Array.isArray(usernames.active_usernames) ? usernames.active_usernames[0] : '') ||
'',
phone: user?.phone_number || '',
userId: user?.id ? String(user.id) : undefined,
photo: user?.profile_photo || chat.photo || null,
chatType: chat.type,
};
}
async getChatHistory(
tenantId: string,
accountId: string,
tgChatId: number,
limit = 50,
fromMessageId?: number,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
const request: Record<string, unknown> = {
'@type': 'getChatHistory',
chat_id: tgChatId,
limit,
only_local: false,
};
if (fromMessageId) {
request.from_message_id = fromMessageId;
}
return this.tdlibClientManager.sendRequest(accountId, request);
}
async searchChats(
tenantId: string,
accountId: string,
query: string,
limit = 50,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'searchChats',
query,
limit,
});
}
async syncChatsFromTdlib(tenantId: string, accountId: string): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
const result = await this.tdlibClientManager.sendRequest(accountId, {
'@type': 'getChats',
limit: 100,
}) as Record<string, any>;
const chatIds = Array.isArray(result.chat_ids) ? result.chat_ids : [];
const synced: TgChat[] = [];
for (const chatId of chatIds) {
try {
const chat = await this.tdlibClientManager.sendRequest(accountId, {
'@type': 'getChat',
chat_id: Number(chatId),
}) as Record<string, any>;
synced.push(await this.persistTdlibChat(accountId, chat));
} catch {
// A deleted/inaccessible chat must not abort the remaining first sync.
}
}
return { total: chatIds.length, synced: synced.length, chatIds };
}
private async persistTdlibChat(accountId: string, chat: Record<string, any>): Promise<TgChat> {
const rawType = String(chat.type?.['@type'] || '');
const typeMap: Record<string, ChatType> = {
chatTypePrivate: ChatType.PRIVATE,
chatTypeBasicGroup: ChatType.GROUP,
chatTypeSupergroup: ChatType.SUPERGROUP,
chatTypeSecret: ChatType.SECRET,
};
const persistedType = rawType === 'chatTypeSupergroup' && chat.type?.is_channel
? ChatType.CHANNEL
: typeMap[rawType] || ChatType.PRIVATE;
const content = chat.last_message?.content || {};
const lastText =
content.text?.text ||
content.caption?.text ||
(content['@type'] ? `[${content['@type']}]` : '');
return this.upsertChat(accountId, {
tgChatId: Number(chat.id),
type: persistedType,
title: String(chat.title || `Telegram ${chat.id}`),
lastMessage: String(lastText || ''),
lastMessageId: Number(chat.last_message?.id || 0),
lastMessageDate: chat.last_message?.date
? new Date(Number(chat.last_message.date) * 1000)
: undefined,
unreadCount: Number(chat.unread_count || 0),
photo: chat.photo,
raw: chat,
});
}
async openChat(tenantId: string, accountId: string, tgChatId: number): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'openChat',
chat_id: tgChatId,
});
}
async closeChat(tenantId: string, accountId: string, tgChatId: number): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'closeChat',
chat_id: tgChatId,
});
}
async setArchived(
tenantId: string,
accountId: string,
tgChatId: number,
archived: boolean,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'addChatToList',
chat_id: tgChatId,
chat_list: { '@type': archived ? 'chatListArchive' : 'chatListMain' },
});
}
async setPinned(
tenantId: string,
accountId: string,
tgChatId: number,
pinned: boolean,
archived = false,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'toggleChatIsPinned',
chat_list: { '@type': archived ? 'chatListArchive' : 'chatListMain' },
chat_id: tgChatId,
is_pinned: pinned,
});
}
async setMute(
tenantId: string,
accountId: string,
tgChatId: number,
seconds: number,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'setChatNotificationSettings',
chat_id: tgChatId,
notification_settings: {
'@type': 'chatNotificationSettings',
use_mute_for: true,
mute_for: Math.max(0, Math.floor(seconds)),
},
});
}
async setMarkedUnread(
tenantId: string,
accountId: string,
tgChatId: number,
marked: boolean,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'toggleChatIsMarkedAsUnread',
chat_id: tgChatId,
is_marked_as_unread: marked,
});
}
async deleteHistory(
tenantId: string,
accountId: string,
tgChatId: number,
options: { removeFromChatList?: boolean; revoke?: boolean },
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'deleteChatHistory',
chat_id: tgChatId,
remove_from_chat_list: options.removeFromChatList ?? false,
revoke: options.revoke ?? false,
});
}
async leaveChat(
tenantId: string,
accountId: string,
tgChatId: number,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'leaveChat',
chat_id: tgChatId,
});
}
async getFolders(tenantId: string, accountId: string): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'getChatFolders',
});
}
async createFolder(
tenantId: string,
accountId: string,
folder: Record<string, unknown>,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'createChatFolder',
folder,
});
}
async editFolder(
tenantId: string,
accountId: string,
folderId: number,
folder: Record<string, unknown>,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'editChatFolder',
chat_folder_id: folderId,
folder,
});
}
async deleteFolder(
tenantId: string,
accountId: string,
folderId: number,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'deleteChatFolder',
chat_folder_id: folderId,
leave_chat_ids: [],
});
}
async upsertChat(accountId: string, data: {
tgChatId: number;
type: ChatType;
title: string;
lastMessage?: string;
lastMessageId?: number;
lastMessageDate?: Date;
unreadCount?: number;
photo?: Record<string, unknown>;
raw?: Record<string, unknown>;
}): Promise<TgChat> {
let chat = await this.chatRepo.findOne({
where: { accountId, tgChatId: data.tgChatId },
});
if (chat) {
Object.assign(chat, data);
} else {
chat = this.chatRepo.create({ accountId, ...data });
}
return this.chatRepo.save(chat);
}
}
@@ -0,0 +1,66 @@
import {
Controller, Get, Post, Delete, Body, Param, Query, UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiSecurity } from '@nestjs/swagger';
import { TgContactsService } from './tg-contacts.service';
import { TenantGuard } from '../../common/guards/tenant.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('TG Contacts')
@ApiBearerAuth()
@ApiSecurity('tenant-id')
@Controller('tg-accounts/:accountId/contacts')
@UseGuards(TenantGuard)
export class TgContactsController {
constructor(private readonly tgContactsService: TgContactsService) {}
@Get()
getContacts(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
) {
return this.tgContactsService.getContacts(tenantId, accountId);
}
@Get('synced')
syncedContacts(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
) {
return this.tgContactsService.syncedContacts(tenantId, accountId);
}
@Post('import')
importContacts(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Body() body: { contacts: Array<{ phoneNumber: string; firstName?: string; lastName?: string }> },
) {
return this.tgContactsService.importContacts(tenantId, accountId, body.contacts);
}
@Delete('remove')
removeContacts(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Body() body: { userIds: number[] },
) {
return this.tgContactsService.removeContacts(tenantId, accountId, body.userIds);
}
@Get('search')
searchUser(
@CurrentUser('tenantId') tenantId: string,
@Param('accountId') accountId: string,
@Query('q') query: string,
@Query('query') queryAlias?: string,
@Query('limit') limit?: string,
) {
return this.tgContactsService.searchUser(
tenantId,
accountId,
query || queryAlias || '',
limit ? Number(limit) : 20,
);
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TgContact } from '../../database/entities/tg-contact.entity';
import { TgContactsService } from './tg-contacts.service';
import { TgContactsController } from './tg-contacts.controller';
import { TdlibModule } from '../tdlib/tdlib.module';
import { TgAccountsModule } from '../tg-accounts/tg-accounts.module';
@Module({
imports: [TypeOrmModule.forFeature([TgContact]), TdlibModule, TgAccountsModule],
providers: [TgContactsService],
controllers: [TgContactsController],
exports: [TgContactsService],
})
export class TgContactsModule {}
@@ -0,0 +1,93 @@
import { Injectable, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TgContact } from '../../database/entities/tg-contact.entity';
import { TdlibClientManagerService } from '../tdlib/tdlib-client-manager.service';
import { TgAccountsService } from '../tg-accounts/tg-accounts.service';
import { AppException } from '../../common/errors/app-exception';
import { ErrorCodes } from '../../common/errors/error-codes';
@Injectable()
export class TgContactsService {
constructor(
@InjectRepository(TgContact)
private contactRepo: Repository<TgContact>,
private tdlibClientManager: TdlibClientManagerService,
private tgAccountsService: TgAccountsService,
) {}
async getContacts(tenantId: string, accountId: string): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'getContacts',
});
}
async importContacts(tenantId: string, accountId: string, contacts: Array<{
phoneNumber: string;
firstName?: string;
lastName?: string;
}>): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'importContacts',
contacts: contacts.map((c) => ({
phone_number: c.phoneNumber,
first_name: c.firstName || '',
last_name: c.lastName || '',
})),
});
}
async removeContacts(tenantId: string, accountId: string, tgUserIds: number[]): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'removeContacts',
user_ids: tgUserIds,
});
}
async searchUser(
tenantId: string,
accountId: string,
query: string,
limit = 20,
): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
const normalized = query.trim();
if (!normalized) {
throw new AppException(
ErrorCodes.VALIDATION_ERROR,
'Search query is required',
HttpStatus.BAD_REQUEST,
);
}
if (/^\+?[0-9][0-9\s()-]{4,}$/.test(normalized)) {
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'searchUserByPhoneNumber',
phone_number: normalized.replace(/[^\d+]/g, ''),
});
}
if (/^@?[a-zA-Z][a-zA-Z0-9_]{3,}$/.test(normalized)) {
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'searchPublicChat',
username: normalized.replace(/^@/, ''),
});
}
return this.tdlibClientManager.sendRequest(accountId, {
'@type': 'searchChatsOnServer',
query: normalized,
limit: Math.min(Math.max(limit, 1), 100),
});
}
async syncedContacts(tenantId: string, accountId: string): Promise<TgContact[]> {
await this.tgAccountsService.findById(tenantId, accountId);
return this.contactRepo.find({ where: { accountId } });
}
}

Some files were not shown because too many files have changed in this diff Show More