feat(gateway): include production Telegram user gateway

Version the NestJS TDLib gateway with Docker deployment, migrations, automated tests and platform integration documentation while excluding all account sessions, credentials, runtime databases and backups.
This commit is contained in:
wmq
2026-08-01 18:38:20 +08:00
parent af49d22ca7
commit 06112602ec
142 changed files with 23107 additions and 7 deletions
@@ -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');
}
}
}