feat(reliability): close messaging gateway P0 gaps

This commit is contained in:
wmq
2026-08-01 23:55:41 +08:00
parent 00b0ebb775
commit ad126606f9
15 changed files with 544 additions and 41 deletions
+5
View File
@@ -38,6 +38,11 @@ CORS_ORIGINS=https://your-control-plane.example
Production uses migrations and keeps TypeORM synchronization disabled.
If Docker build containers cannot reach the default Debian repositories, set
`DEBIAN_MIRROR` and `DEBIAN_SECURITY_MIRROR` to reachable mirrors. HTTP mirrors
can bootstrap older base images that do not yet contain CA certificates; keep
all application and credential traffic on HTTPS.
## Start
From the project root:
@@ -6,6 +6,7 @@ import {
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { TgAccount } from './tg-account.entity';
@@ -18,6 +19,7 @@ export enum ChatType {
}
@Entity('tg_chats')
@Index('IDX_tg_chat_account_identity', ['accountId', 'tgChatId'], { unique: true })
export class TgChat {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner, TableIndex } from 'typeorm';
export class TgChatIdentityUnique1700000003000 implements MigrationInterface {
name = 'TgChatIdentityUnique1700000003000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM tg_chats older
USING tg_chats newer
WHERE older."accountId" = newer."accountId"
AND older."tgChatId" = newer."tgChatId"
AND (
older."updatedAt" < newer."updatedAt"
OR (older."updatedAt" = newer."updatedAt" AND older.id < newer.id)
)
`);
const table = await queryRunner.getTable('tg_chats');
if (!table?.indices.some((index) => index.name === 'IDX_tg_chat_account_identity')) {
await queryRunner.createIndex(
'tg_chats',
new TableIndex({
name: 'IDX_tg_chat_account_identity',
columnNames: ['accountId', 'tgChatId'],
isUnique: true,
}),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('tg_chats');
if (table?.indices.some((index) => index.name === 'IDX_tg_chat_account_identity')) {
await queryRunner.dropIndex('tg_chats', 'IDX_tg_chat_account_identity');
}
}
}
@@ -19,8 +19,8 @@ export class TgChatsService {
return this.chatRepo.find({
where: { accountId },
order: { lastMessageDate: 'DESC', updatedAt: 'DESC' },
take: limit,
skip: offset,
take: Math.min(200, Math.max(1, Number(limit) || 50)),
skip: Math.max(0, Number(offset) || 0),
});
}
@@ -106,6 +106,21 @@ export class TgChatsService {
async syncChatsFromTdlib(tenantId: string, accountId: string): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId);
let loadedPages = 0;
for (; loadedPages < 100; loadedPages++) {
try {
await this.tdlibClientManager.sendRequest(accountId, {
'@type': 'loadChats',
chat_list: { '@type': 'chatListMain' },
limit: 100,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/404|all chats.*loaded|not found/i.test(message)) break;
throw error;
}
}
await new Promise((resolve) => setTimeout(resolve, 250));
const result = await this.tdlibClientManager.sendRequest(accountId, {
'@type': 'getChats',
limit: 100,
@@ -123,7 +138,8 @@ export class TgChatsService {
// A deleted/inaccessible chat must not abort the remaining first sync.
}
}
return { total: chatIds.length, synced: synced.length, chatIds };
const total = await this.chatRepo.count({ where: { accountId } });
return { total, synced: synced.length, loadedPages, chatIds };
}
private async persistTdlibChat(accountId: string, chat: Record<string, any>): Promise<TgChat> {
@@ -331,6 +347,11 @@ export class TgChatsService {
chat = this.chatRepo.create({ accountId, ...data });
}
return this.chatRepo.save(chat);
const { account: _account, ...persisted } = chat;
// TypeORM's DeepPartial type does not understand the JSON transformer fields.
await this.chatRepo.upsert(persisted as any, ['accountId', 'tgChatId']);
return this.chatRepo.findOneOrFail({
where: { accountId, tgChatId: data.tgChatId },
});
}
}
@@ -5,10 +5,11 @@ import { TdlibModule } from '../tdlib/tdlib.module';
import { TgWebhooksModule } from '../tg-webhooks/tg-webhooks.module';
import { TgUpdatesService } from './tg-updates.service';
import { TgAccount } from '../../database/entities/tg-account.entity';
import { TgChat } from '../../database/entities/tg-chat.entity';
@Module({
imports: [
TypeOrmModule.forFeature([TgRawUpdate, TgAccount]),
TypeOrmModule.forFeature([TgRawUpdate, TgAccount, TgChat]),
TdlibModule,
TgWebhooksModule,
],
@@ -9,6 +9,7 @@ import {
TgAccount,
TgAccountStatus,
} from '../../database/entities/tg-account.entity';
import { ChatType, TgChat } from '../../database/entities/tg-chat.entity';
@Injectable()
export class TgUpdatesService implements OnModuleInit, OnModuleDestroy {
@@ -20,6 +21,8 @@ export class TgUpdatesService implements OnModuleInit, OnModuleDestroy {
private readonly rawUpdateRepo: Repository<TgRawUpdate>,
@InjectRepository(TgAccount)
private readonly accountRepo: Repository<TgAccount>,
@InjectRepository(TgChat)
private readonly chatRepo: Repository<TgChat>,
private readonly tdlib: TdlibClientManagerService,
private readonly webhooks: TgWebhooksService,
) {}
@@ -50,6 +53,11 @@ export class TgUpdatesService implements OnModuleInit, OnModuleDestroy {
if (updateType === 'updateAuthorizationState') {
await this.updateAccountAuthorization(update.accountId, update.data);
}
if (updateType === 'updateNewChat') {
await this.persistChat(update.accountId, update.data.chat as Record<string, any>);
} else if (updateType === 'updateChatLastMessage') {
await this.persistChatLastMessage(update.accountId, update.data);
}
const event = this.mapWebhookEvent(updateType, update.data);
if (event) {
@@ -60,6 +68,59 @@ export class TgUpdatesService implements OnModuleInit, OnModuleDestroy {
await this.rawUpdateRepo.save(saved);
}
private messageText(content: Record<string, any> = {}): string {
return String(
content.text?.text
|| content.caption?.text
|| (content['@type'] ? `[${content['@type']}]` : ''),
);
}
private async persistChat(accountId: string, chat: Record<string, any> | undefined) {
if (!chat?.id) return;
const rawType = String(chat.type?.['@type'] || '');
const typeMap: Record<string, ChatType> = {
chatTypePrivate: ChatType.PRIVATE,
chatTypeBasicGroup: ChatType.GROUP,
chatTypeSupergroup: chat.type?.is_channel ? ChatType.CHANNEL : ChatType.SUPERGROUP,
chatTypeSecret: ChatType.SECRET,
};
const existing = await this.chatRepo.findOne({
where: { accountId, tgChatId: Number(chat.id) },
});
const value = existing || this.chatRepo.create({ accountId, tgChatId: Number(chat.id) });
Object.assign(value, {
type: typeMap[rawType] || existing?.type || ChatType.PRIVATE,
title: String(chat.title || existing?.title || `Telegram ${chat.id}`),
lastMessage: this.messageText(chat.last_message?.content) || existing?.lastMessage || '',
lastMessageId: Number(chat.last_message?.id || existing?.lastMessageId || 0),
lastMessageDate: chat.last_message?.date
? new Date(Number(chat.last_message.date) * 1000)
: existing?.lastMessageDate,
unreadCount: Number(chat.unread_count ?? existing?.unreadCount ?? 0),
photo: chat.photo || existing?.photo,
raw: { ...(existing?.raw || {}), ...chat },
});
const { account: _account, ...persisted } = value;
// TypeORM's DeepPartial type does not understand the JSON transformer fields.
await this.chatRepo.upsert(persisted as any, ['accountId', 'tgChatId']);
}
private async persistChatLastMessage(accountId: string, update: Record<string, any>) {
const chatId = Number(update.chat_id || 0);
if (!chatId) return;
const existing = await this.chatRepo.findOne({ where: { accountId, tgChatId: chatId } });
if (!existing) return;
const message = update.last_message || {};
existing.lastMessage = this.messageText(message.content) || existing.lastMessage;
existing.lastMessageId = Number(message.id || existing.lastMessageId || 0);
existing.lastMessageDate = message.date
? new Date(Number(message.date) * 1000)
: existing.lastMessageDate;
existing.raw = { ...(existing.raw || {}), last_message: message };
await this.chatRepo.save(existing);
}
private async updateAccountAuthorization(
accountId: string,
data: Record<string, unknown>,
@@ -159,10 +159,12 @@ describe('Business service behavior', () => {
});
it('persists Telegram broadcast channels as channel instead of supergroup', async () => {
let stored: any;
const repo = {
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
upsert: jest.fn(async (value) => { stored = value; }),
findOneOrFail: jest.fn(async () => stored),
};
const service = new TgChatsService(repo as any, tdlib as any, accounts as any);
const saved = await (service as any).persistTdlibChat('account-1', {