import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; import '../../core/utils/logger.dart'; import '../../models/attachment.dart'; import 'chats.dart' show ChatsModule; class ContactCache { static final Map _nameCache = {}; static final Map _avatarCache = {}; static final Map> _optionsCache = {}; 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']; if (name is String) _nameCache[id] = name; if (avatar is String) _avatarCache[id] = avatar; if (opts is List) _optionsCache[id] = opts.whereType().toSet(); }); } 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 String? get(int id) => _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 clear() { _nameCache.clear(); _avatarCache.clear(); _optionsCache.clear(); _saveTimer?.cancel(); _saveTimer = null; unawaited(_wipePersisted()); } 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, }; final map = {}; for (final id in ids) { final entry = {}; final name = _nameCache[id]; final avatar = _avatarCache[id]; final opts = _optionsCache[id]; if (name != null) entry['n'] = name; if (avatar != null) entry['a'] = avatar; if (opts != null && opts.isNotEmpty) entry['o'] = opts.toList(); 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 { 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 void put(String messageId, TranscriptionResult result) { _cache[messageId] = result; } static TranscriptionResult? get(String messageId) => _cache[messageId]; static bool has(String messageId) => _cache.containsKey(messageId); static void clear() => _cache.clear(); } 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, }); 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: return 'Файл'; 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 ''; } } return ''; } } class AudioUploadInfo { final String url; final int audioId; final String token; AudioUploadInfo({ required this.url, required this.audioId, required this.token, }); } 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; 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, }); CachedMessage copyWith({ String? status, bool? deleted, List? attachments, }) => CachedMessage( id: id, accountId: accountId, chatId: chatId, senderId: senderId, text: text, time: time, status: status ?? this.status, payload: payload, attachments: attachments ?? this.attachments, isControl: isControl, deleted: deleted ?? this.deleted, ); 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; if (payload != null) { final linkType = payload['link']?['type'] as String?; if (linkType == 'FORWARD') { attachments = [ForwardedMessageAttachment.fromMap(payload)]; } else { final attaches = payload['attaches'] as List?; if (attaches != null) { attachments = attaches .map( (a) => MessageAttachment.fromMap( Map.from(a as Map), ), ) .toList(); } } } 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: attachments?.any((a) => a.type == AttachmentType.control) ?? false, deleted: row['deleted'] is int ? row['deleted'] == 1 : row['deleted']?.toString() == '1', ); } 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; ReplyInfo? get replyInfo => ReplyInfo.fromPayload(payload); 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, }; static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) { List? attachments; final attaches = msg['attaches']; if (attaches is List && attaches.isNotEmpty) { attachments = attaches .whereType() .map((a) => MessageAttachment.fromMap(Map.from(a))) .toList(); } 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: Map.from(msg), attachments: attachments, ); } } class MessagesModule { final Api _api; MessagesModule(this._api); /// Загружает историю сообщений для указанного чата. /// /// [fromTime] — опционально, время от которого грузить (миллисекунды). /// Если не указано, грузит самые свежие. /// [count] — количество сообщений. Future> fetchHistory( int accountId, int chatId, { int? fromTime, int count = 50, }) async { final payload = { 'chatId': chatId, 'from': fromTime ?? (DateTime.now().millisecondsSinceEpoch + 86400000), // +1 день для запаса 'forward': 0, '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 = []; final List> rows = []; 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); rows.add(msg.toDbRow()); } if (i > 0 && i % 20 == 0) { await Future.delayed(Duration.zero); } } if (rows.isNotEmpty) { try { await AppDatabase.saveMessages(rows); } catch (e) { logger.e('saveMessages error: $e'); } } return results; } /// Загружает сообщения из локальной базы данных. 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 linkRaw = m['link']; String? linkType; if (linkRaw is Map) { linkType = linkRaw['type'] as String?; } List? attachments; bool isControl = false; if (linkType == 'FORWARD') { final fwdMap = Map.from(m.cast()); attachments = [ForwardedMessageAttachment.fromMap(fwdMap)]; } else { final attaches = m['attaches'] as List?; if (attaches != null) { attachments = attaches .whereType() .map((a) => MessageAttachment.fromMap(Map.from(a))) .toList(); // Detect CONTROL if (attachments.any((a) => a.type == AttachmentType.control)) { isControl = true; } } } 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: Map.from(m.cast()), attachments: attachments, isControl: isControl, ); } 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, { bool notify = true, int? scheduledTime, int? replyToMessageId, }) async { final message = { 'text': text, 'cid': DateTime.now().millisecondsSinceEpoch * -1, 'elements': [], 'attaches': [], }; if (replyToMessageId != null) { message['link'] = { 'type': 'REPLY', 'chatId': chatId, 'messageId': replyToMessageId, }; } if (scheduledTime != null) { message['delayedAttributes'] = { 'timeToFire': scheduledTime, 'notifySender': true, }; } final payload = { 'chatId': chatId, 'message': message, 'notify': notify, }; final response = await _api.sendRequest(Opcode.msgSend, payload); if (!response.isOk) { final msg = (response.payload is Map) ? (response.payload['localizedMessage'] ?? response.payload['message'] ?? 'Ошибка отправки') : 'Ошибка отправки'; throw Exception(msg.toString()); } 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 ''; } /// Пересылает сообщение [messageId] из чата [sourceChatId] в [targetChatId]. /// /// Пересылка — это отдельное сообщение без текста и вложений, со ссылкой /// `link.type = FORWARD`, указывающей на оригинал. Сервер сам подставит /// тело оригинала в ответе. Future forwardMessage( int targetChatId, int sourceChatId, int messageId, { bool notify = true, }) async { final message = { 'isLive': false, 'detectShare': false, 'elements': [], 'attaches': [], 'cid': DateTime.now().millisecondsSinceEpoch * -1, 'link': { 'type': 'FORWARD', 'chatId': sourceChatId, 'messageId': messageId, }, }; final payload = { 'chatId': targetChatId, 'message': message, 'notify': notify, }; final response = await _api.sendRequest(Opcode.msgSend, payload); if (!response.isOk) { final msg = (response.payload is Map) ? (response.payload['localizedMessage'] ?? response.payload['message'] ?? 'Ошибка пересылки') : 'Ошибка пересылки'; throw Exception(msg.toString()); } 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 ''; } /// Загружает отложенные (запланированные) сообщения чата. /// /// В отличие от обычной истории, отложенные сообщения не сохраняются /// в локальную БД — они живут только до момента отправки. 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; } /// Редактирует текст (подпись) обычного сообщения. /// /// Поле `attachments` не передаётся — сервер сохраняет существующие /// вложения. 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 []; final response = await _api.sendRequest(Opcode.msgEdit, payload); return response.isOk; } /// Редактирует отложенное сообщение: меняет текст и/или время отправки. /// /// Вложения сервер сохраняет сам — в 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, }, }; final response = await _api.sendRequest(Opcode.msgEdit, payload); return response.isOk; } 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, }; final response = await _api.sendRequest(Opcode.msgDelete, payload); return response.isOk; } 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) { final text = data['transcription'] as String? ?? ''; if (text.isEmpty) { return TranscriptionResult( status: 1, text: 'не удалось распознать текст', ); } return TranscriptionResult(status: 1, text: text); } 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? ?? '', ); } Future sendFileMessage( int chatId, int fileId, { String? token, bool notify = true, int? scheduledTime, int maxAttempts = 20, Duration retryDelay = const Duration(seconds: 1), Duration initialDelay = const Duration(seconds: 3), }) async { final message = { 'isLive': false, 'detectShare': false, 'elements': [], 'cid': DateTime.now().millisecondsSinceEpoch, 'attaches': [ if (token != null) {'_type': 'FILE', 'token': token} else {'_type': 'FILE', 'fileId': fileId}, ], }; if (scheduledTime != null) { message['delayedAttributes'] = { 'timeToFire': scheduledTime, 'notifySender': true, }; } final payload = { 'chatId': chatId, 'message': message, 'notify': notify, }; await Future.delayed(initialDelay); for (var attempt = 0; attempt < maxAttempts; attempt++) { try { final response = await _api.sendRequest(Opcode.msgSend, payload); if (response.isOk) return true; return false; } 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 false; await Future.delayed(retryDelay); } } return false; } Future requestPhotoUploadUrl() async { final response = await _api.sendRequest(Opcode.photoUpload, {'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': DateTime.now().millisecondsSinceEpoch * -1, '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}; for (var attempt = 0; attempt < maxAttempts; attempt++) { try { final response = await _api.sendRequest(Opcode.msgSend, payload); 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; } 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 null; await Future.delayed(retryDelay); } } return null; } /// Запрашивает URL для загрузки видео (опкод 82). Future requestVideoUploadUrl() async { final response = await _api.sendRequest(Opcode.videoUpload, { 'uploaderType': 0, 'type': 0, '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? ?? '', ); } /// Отправляет сообщение с видео по [token], полученному из /// [requestVideoUploadUrl]. Сервер может ответить `attachment.not.ready`, /// пока обрабатывает загруженное видео — в этом случае запрос повторяется. 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': DateTime.now().millisecondsSinceEpoch * -1, '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}; for (var attempt = 0; attempt < maxAttempts; attempt++) { try { final response = await _api.sendRequest(Opcode.msgSend, payload); 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; } 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 null; await Future.delayed(retryDelay); } } return null; } /// Запрашивает URL для загрузки голосового сообщения (опкод 82). /// /// Тот же опкод, что и у видео, но `uploaderType: 1, type: 2`. В ответе /// `videoId` — это идентификатор аудио (`audioId`), а `token` уже выдан и /// используется в [sendAudioMessage] после загрузки байтов. 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? ?? '', ); } /// Отправляет голосовое сообщение по [token], полученному из /// [requestAudioUploadUrl], после загрузки Ogg/Opus-байтов на CDN. /// /// [duration] — длительность в миллисекундах. [wave] — hex-строка амплитуд /// для дорожки; если пусто, отправляется плоская (нулевая) волна, которую /// сервер принимает. Сервер может ответить `attachment.not.ready`, пока /// обрабатывает загрузку — запрос повторяется. 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': DateTime.now().millisecondsSinceEpoch * -1, '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}; for (var attempt = 0; attempt < maxAttempts; attempt++) { try { final response = await _api.sendRequest(Opcode.msgSend, payload); 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; } 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 null; await Future.delayed(retryDelay); } } return null; } /// Запрашивает URL для загрузки видеосообщения-кружка (опкод 82, /// `uploaderType: 1, type: 1`). Ответ — `vu.oneme.ru/uploadVideo` + token. 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? ?? '', ); } /// Отправляет видеосообщение-кружок (`videoType: 1`) по [token], полученному /// из [requestVideoNoteUploadUrl], после загрузки MP4-байтов на CDN. /// /// [duration] — длительность в мс. [wave] — амплитуды аудиодорожки (бинарь, /// 80 байт; нули допустимы). [thumbhash] — компактный хеш превью (опционально, /// сервер всё равно отдаёт собственный `previewData`). Повторяет запрос на /// `attachment.not.ready`, пока CDN обрабатывает загрузку. 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': DateTime.now().millisecondsSinceEpoch * -1, '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}; for (var attempt = 0; attempt < maxAttempts; attempt++) { try { final response = await _api.sendRequest(Opcode.msgSend, payload); 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; } 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 null; await Future.delayed(retryDelay); } } return null; } Future?> sendLocationMessage( int chatId, double latitude, double longitude, { double zoom = 15, bool notify = true, }) async { final payload = { 'chatId': chatId, 'message': { 'cid': DateTime.now().millisecondsSinceEpoch * -1, 'attaches': [ { '_type': 'LOCATION', 'latitude': latitude, 'longitude': longitude, 'zoom': zoom, }, ], }, 'notify': notify, }; final response = await _api.sendRequest(Opcode.msgSend, payload); 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?> sendPollMessage( int chatId, String title, List answers, { bool multiple = false, bool anonymous = true, bool notify = true, }) async { final settings = (anonymous ? 4 : 0) | (multiple ? 1 : 0); final payload = { 'chatId': chatId, 'message': { 'cid': DateTime.now().millisecondsSinceEpoch * -1, '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); 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 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; } } /// Запрашивает у сервера ссылки на воспроизведение видео (opcode 83). /// /// Формат подтверждён дампом: запрос `{messageId, chatId, token, videoId}`, /// ответ содержит `MP4_1080/MP4_720/...`, `HLS`, `DASH`, `EXTERNAL`. /// Возвращает все доступные progressive-MP4 качества (label → URL), /// отсортированные по убыванию. URL'ы — готовые подписанные ссылки на CDN, /// поддерживающие HTTP range, поэтому пригодны для стриминга. 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 {}; } } /// Возвращает один лучший progressive-MP4 (или HLS как запасной). 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; } } /// Запрашивает у сервера временный CDN-URL для скачивания файла (opcode 88). /// /// Формат подтверждён дампом: запрос `{messageId, chatId, fileId}`, /// ответ `{url: "https://fd.oneme.ru/getfile?..."}`. Future getFileUrl({ required String messageId, required int chatId, required int fileId, }) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { 'messageId': int.tryParse(messageId) ?? 0, 'chatId': chatId, 'fileId': fileId, }); 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(), ); } ChatsModule.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()); } ChatsModule.applyContactUpdate(id); resolvedAny = true; } return resolvedAny; } catch (e) { logger.e('ensureContactNames error: $e'); return false; } } }