feat(gateway): include production Telegram user gateway
Version the NestJS TDLib gateway with Docker deployment, migrations, automated tests and platform integration documentation while excluding all account sessions, credentials, runtime databases and backups.
This commit is contained in:
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Controller, Post, Get, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiSecurity } from '@nestjs/swagger';
|
||||
import { TgDialogsService } from './tg-dialogs.service';
|
||||
import { TenantGuard } from '../../common/guards/tenant.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('TG Dialogs')
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('tenant-id')
|
||||
@Controller('tg-accounts/:accountId/dialogs')
|
||||
@UseGuards(TenantGuard)
|
||||
export class TgDialogsController {
|
||||
constructor(private readonly tgDialogsService: TgDialogsService) {}
|
||||
|
||||
@Get('me')
|
||||
getMe(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
) {
|
||||
return this.tgDialogsService.getMe(tenantId, accountId);
|
||||
}
|
||||
|
||||
@Post('open-by-phone')
|
||||
openByPhone(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Body() body: { phoneNumber: string },
|
||||
) {
|
||||
return this.tgDialogsService.openByPhone(tenantId, accountId, body.phoneNumber);
|
||||
}
|
||||
|
||||
@Post('open-by-username')
|
||||
openByUsername(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Body() body: { username: string },
|
||||
) {
|
||||
return this.tgDialogsService.openByUsername(tenantId, accountId, body.username);
|
||||
}
|
||||
|
||||
@Post('open-by-user/:userId')
|
||||
openByUserId(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('userId') userId: string,
|
||||
) {
|
||||
return this.tgDialogsService.openByUserId(tenantId, accountId, +userId);
|
||||
}
|
||||
|
||||
@Get('sessions')
|
||||
getActiveSessions(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
) {
|
||||
return this.tgDialogsService.getActiveSessions(tenantId, accountId);
|
||||
}
|
||||
|
||||
@Post('sessions/:sessionId/terminate')
|
||||
terminateSession(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
) {
|
||||
return this.tgDialogsService.terminateSession(tenantId, accountId, sessionId);
|
||||
}
|
||||
|
||||
@Post('users/:userId/block')
|
||||
blockUser(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('userId') userId: string,
|
||||
) {
|
||||
return this.tgDialogsService.setUserBlocked(tenantId, accountId, Number(userId), true);
|
||||
}
|
||||
|
||||
@Post('users/:userId/unblock')
|
||||
unblockUser(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('userId') userId: string,
|
||||
) {
|
||||
return this.tgDialogsService.setUserBlocked(tenantId, accountId, Number(userId), false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TdlibModule } from '../tdlib/tdlib.module';
|
||||
import { TgAccountsModule } from '../tg-accounts/tg-accounts.module';
|
||||
import { TgDialogsService } from './tg-dialogs.service';
|
||||
import { TgDialogsController } from './tg-dialogs.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TdlibModule, TgAccountsModule],
|
||||
providers: [TgDialogsService],
|
||||
controllers: [TgDialogsController],
|
||||
})
|
||||
export class TgDialogsModule {}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TdlibClientManagerService } from '../tdlib/tdlib-client-manager.service';
|
||||
import { TgAccountsService } from '../tg-accounts/tg-accounts.service';
|
||||
|
||||
@Injectable()
|
||||
export class TgDialogsService {
|
||||
constructor(
|
||||
private tdlibClientManager: TdlibClientManagerService,
|
||||
private tgAccountsService: TgAccountsService,
|
||||
) {}
|
||||
|
||||
async openByPhone(tenantId: string, accountId: string, phoneNumber: string): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'searchUserByPhoneNumber',
|
||||
phone_number: phoneNumber,
|
||||
});
|
||||
}
|
||||
|
||||
async openByUsername(tenantId: string, accountId: string, username: string): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'searchPublicChat',
|
||||
username,
|
||||
});
|
||||
}
|
||||
|
||||
async openByUserId(tenantId: string, accountId: string, userId: number): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'createPrivateChat',
|
||||
user_id: userId,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
async getMe(tenantId: string, accountId: string): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, { '@type': 'getMe' });
|
||||
}
|
||||
|
||||
async getActiveSessions(tenantId: string, accountId: string): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'getActiveSessions',
|
||||
});
|
||||
}
|
||||
|
||||
async terminateSession(
|
||||
tenantId: string,
|
||||
accountId: string,
|
||||
sessionId: string,
|
||||
): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'terminateSession',
|
||||
session_id: sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
async setUserBlocked(
|
||||
tenantId: string,
|
||||
accountId: string,
|
||||
userId: number,
|
||||
blocked: boolean,
|
||||
): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'setMessageSenderBlockList',
|
||||
sender_id: {
|
||||
'@type': 'messageSenderUser',
|
||||
user_id: userId,
|
||||
},
|
||||
block_list: blocked ? { '@type': 'blockListMain' } : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bull';
|
||||
import { TdlibClientManagerService } from '../tdlib/tdlib-client-manager.service';
|
||||
|
||||
@Processor('file-download')
|
||||
export class FileDownloadProcessor {
|
||||
private readonly logger = new Logger(FileDownloadProcessor.name);
|
||||
|
||||
constructor(private tdlibClientManager: TdlibClientManagerService) {}
|
||||
|
||||
@Process('download')
|
||||
async handleDownload(job: Job<{ accountId: string; tenantId: string; fileId: number }>) {
|
||||
const { accountId, fileId } = job.data;
|
||||
this.logger.log(`Processing file-download job ${job.id} for account ${accountId}, fileId=${fileId}`);
|
||||
|
||||
// 1. Initiate download via TDLib
|
||||
await this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'downloadFile',
|
||||
file_id: fileId,
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
// 2. Poll getFile until download is complete
|
||||
const maxRetries = 30;
|
||||
const pollInterval = 1000;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const file = await this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'getFile',
|
||||
file_id: fileId,
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const local = file?.local as Record<string, unknown> | undefined;
|
||||
if (local?.is_downloading_completed) {
|
||||
const localPath = local.path as string;
|
||||
this.logger.log(`File ${fileId} downloaded to ${localPath}`);
|
||||
return { filePath: localPath, fileId };
|
||||
}
|
||||
|
||||
// Wait before next poll
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
throw new Error(`File ${fileId} download timed out after ${maxRetries} attempts`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Controller, Get, Post, Param, Query, Body, UploadedFile, UploadedFiles, UseInterceptors, UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth, ApiSecurity, ApiConsumes } from '@nestjs/swagger';
|
||||
import { TgFilesService } from './tg-files.service';
|
||||
import { TenantGuard } from '../../common/guards/tenant.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('TG Files')
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('tenant-id')
|
||||
@Controller('tg-accounts/:accountId/files')
|
||||
@UseGuards(TenantGuard)
|
||||
export class TgFilesController {
|
||||
constructor(private readonly tgFilesService: TgFilesService) {}
|
||||
|
||||
@Get()
|
||||
getFiles(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
) {
|
||||
return this.tgFilesService.getFiles(tenantId, accountId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
getFile(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.tgFilesService.getFileById(tenantId, accountId, id);
|
||||
}
|
||||
|
||||
@Post('download/:fileId')
|
||||
downloadFile(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('fileId') fileId: string,
|
||||
@Query('priority') priority?: number,
|
||||
) {
|
||||
return this.tgFilesService.downloadFile(tenantId, accountId, +fileId, priority);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadFile(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('chatId') chatId: number,
|
||||
@Body('kind') kind?: 'document' | 'photo' | 'audio' | 'voice' | 'sticker',
|
||||
) {
|
||||
return this.tgFilesService.uploadFile(tenantId, accountId, file, chatId, kind);
|
||||
}
|
||||
|
||||
@Post('upload-album')
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FilesInterceptor('files', 10))
|
||||
uploadAlbum(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Body('chatId') chatId: number,
|
||||
) {
|
||||
return this.tgFilesService.uploadAlbum(tenantId, accountId, files, chatId);
|
||||
}
|
||||
|
||||
@Get('tdlib/:fileId')
|
||||
getFileInfo(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('fileId') fileId: string,
|
||||
) {
|
||||
return this.tgFilesService.getFile(tenantId, accountId, +fileId);
|
||||
}
|
||||
|
||||
@Get('tdlib/:fileId/base64')
|
||||
getFileBase64(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('fileId') fileId: string,
|
||||
) {
|
||||
return this.tgFilesService.getFileBase64(tenantId, accountId, +fileId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TgFile } from '../../database/entities/tg-file.entity';
|
||||
import { TgFilesService } from './tg-files.service';
|
||||
import { TgFilesController } from './tg-files.controller';
|
||||
import { FileDownloadProcessor } from './file-download.processor';
|
||||
import { TdlibModule } from '../tdlib/tdlib.module';
|
||||
import { TgAccountsModule } from '../tg-accounts/tg-accounts.module';
|
||||
|
||||
const redisEnabled = process.env.REDIS_ENABLED !== 'false';
|
||||
const extraImports: any[] = [];
|
||||
const extraProviders: any[] = [];
|
||||
if (redisEnabled) {
|
||||
const { BullModule } = require('@nestjs/bull');
|
||||
extraImports.push(BullModule.registerQueue({ name: 'file-download' }));
|
||||
extraProviders.push(FileDownloadProcessor);
|
||||
} else {
|
||||
const { getQueueToken } = require('@nestjs/bull');
|
||||
extraProviders.push({
|
||||
provide: getQueueToken('file-download'),
|
||||
useValue: { add: () => Promise.reject(new Error('Redis not available')), process: () => {} },
|
||||
});
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([TgFile]),
|
||||
...extraImports,
|
||||
TdlibModule,
|
||||
TgAccountsModule,
|
||||
],
|
||||
providers: [TgFilesService, ...extraProviders],
|
||||
controllers: [TgFilesController],
|
||||
exports: [TgFilesService],
|
||||
})
|
||||
export class TgFilesModule {}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { Injectable, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Queue } from 'bullmq';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TgFile, FileType, FileStorageBackend } from '../../database/entities/tg-file.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';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
@Injectable()
|
||||
export class TgFilesService {
|
||||
private readonly logger = new Logger(TgFilesService.name);
|
||||
private readonly storagePath: string;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(TgFile)
|
||||
private fileRepo: Repository<TgFile>,
|
||||
@InjectQueue('file-download')
|
||||
private downloadQueue: Queue,
|
||||
private tdlibClientManager: TdlibClientManagerService,
|
||||
private tgAccountsService: TgAccountsService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.storagePath = configService.get('FILE_STORAGE_PATH') || './uploads';
|
||||
if (!fs.existsSync(this.storagePath)) {
|
||||
fs.mkdirSync(this.storagePath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async getFile(tenantId: string, accountId: string, fileId: number): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'getFile',
|
||||
file_id: fileId,
|
||||
});
|
||||
}
|
||||
|
||||
async getFileBase64(
|
||||
tenantId: string,
|
||||
accountId: string,
|
||||
fileId: number,
|
||||
): Promise<{ base64: string; mimeType: string; fileId: number }> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
await this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'downloadFile',
|
||||
file_id: fileId,
|
||||
priority: 16,
|
||||
synchronous: true,
|
||||
});
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const file = await this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'getFile',
|
||||
file_id: fileId,
|
||||
}) as Record<string, any>;
|
||||
const local = file?.local || {};
|
||||
if (local.is_downloading_completed && local.path && fs.existsSync(local.path)) {
|
||||
const extension = path.extname(String(local.path)).toLowerCase();
|
||||
const mimeByExtension: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
||||
'.webp': 'image/webp', '.gif': 'image/gif', '.tgs': 'application/x-tgsticker',
|
||||
'.webm': 'video/webm', '.ogg': 'audio/ogg', '.oga': 'audio/ogg',
|
||||
'.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav',
|
||||
'.pdf': 'application/pdf', '.txt': 'text/plain', '.csv': 'text/csv',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.zip': 'application/zip',
|
||||
};
|
||||
const mimeType = mimeByExtension[extension] || 'application/octet-stream';
|
||||
return {
|
||||
base64: fs.readFileSync(local.path).toString('base64'),
|
||||
mimeType,
|
||||
fileId,
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new AppException(
|
||||
ErrorCodes.FILE_DOWNLOAD_FAILED,
|
||||
'Telegram profile photo download timed out',
|
||||
HttpStatus.GATEWAY_TIMEOUT,
|
||||
);
|
||||
}
|
||||
|
||||
async downloadFile(tenantId: string, accountId: string, fileId: number, priority = 0): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
|
||||
try {
|
||||
const queueJob = await this.downloadQueue.add('download', {
|
||||
accountId,
|
||||
tenantId,
|
||||
fileId,
|
||||
}, {
|
||||
priority,
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
removeOnComplete: 50,
|
||||
});
|
||||
|
||||
return { jobId: queueJob.id, status: 'queued' };
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Failed to queue file download: ${err.message}`);
|
||||
throw new AppException(
|
||||
ErrorCodes.FILE_DOWNLOAD_FAILED,
|
||||
`Failed to queue download: ${err.message}`,
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
tenantId: string,
|
||||
accountId: string,
|
||||
file: Express.Multer.File,
|
||||
chatId: number,
|
||||
kind?: 'document' | 'photo' | 'audio' | 'voice' | 'sticker',
|
||||
): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
|
||||
const maxSize = this.configService.get('FILE_MAX_SIZE') || 209715200;
|
||||
if (file.size > maxSize) {
|
||||
throw new AppException(ErrorCodes.FILE_TOO_LARGE, 'File exceeds maximum size', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const storageDir = path.join(this.storagePath, accountId);
|
||||
if (!fs.existsSync(storageDir)) {
|
||||
fs.mkdirSync(storageDir, { recursive: true });
|
||||
}
|
||||
|
||||
const fileName = `${Date.now()}-${file.originalname}`;
|
||||
const filePath = path.join(storageDir, fileName);
|
||||
fs.writeFileSync(filePath, file.buffer);
|
||||
|
||||
const fileType = this.detectFileType(file.mimetype);
|
||||
|
||||
const tgFile = this.fileRepo.create({
|
||||
accountId,
|
||||
tgFileId: 0, // Will be updated after upload
|
||||
type: fileType,
|
||||
fileName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
storageBackend: FileStorageBackend.LOCAL,
|
||||
storagePath: filePath,
|
||||
});
|
||||
|
||||
const saved = await this.fileRepo.save(tgFile);
|
||||
|
||||
// Trigger TDLib upload
|
||||
try {
|
||||
const inputFile = { '@type': 'inputFileLocal', path: filePath };
|
||||
const contentByKind: Record<string, Record<string, unknown>> = {
|
||||
document: { '@type': 'inputMessageDocument', document: inputFile },
|
||||
photo: { '@type': 'inputMessagePhoto', photo: inputFile },
|
||||
audio: {
|
||||
'@type': 'inputMessageAudio', audio: inputFile,
|
||||
duration: 0, title: '', performer: '',
|
||||
},
|
||||
voice: {
|
||||
'@type': 'inputMessageVoiceNote', voice_note: inputFile,
|
||||
duration: 0, waveform: '',
|
||||
},
|
||||
sticker: {
|
||||
'@type': 'inputMessageSticker', sticker: inputFile,
|
||||
width: 512, height: 512, emoji: '',
|
||||
},
|
||||
};
|
||||
const selectedKind = kind || (
|
||||
fileType === FileType.PHOTO ? 'photo' :
|
||||
fileType === FileType.AUDIO ? 'audio' : 'document'
|
||||
);
|
||||
const result = await this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'sendMessage',
|
||||
chat_id: chatId,
|
||||
input_message_content: contentByKind[selectedKind],
|
||||
}) as Record<string, unknown>;
|
||||
return {
|
||||
file: saved,
|
||||
tdlibResult: result,
|
||||
deliveryState:
|
||||
(result.sending_state as Record<string, unknown> | undefined)?.['@type'] ===
|
||||
'messageSendingStatePending'
|
||||
? 'accepted'
|
||||
: 'sent',
|
||||
};
|
||||
} catch (err: any) {
|
||||
this.logger.error(`File upload/send failed for account ${accountId}: ${err?.message || err}`);
|
||||
throw new AppException(
|
||||
ErrorCodes.FILE_UPLOAD_FAILED,
|
||||
`File upload failed: ${err?.message || 'unknown Telegram error'}`,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadAlbum(
|
||||
tenantId: string,
|
||||
accountId: string,
|
||||
files: Express.Multer.File[],
|
||||
chatId: number,
|
||||
): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
if (!files || files.length < 2) {
|
||||
throw new AppException(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'An album requires at least two files',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
if (files.length > 10) {
|
||||
throw new AppException(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'A Telegram album supports at most 10 files',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const maxSize = Number(this.configService.get('FILE_MAX_SIZE') || 209715200);
|
||||
const storageDir = path.join(this.storagePath, accountId);
|
||||
if (!fs.existsSync(storageDir)) fs.mkdirSync(storageDir, { recursive: true });
|
||||
|
||||
const savedFiles: TgFile[] = [];
|
||||
const inputMessageContents: Record<string, unknown>[] = [];
|
||||
for (const file of files) {
|
||||
if (file.size > maxSize) {
|
||||
throw new AppException(ErrorCodes.FILE_TOO_LARGE, `${file.originalname} exceeds maximum size`, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
const filePath = path.join(storageDir, `${Date.now()}-${file.originalname}`);
|
||||
fs.writeFileSync(filePath, file.buffer);
|
||||
savedFiles.push(await this.fileRepo.save(this.fileRepo.create({
|
||||
accountId,
|
||||
tgFileId: 0,
|
||||
type: this.detectFileType(file.mimetype),
|
||||
fileName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
storageBackend: FileStorageBackend.LOCAL,
|
||||
storagePath: filePath,
|
||||
})));
|
||||
const inputFile = { '@type': 'inputFileLocal', path: filePath };
|
||||
inputMessageContents.push(file.mimetype.startsWith('video/')
|
||||
? { '@type': 'inputMessageVideo', video: inputFile, duration: 0, width: 0, height: 0, supports_streaming: true }
|
||||
: { '@type': 'inputMessagePhoto', photo: inputFile });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'sendMessageAlbum',
|
||||
chat_id: chatId,
|
||||
input_message_contents: inputMessageContents,
|
||||
});
|
||||
return { files: savedFiles, tdlibResult: result, deliveryState: 'accepted' };
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Album send failed for account ${accountId}: ${err?.message || err}`);
|
||||
throw new AppException(
|
||||
ErrorCodes.FILE_UPLOAD_FAILED,
|
||||
`Album upload failed: ${err?.message || 'unknown Telegram error'}`,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getFiles(tenantId: string, accountId: string): Promise<TgFile[]> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.fileRepo.find({ where: { accountId } });
|
||||
}
|
||||
|
||||
async getFileById(tenantId: string, accountId: string, id: string): Promise<TgFile> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
const file = await this.fileRepo.findOne({ where: { id, accountId } });
|
||||
if (!file) {
|
||||
throw new AppException(ErrorCodes.FILE_NOT_FOUND, 'File not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private detectFileType(mimeType: string): FileType {
|
||||
if (mimeType.startsWith('image/')) return FileType.PHOTO;
|
||||
if (mimeType.startsWith('video/')) return FileType.VIDEO;
|
||||
if (mimeType.startsWith('audio/')) return FileType.AUDIO;
|
||||
if (mimeType.includes('pdf') || mimeType.includes('document')) return FileType.DOCUMENT;
|
||||
return FileType.OTHER;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Controller, Delete, Get, Post, Put, Param, Query, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiSecurity } from '@nestjs/swagger';
|
||||
import { TgGroupsService } from './tg-groups.service';
|
||||
import { TenantGuard } from '../../common/guards/tenant.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('TG Groups')
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('tenant-id')
|
||||
@Controller('tg-accounts/:accountId/groups')
|
||||
@UseGuards(TenantGuard)
|
||||
export class TgGroupsController {
|
||||
constructor(private readonly tgGroupsService: TgGroupsService) {}
|
||||
|
||||
@Post()
|
||||
createGroup(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Body() body: { title: string; userIds?: number[] },
|
||||
) {
|
||||
return this.tgGroupsService.createGroup(tenantId, accountId, body);
|
||||
}
|
||||
|
||||
@Get(':tgChatId')
|
||||
getGroup(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('tgChatId') tgChatId: string,
|
||||
) {
|
||||
return this.tgGroupsService.getGroup(tenantId, accountId, +tgChatId);
|
||||
}
|
||||
|
||||
@Get(':tgChatId/members')
|
||||
getMembers(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('tgChatId') tgChatId: string,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.tgGroupsService.getMembers(tenantId, accountId, +tgChatId, limit);
|
||||
}
|
||||
|
||||
@Get(':tgChatId/invite-link')
|
||||
getInviteLink(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('tgChatId') tgChatId: string,
|
||||
) {
|
||||
return this.tgGroupsService.getGroupInviteLink(tenantId, accountId, +tgChatId);
|
||||
}
|
||||
|
||||
@Post('join-by-invite-link')
|
||||
joinByInviteLink(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Body() body: { inviteLink: string },
|
||||
) {
|
||||
return this.tgGroupsService.joinByInviteLink(tenantId, accountId, body.inviteLink);
|
||||
}
|
||||
|
||||
@Put(':tgChatId/title')
|
||||
setTitle(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('tgChatId') tgChatId: string,
|
||||
@Body() body: { title: string },
|
||||
) {
|
||||
return this.tgGroupsService.setTitle(tenantId, accountId, Number(tgChatId), body.title);
|
||||
}
|
||||
|
||||
@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.tgGroupsService.setPhoto(tenantId, accountId, Number(tgChatId), body.photo);
|
||||
}
|
||||
|
||||
@Put(':tgChatId/members/:memberId/status')
|
||||
setMemberStatus(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('tgChatId') tgChatId: string,
|
||||
@Param('memberId') memberId: string,
|
||||
@Body() body: { status: Record<string, unknown> },
|
||||
) {
|
||||
return this.tgGroupsService.setMemberStatus(
|
||||
tenantId, accountId, Number(tgChatId), Number(memberId), body.status,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':tgChatId/members/:memberId/kick')
|
||||
kickMember(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('tgChatId') tgChatId: string,
|
||||
@Param('memberId') memberId: string,
|
||||
) {
|
||||
return this.tgGroupsService.kickMember(
|
||||
tenantId, accountId, Number(tgChatId), Number(memberId),
|
||||
);
|
||||
}
|
||||
|
||||
@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.tgGroupsService.createInviteLink(
|
||||
tenantId, accountId, Number(tgChatId), body,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':tgChatId/invite-links')
|
||||
listInviteLinks(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('tgChatId') tgChatId: string,
|
||||
@Query('creatorUserId') creatorUserId?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.tgGroupsService.listInviteLinks(
|
||||
tenantId, accountId, Number(tgChatId),
|
||||
creatorUserId ? Number(creatorUserId) : 0,
|
||||
limit ? Number(limit) : 50,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':tgChatId/invite-links')
|
||||
editInviteLink(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('tgChatId') tgChatId: string,
|
||||
@Body() body: {
|
||||
inviteLink: string; name?: string; expirationDate?: number;
|
||||
memberLimit?: number; createsJoinRequest?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.tgGroupsService.editInviteLink(
|
||||
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.tgGroupsService.revokeInviteLink(
|
||||
tenantId, accountId, Number(tgChatId), body.inviteLink,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':tgChatId/join-requests')
|
||||
getJoinRequests(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('tgChatId') tgChatId: string,
|
||||
@Query('inviteLink') inviteLink?: string,
|
||||
@Query('query') query?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.tgGroupsService.getJoinRequests(
|
||||
tenantId, accountId, Number(tgChatId), inviteLink || '', query || '',
|
||||
limit ? Number(limit) : 50,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':tgChatId/join-requests/:userId')
|
||||
processJoinRequest(
|
||||
@CurrentUser('tenantId') tenantId: string,
|
||||
@Param('accountId') accountId: string,
|
||||
@Param('tgChatId') tgChatId: string,
|
||||
@Param('userId') userId: string,
|
||||
@Body() body: { approve: boolean },
|
||||
) {
|
||||
return this.tgGroupsService.processJoinRequest(
|
||||
tenantId, accountId, Number(tgChatId), Number(userId), body.approve,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TgGroup } from '../../database/entities/tg-group.entity';
|
||||
import { TgGroupsService } from './tg-groups.service';
|
||||
import { TgGroupsController } from './tg-groups.controller';
|
||||
import { TdlibModule } from '../tdlib/tdlib.module';
|
||||
import { TgAccountsModule } from '../tg-accounts/tg-accounts.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TgGroup]), TdlibModule, TgAccountsModule],
|
||||
providers: [TgGroupsService],
|
||||
controllers: [TgGroupsController],
|
||||
exports: [TgGroupsService],
|
||||
})
|
||||
export class TgGroupsModule {}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TgGroup } from '../../database/entities/tg-group.entity';
|
||||
import { TdlibClientManagerService } from '../tdlib/tdlib-client-manager.service';
|
||||
import { TgAccountsService } from '../tg-accounts/tg-accounts.service';
|
||||
|
||||
interface ChatTypeInfo {
|
||||
basicGroupId?: number;
|
||||
supergroupId?: number;
|
||||
chatId: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TgGroupsService {
|
||||
constructor(
|
||||
@InjectRepository(TgGroup)
|
||||
private groupRepo: Repository<TgGroup>,
|
||||
private tdlibClientManager: TdlibClientManagerService,
|
||||
private tgAccountsService: TgAccountsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve a chat_id to its underlying basic_group_id or supergroup_id
|
||||
*/
|
||||
private async resolveChatType(accountId: string, chatId: number): Promise<ChatTypeInfo> {
|
||||
const chat = await this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'getChat',
|
||||
chat_id: chatId,
|
||||
}) as Record<string, any>;
|
||||
const chatType = chat?.type;
|
||||
return {
|
||||
chatId,
|
||||
basicGroupId: chatType?.['@type'] === 'chatTypeBasicGroup' ? chatType.basic_group_id : undefined,
|
||||
supergroupId: chatType?.['@type'] === 'chatTypeSupergroup' ? chatType.supergroup_id : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async getGroup(tenantId: string, accountId: string, tgChatId: number): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
|
||||
// First get chat info to resolve the basic_group_id
|
||||
const info = await this.resolveChatType(accountId, tgChatId);
|
||||
|
||||
if (!info.basicGroupId) {
|
||||
// Return the raw chat as fallback (might be supergroup or private)
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'getChat',
|
||||
chat_id: tgChatId,
|
||||
});
|
||||
}
|
||||
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'getBasicGroupFullInfo',
|
||||
basic_group_id: info.basicGroupId,
|
||||
});
|
||||
}
|
||||
|
||||
async createGroup(tenantId: string, accountId: string, dto: {
|
||||
title: string;
|
||||
userIds?: number[];
|
||||
}): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'createNewBasicGroupChat',
|
||||
title: dto.title,
|
||||
user_ids: dto.userIds || [],
|
||||
});
|
||||
}
|
||||
|
||||
async getMembers(tenantId: string, accountId: string, tgChatId: number, limit = 50): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
|
||||
// Resolve supergroup_id from chat_id if needed
|
||||
const info = await this.resolveChatType(accountId, tgChatId);
|
||||
const supergroupId = info.supergroupId;
|
||||
|
||||
if (supergroupId) {
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'getSupergroupMembers',
|
||||
supergroup_id: supergroupId,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: try basic group members via getBasicGroupFullInfo
|
||||
if (info.basicGroupId) {
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'getBasicGroupFullInfo',
|
||||
basic_group_id: info.basicGroupId,
|
||||
});
|
||||
}
|
||||
|
||||
// For private chats, just return the chat itself
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'getChat',
|
||||
chat_id: tgChatId,
|
||||
});
|
||||
}
|
||||
|
||||
async getGroupInviteLink(tenantId: string, accountId: string, tgChatId: number): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
// getChatInviteLink uses chat_id directly (not basic_group_id)
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'getChatInviteLink',
|
||||
chat_id: tgChatId,
|
||||
});
|
||||
}
|
||||
|
||||
async joinByInviteLink(
|
||||
tenantId: string,
|
||||
accountId: string,
|
||||
inviteLink: string,
|
||||
): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'joinChatByInviteLink',
|
||||
invite_link: inviteLink,
|
||||
});
|
||||
}
|
||||
|
||||
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 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 setMemberStatus(
|
||||
tenantId: string, accountId: string, chatId: number,
|
||||
memberId: number, status: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'setChatMemberStatus',
|
||||
chat_id: chatId,
|
||||
member_id: { '@type': 'messageSenderUser', user_id: memberId },
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
async kickMember(
|
||||
tenantId: string, accountId: string, chatId: number, memberId: number,
|
||||
): Promise<unknown> {
|
||||
await this.setMemberStatus(tenantId, accountId, chatId, memberId, {
|
||||
'@type': 'chatMemberStatusBanned', banned_until_date: 0,
|
||||
});
|
||||
return this.setMemberStatus(tenantId, accountId, chatId, memberId, {
|
||||
'@type': 'chatMemberStatusLeft',
|
||||
});
|
||||
}
|
||||
|
||||
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 listInviteLinks(
|
||||
tenantId: string, accountId: string, chatId: number,
|
||||
creatorUserId: number, limit: number,
|
||||
): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'getChatInviteLinks',
|
||||
chat_id: chatId,
|
||||
creator_user_id: creatorUserId,
|
||||
is_revoked: false,
|
||||
offset_date: 0,
|
||||
offset_invite_link: '',
|
||||
limit: Math.min(Math.max(limit, 1), 100),
|
||||
});
|
||||
}
|
||||
|
||||
async editInviteLink(
|
||||
tenantId: string, accountId: string, chatId: number,
|
||||
options: {
|
||||
inviteLink: string; name?: string; expirationDate?: number;
|
||||
memberLimit?: number; createsJoinRequest?: boolean;
|
||||
},
|
||||
): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'editChatInviteLink',
|
||||
chat_id: chatId,
|
||||
invite_link: options.inviteLink,
|
||||
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 getJoinRequests(
|
||||
tenantId: string, accountId: string, chatId: number,
|
||||
inviteLink: string, query: string, limit: number,
|
||||
): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'getChatJoinRequests',
|
||||
chat_id: chatId,
|
||||
invite_link: inviteLink,
|
||||
query,
|
||||
offset_request: {
|
||||
'@type': 'chatJoinRequest', user_id: 0, date: 0, bio: '',
|
||||
},
|
||||
limit: Math.min(Math.max(limit, 1), 100),
|
||||
});
|
||||
}
|
||||
|
||||
async processJoinRequest(
|
||||
tenantId: string, accountId: string, chatId: number,
|
||||
userId: number, approve: boolean,
|
||||
): Promise<unknown> {
|
||||
await this.tgAccountsService.findById(tenantId, accountId);
|
||||
return this.tdlibClientManager.sendRequest(accountId, {
|
||||
'@type': 'processChatJoinRequest',
|
||||
chat_id: chatId,
|
||||
user_id: userId,
|
||||
approve,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user