import Foundation import Postbox import SwiftSignalKit import TelegramCore #if canImport(SGLogging) import SGLogging #endif // MARK: - Constants private let messagesPerPage = 1000 // MARK: - Export Progress public enum SGChatExportProgress { case preparing case exporting(current: Int, total: Int) case copyingMedia(current: Int, total: Int) case done(URL) case error(String) } // MARK: - Peer Display Name Helper private func peerDisplayName(_ peer: Peer?) -> String { guard let peer = peer else { return "Unknown" } if let user = peer as? TelegramUser { let first = user.firstName ?? "" let last = user.lastName ?? "" let name = [first, last].filter { !$0.isEmpty }.joined(separator: " ") return name.isEmpty ? (user.username ?? "User") : name } else if let channel = peer as? TelegramChannel { return channel.title } else if let group = peer as? TelegramGroup { return group.title } return "Chat" } private func peerInitial(_ peer: Peer?) -> String { let name = peerDisplayName(peer) return String(name.prefix(1)) } private func userpicColorIndex(_ peer: Peer?) -> Int { guard let peer = peer else { return 1 } let id = peer.id.id._internalGetInt64Value() return Int(abs(id) % 8) + 1 } // MARK: - Chat Title Helper private func chatTitle(peerId: PeerId, transaction: Transaction) -> String { if let peer = transaction.getPeer(peerId) { return peerDisplayName(peer) } return "Chat" } // MARK: - HTML Escaping private func htmlEscape(_ text: String) -> String { return text .replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") .replacingOccurrences(of: "\"", with: """) } // MARK: - Date Formatting private let dateFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "dd.MM.yyyy HH:mm:ss" f.locale = Locale(identifier: "en_US_POSIX") return f }() private let timeFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "HH:mm" f.locale = Locale(identifier: "en_US_POSIX") return f }() private let dateSeparatorFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "d MMMM yyyy" f.locale = Locale(identifier: "en_US") return f }() private let fileDateFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "dd-MM-yyyy_HH-mm-ss" f.locale = Locale(identifier: "en_US_POSIX") return f }() private func timeZoneSuffix() -> String { let tz = TimeZone.current let seconds = tz.secondsFromGMT() let hours = seconds / 3600 let minutes = abs(seconds % 3600) / 60 return String(format: "UTC%+03d:%02d", hours, minutes) } // MARK: - Duration Formatting private func formatDuration(_ seconds: Int) -> String { let m = seconds / 60 let s = seconds % 60 return String(format: "%02d:%02d", m, s) } // MARK: - Media Info private struct MediaFileInfo { let sourceResourcePath: String? let exportSubdir: String let exportFileName: String let htmlBlock: String } private func mediaInfoForMessage( _ message: Message, mediaBox: MediaBox, messageDate: Date ) -> MediaFileInfo? { for media in message.media { if let image = media as? TelegramMediaImage { guard let largest = image.representations.last else { continue } let sourcePath = mediaBox.completedResourcePath(largest.resource, pathExtension: "jpg") let dateStr = fileDateFormatter.string(from: messageDate) let fileName = "photo_\(message.id.id)@\(dateStr).jpg" let thumbFileName = "photo_\(message.id.id)@\(dateStr)_thumb.jpg" let dims = largest.dimensions let w = min(Int(dims.width), 260) let h = Int(Double(dims.height) * Double(w) / max(Double(dims.width), 1)) let html = """
""" return MediaFileInfo( sourceResourcePath: sourcePath, exportSubdir: "photos", exportFileName: fileName, htmlBlock: html ) } if let file = media as? TelegramMediaFile { let sourcePath = mediaBox.completedResourcePath(file.resource) let dateStr = fileDateFormatter.string(from: messageDate) if file.isVoice { let duration = Int(file.duration ?? 0) let fileName = "audio_\(message.id.id)@\(dateStr).ogg" let html = """
Voice message
\(formatDuration(duration))
""" return MediaFileInfo( sourceResourcePath: sourcePath, exportSubdir: "voice_messages", exportFileName: fileName, htmlBlock: html ) } if file.isInstantVideo { let duration = Int(file.duration ?? 0) let fileName = "round_\(message.id.id)@\(dateStr).mp4" let html = """
\(formatDuration(duration))
""" return MediaFileInfo( sourceResourcePath: sourcePath, exportSubdir: "round_video_messages", exportFileName: fileName, htmlBlock: html ) } if file.isSticker { let fileName = "sticker_\(message.id.id)@\(dateStr).webp" let html = """
""" return MediaFileInfo( sourceResourcePath: sourcePath, exportSubdir: "stickers", exportFileName: fileName, htmlBlock: html ) } if file.isVideo { let duration = Int(file.duration ?? 0) let fileName = "video_\(message.id.id)@\(dateStr).mp4" let html = """
\(formatDuration(duration))
""" return MediaFileInfo( sourceResourcePath: sourcePath, exportSubdir: "video_files", exportFileName: fileName, htmlBlock: html ) } // Generic file let origName = file.fileName ?? "file_\(message.id.id)" let fileName = origName let fileSize = file.size ?? 0 let sizeStr = ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file) let html = """
\(htmlEscape(origName))
\(sizeStr)
""" return MediaFileInfo( sourceResourcePath: sourcePath, exportSubdir: "files", exportFileName: fileName, htmlBlock: html ) } } return nil } // MARK: - Reactions HTML private func reactionsHTML(for message: Message) -> String { var reactionsAttr: ReactionsMessageAttribute? for attr in message.attributes { if let r = attr as? ReactionsMessageAttribute { reactionsAttr = r break } } guard let reactionsAttr = reactionsAttr, !reactionsAttr.reactions.isEmpty else { return "" } var html = "\n \n" for reaction in reactionsAttr.reactions { let isActive = reaction.chosenOrder != nil let activeClass = isActive ? " active" : "" var emojiStr = "" switch reaction.value { case let .builtin(emoji): emojiStr = emoji case .custom: emojiStr = "\u{2764}\u{FE0F}" case .stars: emojiStr = "\u{2B50}" } html += " \n" html += " \(emojiStr)\n" html += " \(reaction.count)\n" html += " \n" } html += " \n" return html } // MARK: - Reply HTML private func replyHTML(for message: Message) -> String { for attr in message.attributes { if let replyAttr = attr as? ReplyMessageAttribute { let replyId = replyAttr.messageId.id return """
In reply to this message
""" } } return "" } // MARK: - Forward Info HTML private func forwardHTML( _ message: Message, mediaHTML: String, textHTML: String ) -> String { guard let fwd = message.forwardInfo else { return "" } let authorName = htmlEscape(peerDisplayName(fwd.author)) let authorInitial = peerInitial(fwd.author) let authorColor = userpicColorIndex(fwd.author) let fwdDate = Date(timeIntervalSince1970: Double(fwd.date)) let fwdDateStr = dateFormatter.string(from: fwdDate) var body = "" body += """
\(htmlEscape(authorInitial))
\(authorName) \(fwdDateStr)
""" if !mediaHTML.isEmpty { body += mediaHTML + "\n" } if !textHTML.isEmpty { body += "
\(textHTML)
\n" } body += "
\n" return body } // MARK: - Message Text Processing private func processMessageText(_ text: String) -> String { guard !text.isEmpty else { return "" } // Escape HTML and convert newlines var result = htmlEscape(text) result = result.replacingOccurrences(of: "\n", with: "
") // Basic URL detection and linking if let urlPattern = try? NSRegularExpression(pattern: "(https?://[^\\s<>]+)", options: []) { let range = NSRange(result.startIndex..., in: result) result = urlPattern.stringByReplacingMatches( in: result, options: [], range: range, withTemplate: "$1" ) } return result } // MARK: - Service Message Text private func serviceMessageText(_ message: Message) -> String? { for media in message.media { if let action = media as? TelegramMediaAction { switch action.action { case let .groupCreated(title): let authorName = peerDisplayName(message.author) return "\(htmlEscape(authorName)) created group «\(htmlEscape(title))»" case .pinnedMessageUpdated: let authorName = peerDisplayName(message.author) return "\(htmlEscape(authorName)) pinned a message" case let .addedMembers(peerIds): let authorName = peerDisplayName(message.author) let memberNames = peerIds.compactMap { id -> String? in if let peer = message.peers[id] { return peerDisplayName(peer) } return nil } if memberNames.isEmpty { return "\(htmlEscape(authorName)) added members" } return "\(htmlEscape(authorName)) added \(memberNames.map { htmlEscape($0) }.joined(separator: ", "))" case let .removedMembers(peerIds): let authorName = peerDisplayName(message.author) let memberNames = peerIds.compactMap { id -> String? in if let peer = message.peers[id] { return peerDisplayName(peer) } return nil } if memberNames.isEmpty { return "\(htmlEscape(authorName)) removed a member" } return "\(htmlEscape(authorName)) removed \(memberNames.map { htmlEscape($0) }.joined(separator: ", "))" case .joinedByLink: let authorName = peerDisplayName(message.author) return "\(htmlEscape(authorName)) joined group by link" case let .photoUpdated(photo): let authorName = peerDisplayName(message.author) if photo != nil { return "\(htmlEscape(authorName)) changed group photo" } return "\(htmlEscape(authorName)) removed group photo" case let .titleUpdated(title): let authorName = peerDisplayName(message.author) return "\(htmlEscape(authorName)) changed group name to «\(htmlEscape(title))»" case .historyCleared: return "History cleared" case let .channelMigratedFromGroup(title, _): return "Group «\(htmlEscape(title))» converted to supergroup" case .groupMigratedToChannel: return "Group converted to supergroup" case let .topicCreated(title, _, _): return "Topic «\(htmlEscape(title))» created" case let .phoneCall(_, _, duration, isVideo): let authorName = peerDisplayName(message.author) let callType = isVideo ? "video call" : "call" if let duration = duration, duration > 0 { return "\(htmlEscape(authorName)) made a \(callType) (\(formatDuration(Int(duration))))" } return "\(htmlEscape(authorName)) made a \(callType)" default: return nil } } } return nil } // MARK: - HTML Page Generation private func htmlHeader(chatName: String) -> String { return """ Exported Data
""" } private func htmlFooter() -> String { return """
""" } // MARK: - Export Engine public struct SGChatExport { public static func exportChat( peerId: PeerId, postbox: Postbox, mediaBox: MediaBox ) -> Signal { return Signal { subscriber in subscriber.putNext(.preparing) let disposable = postbox.transaction { transaction -> Void in let title = chatTitle(peerId: peerId, transaction: transaction) // Collect all messages var allMessages: [Message] = [] transaction.withAllMessages(peerId: peerId, namespace: 0) { message in allMessages.append(message) return true } allMessages.sort { $0.timestamp < $1.timestamp } let totalMessages = allMessages.count if totalMessages == 0 { subscriber.putNext(.error("No messages to export")) subscriber.putCompletion() return } // Create export directory let exportDirName = "ChatExport_\(title.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: ":", with: "_"))" let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(exportDirName, isDirectory: true) // Clean up previous export try? FileManager.default.removeItem(at: tempDir) do { try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("css"), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("js"), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("photos"), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("video_files"), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("voice_messages"), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("round_video_messages"), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("stickers"), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("files"), withIntermediateDirectories: true) } catch { subscriber.putNext(.error("Failed to create export directory: \(error.localizedDescription)")) subscriber.putCompletion() return } // Write CSS and JS do { try sgChatExportCSS.write(to: tempDir.appendingPathComponent("css/style.css"), atomically: true, encoding: .utf8) try sgChatExportJS.write(to: tempDir.appendingPathComponent("js/script.js"), atomically: true, encoding: .utf8) } catch { subscriber.putNext(.error("Failed to write assets: \(error.localizedDescription)")) subscriber.putCompletion() return } // Split into pages let totalPages = max(1, (totalMessages + messagesPerPage - 1) / messagesPerPage) var mediaFilesToCopy: [(source: String, destination: URL)] = [] for pageIndex in 0.. 1 ? "messages\(pageIndex).html" : nil) let nextFileName = pageIndex < totalPages - 1 ? "messages\(pageIndex + 2).html" : nil var html = htmlHeader(chatName: title) // Add "Previous messages" pagination link if let prevFileName = prevFileName { html += " Previous messages\n\n" } var lastAuthorId: PeerId? var lastDateStr: String? var dateSeparatorId = -(pageIndex * 100 + 1) for (msgIdx, message) in pageMessages.enumerated() { let globalIdx = startIdx + msgIdx subscriber.putNext(.exporting(current: globalIdx + 1, total: totalMessages)) let messageDate = Date(timeIntervalSince1970: Double(message.timestamp)) let currentDateStr = dateSeparatorFormatter.string(from: messageDate) // Date separator if currentDateStr != lastDateStr { lastDateStr = currentDateStr lastAuthorId = nil html += """
\(currentDateStr)
""" dateSeparatorId -= 1 } // Service message if let serviceText = serviceMessageText(message) { lastAuthorId = nil html += """
\(serviceText)
""" continue } // Regular message let author = message.author let authorId = author?.id let isJoined = authorId == lastAuthorId && message.forwardInfo == nil let joinedClass = isJoined ? " joined" : "" let dateTitle = dateFormatter.string(from: messageDate) + " " + timeZoneSuffix() let timeStr = timeFormatter.string(from: messageDate) // Get media info let mediaInfo = mediaInfoForMessage(message, mediaBox: mediaBox, messageDate: messageDate) if let info = mediaInfo, let sourcePath = info.sourceResourcePath { let destURL = tempDir .appendingPathComponent(info.exportSubdir) .appendingPathComponent(info.exportFileName) mediaFilesToCopy.append((source: sourcePath, destination: destURL)) // For photos, also create a "thumb" copy if info.exportSubdir == "photos" { let thumbName = info.exportFileName.replacingOccurrences(of: ".jpg", with: "_thumb.jpg") let thumbURL = tempDir .appendingPathComponent(info.exportSubdir) .appendingPathComponent(thumbName) mediaFilesToCopy.append((source: sourcePath, destination: thumbURL)) } } let textContent = processMessageText(message.text) let replyBlock = replyHTML(for: message) let reactionsBlock = reactionsHTML(for: message) html += "
\n" // Userpic (only for non-joined messages) if !isJoined { let initial = peerInitial(author) let colorIdx = userpicColorIndex(author) html += """
\(htmlEscape(initial))
""" } html += "
\n" html += "
\(timeStr)
\n" // Author name (only for non-joined messages) if !isJoined { let authorName = htmlEscape(peerDisplayName(author)) html += "
\(authorName)
\n" } // Reply if !replyBlock.isEmpty { html += replyBlock + "\n" } // Forwarded message if message.forwardInfo != nil { let fwdBlock = forwardHTML( message, mediaHTML: mediaInfo?.htmlBlock ?? "", textHTML: textContent ) html += fwdBlock } else { // Media if let mediaBlock = mediaInfo?.htmlBlock { html += mediaBlock + "\n" } // Text if !textContent.isEmpty { html += "
\(textContent)
\n" } } // Reactions if !reactionsBlock.isEmpty { html += reactionsBlock } html += "
\n" html += "
\n\n" lastAuthorId = authorId } // Add "Next messages" pagination link if let nextFileName = nextFileName { html += " Next messages\n\n" } html += htmlFooter() do { let filePath = tempDir.appendingPathComponent(fileName) try html.write(to: filePath, atomically: true, encoding: .utf8) } catch { subscriber.putNext(.error("Failed to write \(fileName): \(error.localizedDescription)")) subscriber.putCompletion() return } } // Copy media files for (idx, mediaCopy) in mediaFilesToCopy.enumerated() { subscriber.putNext(.copyingMedia(current: idx + 1, total: mediaFilesToCopy.count)) do { if !FileManager.default.fileExists(atPath: mediaCopy.destination.path) { try FileManager.default.copyItem(atPath: mediaCopy.source, toPath: mediaCopy.destination.path) } } catch { #if canImport(SGLogging) SGLogger.shared.log("SGChatExport", "Failed to copy media: \(error.localizedDescription)") #endif } } subscriber.putNext(.done(tempDir)) subscriber.putCompletion() }.start() return ActionDisposable { disposable.dispose() } } } }