небольшие изменения

This commit is contained in:
Jganenokk
2026-07-05 19:07:27 +07:00
parent 26f0094705
commit 9f9b0e0734
214 changed files with 27592 additions and 15353 deletions
+148 -295
View File
@@ -10,7 +10,7 @@ import '../../core/storage/app_database.dart';
import '../../core/utils/logger.dart';
import '../../core/utils/text_format.dart';
import '../../models/attachment.dart';
import 'chats.dart' show ChatsModule;
import 'chats.dart' show chats;
class ContactCache {
static final Map<int, String> _nameCache = {};
@@ -337,6 +337,10 @@ class ReplyInfo {
return '';
case AttachmentType.inlineKeyboard:
return '';
case AttachmentType.forward:
return 'Переслано';
case AttachmentType.unknown:
return 'Вложение';
}
}
return '';
@@ -434,6 +438,28 @@ class CachedMessage {
return list;
}
static (List<MessageAttachment>?, bool) parseAttachments(
Map<String, dynamic> map,
) {
List<MessageAttachment>? 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>()
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
.toList();
}
}
final isControl =
attachments?.any((a) => a.type == AttachmentType.control) ?? false;
return (attachments, isControl);
}
factory CachedMessage.fromDbRow(Map<String, dynamic> row) {
Map<String, dynamic>? payload;
final payloadRaw = row['payload'];
@@ -444,22 +470,11 @@ class CachedMessage {
}
List<MessageAttachment>? attachments;
bool isControl = false;
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<String, dynamic>.from(a as Map),
),
)
.toList();
}
}
final parsed = parseAttachments(payload);
attachments = parsed.$1;
isControl = parsed.$2;
}
return CachedMessage(
@@ -480,8 +495,7 @@ class CachedMessage {
status: row['status']?.toString(),
payload: payload,
attachments: attachments,
isControl:
attachments?.any((a) => a.type == AttachmentType.control) ?? false,
isControl: isControl,
deleted: row['deleted'] is int
? row['deleted'] == 1
: row['deleted']?.toString() == '1',
@@ -503,7 +517,8 @@ class CachedMessage {
ReplyInfo? get replyInfo => ReplyInfo.fromPayload(payload);
List<FormatRange> get formatRanges => parseFormatElements(payload?['elements']);
List<FormatRange> get formatRanges =>
parseFormatElements(payload?['elements']);
static List<CachedMessage> _decodeRows(List<Map<String, dynamic>> rows) =>
rows.map(CachedMessage.fromDbRow).toList();
@@ -531,14 +546,8 @@ class CachedMessage {
};
static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) {
List<MessageAttachment>? attachments;
final attaches = msg['attaches'];
if (attaches is List && attaches.isNotEmpty) {
attachments = attaches
.whereType<Map>()
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
.toList();
}
final full = Map<String, dynamic>.from(msg);
final parsed = parseAttachments(full);
return CachedMessage(
id: msg['id']?.toString() ?? '',
accountId: accountId,
@@ -547,8 +556,9 @@ class CachedMessage {
text: msg['text'] as String?,
time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch,
status: (msg['status'] as String?) ?? 'sent',
payload: Map<String, dynamic>.from(msg),
attachments: attachments,
payload: full,
attachments: parsed.$1,
isControl: parsed.$2,
);
}
}
@@ -558,11 +568,6 @@ class MessagesModule {
MessagesModule(this._api);
/// Загружает историю сообщений для указанного чата.
///
/// [fromTime] — опционально, время от которого грузить (миллисекунды).
/// Если не указано, грузит самые свежие.
/// [count] — количество сообщений.
Future<List<CachedMessage>> fetchHistory(
int accountId,
int chatId, {
@@ -573,10 +578,7 @@ class MessagesModule {
}) async {
final payload = {
'chatId': chatId,
'from':
fromTime ??
(DateTime.now().millisecondsSinceEpoch +
86400000), // +1 день для запаса
'from': fromTime ?? (DateTime.now().millisecondsSinceEpoch + 86400000),
'forward': forward,
'backward': backward ?? count,
'getMessages': true,
@@ -614,9 +616,7 @@ class MessagesModule {
if (toSave.isNotEmpty) {
try {
await AppDatabase.saveMessages(
toSave.map((m) => m.toDbRow()).toList(),
);
await AppDatabase.saveMessages(toSave.map((m) => m.toDbRow()).toList());
} catch (e) {
logger.e('saveMessages error: $e');
}
@@ -625,11 +625,6 @@ class MessagesModule {
return toSave;
}
/// Поиск сообщений в чате по строке [query] (opcode 73).
///
/// Возвращает сырые записи результата вида
/// `{'message': {...}, 'highlights': [...]}`, отсортированные сервером
/// от новых к старым.
Future<List<Map<String, dynamic>>> searchMessages(
int chatId,
String query, {
@@ -691,7 +686,6 @@ class MessagesModule {
return out;
}
/// Загружает сообщения из локальной базы данных.
Future<List<CachedMessage>> getLocalHistory(
int accountId,
int chatId, {
@@ -715,30 +709,8 @@ class MessagesModule {
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<MessageAttachment>? attachments;
bool isControl = false;
if (linkType == 'FORWARD') {
final fwdMap = Map<String, dynamic>.from(m.cast());
attachments = [ForwardedMessageAttachment.fromMap(fwdMap)];
} else {
final attaches = m['attaches'] as List?;
if (attaches != null) {
attachments = attaches
.whereType<Map>()
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
.toList();
// Detect CONTROL
if (attachments.any((a) => a.type == AttachmentType.control)) {
isControl = true;
}
}
}
final full = Map<String, dynamic>.from(m.cast());
final parsed = CachedMessage.parseAttachments(full);
return CachedMessage(
id: id,
@@ -748,9 +720,9 @@ class MessagesModule {
text: m['text']?.toString(),
time: _parseIntField(m['time']),
status: m['status']?.toString(),
payload: Map<String, dynamic>.from(m.cast()),
attachments: attachments,
isControl: isControl,
payload: full,
attachments: parsed.$1,
isControl: parsed.$2,
);
}
@@ -789,20 +761,18 @@ class MessagesModule {
'notifySender': true,
};
}
final payload = {
'chatId': chatId,
'message': message,
'notify': notify,
};
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
return _sendAndExtractMessageId(payload, 'Ошибка отправки');
}
Future<String> _sendAndExtractMessageId(
Map<String, dynamic> payload,
String defaultError,
) async {
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());
_throwSendError(response.payload, defaultError);
}
final data = response.payload;
if (data is Map) {
@@ -815,11 +785,46 @@ class MessagesModule {
return '';
}
/// Пересылает сообщение [messageId] из чата [sourceChatId] в [targetChatId].
///
/// Пересылка — это отдельное сообщение без текста и вложений, со ссылкой
/// `link.type = FORWARD`, указывающей на оригинал. Сервер сам подставит
/// тело оригинала в ответе.
Never _throwSendError(dynamic payload, String fallback) {
final msg = (payload is Map)
? (payload['localizedMessage'] ?? payload['message'] ?? fallback)
: fallback;
throw Exception(msg.toString());
}
Map<String, dynamic>? _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<String, dynamic>.from(msg);
}
return null;
}
Future<T> _sendWithNotReadyRetry<T>({
required Map<String, dynamic> 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<String> forwardMessage(
int targetChatId,
int sourceChatId,
@@ -844,24 +849,7 @@ class MessagesModule {
'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 '';
return _sendAndExtractMessageId(payload, 'Ошибка пересылки');
}
static CachedMessage buildForwardMessage({
@@ -955,18 +943,13 @@ class MessagesModule {
],
'attaches': [],
};
final response = await _api.sendRequest(Opcode.msgSend, {
return _api.sendRequestOk(Opcode.msgSend, {
'chatId': chatId,
'message': message,
'notify': true,
});
return response.isOk;
}
/// Загружает отложенные (запланированные) сообщения чата.
///
/// В отличие от обычной истории, отложенные сообщения не сохраняются
/// в локальную БД — они живут только до момента отправки.
Future<List<CachedMessage>> fetchDelayedMessages(
int accountId,
int chatId,
@@ -1009,10 +992,6 @@ class MessagesModule {
return results;
}
/// Редактирует текст (подпись) обычного сообщения.
///
/// Поле `attachments` не передаётся — сервер сохраняет существующие
/// вложения.
Future<bool> editMessage(
int chatId,
String messageId, {
@@ -1031,13 +1010,9 @@ class MessagesModule {
};
if (sendAttachments) payload['attachments'] = const <dynamic>[];
final response = await _api.sendRequest(Opcode.msgEdit, payload);
return response.isOk;
return _api.sendRequestOk(Opcode.msgEdit, payload);
}
/// Редактирует отложенное сообщение: меняет текст и/или время отправки.
///
/// Вложения сервер сохраняет сам — в payload они не передаются.
Future<bool> editScheduledMessage(
int chatId,
String messageId, {
@@ -1052,14 +1027,10 @@ class MessagesModule {
'chatId': chatId,
'elements': <dynamic>[],
'text': text,
'delayedAttributes': {
'timeToFire': timeToFire,
'notifySender': true,
},
'delayedAttributes': {'timeToFire': timeToFire, 'notifySender': true},
};
final response = await _api.sendRequest(Opcode.msgEdit, payload);
return response.isOk;
return _api.sendRequestOk(Opcode.msgEdit, payload);
}
Future<bool> deleteMessages(
@@ -1081,8 +1052,7 @@ class MessagesModule {
'itemType': itemType,
};
final response = await _api.sendRequest(Opcode.msgDelete, payload);
return response.isOk;
return _api.sendRequestOk(Opcode.msgDelete, payload);
}
Future<Map<String, dynamic>?> sendButtonCallback({
@@ -1168,7 +1138,6 @@ class MessagesModule {
int? scheduledTime,
int maxAttempts = 20,
Duration retryDelay = const Duration(seconds: 1),
Duration initialDelay = const Duration(seconds: 3),
}) async {
final message = <String, dynamic>{
'isLive': false,
@@ -1188,29 +1157,15 @@ class MessagesModule {
'notifySender': true,
};
}
final payload = {
'chatId': chatId,
'message': message,
'notify': notify,
};
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;
return _sendWithNotReadyRetry<bool>(
payload: payload,
maxAttempts: maxAttempts,
retryDelay: retryDelay,
onResult: (response) => response.isOk,
onExhausted: false,
);
}
Future<String?> requestPhotoUploadUrl() async {
@@ -1246,29 +1201,15 @@ class MessagesModule {
}
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<String, dynamic>.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;
return _sendWithNotReadyRetry<Map<String, dynamic>?>(
payload: payload,
maxAttempts: maxAttempts,
retryDelay: retryDelay,
onResult: _sentMessageMap,
onExhausted: null,
);
}
/// Запрашивает URL для загрузки видео (опкод 82).
Future<VideoUploadInfo?> requestVideoUploadUrl() async {
final response = await _api.sendRequest(Opcode.videoUpload, {
'uploaderType': 0,
@@ -1293,9 +1234,6 @@ class MessagesModule {
);
}
/// Отправляет сообщение с видео по [token], полученному из
/// [requestVideoUploadUrl]. Сервер может ответить `attachment.not.ready`,
/// пока обрабатывает загруженное видео — в этом случае запрос повторяется.
Future<Map<String, dynamic>?> sendVideoMessage(
int chatId,
String token, {
@@ -1323,33 +1261,15 @@ class MessagesModule {
}
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<String, dynamic>.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;
return _sendWithNotReadyRetry<Map<String, dynamic>?>(
payload: payload,
maxAttempts: maxAttempts,
retryDelay: retryDelay,
onResult: _sentMessageMap,
onExhausted: null,
);
}
/// Запрашивает URL для загрузки голосового сообщения (опкод 82).
///
/// Тот же опкод, что и у видео, но `uploaderType: 1, type: 2`. В ответе
/// `videoId` — это идентификатор аудио (`audioId`), а `token` уже выдан и
/// используется в [sendAudioMessage] после загрузки байтов.
Future<AudioUploadInfo?> requestAudioUploadUrl() async {
final response = await _api.sendRequest(Opcode.videoUpload, {
'uploaderType': 1,
@@ -1374,13 +1294,6 @@ class MessagesModule {
);
}
/// Отправляет голосовое сообщение по [token], полученному из
/// [requestAudioUploadUrl], после загрузки Ogg/Opus-байтов на CDN.
///
/// [duration] — длительность в миллисекундах. [wave] — hex-строка амплитуд
/// для дорожки; если пусто, отправляется плоская (нулевая) волна, которую
/// сервер принимает. Сервер может ответить `attachment.not.ready`, пока
/// обрабатывает загрузку — запрос повторяется.
Future<Map<String, dynamic>?> sendAudioMessage(
int chatId,
String token, {
@@ -1413,30 +1326,15 @@ class MessagesModule {
}
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<String, dynamic>.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;
return _sendWithNotReadyRetry<Map<String, dynamic>?>(
payload: payload,
maxAttempts: maxAttempts,
retryDelay: retryDelay,
onResult: _sentMessageMap,
onExhausted: null,
);
}
/// Запрашивает URL для загрузки видеосообщения-кружка (опкод 82,
/// `uploaderType: 1, type: 1`). Ответ — `vu.oneme.ru/uploadVideo` + token.
Future<VideoUploadInfo?> requestVideoNoteUploadUrl() async {
final response = await _api.sendRequest(Opcode.videoUpload, {
'uploaderType': 1,
@@ -1461,13 +1359,6 @@ class MessagesModule {
);
}
/// Отправляет видеосообщение-кружок (`videoType: 1`) по [token], полученному
/// из [requestVideoNoteUploadUrl], после загрузки MP4-байтов на CDN.
///
/// [duration] — длительность в мс. [wave] — амплитуды аудиодорожки (бинарь,
/// 80 байт; нули допустимы). [thumbhash] — компактный хеш превью (опционально,
/// сервер всё равно отдаёт собственный `previewData`). Повторяет запрос на
/// `attachment.not.ready`, пока CDN обрабатывает загрузку.
Future<Map<String, dynamic>?> sendVideoNoteMessage(
int chatId,
String token, {
@@ -1496,26 +1387,13 @@ class MessagesModule {
};
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<String, dynamic>.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;
return _sendWithNotReadyRetry<Map<String, dynamic>?>(
payload: payload,
maxAttempts: maxAttempts,
retryDelay: retryDelay,
onResult: _sentMessageMap,
onExhausted: null,
);
}
Future<Map<String, dynamic>?> sendLocationMessage(
@@ -1542,15 +1420,12 @@ class MessagesModule {
};
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<String, dynamic>.from(msg);
}
return null;
return _sentMessageMap(response);
}
static const int _pollAnonymousFlag = 4;
static const int _pollMultipleFlag = 1;
Future<Map<String, dynamic>?> sendPollMessage(
int chatId,
String title,
@@ -1559,7 +1434,9 @@ class MessagesModule {
bool anonymous = true,
bool notify = true,
}) async {
final settings = (anonymous ? 4 : 0) | (multiple ? 1 : 0);
final settings =
(anonymous ? _pollAnonymousFlag : 0) |
(multiple ? _pollMultipleFlag : 0);
final payload = {
'chatId': chatId,
'message': {
@@ -1579,13 +1456,7 @@ class MessagesModule {
};
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<String, dynamic>.from(msg);
}
return null;
return _sentMessageMap(response);
}
void sendTyping(int chatId, String type) {
@@ -1616,13 +1487,7 @@ class MessagesModule {
};
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<String, dynamic>.from(msg);
}
return null;
return _sentMessageMap(response);
}
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
@@ -1662,13 +1527,6 @@ class MessagesModule {
}
}
/// Запрашивает у сервера ссылки на воспроизведение видео (opcode 83).
///
/// Формат подтверждён дампом: запрос `{messageId, chatId, token, videoId}`,
/// ответ содержит `MP4_1080/MP4_720/...`, `HLS`, `DASH`, `EXTERNAL`.
/// Возвращает все доступные progressive-MP4 качества (label → URL),
/// отсортированные по убыванию. URL'ы — готовые подписанные ссылки на CDN,
/// поддерживающие HTTP range, поэтому пригодны для стриминга.
Future<Map<String, String>> getVideoSources({
required String messageId,
required int chatId,
@@ -1713,7 +1571,6 @@ class MessagesModule {
}
}
/// Возвращает один лучший progressive-MP4 (или HLS как запасной).
Future<String?> getVideoUrl({
required String messageId,
required int chatId,
@@ -1769,10 +1626,6 @@ class MessagesModule {
}
}
/// Запрашивает у сервера временный CDN-URL для скачивания файла (opcode 88).
///
/// Формат подтверждён дампом: запрос `{messageId, chatId, fileId}`,
/// ответ `{url: "https://fd.oneme.ru/getfile?..."}`.
Future<String?> getFileUrl({
required String messageId,
required int chatId,
@@ -1836,7 +1689,7 @@ class MessagesModule {
);
}
ChatsModule.applyContactUpdate(contactId);
chats.applyContactUpdate(contactId);
return fullName;
}
}
@@ -1898,7 +1751,7 @@ class MessagesModule {
ContactCache.putOptions(id, rawOpts.whereType<String>().toSet());
}
ChatsModule.applyContactUpdate(id);
chats.applyContactUpdate(id);
resolvedAny = true;
}