mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-09-24 13:20:44 +02:00
feat: add Ghostgram features
- Anti-Delete: save deleted messages locally - Ghost Mode: hide online status, read receipts - Voice Morpher: audio processing effects - Device Spoof: spoof device info - Custom GhostIcon app icon - User Notes: personal notes for contacts - Misc settings and controllers - GPLv2 License
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import Foundation
|
||||
|
||||
/// Менеджер для сохранения удалённых сообщений
|
||||
/// Перехватывает сообщения перед удалением и архивирует их локально
|
||||
public final class AntiDeleteManager {
|
||||
|
||||
public static let shared = AntiDeleteManager()
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let enabledKey = "antiDelete.enabled"
|
||||
private let archiveMediaKey = "antiDelete.archiveMedia"
|
||||
private let archiveKey = "antiDelete.archive"
|
||||
private let deletedIdsKey = "antiDelete.deletedIds"
|
||||
|
||||
/// Включено ли сохранение удалённых сообщений
|
||||
public var isEnabled: Bool {
|
||||
get { defaults.bool(forKey: enabledKey) }
|
||||
set { defaults.set(newValue, forKey: enabledKey) }
|
||||
}
|
||||
|
||||
/// Сохранять ли медиа-контент
|
||||
public var archiveMedia: Bool {
|
||||
get { defaults.bool(forKey: archiveMediaKey) }
|
||||
set { defaults.set(newValue, forKey: archiveMediaKey) }
|
||||
}
|
||||
|
||||
// MARK: - Deleted Message IDs Storage
|
||||
|
||||
private var deletedMessageIds: Set<String> = []
|
||||
private let deletedIdsLock = NSLock()
|
||||
|
||||
/// Пометить сообщение как удалённое
|
||||
public func markAsDeleted(peerId: Int64, messageId: Int32) {
|
||||
let key = "\(peerId)_\(messageId)"
|
||||
deletedIdsLock.lock()
|
||||
deletedMessageIds.insert(key)
|
||||
deletedIdsLock.unlock()
|
||||
saveDeletedIds()
|
||||
}
|
||||
|
||||
/// Проверить, является ли сообщение удалённым
|
||||
public func isMessageDeleted(peerId: Int64, messageId: Int32) -> Bool {
|
||||
guard isEnabled else { return false }
|
||||
let key = "\(peerId)_\(messageId)"
|
||||
deletedIdsLock.lock()
|
||||
defer { deletedIdsLock.unlock() }
|
||||
return deletedMessageIds.contains(key)
|
||||
}
|
||||
|
||||
/// Проверить, является ли сообщение удалённым (по тексту - legacy)
|
||||
public func isMessageDeleted(text: String) -> Bool {
|
||||
guard isEnabled else { return false }
|
||||
// Legacy: проверяем наличие дефолтного префикса для обратной совместимости
|
||||
let defaultPrefix = "🗑️ "
|
||||
return text.hasPrefix(defaultPrefix)
|
||||
}
|
||||
|
||||
private func saveDeletedIds() {
|
||||
deletedIdsLock.lock()
|
||||
let ids = Array(deletedMessageIds)
|
||||
deletedIdsLock.unlock()
|
||||
defaults.set(ids, forKey: deletedIdsKey)
|
||||
}
|
||||
|
||||
private func loadDeletedIds() {
|
||||
if let ids = defaults.stringArray(forKey: deletedIdsKey) {
|
||||
deletedIdsLock.lock()
|
||||
deletedMessageIds = Set(ids)
|
||||
deletedIdsLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Archived Messages Storage
|
||||
|
||||
/// Структура архивированного сообщения
|
||||
public struct ArchivedMessage: Codable {
|
||||
public let globalId: Int32
|
||||
public let peerId: Int64
|
||||
public let messageId: Int32
|
||||
public let timestamp: Int32
|
||||
public let deletedAt: Int32
|
||||
public let authorId: Int64?
|
||||
public let text: String
|
||||
public let forwardAuthorId: Int64?
|
||||
public let mediaDescription: String?
|
||||
|
||||
public init(
|
||||
globalId: Int32,
|
||||
peerId: Int64,
|
||||
messageId: Int32,
|
||||
timestamp: Int32,
|
||||
deletedAt: Int32,
|
||||
authorId: Int64?,
|
||||
text: String,
|
||||
forwardAuthorId: Int64?,
|
||||
mediaDescription: String?
|
||||
) {
|
||||
self.globalId = globalId
|
||||
self.peerId = peerId
|
||||
self.messageId = messageId
|
||||
self.timestamp = timestamp
|
||||
self.deletedAt = deletedAt
|
||||
self.authorId = authorId
|
||||
self.text = text
|
||||
self.forwardAuthorId = forwardAuthorId
|
||||
self.mediaDescription = mediaDescription
|
||||
}
|
||||
}
|
||||
|
||||
private var archivedMessages: [ArchivedMessage] = []
|
||||
private let archiveLock = NSLock()
|
||||
|
||||
private init() {
|
||||
// Set default values
|
||||
if defaults.object(forKey: enabledKey) == nil {
|
||||
defaults.set(true, forKey: enabledKey)
|
||||
}
|
||||
if defaults.object(forKey: archiveMediaKey) == nil {
|
||||
defaults.set(true, forKey: archiveMediaKey)
|
||||
}
|
||||
loadArchive()
|
||||
loadDeletedIds()
|
||||
}
|
||||
|
||||
// MARK: - Archive Operations
|
||||
|
||||
/// Архивировать сообщение перед удалением
|
||||
/// - Parameters:
|
||||
/// - globalId: Глобальный ID сообщения
|
||||
/// - peerId: ID чата
|
||||
/// - messageId: Локальный ID сообщения
|
||||
/// - timestamp: Время отправки
|
||||
/// - authorId: ID автора
|
||||
/// - text: Текст сообщения
|
||||
/// - forwardAuthorId: ID автора пересланного сообщения
|
||||
/// - mediaDescription: Описание медиа (тип, размер)
|
||||
public func archiveMessage(
|
||||
globalId: Int32,
|
||||
peerId: Int64,
|
||||
messageId: Int32,
|
||||
timestamp: Int32,
|
||||
authorId: Int64?,
|
||||
text: String,
|
||||
forwardAuthorId: Int64? = nil,
|
||||
mediaDescription: String? = nil
|
||||
) {
|
||||
guard isEnabled else { return }
|
||||
|
||||
let archived = ArchivedMessage(
|
||||
globalId: globalId,
|
||||
peerId: peerId,
|
||||
messageId: messageId,
|
||||
timestamp: timestamp,
|
||||
deletedAt: Int32(Date().timeIntervalSince1970),
|
||||
authorId: authorId,
|
||||
text: text,
|
||||
forwardAuthorId: forwardAuthorId,
|
||||
mediaDescription: mediaDescription
|
||||
)
|
||||
|
||||
archiveLock.lock()
|
||||
defer { archiveLock.unlock() }
|
||||
|
||||
// Avoid duplicates
|
||||
if !archivedMessages.contains(where: { $0.globalId == globalId }) {
|
||||
archivedMessages.append(archived)
|
||||
saveArchive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Получить все архивированные сообщения
|
||||
public func getAllArchivedMessages() -> [ArchivedMessage] {
|
||||
archiveLock.lock()
|
||||
defer { archiveLock.unlock() }
|
||||
return archivedMessages.sorted { $0.deletedAt > $1.deletedAt }
|
||||
}
|
||||
|
||||
/// Получить архивированные сообщения для конкретного чата
|
||||
/// - Parameter peerId: ID чата
|
||||
public func getArchivedMessages(forPeerId peerId: Int64) -> [ArchivedMessage] {
|
||||
archiveLock.lock()
|
||||
defer { archiveLock.unlock() }
|
||||
return archivedMessages
|
||||
.filter { $0.peerId == peerId }
|
||||
.sorted { $0.deletedAt > $1.deletedAt }
|
||||
}
|
||||
|
||||
/// Количество архивированных сообщений
|
||||
public var archivedCount: Int {
|
||||
archiveLock.lock()
|
||||
defer { archiveLock.unlock() }
|
||||
return archivedMessages.count
|
||||
}
|
||||
|
||||
/// Получить данные архивированных сообщений для удаления из диалогов
|
||||
/// Возвращает массив (peerId, messageId)
|
||||
public func getArchivedMessageData() -> [(peerId: Int64, messageId: Int32)] {
|
||||
archiveLock.lock()
|
||||
defer { archiveLock.unlock() }
|
||||
return archivedMessages.map { (peerId: $0.peerId, messageId: $0.messageId) }
|
||||
}
|
||||
|
||||
/// Очистить архив
|
||||
public func clearArchive() {
|
||||
archiveLock.lock()
|
||||
defer { archiveLock.unlock() }
|
||||
archivedMessages.removeAll()
|
||||
saveArchive()
|
||||
}
|
||||
|
||||
/// Удалить конкретное сообщение из архива
|
||||
public func removeFromArchive(globalId: Int32) {
|
||||
archiveLock.lock()
|
||||
defer { archiveLock.unlock() }
|
||||
archivedMessages.removeAll { $0.globalId == globalId }
|
||||
saveArchive()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func saveArchive() {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(archivedMessages)
|
||||
defaults.set(data, forKey: archiveKey)
|
||||
} catch {
|
||||
print("[AntiDelete] Failed to save archive: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadArchive() {
|
||||
guard let data = defaults.data(forKey: archiveKey) else { return }
|
||||
do {
|
||||
archivedMessages = try JSONDecoder().decode([ArchivedMessage].self, from: data)
|
||||
} catch {
|
||||
print("[AntiDelete] Failed to load archive: \(error)")
|
||||
archivedMessages = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import Postbox
|
||||
|
||||
// MARK: - DeletedMessageAttribute
|
||||
// This attribute marks a message as "deleted but visible" in the chat
|
||||
// When anti-delete is enabled, messages are not removed but marked with this attribute
|
||||
|
||||
public class DeletedMessageAttribute: MessageAttribute, Equatable {
|
||||
public let deletedAt: Int32
|
||||
public let deletedByPeerId: Int64?
|
||||
|
||||
public var associatedMessageIds: [MessageId] { return [] }
|
||||
public var associatedPeerIds: [PeerId] { return [] }
|
||||
public var automaticTimestampBasedAttribute: (UInt32, Int32)? { return nil }
|
||||
|
||||
public init(deletedAt: Int32, deletedByPeerId: Int64? = nil) {
|
||||
self.deletedAt = deletedAt
|
||||
self.deletedByPeerId = deletedByPeerId
|
||||
}
|
||||
|
||||
public required init(decoder: PostboxDecoder) {
|
||||
self.deletedAt = decoder.decodeInt32ForKey("d", orElse: 0)
|
||||
self.deletedByPeerId = decoder.decodeOptionalInt64ForKey("p")
|
||||
}
|
||||
|
||||
public func encode(_ encoder: PostboxEncoder) {
|
||||
encoder.encodeInt32(self.deletedAt, forKey: "d")
|
||||
if let peerId = self.deletedByPeerId {
|
||||
encoder.encodeInt64(peerId, forKey: "p")
|
||||
}
|
||||
}
|
||||
|
||||
public static func ==(lhs: DeletedMessageAttribute, rhs: DeletedMessageAttribute) -> Bool {
|
||||
return lhs.deletedAt == rhs.deletedAt && lhs.deletedByPeerId == rhs.deletedByPeerId
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper extension for Message
|
||||
public extension Message {
|
||||
var isDeletedButVisible: Bool {
|
||||
return self.attributes.contains(where: { $0 is DeletedMessageAttribute })
|
||||
}
|
||||
|
||||
var deletedMessageAttribute: DeletedMessageAttribute? {
|
||||
return self.attributes.first(where: { $0 is DeletedMessageAttribute }) as? DeletedMessageAttribute
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
|
||||
|
||||
/// Manages edit history for messages
|
||||
/// GHOSTGRAM: Stores original message text before edits for history viewing
|
||||
public final class EditHistoryManager {
|
||||
public static let shared = EditHistoryManager()
|
||||
|
||||
private let historyKey = "ghostgram_edit_history"
|
||||
private var editHistory: [String: [EditRecord]] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
public struct EditRecord: Codable, Equatable {
|
||||
public let text: String
|
||||
public let editDate: Int32
|
||||
|
||||
public init(text: String, editDate: Int32) {
|
||||
self.text = text
|
||||
self.editDate = editDate
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
loadHistory()
|
||||
}
|
||||
|
||||
/// Creates a unique key for message identification
|
||||
private func messageKey(peerId: Int64, messageId: Int32) -> String {
|
||||
return "\(peerId)_\(messageId)"
|
||||
}
|
||||
|
||||
/// Saves the original text before an edit
|
||||
/// Call this BEFORE the message is updated with new text
|
||||
public func saveOriginalText(peerId: Int64, messageId: Int32, originalText: String, editDate: Int32) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
let key = messageKey(peerId: peerId, messageId: messageId)
|
||||
|
||||
// Don't save empty text
|
||||
guard !originalText.isEmpty else { return }
|
||||
|
||||
// Get existing history or create new
|
||||
var history = editHistory[key] ?? []
|
||||
|
||||
// Check if this exact text already exists (avoid duplicates)
|
||||
if history.last?.text != originalText {
|
||||
let record = EditRecord(text: originalText, editDate: editDate)
|
||||
history.append(record)
|
||||
editHistory[key] = history
|
||||
saveHistory()
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets edit history for a message
|
||||
public func getEditHistory(peerId: Int64, messageId: Int32) -> [EditRecord] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
let key = messageKey(peerId: peerId, messageId: messageId)
|
||||
return editHistory[key] ?? []
|
||||
}
|
||||
|
||||
/// Checks if a message has edit history
|
||||
public func hasEditHistory(peerId: Int64, messageId: Int32) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
let key = messageKey(peerId: peerId, messageId: messageId)
|
||||
return !(editHistory[key]?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
/// Clears history for a specific message
|
||||
public func clearHistory(peerId: Int64, messageId: Int32) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
let key = messageKey(peerId: peerId, messageId: messageId)
|
||||
editHistory.removeValue(forKey: key)
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
/// Clears all edit history
|
||||
public func clearAllHistory() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
editHistory.removeAll()
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func saveHistory() {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(editHistory)
|
||||
UserDefaults.standard.set(data, forKey: historyKey)
|
||||
} catch {
|
||||
// Silent fail - non-critical feature
|
||||
}
|
||||
}
|
||||
|
||||
private func loadHistory() {
|
||||
guard let data = UserDefaults.standard.data(forKey: historyKey) else { return }
|
||||
do {
|
||||
editHistory = try JSONDecoder().decode([String: [EditRecord]].self, from: data)
|
||||
} catch {
|
||||
editHistory = [:]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user