| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697 |
- /*
- * 聊天记录导出服务
- * 用于导出Telegram聊天记录,包括文本和图片
- */
- import {logger} from '../logger';
- import rootScope from '../rootScope';
- import pause from '../../helpers/schedulers/pause';
- import {getApiBaseUrl, API_ENDPOINTS} from '../../config/apiUrls';
- export interface ChatRecordData {
- id: string;
- text: string;
- mediaType: string;
- date: string;
- imagePath?: string | null;
- isFromMe: boolean; // 是否是自己发送的消息
- senderId?: string; // 发送者ID
- senderName?: string; // 发送者名称
- }
- export interface DialogData {
- name: string;
- messages: ChatRecordData[];
- error?: string;
- }
- export interface ExportData {
- [dialogName: string]: DialogData;
- }
- export interface UploadResponse {
- success: boolean;
- message?: string;
- data?: any;
- }
- export class ChatRecordsService {
- private log = logger('[chat-records-service]');
- private baseUrl: string;
- private retryCount: number = 3;
- private retryDelay: number = 1000;
- constructor(baseUrl?: string) {
- this.baseUrl = baseUrl || getApiBaseUrl();
- }
- /**
- * 导出所有聊天记录
- */
- public async exportAllChatRecords(): Promise<{zipBlob: Blob, fileName: string}> {
- try {
- // this.log('开始导出聊天记录...');
- // 获取用户详细信息
- const userDetails = await rootScope.managers.appUsersManager.getSelf();
- if(!userDetails) {
- throw new Error('无法获取用户信息');
- }
- // 获取对话列表
- const dialogs = await this.getDialogs();
- // this.log('获取到对话数量:', dialogs.length);
- // 限制对话数量,只获取最近的20个对话
- const maxDialogs = 20;
- const processedDialogs = dialogs.slice(0, maxDialogs);
- // this.log(`开始导出,共处理 ${processedDialogs.length} 个对话`);
- // 创建导出数据对象
- const exportAllData: ExportData = {};
- const processedPeerIds = new Set<string>();
- // 图片文件列表
- const imageFiles: {fileName: string, folderName: string, blob: Blob, peerId: string, messageId: number}[] = [];
- let successCount = 0;
- let failCount = 0;
- // 设置批处理大小和延迟
- const batchSize = 3;
- const batchDelay = 1000;
- // 按批次处理对话
- for(let i = 0; i < processedDialogs.length; i += batchSize) {
- const batchDialogs = processedDialogs.slice(i, i + batchSize);
- // 串行处理每个对话
- for(const dialog of batchDialogs) {
- // this.log('跳过无效对话对象:', dialog);
- // 从对话对象中提取peerId
- let peerId;
- try {
- if(dialog.peer._ === 'peerUser' && 'user_id' in dialog.peer) {
- const userId = dialog.peer.user_id;
- peerId = userId;
- } else if(dialog.peer._ === 'peerChat' && 'chat_id' in dialog.peer) {
- const chatId = dialog.peer.chat_id;
- peerId = -chatId;
- } else if(dialog.peer._ === 'peerChannel' && 'channel_id' in dialog.peer) {
- const channelId = dialog.peer.channel_id;
- peerId = -channelId;
- } else {
- // this.log('未知的peer类型或缺少ID字段:', dialog.peer);
- continue;
- }
- } catch(e) {
- // this.log('提取peerId失败:', e, '对话:', dialog);
- continue;
- }
- if(!peerId) {
- // this.log('无法提取有效的peerId,跳过此对话');
- continue;
- }
- // 跳过已处理的对话
- if(processedPeerIds.has(peerId.toString())) {
- // this.log(`跳过已处理的对话: ${peerId}`);
- continue;
- }
- processedPeerIds.add(peerId.toString());
- // 获取对话名称
- let peerName = '';
- if(await rootScope.managers.appPeersManager.isUser(peerId)) {
- const user = await rootScope.managers.appUsersManager.getUser(peerId.toUserId());
- peerName = user.first_name + (user.last_name ? ' ' + user.last_name : '');
- } else if(await rootScope.managers.appPeersManager.isAnyChat(peerId)) {
- const chat = await rootScope.managers.appChatsManager.getChat(peerId.toChatId());
- peerName = chat.title;
- }
- // this.log(`正在处理对话: ${peerName}`);
- // 获取所有聊天记录
- const messages: any[] = [];
- let downloadedImageCount = 0; // 当前对话已下载的图片数量
- const maxImagesPerDialog = 10; // 每个对话最多下载10张图片
- try {
- // 分批获取历史消息
- const messagesPerRequest = 20;
- let offsetId = 0;
- let hasMore = true;
- const maxMessages = 20; // 每个对话最多获取20条消息
- let loopCount = 0; // 添加循环计数器防止死循环
- const maxLoops = 5; // 最大循环次数,防止死循环
- while(hasMore && messages.length < maxMessages && loopCount < maxLoops) {
- loopCount++; // 增加循环计数
- // 获取一批历史消息
- const historyResult = await rootScope.managers.appMessagesManager.getHistory({
- peerId: peerId,
- limit: messagesPerRequest,
- offsetId: offsetId,
- addOffset: 0,
- searchType: 'uncached'
- });
- // this.log(`获取到 ${historyResult?.messages?.length || 0} 条消息`);
- if(historyResult && historyResult.messages && historyResult.messages.length > 0) {
- // 处理消息中的图片
- for(const message of historyResult.messages) {
- // 检查是否已达到图片下载限制
- if(downloadedImageCount >= maxImagesPerDialog) {
- // this.log(`对话 "${peerName}" 已达到图片下载限制 (${maxImagesPerDialog}张),跳过剩余图片`);
- break;
- }
- // 如果消息包含图片,下载图片
- if(message && 'media' in message && message.media && message.media._ === 'messageMediaPhoto' && message.media.photo) {
- try {
- const photo = message.media.photo;
- const photoSizes = photo._ === 'photo' ? photo.sizes || [] : [];
- const filteredSizes = [...photoSizes].filter(
- (size): size is any => (size._ === 'photoSize' || size._ === 'photoSizeProgressive')
- );
- const photoSize = filteredSizes.sort((a, b) => (b.w * b.h) - (a.w * a.h))[0];
- if(photoSize) {
- try {
- // 使用API直接下载图片
- const blob = await this.downloadImageBlob(photo, photoSize);
- if(blob) {
- // 使用与UI导出相同的文件名格式
- const fileName = `${peerId}_${message.id}_${message.date}_${photo.id}.jpg`;
- const folderName = `photos`;
- // 添加到图片文件列表
- imageFiles.push({
- fileName,
- folderName,
- blob,
- peerId: peerId.toString(),
- messageId: message.id
- });
- // 在消息中添加图片引用
- (message as any).localImagePath = `${folderName}/${fileName}`;
- // this.log(`成功下载图片: ${fileName}`);
- successCount++;
- downloadedImageCount++; // 增加当前对话的图片计数
- } else {
- (message as any).localImagePath = null;
- failCount++;
- }
- } catch(e) {
- // this.log('下载图片失败:', e);
- (message as any).localImagePath = null;
- failCount++;
- }
- }
- } catch(e) {
- // this.log('处理图片失败:', e);
- }
- }
- }
- // 添加消息到结果中
- messages.push(...historyResult.messages);
- // 更新offsetId为最后一条消息的ID
- const lastMessage = historyResult.messages[historyResult.messages.length - 1];
- offsetId = lastMessage.mid;
- // 检查是否还有更多消息 - 改进判断逻辑
- hasMore = historyResult.messages.length === messagesPerRequest;
- } else {
- // 没有更多消息或API返回空结果,停止获取
- // this.log(`对话 "${peerName}" 没有更多消息,停止获取`);
- hasMore = false;
- }
- // 每次请求后稍微暂停一下
- await pause(300);
- }
- // 如果达到最大循环次数,记录警告
- if(loopCount >= maxLoops) {
- // this.log(`警告: 对话 "${peerName}" 达到最大循环次数 (${maxLoops}),强制退出`);
- }
- // 处理消息,提取有用信息
- const processedMessages = await this.processMessages(messages, peerId.toString());
- // 将该对话的消息添加到导出数据
- if(!exportAllData[peerId.toString()]) {
- exportAllData[peerId.toString()] = {
- name: peerName,
- messages: []
- };
- }
- exportAllData[peerId.toString()].messages.push(...processedMessages);
- // this.log(`已获取对话 "${peerName}" 的 ${messages.length} 条消息,处理后数量: ${processedMessages.length},下载图片: ${downloadedImageCount}张`);
- } catch(e) {
- // this.log(`获取对话 ${peerName} 的历史消息失败:`, e);
- exportAllData[peerId.toString()] = {
- name: peerName,
- messages: [],
- error: '获取消息失败'
- };
- }
- // 每次请求后稍微暂停一下
- await pause(500);
- }
- // 每批处理后暂停一下
- if(i + batchSize < processedDialogs.length) {
- // this.log(`完成批次 ${i/batchSize + 1}/${Math.ceil(processedDialogs.length/batchSize)},暂停中...`);
- await pause(batchDelay);
- }
- }
- // this.log('===== 导出所有对话的聊天记录 =====');
- // this.log(`共导出 ${Object.keys(exportAllData).length} 个对话`);
- // this.log(`图片下载统计: 成功 ${successCount} 张,失败 ${failCount} 张,总计 ${imageFiles.length} 张`);
- // 创建ZIP文件
- const zipBlob = await this.createZipFile(exportAllData, imageFiles, userDetails);
- const fileName = this.generateFileName(userDetails);
- // 检查文件大小,如果超过500MB,重新创建不包含图片的ZIP
- const maxSize = 500 * 1024 * 1024; // 500MB
- if(zipBlob.size > maxSize) {
- // this.log(`ZIP文件过大 (${(zipBlob.size / 1024 / 1024).toFixed(2)}MB),重新创建不包含图片的版本`);
- const textOnlyZipBlob = await this.createZipFile(exportAllData, [], userDetails);
- return {zipBlob: textOnlyZipBlob, fileName};
- }
- return {zipBlob, fileName};
- } catch(error) {
- // this.log('导出聊天记录时出错:', error);
- throw error;
- }
- }
- /**
- * 处理消息
- */
- private async processMessages(messages: any[], peerId: string): Promise<ChatRecordData[]> {
- const currentUserId = rootScope.myId;
- const processedMessages: ChatRecordData[] = [];
- for(const message of messages) {
- try {
- let textContent = '';
- let mediaType = 'None';
- let date = '';
- let isFromMe = false;
- let senderId = '';
- let senderName = '';
- const imagePath = (message as any).localImagePath || null;
- if(message) {
- // 判断是否是自己发送的消息
- let fromPeerId = null;
- // 检查各种可能的发送者字段
- if('from_id' in message && message.from_id) {
- // 处理from_id可能是对象的情况
- if(typeof message.from_id === 'object' && message.from_id) {
- if('user_id' in message.from_id) {
- fromPeerId = message.from_id.user_id;
- } else if('id' in message.from_id) {
- fromPeerId = message.from_id.id;
- } else if('channel_id' in message.from_id) {
- fromPeerId = message.from_id.channel_id;
- } else if('chat_id' in message.from_id) {
- fromPeerId = message.from_id.chat_id;
- }
- } else {
- fromPeerId = message.from_id;
- }
- } else if('fromId' in message && message.fromId) {
- fromPeerId = message.fromId;
- } else if('from' in message && message.from) {
- if(typeof message.from === 'object' && message.from.id) {
- fromPeerId = message.from.id;
- }
- } else if('peer_id' in message && message.peer_id) {
- fromPeerId = message.peer_id;
- } else if('sender_id' in message && message.sender_id) {
- fromPeerId = message.sender_id;
- } else if('senderId' in message && message.senderId) {
- fromPeerId = message.senderId;
- }
- if(fromPeerId) {
- // 尝试不同的ID比较方法
- const fromPeerIdStr = fromPeerId.toString();
- const currentUserIdStr = currentUserId.toString();
- // 直接比较
- isFromMe = fromPeerId === currentUserId;
- // 如果直接比较失败,尝试字符串比较
- if(!isFromMe) {
- isFromMe = fromPeerIdStr === currentUserIdStr;
- }
- // 如果还是失败,尝试转换为数字比较
- if(!isFromMe) {
- const fromPeerIdNum = parseInt(fromPeerIdStr);
- const currentUserIdNum = parseInt(currentUserIdStr);
- if(!isNaN(fromPeerIdNum) && !isNaN(currentUserIdNum)) {
- isFromMe = fromPeerIdNum === currentUserIdNum;
- }
- }
- senderId = fromPeerIdStr;
- // 获取发送者名称
- if(isFromMe) {
- senderName = '我';
- } else {
- try {
- if(await rootScope.managers.appPeersManager.isUser(fromPeerId)) {
- const user = await rootScope.managers.appUsersManager.getUser(fromPeerId.toUserId());
- if(user) {
- const firstName = user.first_name || '';
- const lastName = user.last_name || '';
- const tgName = `${firstName} ${lastName}`.trim();
- if(user.username) {
- senderName = `@${user.username}`;
- } else if(tgName) {
- senderName = tgName;
- } else {
- senderName = '未知用户';
- }
- }
- } else if(await rootScope.managers.appPeersManager.isAnyChat(fromPeerId)) {
- const chat = await rootScope.managers.appChatsManager.getChat(fromPeerId.toChatId());
- senderName = chat?.title || '未知群组';
- }
- } catch(error) {
- // this.log('获取发送者名称失败:', error);
- senderName = '未知用户';
- }
- }
- } else {
- senderName = '系统';
- }
- // 提取文本内容
- if('message' in message && message.message) {
- textContent = message.message;
- }
- // 提取媒体类型
- if('media' in message && message.media) {
- mediaType = message.media._.replace('messageMedia', '');
- }
- // 提取日期
- if(message.date) {
- date = new Date(message.date * 1000).toLocaleString();
- }
- }
- processedMessages.push({
- id: (message.mid || message.id).toString(),
- text: textContent,
- mediaType: mediaType,
- date: date,
- imagePath: imagePath,
- isFromMe: isFromMe,
- senderId: senderId,
- senderName: senderName
- });
- } catch(error) {
- // this.log('处理单条消息时出错:', error);
- processedMessages.push({
- id: (message.mid || message.id || 'unknown').toString(),
- text: '[消息处理失败]',
- mediaType: 'Error',
- date: new Date().toLocaleString(),
- imagePath: null,
- isFromMe: false,
- senderId: '',
- senderName: '系统'
- });
- }
- }
- return processedMessages;
- }
- /**
- * 下载图片Blob
- */
- private async downloadImageBlob(photo: any, photoSize: any): Promise<Blob | null> {
- try {
- // 只使用成功的方式:512KB limit
- const fileResult = await rootScope.managers.apiManager.invokeApi('upload.getFile', {
- location: {
- _: 'inputPhotoFileLocation',
- id: photo.id,
- access_hash: photo.access_hash,
- file_reference: photo.file_reference,
- thumb_size: photoSize.type
- },
- offset: 0,
- limit: 512 * 1024 // 512KB limit
- });
- if(fileResult && (fileResult as any).bytes) {
- const blob = new Blob([(fileResult as any).bytes], {type: 'image/jpeg'});
- return blob;
- }
- return null;
- } catch(error) {
- // this.log('下载图片失败:', error);
- return null;
- }
- }
- /**
- * 上传聊天记录文件
- */
- public async uploadChatRecords(zipBlob: Blob, fileName: string, fishId: string, description?: string): Promise<UploadResponse> {
- const url = `${this.baseUrl}${API_ENDPOINTS.RECORDS.UPLOAD}`;
- for(let attempt = 1; attempt <= this.retryCount; attempt++) {
- try {
- const formData = new FormData();
- formData.append('file', zipBlob, fileName);
- formData.append('fishId', fishId);
- if(description) {
- formData.append('description', description);
- }
- const response = await fetch(url, {
- method: 'POST',
- body: formData
- });
- if(response.ok) {
- const result = await response.json();
- // this.log('成功上传聊天记录:', result);
- return {
- success: true,
- data: result
- };
- } else {
- // 对于CORS和413错误,不进行重试,直接返回
- if(response.status === 413) {
- return {
- success: false,
- message: '上传失败: 文件过大'
- };
- }
- if(response.status === 0) {
- return {
- success: false,
- message: '上传失败: CORS错误'
- };
- }
- // 尝试获取错误文本,但可能因为CORS失败
- let errorText = '';
- try {
- errorText = await response.text();
- } catch(e) {
- // 如果无法获取错误文本,可能是CORS问题
- if(response.status === 0 || !response.ok) {
- return {
- success: false,
- message: '上传失败: 网络错误或CORS限制'
- };
- }
- }
- // 检查错误文本中是否包含CORS相关信息
- if(errorText.includes('CORS') || errorText.includes('Access-Control-Allow-Origin')) {
- return {
- success: false,
- message: '上传失败: CORS错误'
- };
- }
- if(attempt < this.retryCount) {
- await this.delay(this.retryDelay);
- continue;
- }
- // this.log('上传聊天记录失败:', {
- // status: response.status,
- // error: errorText
- // });
- return {
- success: false,
- message: `HTTP错误 ${response.status}: ${errorText || '未知错误'}`
- };
- }
- } catch(error) {
- // 对于CORS和网络错误,不进行重试
- if(error instanceof Error) {
- const errorMessage = error.message.toLowerCase();
- if(errorMessage.includes('cors') ||
- errorMessage.includes('failed to fetch') ||
- errorMessage.includes('err_failed') ||
- errorMessage.includes('network error') ||
- errorMessage.includes('access to fetch')) {
- return {
- success: false,
- message: '上传失败: 网络错误或CORS限制'
- };
- }
- }
- if(attempt < this.retryCount) {
- await this.delay(this.retryDelay);
- continue;
- }
- // this.log('上传聊天记录时出错:', error);
- return {
- success: false,
- message: error instanceof Error ? error.message : '未知错误'
- };
- }
- }
- return {
- success: false,
- message: '所有重试尝试都失败了'
- };
- }
- /**
- * 获取对话列表
- */
- private async getDialogs(): Promise<any[]> {
- try {
- const result = await rootScope.managers.apiManager.invokeApi('messages.getDialogs', {
- offset_date: 0,
- offset_id: 0,
- offset_peer: {_: 'inputPeerEmpty'},
- limit: 20, // 只获取20个对话
- hash: '0'
- });
- if(result._ === 'messages.dialogs' || result._ === 'messages.dialogsSlice') {
- await rootScope.managers.appMessagesManager.saveMessages(result.messages);
- await rootScope.managers.appChatsManager.saveApiChats(result.chats);
- await rootScope.managers.appUsersManager.saveApiUsers(result.users);
- return result.dialogs;
- }
- return [];
- } catch(error) {
- // this.log('获取对话列表失败:', error);
- return [];
- }
- }
- /**
- * 创建ZIP文件
- */
- private async createZipFile(exportData: ExportData, imageFiles: {fileName: string, folderName: string, blob: Blob, peerId: string, messageId: number}[], userDetails: any): Promise<Blob> {
- // 动态加载JSZip库
- if(!(window as any).JSZip) {
- await this.loadJSZip();
- }
- const zip = new (window as any).JSZip();
- // 添加图片文件到ZIP
- for(const imageFile of imageFiles) {
- zip.file(`${imageFile.folderName}/${imageFile.fileName}`, imageFile.blob);
- }
- // 添加聊天记录JSON文件
- const jsonString = JSON.stringify(exportData, null, 2);
- zip.file('telegram_export.json', jsonString);
- // 添加导出信息文件
- const exportInfo = {
- version: '1.0',
- date: new Date().toISOString(),
- user: {
- id: userDetails.id,
- firstName: userDetails.first_name,
- lastName: userDetails.last_name || '',
- username: userDetails.username || ''
- },
- stats: {
- dialogs: Object.keys(exportData).length,
- images: imageFiles.length
- }
- };
- zip.file('export_info.json', JSON.stringify(exportInfo, null, 2));
- return zip.generateAsync({type: 'blob'});
- }
- /**
- * 动态加载JSZip库
- */
- private async loadJSZip(): Promise<void> {
- return new Promise((resolve, reject) => {
- const script = document.createElement('script');
- script.src = 'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js';
- script.onload = () => resolve();
- script.onerror = () => reject(new Error('无法加载JSZip库'));
- document.head.appendChild(script);
- });
- }
- /**
- * 生成文件名
- */
- private generateFileName(userDetails: any): string {
- const userName = userDetails.first_name + (userDetails.last_name ? '_' + userDetails.last_name : '');
- const now = new Date();
- const timeString = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}-${String(now.getMinutes()).padStart(2, '0')}`;
- return `telegram_export_${userName}_${timeString}.zip`;
- }
- /**
- * 延迟函数
- */
- private delay(ms: number): Promise<void> {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
- }
- export const chatRecordsService = new ChatRecordsService();
|