Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06112602ec |
@@ -28,6 +28,8 @@ storage/
|
|||||||
*.sqlite3
|
*.sqlite3
|
||||||
*.dump
|
*.dump
|
||||||
*.sql
|
*.sql
|
||||||
|
*.rdb
|
||||||
|
*.binlog
|
||||||
|
|
||||||
# Test and diagnostic output
|
# Test and diagnostic output
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
本项目遵循语义化版本。早期版本由经过脱敏的本地源码快照恢复,因此使用带日期的 `snapshot-*` 标签;自 `v1.0.0` 起按正式发布流程维护。
|
本项目遵循语义化版本。早期版本由经过脱敏的本地源码快照恢复,因此使用带日期的 `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
|
## [1.0.0] - 2026-07-30
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
灵犀跨境是一套面向跨境业务团队的多渠道客户经营 SaaS。系统将 Telegram、普通 WhatsApp 账号和企业邮箱接入统一消息中心,并提供上下文翻译、逐句意图识别、情绪与风险分析、客户画像、商机、任务、知识库和经营分析能力。
|
灵犀跨境是一套面向跨境业务团队的多渠道客户经营 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
|
```powershell
|
||||||
Copy-Item .env.example .env
|
Copy-Item .env.example .env
|
||||||
@@ -51,6 +51,12 @@ npm ci
|
|||||||
npm test
|
npm test
|
||||||
Pop-Location
|
Pop-Location
|
||||||
|
|
||||||
|
Push-Location telegram-gateway
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
npm run test:e2e
|
npm run test:e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -63,6 +69,7 @@ E2E 默认访问 `http://127.0.0.1:8088`,可通过 `E2E_BASE_URL` 修改。
|
|||||||
- [配置说明](docs/CONFIGURATION.md)
|
- [配置说明](docs/CONFIGURATION.md)
|
||||||
- [部署与回滚](docs/DEPLOYMENT.md)
|
- [部署与回滚](docs/DEPLOYMENT.md)
|
||||||
- [接口目录](docs/API.md)
|
- [接口目录](docs/API.md)
|
||||||
|
- [Telegram Gateway 接入](docs/TELEGRAM-GATEWAY.md)
|
||||||
- [测试策略](docs/TESTING.md)
|
- [测试策略](docs/TESTING.md)
|
||||||
- [运维手册](docs/OPERATIONS.md)
|
- [运维手册](docs/OPERATIONS.md)
|
||||||
- [备份恢复](docs/BACKUP-RESTORE.md)
|
- [备份恢复](docs/BACKUP-RESTORE.md)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ Nginx (web:80)
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `src/` | React 页面、组件、状态、API 客户端、国际化与类型 |
|
| `src/` | React 页面、组件、状态、API 客户端、国际化与类型 |
|
||||||
| `server/src/index.js` | 认证、租户业务、消息、AI、渠道、Webhook、WebSocket 与数据初始化 |
|
| `server/src/index.js` | 认证、租户业务、消息、AI、渠道、Webhook、WebSocket 与数据初始化 |
|
||||||
|
| `telegram-gateway/` | NestJS + TDLib 多租户 Telegram 普通账号网关,独立部署 |
|
||||||
| `whatsapp-gateway/` | 普通 WhatsApp 多账号连接、消息/媒体/群组/联系人操作 |
|
| `whatsapp-gateway/` | 普通 WhatsApp 多账号连接、消息/媒体/群组/联系人操作 |
|
||||||
| `tests/e2e/` | Playwright 桌面与移动端验收 |
|
| `tests/e2e/` | Playwright 桌面与移动端验收 |
|
||||||
| `docker/` | Nginx 与邮件协议测试辅助镜像 |
|
| `docker/` | Nginx 与邮件协议测试辅助镜像 |
|
||||||
@@ -74,6 +75,6 @@ Nginx (web:80)
|
|||||||
|
|
||||||
## 6. 当前约束
|
## 6. 当前约束
|
||||||
|
|
||||||
- Telegram Gateway 为独立仓库和独立部署,本仓库只保存适配与配置。
|
- Telegram Gateway 源码随本仓库版本化,但使用独立 Compose、数据库、Redis 和 TDLib 数据卷部署。
|
||||||
- 普通 WhatsApp 接入依赖非官方 Web 多设备协议,生产使用前必须评估账号风控和平台条款。
|
- 普通 WhatsApp 接入依赖非官方 Web 多设备协议,生产使用前必须评估账号风控和平台条款。
|
||||||
- API 目前集中在单入口文件,业务增长后应按领域拆分路由、服务、仓储和迁移文件。
|
- API 目前集中在单入口文件,业务增长后应按领域拆分路由、服务、仓储和迁移文件。
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
```bash
|
```bash
|
||||||
git clone ssh://git@git.ohkeji.cn:23/wmq/lingxi-web.git
|
git clone ssh://git@git.ohkeji.cn:23/wmq/lingxi-web.git
|
||||||
cd lingxi-web
|
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
|
cp .env.example .env
|
||||||
# 编辑 .env 并替换全部占位值
|
# 编辑 .env 并替换全部占位值
|
||||||
docker compose --env-file .env build
|
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
|
docker compose logs --tail=100 api whatsapp-gateway web
|
||||||
```
|
```
|
||||||
|
|
||||||
|
平台的 `TELEGRAM_GATEWAY_URL` 指向网关 `/api` 地址。生产环境应通过私有网络连接,不把 Gateway 管理接口直接暴露公网。详细配置见 [Telegram Gateway 接入指南](TELEGRAM-GATEWAY.md)。
|
||||||
|
|
||||||
## 3. 生产入口
|
## 3. 生产入口
|
||||||
|
|
||||||
在 Compose 外层部署 Caddy、Nginx 或云负载均衡:
|
在 Compose 外层部署 Caddy、Nginx 或云负载均衡:
|
||||||
|
|||||||
@@ -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 可用。
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
| 邮件协议 | `npm run test:email-protocol --prefix server` | IMAP/SMTP 协议 |
|
| 邮件协议 | `npm run test:email-protocol --prefix server` | IMAP/SMTP 协议 |
|
||||||
| 邮件 API E2E | `npm run test:email-api --prefix server` | 真实测试邮件服务链路 |
|
| 邮件 API E2E | `npm run test:email-api --prefix server` | 真实测试邮件服务链路 |
|
||||||
| WhatsApp 网关 | `npm test --prefix whatsapp-gateway` | 路由、状态、消息与可靠性 |
|
| WhatsApp 网关 | `npm test --prefix whatsapp-gateway` | 路由、状态、消息与可靠性 |
|
||||||
|
| Telegram 网关 | `npm run build --prefix telegram-gateway`、`npm test --prefix telegram-gateway` | NestJS 编译、业务服务、路由、迁移与网关逻辑 |
|
||||||
| 浏览器 E2E | `npm run test:e2e` | 桌面/移动端主要用户路径 |
|
| 浏览器 E2E | `npm run test:e2e` | 桌面/移动端主要用户路径 |
|
||||||
|
|
||||||
## 提交前最低要求
|
## 提交前最低要求
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lingxi-crossborder-saas",
|
"name": "lingxi-crossborder-saas",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lingxi-crossborder-saas",
|
"name": "lingxi-crossborder-saas",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "lingxi-crossborder-saas",
|
"name": "lingxi-crossborder-saas",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "灵犀跨境 - Telegram、WhatsApp 与企业邮箱 AI 客户经营平台",
|
"description": "灵犀跨境 - Telegram、WhatsApp 与企业邮箱 AI 客户经营平台",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
tdlib_data
|
||||||
|
uploads
|
||||||
|
data
|
||||||
|
test
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.claude
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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');
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true,
|
||||||
|
"assets": ["**/*.js"],
|
||||||
|
"watchAssets": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11937
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, Worker></code></td><td>accountId → Worker 线程映射</td></tr>
|
||||||
|
<tr><td><code>pendingRequests</code></td><td><code>Map<string, PendingRequest></code></td><td>UUID → Promise 映射(请求-响应匹配)</td></tr>
|
||||||
|
<tr><td><code>accountAuthState</code></td><td><code>Map<string, string></code></td><td>追踪每个账号的授权状态</td></tr>
|
||||||
|
<tr><td><code>clientIdToAccountId</code></td><td><code>Map<number, string></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 | 生成于 2026-06-24 | 基于源码自动分析</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user