578 lines
16 KiB
Dart
578 lines
16 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import '../api.dart';
|
|
import '../../core/protocol/opcode_map.dart';
|
|
import '../../core/storage/app_database.dart';
|
|
import '../../models/attachment.dart';
|
|
|
|
class ContactCache {
|
|
static final Map<int, String> _nameCache = {};
|
|
static final Map<int, String> _avatarCache = {};
|
|
static final Map<int, Set<String>> _optionsCache = {};
|
|
|
|
static void put(int id, String name) => _nameCache[id] = name;
|
|
|
|
static void putAvatar(int id, String? baseUrl) {
|
|
if (baseUrl != null) _avatarCache[id] = baseUrl;
|
|
}
|
|
|
|
static void putOptions(int id, Set<String> opts) => _optionsCache[id] = opts;
|
|
|
|
static String? get(int id) => _nameCache[id];
|
|
static String? getAvatar(int id) => _avatarCache[id];
|
|
static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false;
|
|
}
|
|
|
|
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<String, TranscriptionResult> _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);
|
|
}
|
|
|
|
class FileHistoryEntry {
|
|
final int fileId;
|
|
final String? url;
|
|
final String? token;
|
|
final DateTime sentAt;
|
|
|
|
FileHistoryEntry({
|
|
required this.fileId,
|
|
this.url,
|
|
this.token,
|
|
required this.sentAt,
|
|
});
|
|
}
|
|
|
|
class FileHistoryCache {
|
|
static final List<FileHistoryEntry> _history = [];
|
|
|
|
static List<FileHistoryEntry> get history => List.unmodifiable(_history);
|
|
|
|
static void add(FileHistoryEntry entry) {
|
|
_history.insert(0, entry);
|
|
if (_history.length > 50) _history.removeLast();
|
|
}
|
|
|
|
static bool get isEmpty => _history.isEmpty;
|
|
}
|
|
|
|
class FileUploadInfo {
|
|
final String url;
|
|
final int fileId;
|
|
final String token;
|
|
|
|
FileUploadInfo({
|
|
required this.url,
|
|
required this.fileId,
|
|
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<String, dynamic>? payload;
|
|
final List<MessageAttachment>? attachments;
|
|
final bool isControl;
|
|
|
|
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,
|
|
});
|
|
|
|
factory CachedMessage.fromDbRow(Map<String, dynamic> row) {
|
|
Map<String, dynamic>? payload;
|
|
final payloadRaw = row['payload'];
|
|
if (payloadRaw is String && payloadRaw.isNotEmpty) {
|
|
try {
|
|
payload = jsonDecode(payloadRaw) as Map<String, dynamic>;
|
|
} catch (_) {}
|
|
}
|
|
|
|
List<MessageAttachment>? 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<String, dynamic>.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,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toDbRow() => {
|
|
'id': id,
|
|
'account_id': accountId,
|
|
'chat_id': chatId,
|
|
'sender_id': senderId,
|
|
'text': text,
|
|
'time': time,
|
|
'status': status,
|
|
'payload': payload != null ? jsonEncode(payload) : null,
|
|
};
|
|
}
|
|
|
|
class MessagesModule {
|
|
final Api _api;
|
|
|
|
MessagesModule(this._api);
|
|
|
|
/// Загружает историю сообщений для указанного чата.
|
|
///
|
|
/// [fromTime] — опционально, время от которого грузить (миллисекунды).
|
|
/// Если не указано, грузит самые свежие.
|
|
/// [count] — количество сообщений.
|
|
Future<List<CachedMessage>> 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<CachedMessage> results = [];
|
|
final List<Map<String, dynamic>> rows = [];
|
|
|
|
for (var i = 0; i < messagesData.length; i++) {
|
|
final m = messagesData[i];
|
|
if (m is! Map) continue;
|
|
|
|
final msg = _parseMessage(m.cast<dynamic, dynamic>(), 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) {
|
|
AppDatabase.saveMessages(rows).catchError((e) {
|
|
debugPrint('saveMessages error: $e');
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/// Загружает сообщения из локальной базы данных.
|
|
Future<List<CachedMessage>> getLocalHistory(
|
|
int accountId,
|
|
int chatId, {
|
|
int limit = 50,
|
|
int offset = 0,
|
|
}) async {
|
|
final rows = await AppDatabase.loadMessages(
|
|
accountId,
|
|
chatId,
|
|
limit: limit,
|
|
offset: offset,
|
|
);
|
|
return rows.map(CachedMessage.fromDbRow).toList();
|
|
}
|
|
|
|
CachedMessage? _parseMessage(
|
|
Map<dynamic, dynamic> 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<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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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<String, dynamic>.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<String> sendMessage(
|
|
int accountId,
|
|
int chatId,
|
|
String text, {
|
|
bool notify = true,
|
|
}) async {
|
|
final payload = {
|
|
'chatId': chatId,
|
|
'message': {
|
|
'text': text,
|
|
'cid': DateTime.now().millisecondsSinceEpoch * -1,
|
|
'elements': [],
|
|
'attaches': [],
|
|
},
|
|
'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<TranscriptionResult> 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<FileUploadInfo?> 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<bool> sendFileMessage(
|
|
int chatId,
|
|
int fileId, {
|
|
String? token,
|
|
bool notify = true,
|
|
}) async {
|
|
final payload = {
|
|
'chatId': chatId,
|
|
'message': {
|
|
'isLive': false,
|
|
'detectShare': false,
|
|
'elements': <dynamic>[],
|
|
'cid': DateTime.now().millisecondsSinceEpoch,
|
|
'attaches': [
|
|
if (token != null)
|
|
{'_type': 'FILE', 'token': token}
|
|
else
|
|
{'_type': 'FILE', 'fileId': fileId}
|
|
],
|
|
},
|
|
'notify': notify,
|
|
};
|
|
|
|
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
|
return response.isOk;
|
|
}
|
|
|
|
Future<Uint8List?> 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<int>) return Uint8List.fromList(content);
|
|
return null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<String?> 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<Uint8List?> 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<int>) return Uint8List.fromList(content);
|
|
return null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<String?> getVideoUrl(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;
|
|
|
|
return data['content'] as String?;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<Uint8List?> 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<int>) return Uint8List.fromList(content);
|
|
return null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<String?> getFileUrl(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;
|
|
|
|
return data['content'] as String?;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<String?> 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<String>().toSet());
|
|
}
|
|
|
|
return fullName;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint('searchContactById error: $e');
|
|
}
|
|
return null;
|
|
}
|
|
}
|