import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../api.dart'; import '../../core/config/qlyra_settings.dart'; import '../../core/contacts/device_contacts_service.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/crypto/e2ee_service.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/message_ranges.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import '../../core/utils/text_format.dart'; import '../../models/attachment.dart'; import 'chats.dart' show chats; import 'message_copy.dart'; class ContactCache { static final Map _nameCache = {}; static final Map _avatarCache = {}; static final Map> _optionsCache = {}; static final Map _phoneCache = {}; static const _prefsKey = 'contact_cache_v1'; static Timer? _saveTimer; static bool _loaded = false; static Future load() async { if (_loaded) return; _loaded = true; final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_prefsKey); if (raw == null) return; try { final decoded = jsonDecode(raw); if (decoded is! Map) return; decoded.forEach((key, value) { final id = int.tryParse(key.toString()); if (id == null || value is! Map) return; final name = value['n']; final avatar = value['a']; final opts = value['o']; final phone = value['p']; if (name is String) _nameCache[id] = name; if (avatar is String) _avatarCache[id] = avatar; if (opts is List) _optionsCache[id] = opts.whereType().toSet(); if (phone is int && phone > 0) _phoneCache[id] = phone; }); } catch (_) {} } static void put(int id, String name) { _nameCache[id] = name; _scheduleSave(); } static void putAvatar(int id, String? baseUrl) { if (baseUrl != null) { _avatarCache[id] = baseUrl; _scheduleSave(); } } static void putOptions(int id, Set opts) { _optionsCache[id] = opts; _scheduleSave(); } static void putPhone(int id, int phone) { if (phone <= 0 || _phoneCache[id] == phone) return; _phoneCache[id] = phone; _scheduleSave(); } static String? get(int id) { final phone = _phoneCache[id]; if (phone != null) { final book = DeviceContactsService.nameForPhone(phone); if (book != null && book.isNotEmpty) return book; } return _nameCache[id]; } static String? getAvatar(int id) => _avatarCache[id]; static Set? getOptions(int id) => _optionsCache[id]; static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false; static void remove(int id) { _nameCache.remove(id); _avatarCache.remove(id); _optionsCache.remove(id); _phoneCache.remove(id); _scheduleSave(); } static void clear() { _nameCache.clear(); _avatarCache.clear(); _optionsCache.clear(); _phoneCache.clear(); _saveTimer?.cancel(); _saveTimer = null; unawaited(_wipePersisted()); } @visibleForTesting static void resetMemoryForTesting() { _nameCache.clear(); _avatarCache.clear(); _optionsCache.clear(); _phoneCache.clear(); _saveTimer?.cancel(); _saveTimer = null; _loaded = false; } static void _scheduleSave() { _saveTimer?.cancel(); _saveTimer = Timer(const Duration(seconds: 3), () => unawaited(_save())); } static Future _save() async { final ids = { ..._nameCache.keys, ..._avatarCache.keys, ..._optionsCache.keys, ..._phoneCache.keys, }; final map = {}; for (final id in ids) { final entry = {}; final name = _nameCache[id]; final avatar = _avatarCache[id]; final opts = _optionsCache[id]; final phone = _phoneCache[id]; if (name != null) entry['n'] = name; if (avatar != null) entry['a'] = avatar; if (opts != null && opts.isNotEmpty) entry['o'] = opts.toList(); if (phone != null) entry['p'] = phone; if (entry.isNotEmpty) map['$id'] = entry; } final prefs = await SharedPreferences.getInstance(); await prefs.setString(_prefsKey, jsonEncode(map)); } static Future _wipePersisted() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_prefsKey); } } class TranscriptionResult { static const String emptyText = 'Не распознали голос'; final int status; final String? text; final String? messageId; final int? chatId; final int? mediaId; TranscriptionResult({ required this.status, this.text, this.messageId, this.chatId, this.mediaId, }); } class TranscriptionCache { static final Map _cache = {}; static final Map> _listeners = {}; static final Set _expanded = {}; static void put( String messageId, TranscriptionResult result, { bool expanded = false, }) { _cache[messageId] = result; if (expanded) _expanded.add(messageId); final listeners = _listeners[messageId]; if (listeners == null) return; for (final listener in listeners.toList()) { listener(); } } static TranscriptionResult? get(String messageId) => _cache[messageId]; static bool has(String messageId) => _cache.containsKey(messageId); static bool isExpanded(String messageId) => _expanded.contains(messageId); static void setExpanded(String messageId, bool value) { if (isExpanded(messageId) == value) return; if (value) { _expanded.add(messageId); } else { _expanded.remove(messageId); } for (final listener in _listeners[messageId]?.toList() ?? []) { listener(); } } static void listen(String messageId, VoidCallback listener) { _listeners.putIfAbsent(messageId, () => {}).add(listener); } static void unlisten(String messageId, VoidCallback listener) { final listeners = _listeners[messageId]; if (listeners == null) return; listeners.remove(listener); if (listeners.isEmpty) _listeners.remove(messageId); } static void clear() { _cache.clear(); _expanded.clear(); } } class TranscriptionPushHandler { static StreamSubscription? _sub; static void attach(Api api) { _sub?.cancel(); _sub = api.pushStream .where((p) => p.opcode == Opcode.transcriptionResult) .listen(_onPush); } static void _onPush(Packet packet) { final payload = packet.payload; if (payload is! Map) return; final source = payload['message'] is Map ? payload['message'] as Map : payload; final messageId = (source['messageId'] ?? source['msgId'])?.toString(); if (messageId == null || messageId.isEmpty) return; final status = source['transcriptionStatus'] as int? ?? 1; final rawText = source['transcription'] as String?; if (status != 1) return; TranscriptionCache.put( messageId, TranscriptionResult( status: 1, text: rawText ?? '', messageId: messageId, chatId: source['chatId'] as int?, mediaId: source['mediaId'] as int?, ), expanded: true, ); } } class FileHistoryEntry { final int fileId; final String? url; final String? token; final String? filename; final int? size; final DateTime sentAt; FileHistoryEntry({ required this.fileId, this.url, this.token, this.filename, this.size, required this.sentAt, }); Map toJson() => { 'fileId': fileId, if (url != null) 'url': url, if (token != null) 'token': token, if (filename != null) 'filename': filename, if (size != null) 'size': size, 'sentAt': sentAt.millisecondsSinceEpoch, }; static FileHistoryEntry? fromJson(Map j) { final id = j['fileId']; final ts = j['sentAt']; if (id is! int || ts is! int) return null; return FileHistoryEntry( fileId: id, url: j['url'] as String?, token: j['token'] as String?, filename: j['filename'] as String?, size: j['size'] as int?, sentAt: DateTime.fromMillisecondsSinceEpoch(ts), ); } } class FileHistoryCache { static const _prefKey = 'file_history_v1'; static const _maxEntries = 50; static final ValueNotifier> notifier = ValueNotifier( const [], ); static List get history => notifier.value; static bool get isEmpty => notifier.value.isEmpty; static SharedPreferences? _prefs; static Future load(SharedPreferences prefs) async { _prefs = prefs; final raw = prefs.getString(_prefKey); if (raw == null) return; try { final list = jsonDecode(raw); if (list is! List) return; final entries = []; for (final e in list) { if (e is Map) { final entry = FileHistoryEntry.fromJson(Map.from(e)); if (entry != null) entries.add(entry); } } notifier.value = entries; } catch (_) {} } static void add(FileHistoryEntry entry) { final next = [ entry, ...notifier.value.where((e) => e.fileId != entry.fileId), ]; if (next.length > _maxEntries) next.removeRange(_maxEntries, next.length); notifier.value = next; _persist(); } static void remove(int fileId) { final next = notifier.value.where((e) => e.fileId != fileId).toList(); if (next.length == notifier.value.length) return; notifier.value = next; _persist(); } static void _persist() { final prefs = _prefs; if (prefs == null) return; final encoded = jsonEncode(notifier.value.map((e) => e.toJson()).toList()); prefs.setString(_prefKey, encoded); } } class FileUploadInfo { final String url; final int fileId; final String token; FileUploadInfo({ required this.url, required this.fileId, required this.token, }); } class VideoUploadInfo { final String url; final int videoId; final String token; VideoUploadInfo({ required this.url, required this.videoId, required this.token, }); } class ReplyInfo { final String? messageId; final int senderId; final String? text; final int? time; final List? attachments; const ReplyInfo({ this.messageId, required this.senderId, this.text, this.time, this.attachments, }); bool get missing => previewText().isEmpty; static ReplyInfo? fromPayload(Map? payload) { if (payload == null) return null; final link = payload['link']; if (link is! Map) return null; if ((link['type'] as String?)?.toUpperCase() != 'REPLY') return null; final msg = link['message']; if (msg is! Map) { final mid = link['messageId']; if (mid == null) return null; return ReplyInfo(messageId: mid.toString(), senderId: 0); } List? attaches; final raw = msg['attaches']; if (raw is List && raw.isNotEmpty) { attaches = raw .whereType() .map((a) => MessageAttachment.fromMap(Map.from(a))) .toList(); } final sender = msg['sender']; return ReplyInfo( messageId: msg['id']?.toString(), senderId: sender is int ? sender : int.tryParse(sender?.toString() ?? '') ?? 0, text: msg['text']?.toString(), time: msg['time'] is int ? msg['time'] as int : null, attachments: attaches, ); } String previewText() { final t = text; if (t != null && t.trim().isNotEmpty) return t; final a = attachments; if (a != null && a.isNotEmpty) { switch (a.first.type) { case AttachmentType.photo: return 'Фото'; case AttachmentType.video: return 'Видео'; case AttachmentType.audio: return 'Голосовое сообщение'; case AttachmentType.file: final file = a.first; final name = file is FileAttachment ? file.name?.trim() : null; return name == null || name.isEmpty ? 'Файл' : name; case AttachmentType.sticker: return 'Стикер'; case AttachmentType.contact: return 'Контакт'; case AttachmentType.location: return 'Геолокация'; case AttachmentType.poll: return 'Опрос'; case AttachmentType.call: return 'Звонок'; case AttachmentType.share: return 'Ссылка'; case AttachmentType.control: return ''; case AttachmentType.inlineKeyboard: return ''; case AttachmentType.forward: return 'Переслано'; case AttachmentType.unknown: return 'Вложение'; } } return ''; } } class AudioUploadInfo { final String url; final int audioId; final String token; AudioUploadInfo({ required this.url, required this.audioId, required this.token, }); } final Expando> _formatRangesCache = Expando>('formatRanges'); class CachedMessage { final String id; final int accountId; final int chatId; final int senderId; final String? text; final int time; final String? status; final Map? payload; final List? attachments; final bool isControl; final bool deleted; final List>? editHistory; // #***! открытый текст лежит запечатанным локальным ключом final Uint8List? sealedText; final int e2ee; final int? typingMs; static const int e2eeNone = 0; static const int e2eeText = 1; static const int e2eeFailed = 2; static const int e2eeFile = 3; const CachedMessage({ required this.id, required this.accountId, required this.chatId, required this.senderId, this.text, required this.time, this.status, this.payload, this.attachments, this.isControl = false, this.deleted = false, this.editHistory, this.sealedText, this.e2ee = e2eeNone, this.typingMs, }); ControlAttachment? get controlAttachment => attachments?.whereType().firstOrNull; ForwardedMessageAttachment? get forwardedAttachment => attachments?.whereType().firstOrNull; String? get selectableText { final own = text; if (own != null && own.isNotEmpty) return own; final forwarded = forwardedAttachment?.originalText; if (forwarded != null && forwarded.isNotEmpty) return forwarded; return null; } String? get botStartPayload { final control = controlAttachment; if (control == null || !control.isBotStart) return null; final payload = (control.startPayload ?? text)?.trim(); return (payload == null || payload.isEmpty) ? null : payload; } bool get isSilentBotStart => (controlAttachment?.isBotStart ?? false) && botStartPayload == null; CachedMessage copyWith({ String? status, bool? deleted, List? attachments, List>? editHistory, Map? payload, Uint8List? sealedText, int? e2ee, int? typingMs, }) => CachedMessage( id: id, accountId: accountId, chatId: chatId, senderId: senderId, text: text, time: time, status: status ?? this.status, payload: payload ?? this.payload, attachments: attachments ?? this.attachments, isControl: isControl, deleted: deleted ?? this.deleted, editHistory: editHistory ?? this.editHistory, sealedText: sealedText ?? this.sealedText, e2ee: e2ee ?? this.e2ee, typingMs: typingMs ?? this.typingMs, ); CachedMessage withSendFailure(Object error) { final permanent = isPermanentSendFailure(error); return copyWith( status: permanent ? 'error' : 'pending', payload: {...?payload, if (permanent) '_sendFailure': error.toString()}, ); } String? get sendFailure { if (status != 'error') return null; final reason = payload?['_sendFailure']; return reason is String && reason.trim().isNotEmpty ? reason : null; } // #***! история правок это список прошлых версий текста static List>? parseEditHistory(dynamic raw) { if (raw is! String || raw.isEmpty) return null; try { final decoded = jsonDecode(raw); if (decoded is List) { final list = decoded .whereType() .map((e) => Map.from(e)) .toList(); return list.isEmpty ? null : list; } } catch (_) {} return null; } static List> appendEditHistory( List>? current, String? oldText, int time, ) { final list = current != null ? List>.from(current) : >[]; if (list.isNotEmpty && (list.last['text'] as String?) == oldText) { return list; } list.add({'text': oldText, 'time': time}); return list; } static (List?, bool) parseAttachments( Map map, ) { List? attachments; final link = map['link']; final linkType = link is Map ? link['type'] as String? : null; if (linkType == 'FORWARD') { attachments = [ForwardedMessageAttachment.fromMap(map)]; } else { final attaches = map['attaches'] as List?; if (attaches != null) { attachments = attaches .whereType() .map((a) => MessageAttachment.fromMap(Map.from(a))) .toList(); } } final isControl = attachments?.any((a) => a.type == AttachmentType.control) ?? false; return (attachments, isControl); } factory CachedMessage.fromDbRow(Map row) { Map? payload; final payloadRaw = row['payload']; if (payloadRaw is String && payloadRaw.isNotEmpty) { try { payload = jsonDecode(payloadRaw) as Map; } catch (_) {} } List? attachments; bool isControl = false; if (payload != null) { final parsed = parseAttachments(payload); attachments = parsed.$1; isControl = parsed.$2; } return CachedMessage( id: row['id']?.toString() ?? '', accountId: row['account_id'] is int ? row['account_id'] as int : int.tryParse(row['account_id']?.toString() ?? '') ?? 0, chatId: row['chat_id'] is int ? row['chat_id'] as int : int.tryParse(row['chat_id']?.toString() ?? '') ?? 0, senderId: row['sender_id'] is int ? row['sender_id'] as int : int.tryParse(row['sender_id']?.toString() ?? '') ?? 0, text: row['text']?.toString(), time: row['time'] is int ? row['time'] as int : int.tryParse(row['time']?.toString() ?? '') ?? 0, status: row['status']?.toString(), payload: payload, attachments: attachments, isControl: isControl, deleted: row['deleted'] is int ? row['deleted'] == 1 : row['deleted']?.toString() == '1', editHistory: parseEditHistory(row['edit_history']), sealedText: row['text_sealed'] is Uint8List ? row['text_sealed'] as Uint8List : null, e2ee: row['e2ee'] is int ? row['e2ee'] as int : 0, typingMs: row['typing_ms'] is int ? row['typing_ms'] as int : null, ); } int? get delayedTimeToFire { final attrs = payload?['delayedAttributes']; if (attrs is Map) { final t = attrs['timeToFire']; if (t is int) return t; if (t is String) return int.tryParse(t); } return null; } bool get isDelayed => delayedTimeToFire != null; // #***! у своего сообщения до ответа сервера payload'а нет, собираем его // из вложений чтобы превью считал тот же код что и для входящих Map get previewPayload => payload ?? { 'text': text, 'attaches': [ for (final attachment in attachments ?? const []) attachment.toMap(), ], }; ReplyInfo? get replyInfo => ReplyInfo.fromPayload(payload); // #***! геттер зовётся на каждую перестройку пузыря, а разбор payload // каждый раз рождал новый список объектов List get formatRanges => _formatRangesCache[this] ??= parseFormatElements(payload?['elements']); static List _decodeRows(List> rows) => rows.map(CachedMessage.fromDbRow).toList(); static Future> fromDbRowsAsync( List> rows, ) { if (rows.length < 20) { return Future.value(_decodeRows(rows)); } return compute(_decodeRows, rows); } Map toDbRow() => { 'id': id, 'account_id': accountId, 'chat_id': chatId, 'sender_id': senderId, 'text': text, 'time': time, 'status': status, 'payload': payload != null ? jsonEncode(payload) : null, 'deleted': deleted ? 1 : 0, 'edit_history': editHistory != null ? jsonEncode(editHistory) : null, 'text_sealed': sealedText, 'e2ee': e2ee, 'typing_ms': typingMs, }; static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) { final full = Map.from(msg); final parsed = parseAttachments(full); return CachedMessage( id: msg['id']?.toString() ?? '', accountId: accountId, chatId: chatId, senderId: msg['sender'] as int? ?? 0, text: msg['text'] as String?, time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch, status: (msg['status'] as String?) ?? 'sent', payload: full, attachments: parsed.$1, isControl: parsed.$2, ); } } class MessagesModule { int get sessionEpoch => _api.sessionEpoch; final Api _api; MessagesModule(this._api); static int _lastCid = 0; static int _nextCid() { final now = DateTime.now().millisecondsSinceEpoch; _lastCid = now > _lastCid ? now : _lastCid + 1; return _lastCid; } // #***! история с сервера Future> fetchHistory( int accountId, int chatId, { int? fromTime, int count = 50, int forward = 0, int? backward, }) async { final payload = { 'chatId': chatId, 'from': fromTime ?? (DateTime.now().millisecondsSinceEpoch + 86400000), 'forward': forward, 'backward': backward ?? count, 'getMessages': true, }; final response = await _api.sendRequest(Opcode.chatHistory, payload); if (!response.isOk) return []; final data = response.payload; if (data is! Map) return []; final messagesData = data['messages']; if (messagesData is! List) return []; final List results = []; for (var i = 0; i < messagesData.length; i++) { final m = messagesData[i]; if (m is! Map) continue; final msg = _parseMessage(m.cast(), accountId, chatId); if (msg != null) { results.add(msg); } if (i > 0 && i % 20 == 0) { await Future.delayed(Duration.zero); } } final merged = results.isNotEmpty ? await _mergeLocalFields(accountId, chatId, results) : results; final toSave = await E2eeService.instance.inspectHistory( accountId, chatId, merged, ); if (toSave.isNotEmpty) { try { await AppDatabase.saveMessages(toSave.map((m) => m.toDbRow()).toList()); } catch (e) { logger.e('saveMessages error: $e'); return toSave; } } await _recordCoverage( accountId, chatId, fromTime: fromTime, forward: forward, backward: backward ?? count, messagesData: messagesData, ); return toSave; } Future _recordCoverage( int accountId, int chatId, { required int? fromTime, required int forward, required int backward, required List messagesData, }) async { final range = MessageRanges.coverageOfFetch( fromTime: fromTime, forward: forward, backward: backward, times: [ for (final m in messagesData) if (m is Map && m['time'] is int) m['time'] as int, ], ); if (range == null) return; try { await AppDatabase.addMessageRange(accountId, chatId, range); } catch (e) { logger.w('addMessageRange error: $e'); } } Future>> searchMessages( int chatId, String query, { int count = 30, }) async { final response = await _api.sendRequest(Opcode.msgSearch, { 'chatId': chatId, 'query': query, 'count': count, }); if (!response.isOk) return const []; final data = response.payload; if (data is! Map) return const []; final result = data['result']; if (result is! List) return const []; return result .whereType() .map((e) => Map.from(e.cast())) .toList(); } Future> _mergeLocalFields( int accountId, int chatId, List serverMessages, ) async { final cachedRows = await AppDatabase.loadMessagesByIds( accountId, chatId, serverMessages.map((m) => m.id).toList(), ); final byId = >{}; for (final row in cachedRows) { final id = row['id']?.toString(); if (id != null) byId[id] = row; } final now = DateTime.now().millisecondsSinceEpoch; final out = []; for (final msg in serverMessages) { final existing = byId[msg.id]; if (existing == null) { out.add(msg); continue; } var history = CachedMessage.parseEditHistory(existing['edit_history']); final oldText = existing['text']?.toString(); if (QlyraSettings.viewRedacted.value && (oldText ?? '') != (msg.text ?? '') && oldText != null && oldText.isNotEmpty) { history = CachedMessage.appendEditHistory(history, oldText, now); } final typingMs = existing['typing_ms']; out.add( msg.copyWith( editHistory: history, typingMs: typingMs is int ? typingMs : null, ), ); } return out; } Future> getLocalHistory( int accountId, int chatId, { int limit = 50, int offset = 0, }) async { final rows = await AppDatabase.loadMessages( accountId, chatId, limit: limit, offset: offset, ); return CachedMessage.fromDbRowsAsync(rows); } CachedMessage? _parseMessage( Map m, int accountId, int chatId, ) { final id = m['id']?.toString(); if (id == null) return null; final full = Map.from(m.cast()); final parsed = CachedMessage.parseAttachments(full); return CachedMessage( id: id, accountId: accountId, chatId: chatId, senderId: _parseIntField(m['sender']), text: m['text']?.toString(), time: _parseIntField(m['time']), status: m['status']?.toString(), payload: full, attachments: parsed.$1, isControl: parsed.$2, ); } int _parseIntField(dynamic value) { if (value == null) return 0; if (value is int) return value; if (value is String) return int.tryParse(value) ?? 0; return int.tryParse(value.toString()) ?? 0; } Future sendMessage( int accountId, int chatId, String text, { int? clientId, bool notify = true, int? scheduledTime, int? replyToMessageId, int? replySourceChatId, List> elements = const [], void Function()? beforeSend, }) async { final message = { 'text': text, 'cid': clientId ?? DateTime.now().millisecondsSinceEpoch * -1, 'elements': elements, 'attaches': [], }; if (replyToMessageId != null) { message['link'] = { 'type': 'REPLY', 'chatId': replySourceChatId ?? chatId, 'messageId': replyToMessageId, }; } if (scheduledTime != null) { message['delayedAttributes'] = { 'timeToFire': scheduledTime, 'notifySender': true, }; } final payload = {'chatId': chatId, 'message': message, 'notify': notify}; return _sendAndExtractMessageId( payload, 'Ошибка отправки', beforeSend: beforeSend, ); } Future sendControlMessage( int chatId, Map control, { bool notify = true, }) { final payload = { 'chatId': chatId, 'message': { 'cid': -_nextCid(), 'text': '', 'attaches': [control], }, 'notify': notify, }; return _api.sendRequest(Opcode.msgSend, payload); } Future?> sendBotStart( int chatId, [ String? startPayload, ]) async { final response = await _api.sendRequest(Opcode.msgSend, { 'chatId': chatId, 'message': { 'cid': -_nextCid(), 'attaches': [ { '_type': 'CONTROL', 'event': ControlAttachment.botStartedEvent, 'startPayload': ?startPayload, }, ], }, }); return _sentMessageMap(response); } Future _sendAndExtractMessageId( Map payload, String defaultError, { void Function()? beforeSend, }) async { final response = await _api.sendRequest( Opcode.msgSend, payload, beforeSend: beforeSend, ); if (!response.isOk) { _throwSendError(response.payload, defaultError); } final data = response.payload; if (data is Map) { final msgMap = data['message']; if (msgMap is Map) { final id = msgMap['id']; if (id != null) return id.toString(); } } return ''; } Never _throwSendError(dynamic payload, String fallback) { final msg = (payload is Map) ? (payload['localizedMessage'] ?? payload['message'] ?? fallback) : fallback; throw Exception(msg.toString()); } Map? _sentMessageMap(Packet response) { if (!response.isOk) return null; final data = response.payload; if (data is Map) { final msg = data['message']; if (msg is Map) return Map.from(msg); } return null; } Future _sendWithNotReadyRetry({ required Map payload, required int maxAttempts, required Duration retryDelay, required T Function(Packet response) onResult, required T onExhausted, }) async { for (var attempt = 0; attempt < maxAttempts; attempt++) { try { final response = await _api.sendRequest(Opcode.msgSend, payload); return onResult(response); } on PacketError catch (e) { if (!(e.errorKey?.contains('not.ready') ?? false)) { logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}'); rethrow; } if (attempt == maxAttempts - 1) return onExhausted; await Future.delayed(retryDelay); } } return onExhausted; } Future forwardMessage( int targetChatId, int sourceChatId, int messageId, { bool notify = true, void Function()? beforeSend, }) async { final message = { 'isLive': false, 'detectShare': false, 'elements': [], 'attaches': [], 'cid': -_nextCid(), 'link': { 'type': 'FORWARD', 'chatId': sourceChatId, 'messageId': messageId, }, }; final payload = { 'chatId': targetChatId, 'message': message, 'notify': notify, }; return _sendAndExtractMessageId( payload, 'Ошибка пересылки', beforeSend: beforeSend, ); } Future> sendMessageCopy( int chatId, MessageCopy copy, { bool notify = true, void Function()? beforeSend, }) async { final response = await _api.sendRequest(Opcode.msgSend, { 'chatId': chatId, 'message': { 'cid': -_nextCid(), if (copy.text.isNotEmpty) 'text': copy.text, 'elements': copy.elements, 'attaches': copy.wireAttaches, }, 'notify': notify, }, beforeSend: beforeSend); final sent = _sentMessageMap(response); if (sent == null) _throwSendError(response.payload, 'Ошибка пересылки'); return sent; } // #***! локальная копия чтоб пересланное появилось сразу static CachedMessage buildForwardMessage({ required int myId, required int targetChatId, required int sourceChatId, required CachedMessage source, required String tempId, required int time, required String status, String? sourceChatName, String? sourceChatIconUrl, String? sourceChatType, }) { final srcPayload = source.payload; final srcLink = srcPayload?['link']; final isForwardedSource = srcLink is Map && srcLink['type']?.toString().toUpperCase() == 'FORWARD' && srcLink['message'] is Map; Map originalMsg; if (isForwardedSource) { originalMsg = Map.from(srcLink['message'] as Map); } else { final originalType = srcPayload?['type']?.toString() ?? sourceChatType; originalMsg = { 'id': int.tryParse(source.id) ?? source.id, 'type': ?originalType, 'sender': source.senderId, 'time': source.time, 'text': source.text, 'attaches': (srcPayload?['attaches'] as List?) ?? const [], 'elements': (srcPayload?['elements'] as List?) ?? const [], }; } final isChannelSource = originalMsg['type']?.toString().toUpperCase() == 'CHANNEL'; final rawChannelName = isForwardedSource ? srcLink['chatName'] : sourceChatName; final rawChannelIconUrl = isForwardedSource ? srcLink['chatIconUrl'] : sourceChatIconUrl; final channelName = rawChannelName?.toString().trim(); final channelIconUrl = rawChannelIconUrl?.toString().trim(); final payload = { 'elements': const [], 'attaches': const [], 'link': { 'type': 'FORWARD', 'chatId': sourceChatId, 'messageId': int.tryParse(source.id) ?? source.id, 'message': originalMsg, if (isChannelSource && channelName != null && channelName.isNotEmpty) 'chatName': channelName, if (isChannelSource && channelIconUrl != null && channelIconUrl.isNotEmpty) 'chatIconUrl': channelIconUrl, }, }; return CachedMessage( id: tempId, accountId: myId, chatId: targetChatId, senderId: myId, text: null, time: time, status: status, payload: payload, attachments: [ForwardedMessageAttachment.fromMap(payload)], ); } static CachedMessage reidentifyMessage( CachedMessage message, String newId, { String? status, }) => CachedMessage( id: newId, accountId: message.accountId, chatId: message.chatId, senderId: message.senderId, text: message.text, time: message.time, status: status ?? message.status, payload: message.payload, attachments: message.attachments, isControl: message.isControl, deleted: message.deleted, editHistory: message.editHistory, typingMs: message.typingMs, ); static String forwardPreviewText(CachedMessage message) { final link = message.payload?['link']; if (link is Map && link['message'] is Map) { final original = link['message'] as Map; final text = original['text']; if (text is String && text.trim().isNotEmpty) return text; } return 'Пересланное сообщение'; } Future sendLinkMessage(int chatId, String url) async { final message = { 'text': url, 'cid': -_nextCid(), 'elements': [ { 'type': 'LINK', 'from': 0, 'length': url.length, 'attributes': {'url': url}, }, ], 'attaches': [], }; return _api.sendRequestOk(Opcode.msgSend, { 'chatId': chatId, 'message': message, 'notify': true, }); } Future> fetchDelayedMessages( int accountId, int chatId, ) async { final payload = { 'chatId': chatId, 'forward': 0, 'backwardTime': 0, 'getChat': false, 'from': 1, 'itemType': 'DELAYED', 'getMessages': true, 'forwardTime': 0, 'interactive': true, 'backward': 150, }; final response = await _api.sendRequest(Opcode.chatHistory, payload); if (!response.isOk) return []; final data = response.payload; if (data is! Map) return []; final messagesData = data['messages']; if (messagesData is! List) return []; final results = []; for (final m in messagesData) { if (m is! Map) continue; final msg = _parseMessage(m.cast(), accountId, chatId); if (msg != null) results.add(msg); } results.sort( (a, b) => (a.delayedTimeToFire ?? a.time).compareTo( b.delayedTimeToFire ?? b.time, ), ); return results; } Future editMessage( int chatId, String messageId, { required String text, List> elements = const [], bool sendAttachments = false, }) async { final id = int.tryParse(messageId); if (id == null) return false; final payload = { 'messageId': id, 'chatId': chatId, 'elements': elements, 'text': text, }; if (sendAttachments) payload['attachments'] = const []; return _api.sendRequestOk(Opcode.msgEdit, payload); } Future editScheduledMessage( int chatId, String messageId, { required String text, required int timeToFire, }) async { final id = int.tryParse(messageId); if (id == null) return false; final payload = { 'messageId': id, 'chatId': chatId, 'elements': [], 'text': text, 'delayedAttributes': {'timeToFire': timeToFire, 'notifySender': true}, }; return _api.sendRequestOk(Opcode.msgEdit, payload); } Future deleteMessages( int chatId, List messageIds, { bool forEveryone = false, String itemType = 'REGULAR', }) async { final ids = messageIds .map((id) => int.tryParse(id)) .whereType() .toList(); if (ids.isEmpty) return false; final payload = { 'messageIds': ids, 'chatId': chatId, 'forMe': !forEveryone, 'itemType': itemType, }; return _api.sendRequestOk(Opcode.msgDelete, payload); } Future<({bool ok, Map? info})> setReaction( int chatId, String messageId, String emoji, ) async { final id = int.tryParse(messageId); if (id == null) return (ok: false, info: null); final response = await _api.sendRequest(Opcode.msgReaction, { 'chatId': chatId, 'messageId': id, 'reaction': {'reactionType': 'EMOJI', 'id': emoji}, }); return _applyReactionResponse(chatId, messageId, response); } Future<({bool ok, Map? info})> cancelReaction( int chatId, String messageId, ) async { final id = int.tryParse(messageId); if (id == null) return (ok: false, info: null); final response = await _api.sendRequest(Opcode.msgCancelReaction, { 'chatId': chatId, 'messageId': id, }); return _applyReactionResponse(chatId, messageId, response); } Future> getDetailedReactions( int chatId, String messageId, { int count = 100, }) async { final id = int.tryParse(messageId); if (id == null) return const {}; if (_api.state != SessionState.online) return const {}; try { final response = await _api.sendRequest(Opcode.msgGetDetailedReactions, { 'chatId': chatId, 'messageId': id, 'count': count, }); if (!response.isOk) return const {}; final payload = response.payload; if (payload is! Map) return const {}; return _parseDetailedReactions(payload['reactions']); } catch (e) { logger.e('getDetailedReactions error: $e'); return const {}; } } static Map _parseDetailedReactions(dynamic raw) { if (raw is! List) return const {}; final result = {}; for (final entry in raw.whereType()) { final userId = entry['userId']; final reaction = entry['reaction']; if (userId is! int || reaction is! String || reaction.isEmpty) continue; result[userId] = reaction; } return result; } Future<({bool ok, Map? info})> _applyReactionResponse( int chatId, String messageId, Packet response, ) async { if (!response.isOk) return (ok: false, info: null); final payload = response.payload; final info = payload is Map ? _normalizeReactionInfo(payload['reactionInfo']) : null; try { await _persistReaction(chatId, messageId, info); } catch (e) { logger.w('Реакция на $messageId не сохранилась: $e'); } return (ok: true, info: info); } static Map? _normalizeReactionInfo(dynamic raw) { if (raw is! Map) return null; final rawCounters = raw['counters']; if (rawCounters is! List) return null; final counters = >[]; for (final c in rawCounters) { if (c is! Map) continue; final reaction = c['reaction']?.toString(); if (reaction == null || reaction.isEmpty) continue; final count = c['count']; counters.add({'reaction': reaction, 'count': count is int ? count : 0}); } if (counters.isEmpty) return null; final your = raw['yourReaction']?.toString(); final total = raw['totalCount']; return { 'counters': counters, if (your != null && your.isNotEmpty) 'yourReaction': your, 'totalCount': total is int ? total : counters.fold(0, (a, b) => a + (b['count'] as int)), }; } Future _persistReaction( int chatId, String messageId, Map? info, ) async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return; final existing = await AppDatabase.loadMessage( accountId, chatId, messageId, ); if (existing == null) return; Map payloadMap; final raw = existing['payload']; if (raw is String && raw.isNotEmpty) { try { payloadMap = Map.from(jsonDecode(raw) as Map); } catch (_) { payloadMap = {}; } } else { payloadMap = {}; } if (info == null) { payloadMap.remove('reactionInfo'); } else { payloadMap['reactionInfo'] = info; } final newRow = Map.from(existing); newRow['payload'] = jsonEncode(payloadMap); await AppDatabase.saveMessages([newRow]); } Future?> sendButtonCallback({ required int chatId, required String messageId, required String callbackId, String? payload, }) async { final mid = int.tryParse(messageId); if (mid == null) return null; final request = { 'chatId': chatId, 'messageId': mid, 'callbackId': callbackId, 'payload': ?payload, }; final response = await _api.sendRequest(Opcode.msgSendCallback, request); if (!response.isOk) return null; final data = response.payload; return data is Map ? Map.from(data) : null; } Future requestTranscription( int chatId, int messageId, int mediaId, ) async { final payload = { 'chatId': chatId, 'messageId': messageId, 'mediaId': mediaId, }; final response = await _api.sendRequest(Opcode.audioTranscription, payload); if (!response.isOk) return TranscriptionResult(status: -1); final data = response.payload; if (data is! Map) return TranscriptionResult(status: -1); final transcriptionStatus = data['transcriptionStatus'] as int? ?? -1; if (transcriptionStatus == 1) { return TranscriptionResult( status: 1, text: data['transcription'] as String? ?? '', ); } return TranscriptionResult(status: transcriptionStatus); } Future requestUploadUrl({int count = 1}) async { final payload = {'count': count}; final response = await _api.sendRequest(Opcode.fileUpload, payload); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; final infoList = data['info'] as List?; if (infoList == null || infoList.isEmpty) return null; final info = infoList.first; if (info is! Map) return null; return FileUploadInfo( url: info['url'] as String? ?? '', fileId: info['fileId'] as int? ?? 0, token: info['token'] as String? ?? '', ); } /// Returns the server-assigned message id, or null on failure. The id is /// required to later resolve a download URL — a message still carrying its /// local temp id resolves to messageId 0 and the server rejects it. Future sendFileMessage( int chatId, int fileId, { String? token, String? text, bool notify = true, int? scheduledTime, int maxAttempts = 20, Duration retryDelay = const Duration(seconds: 1), }) async { final message = { 'isLive': false, 'detectShare': false, 'elements': [], 'cid': _nextCid(), 'attaches': [ if (token != null) {'_type': 'FILE', 'token': token} else {'_type': 'FILE', 'fileId': fileId}, ], }; if (text != null && text.isNotEmpty) message['text'] = text; if (scheduledTime != null) { message['delayedAttributes'] = { 'timeToFire': scheduledTime, 'notifySender': true, }; } final payload = {'chatId': chatId, 'message': message, 'notify': notify}; return _sendWithNotReadyRetry( payload: payload, maxAttempts: maxAttempts, retryDelay: retryDelay, onResult: (response) => _sentMessageMap(response)?['id']?.toString(), onExhausted: null, ); } Future requestPhotoUploadUrl({int? type}) async { final response = await _api.sendRequest(Opcode.photoUpload, { 'type': ?type, 'count': 1, }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; return data['url'] as String?; } Future?> sendPhotoMessage( int chatId, List photoTokens, { String? caption, bool notify = true, int? scheduledTime, int maxAttempts = 20, Duration retryDelay = const Duration(seconds: 1), }) async { final message = { 'cid': -_nextCid(), 'attaches': [ for (final token in photoTokens) {'_type': 'PHOTO', 'photoToken': token}, ], }; if (caption != null && caption.isNotEmpty) message['text'] = caption; if (scheduledTime != null) { message['delayedAttributes'] = { 'timeToFire': scheduledTime, 'notifySender': true, }; } final payload = {'chatId': chatId, 'message': message, 'notify': notify}; return _sendWithNotReadyRetry?>( payload: payload, maxAttempts: maxAttempts, retryDelay: retryDelay, onResult: _sentMessageMap, onExhausted: null, ); } Future requestVideoUploadUrl({int type = 0}) async { final response = await _api.sendRequest(Opcode.videoUpload, { 'uploaderType': 0, 'type': type, 'count': 1, }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; final infoList = data['info'] as List?; if (infoList == null || infoList.isEmpty) return null; final info = infoList.first; if (info is! Map) return null; return VideoUploadInfo( url: info['url'] as String? ?? '', videoId: info['videoId'] as int? ?? 0, token: info['token'] as String? ?? '', ); } Future?> sendVideoMessage( int chatId, String token, { String? caption, bool notify = true, int? scheduledTime, int maxAttempts = 30, Duration retryDelay = const Duration(seconds: 1), }) async { final message = { 'isLive': false, 'detectShare': false, 'elements': [], 'cid': -_nextCid(), 'attaches': [ {'videoType': 0, '_type': 'VIDEO', 'token': token}, ], }; if (caption != null && caption.isNotEmpty) message['text'] = caption; if (scheduledTime != null) { message['delayedAttributes'] = { 'timeToFire': scheduledTime, 'notifySender': true, }; } final payload = {'chatId': chatId, 'message': message, 'notify': notify}; return _sendWithNotReadyRetry?>( payload: payload, maxAttempts: maxAttempts, retryDelay: retryDelay, onResult: _sentMessageMap, onExhausted: null, ); } Future requestAudioUploadUrl() async { final response = await _api.sendRequest(Opcode.videoUpload, { 'uploaderType': 1, 'type': 2, 'count': 1, }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; final infoList = data['info'] as List?; if (infoList == null || infoList.isEmpty) return null; final info = infoList.first; if (info is! Map) return null; return AudioUploadInfo( url: info['url'] as String? ?? '', audioId: info['videoId'] as int? ?? 0, token: info['token'] as String? ?? '', ); } Future?> sendAudioMessage( int chatId, String token, { required int duration, Uint8List? wave, bool notify = true, int? scheduledTime, int maxAttempts = 30, Duration retryDelay = const Duration(seconds: 1), }) async { final message = { 'isLive': false, 'detectShare': false, 'elements': [], 'cid': -_nextCid(), 'attaches': [ { 'duration': duration, '_type': 'AUDIO', 'wave': (wave != null && wave.isNotEmpty) ? wave : Uint8List(80), 'token': token, }, ], }; if (scheduledTime != null) { message['delayedAttributes'] = { 'timeToFire': scheduledTime, 'notifySender': true, }; } final payload = {'chatId': chatId, 'message': message, 'notify': notify}; return _sendWithNotReadyRetry?>( payload: payload, maxAttempts: maxAttempts, retryDelay: retryDelay, onResult: _sentMessageMap, onExhausted: null, ); } Future requestVideoNoteUploadUrl() async { final response = await _api.sendRequest(Opcode.videoUpload, { 'uploaderType': 1, 'type': 1, 'count': 1, }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; final infoList = data['info'] as List?; if (infoList == null || infoList.isEmpty) return null; final info = infoList.first; if (info is! Map) return null; return VideoUploadInfo( url: info['url'] as String? ?? '', videoId: info['videoId'] as int? ?? 0, token: info['token'] as String? ?? '', ); } Future?> sendVideoNoteMessage( int chatId, String token, { required int duration, Uint8List? wave, String? thumbhash, bool notify = true, int maxAttempts = 30, Duration retryDelay = const Duration(seconds: 1), }) async { final message = { 'isLive': false, 'detectShare': false, 'elements': [], 'cid': -_nextCid(), 'attaches': [ { 'duration': duration, 'videoType': 1, '_type': 'VIDEO', 'wave': (wave != null && wave.isNotEmpty) ? wave : Uint8List(80), 'token': token, if (thumbhash != null && thumbhash.isNotEmpty) 'thumbhash': thumbhash, }, ], }; final payload = {'chatId': chatId, 'message': message, 'notify': notify}; return _sendWithNotReadyRetry?>( payload: payload, maxAttempts: maxAttempts, retryDelay: retryDelay, onResult: _sentMessageMap, onExhausted: null, ); } Future?> sendLocationMessage( int chatId, double latitude, double longitude, { double zoom = 15, bool notify = true, }) async { final payload = { 'chatId': chatId, 'message': { 'cid': -_nextCid(), 'attaches': [ { '_type': 'LOCATION', 'latitude': latitude, 'longitude': longitude, 'zoom': zoom, }, ], }, 'notify': notify, }; final response = await _api.sendRequest(Opcode.msgSend, payload); return _sentMessageMap(response); } Future?> sendContactMessage( int chatId, int contactId, { bool notify = true, }) async { final payload = { 'chatId': chatId, 'message': { 'cid': -_nextCid(), 'attaches': [ {'_type': 'CONTACT', 'contactId': contactId}, ], }, 'notify': notify, }; final response = await _api.sendRequest(Opcode.msgSend, payload); return _sentMessageMap(response); } static const int _pollAnonymousFlag = 4; static const int _pollMultipleFlag = 1; Future?> sendPollMessage( int chatId, String title, List answers, { bool multiple = false, bool anonymous = true, bool notify = true, }) async { final settings = (anonymous ? _pollAnonymousFlag : 0) | (multiple ? _pollMultipleFlag : 0); final payload = { 'chatId': chatId, 'message': { 'cid': -_nextCid(), 'attaches': [ { '_type': 'POLL', 'title': title, 'settings': settings, 'answers': [ for (final a in answers) {'text': a}, ], }, ], }, 'notify': notify, }; final response = await _api.sendRequest(Opcode.msgSend, payload); return _sentMessageMap(response); } void sendTyping(int chatId, String type) { unawaited(() async { try { await _api.sendRequest(Opcode.msgTyping, { 'chatId': chatId, 'type': type, }); } catch (_) {} }()); } Future?> sendStickerMessage( int chatId, int stickerId, { bool notify = true, }) async { final payload = { 'chatId': chatId, 'message': { 'cid': -_nextCid(), 'attaches': [ {'_type': 'STICKER', 'stickerId': stickerId}, ], }, 'notify': notify, }; final response = await _api.sendRequest(Opcode.msgSend, payload); return _sentMessageMap(response); } Future downloadPhoto(String baseUrl, String photoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { 'url': baseUrl, 'token': photoToken, }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; final content = data['content']; if (content is Uint8List) return content; if (content is List) return Uint8List.fromList(content); return null; } catch (e) { return null; } } Future getPhotoUrl(String baseUrl, String photoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { 'url': baseUrl, 'token': photoToken, }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; return data['content'] as String?; } catch (e) { return null; } } Future> getVideoSources({ required String messageId, required int chatId, required String token, required int videoId, }) async { try { final response = await _api.sendRequest(Opcode.videoPlay, { 'messageId': int.tryParse(messageId) ?? 0, 'chatId': chatId, 'token': token, 'videoId': videoId, }); if (!response.isOk) return const {}; final data = response.payload; if (data is! Map) return const {}; const mp4Keys = { 'MP4_1080': '1080p', 'MP4_720': '720p', 'MP4_480': '480p', 'MP4_360': '360p', 'MP4_240': '240p', 'MP4_144': '144p', }; final sources = {}; for (final entry in mp4Keys.entries) { final url = data[entry.key]; if (url is String && url.isNotEmpty) sources[entry.value] = url; } if (sources.isEmpty) { final hls = data['HLS']; if (hls is String && hls.isNotEmpty) sources['Авто'] = hls; final external = data['EXTERNAL']; if (external is String && external.isNotEmpty) { sources['Источник'] = external; } } return sources; } catch (_) { return const {}; } } Future getVideoUrl({ required String messageId, required int chatId, required String token, required int videoId, }) async { final sources = await getVideoSources( messageId: messageId, chatId: chatId, token: token, videoId: videoId, ); return sources.values.isEmpty ? null : sources.values.first; } Future downloadVideo(String baseUrl, String videoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { 'url': baseUrl, 'token': videoToken, }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; final content = data['content']; if (content is Uint8List) return content; if (content is List) return Uint8List.fromList(content); return null; } catch (e) { return null; } } Future downloadFile(String baseUrl, String fileToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { 'url': baseUrl, 'token': fileToken, }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; final content = data['content']; if (content is Uint8List) return content; if (content is List) return Uint8List.fromList(content); return null; } catch (e) { return null; } } Future getFileUrl({ required String messageId, required int chatId, required int fileId, }) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { 'fileId': fileId, 'chatId': chatId, 'messageId': int.tryParse(messageId) ?? 0, 'itemType': 'REGULAR', }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; return data['url'] as String?; } catch (e) { return null; } } Future searchContactById(int contactId) async { final cached = ContactCache.get(contactId); if (cached != null) return cached; if (_api.state != SessionState.online) return null; try { final response = await _api.sendRequest(Opcode.contactInfo, { 'contactIds': [contactId], }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; final contacts = data['contacts'] as List?; if (contacts != null && contacts.isNotEmpty) { final contact = contacts.first; if (contact is Map) { final names = contact['names'] as List?; if (names != null && names.isNotEmpty) { final name = names.first; if (name is Map) { final firstName = name['firstName'] as String? ?? ''; final lastName = name['lastName'] as String?; final fullName = lastName != null ? '$firstName $lastName' : firstName; ContactCache.put(contactId, fullName); final baseUrl = contact['baseUrl'] as String?; ContactCache.putAvatar(contactId, baseUrl); final rawOpts = contact['options']; if (rawOpts is List) { ContactCache.putOptions( contactId, rawOpts.whereType().toSet(), ); } chats.applyContactUpdate(contactId); return fullName; } } } } } catch (e) { logger.e('searchContactById error: $e'); } return null; } Future ensureContactNames(Iterable ids) async { final missing = ids .where((id) => id != 0 && ContactCache.get(id) == null) .toSet(); if (missing.isEmpty) return false; if (_api.state != SessionState.online) return false; try { final response = await _api.sendRequest(Opcode.contactInfo, { 'contactIds': missing.toList(), }); if (!response.isOk) return false; final data = response.payload; if (data is! Map) return false; final contacts = data['contacts']; if (contacts is! List) return false; var resolvedAny = false; for (final raw in contacts.whereType()) { final id = raw['id']; if (id is! int) continue; final names = raw['names']; if (names is List && names.isNotEmpty) { final nameRaw = names.firstWhere( (n) => n is Map && n['type'] == 'ONEME', orElse: () => names.firstWhere((n) => n is Map, orElse: () => null), ); if (nameRaw is Map) { final firstName = (nameRaw['firstName'] as String?) ?? ''; final lastName = nameRaw['lastName'] as String?; final fullName = (lastName != null && lastName.isNotEmpty) ? '$firstName $lastName' : firstName; if (fullName.isNotEmpty) ContactCache.put(id, fullName); } } final baseUrl = raw['baseUrl'] as String?; if (baseUrl != null && baseUrl.isNotEmpty) { ContactCache.putAvatar(id, baseUrl); } final rawOpts = raw['options']; if (rawOpts is List) { ContactCache.putOptions(id, rawOpts.whereType().toSet()); } chats.applyContactUpdate(id); resolvedAny = true; } return resolvedAny; } catch (e) { logger.e('ensureContactNames error: $e'); return false; } } }