feat(reliability): close messaging gateway P0 gaps
This commit is contained in:
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user