Import IKAR messenger client and server

This commit is contained in:
sevenhill
2026-10-07 22:06:07 +03:00
commit 69ea5091eb
1951 changed files with 424546 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
enum AdminChatKind { channel, group }
enum AdminRight {
editInfo(8),
createPosts(32 | 256),
editPosts(512),
deletePosts(1024),
deleteMessages(1),
pinMessages(16),
manageFollowers(2 | 128),
manageMembers(2),
editLink(128),
viewStats(2048),
manageAdmins(4);
const AdminRight(this.mask);
final int mask;
}
class AdminRights {
static const int ownerBits = 4095;
static const int _groupBaseBits = 32 | 64;
final int bits;
const AdminRights(this.bits);
static List<List<AdminRight>> layoutOf(AdminChatKind kind) => switch (kind) {
AdminChatKind.channel => const [
[AdminRight.editInfo],
[
AdminRight.createPosts,
AdminRight.editPosts,
AdminRight.deletePosts,
AdminRight.pinMessages,
],
[AdminRight.manageFollowers],
[AdminRight.viewStats],
[AdminRight.manageAdmins],
],
AdminChatKind.group => const [
[AdminRight.editInfo],
[AdminRight.deleteMessages, AdminRight.pinMessages],
[AdminRight.manageMembers, AdminRight.editLink],
[AdminRight.manageAdmins],
],
};
static AdminRights appointDefaults(AdminChatKind kind) => switch (kind) {
AdminChatKind.channel => _union(layoutOf(kind).expand((group) => group), 0),
AdminChatKind.group => _union(const [
AdminRight.editInfo,
AdminRight.deleteMessages,
AdminRight.pinMessages,
AdminRight.manageMembers,
], _groupBaseBits),
};
static AdminRights _union(Iterable<AdminRight> rights, int base) =>
AdminRights(rights.fold(base, (bits, right) => bits | right.mask));
bool has(AdminRight right) => bits & right.mask == right.mask;
AdminRights withRight(AdminRight right, bool enabled) =>
AdminRights(enabled ? bits | right.mask : bits & ~right.mask);
bool isEmptyFor(AdminChatKind kind) => layoutOf(
kind,
).expand((group) => group).every((right) => bits & right.mask == 0);
@override
bool operator ==(Object other) => other is AdminRights && other.bits == bits;
@override
int get hashCode => bits.hashCode;
}
+31
View File
@@ -0,0 +1,31 @@
class Animoji {
final int id;
final String emoji;
final int setId;
final String? iconUrl;
final String? lottieUrl;
final String? lottiePlayUrl;
const Animoji({
required this.id,
required this.emoji,
this.setId = 0,
this.iconUrl,
this.lottieUrl,
this.lottiePlayUrl,
});
static Animoji? fromMap(Map<dynamic, dynamic> map) {
final id = map['id'];
final emoji = map['emoji']?.toString();
if (id is! int || emoji == null || emoji.isEmpty) return null;
return Animoji(
id: id,
emoji: emoji,
setId: map['setId'] is int ? map['setId'] as int : 0,
iconUrl: map['iconUrl']?.toString(),
lottieUrl: map['lottieUrl']?.toString(),
lottiePlayUrl: map['lottiePlayUrl']?.toString(),
);
}
}
+822
View File
@@ -0,0 +1,822 @@
import 'dart:convert';
import '../core/utils/parse.dart';
import '../core/utils/text_format.dart';
enum AttachmentType {
photo,
video,
audio,
file,
contact,
location,
sticker,
control,
poll,
share,
call,
inlineKeyboard,
forward,
unknown,
}
String? decodeAttachPreview(dynamic raw) {
if (raw is String) return raw;
if (raw is List) {
try {
final bytes = List<int>.from(raw);
return 'data:image/webp;base64,${base64Encode(bytes)}';
} catch (_) {}
}
return null;
}
abstract class MessageAttachment {
final AttachmentType type;
final String? previewData;
final String? baseUrl;
final String? fileUrl;
const MessageAttachment({
required this.type,
this.previewData,
this.baseUrl,
this.fileUrl,
});
factory MessageAttachment.fromMap(Map<String, dynamic> map) {
final type = (map['_type'] as String? ?? '').toUpperCase();
switch (type) {
case 'PHOTO':
return PhotoAttachment.fromMap(map);
case 'VIDEO':
return VideoAttachment.fromMap(map);
case 'AUDIO':
return AudioAttachment.fromMap(map);
case 'FILE':
return FileAttachment.fromMap(map);
case 'STICKER':
return StickerAttachment.fromMap(map);
case 'CONTACT':
return ContactAttachment.fromMap(map);
case 'LOCATION':
return LocationAttachment.fromMap(map);
case 'CONTROL':
return ControlAttachment.fromMap(map);
case 'POLL':
return PollAttachment.fromMap(map);
case 'CALL':
return CallAttachment.fromMap(map);
case 'SHARE':
return ShareAttachment.fromMap(map);
case 'INLINE_KEYBOARD':
return InlineKeyboardAttachment.fromMap(map);
default:
return UnknownAttachment(map);
}
}
Map<String, dynamic> toMap();
}
class PhotoAttachment extends MessageAttachment {
final int? photoId;
final String? photoToken;
final int? width;
final int? height;
final int? size;
final String? localPath;
const PhotoAttachment({
super.previewData,
super.baseUrl,
super.fileUrl,
this.photoId,
this.photoToken,
this.width,
this.height,
this.size,
this.localPath,
}) : super(type: AttachmentType.photo);
factory PhotoAttachment.fromMap(Map<String, dynamic> map) {
return PhotoAttachment(
previewData: decodeAttachPreview(map['previewData']),
baseUrl: (map['baseUrl'] ?? map['url']) as String?,
photoId: map['photoId'] as int?,
photoToken: map['photoToken'] as String?,
width: map['width'] as int?,
height: map['height'] as int?,
size: map['size'] as int?,
localPath: map['localPath'] as String?,
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'PHOTO',
'previewData': previewData,
'baseUrl': baseUrl,
'photoId': photoId,
'photoToken': photoToken,
'width': width,
'height': height,
'size': size,
'localPath': localPath,
};
}
class VideoAttachment extends MessageAttachment {
final int? videoId;
final String? videoToken;
final String? thumbnail;
final int? width;
final int? height;
final int? duration;
final int? size;
final String? localPath;
final int? videoType;
bool get isNote => videoType == 1;
const VideoAttachment({
super.previewData,
super.baseUrl,
super.fileUrl,
this.videoId,
this.videoToken,
this.thumbnail,
this.width,
this.height,
this.duration,
this.size,
this.videoType,
this.localPath,
}) : super(type: AttachmentType.video);
factory VideoAttachment.fromMap(Map<String, dynamic> map) {
return VideoAttachment(
previewData: decodeAttachPreview(map['previewData']),
baseUrl: map['baseUrl'] as String?,
videoId: map['videoId'] as int?,
videoToken: (map['token'] ?? map['videoToken'])?.toString(),
thumbnail: map['thumbnail'] as String?,
width: map['width'] as int?,
height: map['height'] as int?,
duration: map['duration'] as int?,
size: map['size'] as int?,
videoType: map['videoType'] as int?,
localPath: map['localPath'] as String?,
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'VIDEO',
'previewData': previewData,
'baseUrl': baseUrl,
'videoId': videoId,
'videoToken': videoToken,
'thumbnail': thumbnail,
'width': width,
'height': height,
'duration': duration,
'size': size,
'videoType': videoType,
'localPath': localPath,
};
}
class AudioAttachment extends MessageAttachment {
final int? audioId;
final String? audioToken;
final int? duration;
final int? size;
final String? waveform;
const AudioAttachment({
super.previewData,
super.baseUrl,
super.fileUrl,
this.audioId,
this.audioToken,
this.duration,
this.size,
this.waveform,
}) : super(type: AttachmentType.audio);
factory AudioAttachment.fromMap(Map<String, dynamic> map) {
String? waveStr;
final waveRaw = map['wave'];
if (waveRaw is String) {
waveStr = waveRaw;
} else if (waveRaw is List) {
try {
final bytes = List<int>.from(waveRaw);
waveStr = String.fromCharCodes(bytes);
} catch (_) {}
}
return AudioAttachment(
previewData: decodeAttachPreview(map['previewData']),
baseUrl: map['baseUrl']?.toString(),
fileUrl: map['url']?.toString(),
audioId: map['audioId'] as int?,
audioToken: map['token']?.toString(),
duration: map['duration'] as int?,
size: map['size'] as int?,
waveform: waveStr,
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'AUDIO',
'previewData': previewData,
'baseUrl': baseUrl,
'audioId': audioId,
'audioToken': audioToken,
'duration': duration,
'size': size,
'waveform': waveform,
};
}
class FileAttachment extends MessageAttachment {
final int? fileId;
final String? fileToken;
final String? name;
final int? size;
final PhotoAttachment? preview;
const FileAttachment({
super.previewData,
super.baseUrl,
super.fileUrl,
this.fileId,
this.fileToken,
this.name,
this.size,
this.preview,
}) : super(type: AttachmentType.file);
factory FileAttachment.fromMap(Map<String, dynamic> map) {
PhotoAttachment? preview;
final previewRaw = map['preview'];
if (previewRaw is Map) {
preview = PhotoAttachment.fromMap(Map<String, dynamic>.from(previewRaw));
}
return FileAttachment(
previewData: decodeAttachPreview(map['previewData']),
baseUrl: map['baseUrl'] as String?,
fileId: map['fileId'] as int?,
fileToken: (map['fileToken'] ?? map['token'])?.toString(),
name: map['name'] as String?,
size: map['size'] as int?,
preview: preview,
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'FILE',
'previewData': previewData,
'baseUrl': baseUrl,
'fileId': fileId,
'fileToken': fileToken,
'name': name,
'size': size,
if (preview != null) 'preview': preview!.toMap(),
};
}
class StickerAttachment extends MessageAttachment {
final String? stickerId;
final String? stickerPackId;
final String? lottieUrl;
final int? width;
final int? height;
const StickerAttachment({
super.previewData,
super.baseUrl,
super.fileUrl,
this.stickerId,
this.stickerPackId,
this.lottieUrl,
this.width,
this.height,
}) : super(type: AttachmentType.sticker);
bool get isAnimated => lottieUrl != null && lottieUrl!.isNotEmpty;
factory StickerAttachment.fromMap(Map<String, dynamic> map) {
return StickerAttachment(
previewData: decodeAttachPreview(map['previewData']),
baseUrl: (map['url'] ?? map['baseUrl'])?.toString(),
stickerId: map['stickerId']?.toString(),
stickerPackId:
map['setId']?.toString() ?? map['stickerPackId']?.toString(),
lottieUrl: map['lottieUrl']?.toString(),
width: map['width'] as int?,
height: map['height'] as int?,
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'STICKER',
'previewData': previewData,
'baseUrl': baseUrl,
'stickerId': stickerId,
'stickerPackId': stickerPackId,
'lottieUrl': lottieUrl,
'width': width,
'height': height,
};
}
class ContactAttachment extends MessageAttachment {
final String? userId;
final String? firstName;
final String? lastName;
final String? phoneNumber;
final String? photoUrl;
final int? contactId;
final String? name;
const ContactAttachment({
super.previewData,
super.baseUrl,
super.fileUrl,
this.userId,
this.firstName,
this.lastName,
this.phoneNumber,
this.photoUrl,
this.contactId,
this.name,
}) : super(type: AttachmentType.contact);
factory ContactAttachment.fromMap(Map<String, dynamic> map) {
return ContactAttachment(
previewData: map['previewData']?.toString(),
baseUrl: map['baseUrl']?.toString(),
userId: map['userId']?.toString(),
firstName: map['firstName']?.toString(),
lastName: map['lastName']?.toString(),
phoneNumber: map['phoneNumber']?.toString(),
photoUrl: map['photoUrl']?.toString(),
contactId: map['contactId'] is int
? map['contactId'] as int
: int.tryParse(map['contactId']?.toString() ?? ''),
name: map['name']?.toString(),
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'CONTACT',
'previewData': previewData,
'baseUrl': baseUrl,
'userId': userId,
'firstName': firstName,
'lastName': lastName,
'phoneNumber': phoneNumber,
'photoUrl': photoUrl,
'contactId': contactId,
'name': name,
};
}
class LocationAttachment extends MessageAttachment {
final double? latitude;
final double? longitude;
final double? zoom;
final String? title;
final String? address;
const LocationAttachment({
super.previewData,
super.baseUrl,
super.fileUrl,
this.latitude,
this.longitude,
this.zoom,
this.title,
this.address,
}) : super(type: AttachmentType.location);
factory LocationAttachment.fromMap(Map<String, dynamic> map) {
return LocationAttachment(
previewData: map['previewData'] as String?,
baseUrl: map['baseUrl'] as String?,
latitude: (map['latitude'] as num?)?.toDouble(),
longitude: (map['longitude'] as num?)?.toDouble(),
zoom: (map['zoom'] as num?)?.toDouble(),
title: map['title'] as String?,
address: map['address'] as String?,
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'LOCATION',
'previewData': previewData,
'baseUrl': baseUrl,
'latitude': latitude,
'longitude': longitude,
'zoom': zoom,
'title': title,
'address': address,
};
}
class ControlAttachment extends MessageAttachment {
static const String botStartedEvent = 'botStarted';
final String? event;
final String? title;
final List<int>? userIds;
final int? userId;
final String? startPayload;
final String? imageUrl;
final String? fullImageUrl;
const ControlAttachment({
super.previewData,
super.baseUrl,
super.fileUrl,
this.event,
this.title,
this.userIds,
this.userId,
this.startPayload,
this.imageUrl,
this.fullImageUrl,
}) : super(type: AttachmentType.control);
bool get isBotStart => event == botStartedEvent;
factory ControlAttachment.fromMap(Map<String, dynamic> map) {
String? title = map['title']?.toString();
if ((title == null || title.isEmpty) && map['shortMessage'] != null) {
title = map['shortMessage'].toString();
}
return ControlAttachment(
previewData: map['previewData']?.toString(),
baseUrl: map['baseUrl']?.toString(),
event: map['event']?.toString(),
title: title,
userIds: map['userIds'] is List ? parseIntList(map['userIds']) : null,
userId: map['userId'] is int
? map['userId'] as int
: int.tryParse(map['userId']?.toString() ?? ''),
startPayload: map['startPayload']?.toString(),
imageUrl: map['url']?.toString(),
fullImageUrl: map['fullUrl']?.toString(),
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'CONTROL',
'previewData': previewData,
'baseUrl': baseUrl,
'event': event,
'title': title,
'userIds': userIds,
'userId': userId,
'startPayload': startPayload,
'url': imageUrl,
'fullUrl': fullImageUrl,
};
}
class PollAttachment extends MessageAttachment {
final int pollId;
final String? title;
const PollAttachment({required this.pollId, this.title})
: super(type: AttachmentType.poll);
factory PollAttachment.fromMap(Map<String, dynamic> map) {
final id = map['pollId'] ?? map['id'];
return PollAttachment(
pollId: id is int ? id : int.tryParse(id?.toString() ?? '') ?? 0,
title: (map['title'] ?? map['question'])?.toString(),
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'POLL',
'pollId': pollId,
'title': title,
};
}
class CallAttachment extends MessageAttachment {
final bool isVideo;
final int durationMs;
final String? hangupType;
final String? conversationId;
final String? joinLink;
final List<int> contactIds;
const CallAttachment({
required this.isVideo,
this.durationMs = 0,
this.hangupType,
this.conversationId,
this.joinLink,
this.contactIds = const [],
}) : super(type: AttachmentType.call);
bool get isGroup => joinLink != null;
bool get isMissedOrFailed =>
durationMs == 0 ||
hangupType == 'CANCELED' ||
hangupType == 'REJECTED' ||
hangupType == 'MISSED';
factory CallAttachment.fromMap(Map<String, dynamic> map) {
return CallAttachment(
isVideo: (map['callType']?.toString().toUpperCase() == 'VIDEO'),
durationMs: (map['duration'] as num?)?.toInt() ?? 0,
hangupType: map['hangupType']?.toString(),
conversationId: map['conversationId']?.toString(),
joinLink: map['joinLink']?.toString(),
contactIds: parseIntList(map['contactIds']),
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'CALL',
'callType': isVideo ? 'VIDEO' : 'AUDIO',
'duration': durationMs,
'hangupType': hangupType,
'conversationId': conversationId,
if (joinLink != null) 'joinLink': joinLink,
if (contactIds.isNotEmpty) 'contactIds': contactIds,
};
}
class ShareAttachment extends MessageAttachment {
final int? shareId;
final String? title;
final String? description;
final String? url;
final String? host;
final PhotoAttachment? image;
const ShareAttachment({
this.shareId,
this.title,
this.description,
this.url,
this.host,
this.image,
}) : super(type: AttachmentType.share);
factory ShareAttachment.fromMap(Map<String, dynamic> map) {
PhotoAttachment? image;
final imageRaw = map['image'];
if (imageRaw is Map) {
image = PhotoAttachment.fromMap(Map<String, dynamic>.from(imageRaw));
}
return ShareAttachment(
shareId: map['shareId'] as int?,
title: map['title']?.toString(),
description: map['description']?.toString(),
url: map['url']?.toString(),
host: map['host']?.toString(),
image: image,
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'SHARE',
'shareId': shareId,
'title': title,
'description': description,
'url': url,
'host': host,
if (image != null) 'image': image!.toMap(),
};
}
class InlineKeyboardButton {
final String type;
final String text;
final String? url;
final String? webApp;
final int? contactId;
final String? payload;
const InlineKeyboardButton({
required this.type,
required this.text,
this.url,
this.webApp,
this.contactId,
this.payload,
});
factory InlineKeyboardButton.fromMap(Map<String, dynamic> map) {
return InlineKeyboardButton(
type: (map['type'] as String? ?? '').toUpperCase(),
text: map['text']?.toString() ?? '',
url: map['url']?.toString(),
webApp: map['webApp']?.toString(),
contactId: map['contactId'] is int
? map['contactId'] as int
: int.tryParse(map['contactId']?.toString() ?? ''),
payload: map['payload']?.toString(),
);
}
Map<String, dynamic> toMap() => {
'type': type,
'text': text,
if (url != null) 'url': url,
if (webApp != null) 'webApp': webApp,
if (contactId != null) 'contactId': contactId,
if (payload != null) 'payload': payload,
};
}
class InlineKeyboardAttachment extends MessageAttachment {
final String? callbackId;
final List<List<InlineKeyboardButton>> rows;
const InlineKeyboardAttachment({this.callbackId, required this.rows})
: super(type: AttachmentType.inlineKeyboard);
bool get isEmpty => rows.every((row) => row.isEmpty);
factory InlineKeyboardAttachment.fromMap(Map<String, dynamic> map) {
final keyboard = map['keyboard'];
final rawRows = keyboard is Map ? keyboard['buttons'] as List? : null;
final rows = <List<InlineKeyboardButton>>[];
if (rawRows != null) {
for (final row in rawRows) {
if (row is! List) continue;
rows.add(
row
.whereType<Map>()
.map(
(b) =>
InlineKeyboardButton.fromMap(Map<String, dynamic>.from(b)),
)
.toList(),
);
}
}
return InlineKeyboardAttachment(
callbackId: map['callbackId']?.toString(),
rows: rows,
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'INLINE_KEYBOARD',
if (callbackId != null) 'callbackId': callbackId,
'keyboard': {
'buttons': rows.map((row) => row.map((b) => b.toMap()).toList()).toList(),
},
};
}
class ForwardedMessageAttachment extends MessageAttachment {
final int originalSenderId;
final String? originalSenderName;
final String? originalSenderAvatar;
final String? originalType;
final String? originalMessageId;
final int? originalTime;
final String? originalText;
final int? originalChatId;
final String? originalChatAccess;
final List<FormatRange> originalFormatRanges;
final List<MessageAttachment>? originalAttachments;
final ContactAttachment? originalContact;
const ForwardedMessageAttachment({
required this.originalSenderId,
this.originalSenderName,
this.originalSenderAvatar,
this.originalType,
this.originalMessageId,
this.originalTime,
this.originalText,
this.originalChatId,
this.originalChatAccess,
this.originalFormatRanges = const [],
this.originalAttachments,
this.originalContact,
}) : super(type: AttachmentType.forward);
bool get isChannel => originalType == 'CHANNEL';
bool get isFromPrivateChat => originalChatAccess == 'PRIVATE';
factory ForwardedMessageAttachment.fromMap(Map<String, dynamic> map) {
final linkRaw = map['link'];
Map<String, dynamic>? link;
if (linkRaw is Map) {
link = Map<String, dynamic>.from(linkRaw);
}
Map<String, dynamic>? message;
if (link != null) {
final msgRaw = link['message'];
if (msgRaw is Map) {
message = Map<String, dynamic>.from(msgRaw);
}
}
List<MessageAttachment>? originalAttaches;
ContactAttachment? originalContact;
if (message != null) {
final attaches = message['attaches'] as List?;
if (attaches != null) {
final contactAttaches = attaches
.whereType<Map>()
.where((a) => (a['_type'] as String?)?.toUpperCase() == 'CONTACT')
.toList();
if (contactAttaches.isNotEmpty) {
originalContact = ContactAttachment.fromMap(
Map<String, dynamic>.from(contactAttaches.first),
);
}
originalAttaches = attaches
.whereType<Map>()
.where((a) {
final type = (a['_type'] as String?)?.toUpperCase();
return type != 'CONTACT';
})
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
.toList();
}
}
final originalType = message?['type']?.toString().toUpperCase();
final isChannel = originalType == 'CHANNEL';
final channelName = link?['chatName']?.toString().trim();
final channelAvatar = link?['chatIconUrl']?.toString().trim();
return ForwardedMessageAttachment(
originalSenderId: parseIntOrNull(message?['sender']) ?? 0,
originalSenderName:
isChannel && channelName != null && channelName.isNotEmpty
? channelName
: null,
originalSenderAvatar:
isChannel && channelAvatar != null && channelAvatar.isNotEmpty
? channelAvatar
: null,
originalType: originalType,
originalMessageId: message?['id']?.toString(),
originalTime: parseIntOrNull(message?['time']),
originalText: message?['text']?.toString(),
originalChatId: parseIntOrNull(link?['chatId']),
originalChatAccess: link?['chatAccessType']?.toString().toUpperCase(),
originalFormatRanges: parseFormatElements(message?['elements']),
originalAttachments: originalAttaches,
originalContact: originalContact,
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'FORWARD',
'originalSenderId': originalSenderId,
'originalSenderName': originalSenderName,
'originalSenderAvatar': originalSenderAvatar,
'originalType': originalType,
'originalMessageId': originalMessageId,
'originalTime': originalTime,
'originalText': originalText,
'originalChatId': originalChatId,
'originalChatAccess': originalChatAccess,
'originalElements': serializeFormatElements(originalFormatRanges),
};
}
class UnknownAttachment extends MessageAttachment {
final Map<String, dynamic> rawData;
const UnknownAttachment(this.rawData) : super(type: AttachmentType.unknown);
@override
Map<String, dynamic> toMap() => rawData;
}
+109
View File
@@ -0,0 +1,109 @@
import '../core/utils/text_format.dart';
import 'contact_info.dart';
class BotCommand {
final String name;
final String? description;
const BotCommand({required this.name, this.description});
factory BotCommand.fromMap(Map map) => BotCommand(
name: map['name']?.toString() ?? '',
description: (map['description'] as String?)?.trim().isNotEmpty == true
? (map['description'] as String).trim()
: null,
);
String get slash => '/$name';
}
// #***! данные бота плюс карточка контакта
class BotStartMessage {
final String text;
final List<FormatRange> ranges;
const BotStartMessage({required this.text, this.ranges = const []});
static BotStartMessage? fromPayload(Object? raw) {
if (raw is! Map) return null;
final body = raw['text'];
if (body is! Map) return null;
final text = body['text'];
if (text is! String || text.trim().isEmpty) return null;
return BotStartMessage(
text: text,
ranges: parseFormatElements(body['elements']),
);
}
({String heading, String body, List<FormatRange> bodyRanges}) get sections {
var split = 0;
for (final range in ranges) {
if (range.format == TextFormat.heading && range.start == 0) {
split = range.end.clamp(0, text.length);
break;
}
}
final rest = text.substring(split);
final bodyStart = split + rest.length - rest.trimLeft().length;
final body = text.substring(bodyStart).trimRight();
return (
heading: text.substring(0, split).trim(),
body: body,
bodyRanges: [
for (final range in ranges)
if (range.format != TextFormat.heading &&
range.start >= bodyStart &&
range.start - bodyStart < body.length)
FormatRange(
format: range.format,
start: range.start - bodyStart,
length: range.end - bodyStart > body.length
? body.length - (range.start - bodyStart)
: range.length,
entityId: range.entityId,
entityName: range.entityName,
attributes: range.attributes,
),
],
);
}
}
class BotInfo {
final int botId;
final List<BotCommand> commands;
final ContactInfo? contact;
final BotStartMessage? startMessage;
const BotInfo({
required this.botId,
required this.commands,
this.contact,
this.startMessage,
});
factory BotInfo.fromPayload(int botId, Map<String, dynamic> payload) {
final rawCommands = payload['commands'];
final commands = <BotCommand>[];
if (rawCommands is List) {
for (final c in rawCommands.whereType<Map>()) {
final command = BotCommand.fromMap(c);
if (command.name.isNotEmpty) commands.add(command);
}
}
final rawContact = payload['contact'];
return BotInfo(
botId: botId,
commands: commands,
contact: rawContact is Map
? ContactInfo.fromMap(Map<String, dynamic>.from(rawContact))
: null,
startMessage: BotStartMessage.fromPayload(payload['startMessage']),
);
}
String? get description => contact?.raw['description'] as String?;
String? get link => contact?.raw['link'] as String?;
}
+60
View File
@@ -0,0 +1,60 @@
import 'dart:convert';
import '../core/utils/parse.dart';
class ChatCall {
final String joinLink;
final bool isVideo;
final int participantsCount;
final List<int> participantIds;
const ChatCall({
required this.joinLink,
required this.isVideo,
required this.participantsCount,
this.participantIds = const [],
});
static ChatCall? fromServer(Object? raw) {
if (raw is! Map) return null;
final joinLink = raw['joinLink']?.toString() ?? '';
if (joinLink.isEmpty) return null;
final participantIds = parseIntList(raw['previewParticipantIds']);
final approxCount = parseIntOrNull(raw['approxParticipantsCount']) ?? 0;
final participantsCount = approxCount > 0
? approxCount
: participantIds.length;
if (participantsCount <= 0) return null;
return ChatCall(
joinLink: joinLink,
isVideo: raw['callType']?.toString().toUpperCase() == 'VIDEO',
participantsCount: participantsCount,
participantIds: participantIds,
);
}
String encode() => jsonEncode({
'l': joinLink,
if (isVideo) 'v': true,
'c': participantsCount,
if (participantIds.isNotEmpty) 'p': participantIds,
});
static ChatCall? decode(String? raw) {
if (raw == null || raw.isEmpty) return null;
try {
final map = jsonDecode(raw);
if (map is! Map) return null;
final joinLink = map['l']?.toString() ?? '';
if (joinLink.isEmpty) return null;
return ChatCall(
joinLink: joinLink,
isVideo: map['v'] == true,
participantsCount: parseIntOrNull(map['c']) ?? 0,
participantIds: parseIntList(map['p']),
);
} catch (_) {
return null;
}
}
}
+90
View File
@@ -0,0 +1,90 @@
import 'admin_rights.dart';
// #***! расширенная карточка чата
class ChatInfo {
final Map<String, dynamic> raw;
final List<int> participantIds;
final Set<int> adminIds;
final int? owner;
const ChatInfo({
required this.raw,
required this.participantIds,
required this.adminIds,
required this.owner,
});
factory ChatInfo.fromMap(Map<String, dynamic> map) {
return ChatInfo(
raw: map,
participantIds: _idKeys(map['participants']),
adminIds: _idKeys(map['adminParticipants']).toSet(),
owner: map['owner'] as int?,
);
}
bool isAdmin(int id) => adminIds.contains(id);
bool isOwner(int id) => owner != null && id == owner;
bool option(String name) {
final opts = raw['options'];
return opts is Map && opts[name] == true;
}
bool canSeeInviteLink(int id) =>
isAdmin(id) || isOwner(id) || option('MEMBERS_CAN_SEE_PRIVATE_LINK');
bool get isPublic => raw['access'] == 'PUBLIC';
bool canSeeLink(int id) => isPublic || canSeeInviteLink(id);
String? adminAlias(int id) {
final entry = _adminEntry(id);
if (entry == null) return null;
final alias = entry['alias'];
if (alias is String && alias.trim().isNotEmpty) return alias.trim();
return null;
}
AdminRights rightsOf(int id) {
if (isOwner(id)) {
return const AdminRights(AdminRights.ownerBits);
}
final permissions = _adminEntry(id)?['permissions'];
return AdminRights(permissions is int ? permissions : 0);
}
bool can(int id, AdminRight right) =>
isOwner(id) || (isAdmin(id) && rightsOf(id).has(right));
Map<dynamic, dynamic>? _adminEntry(int id) {
final source = raw['adminParticipants'];
if (source is! Map) return null;
final entry = source[id.toString()] ?? source[id];
return entry is Map ? entry : null;
}
int? get participantsCount => raw['participantsCount'] as int?;
int? get blockedParticipantsCount => raw['blockedParticipantsCount'] as int?;
// #***! счётчик заявок на вступление, приходит в объекте чата
int get pendingJoinRequestsCount =>
(raw['pendingJoinRequestsCount'] as int?) ?? 0;
String? get link => raw['link'] as String?;
bool get joinRequests => option('JOIN_REQUEST');
bool get commentsEnabled => option('COMMENTS');
String? get title => raw['title'] as String?;
String? get iconUrl => raw['baseIconUrl'] as String?;
AdminChatKind get adminKind =>
raw['type'] == 'CHANNEL' ? AdminChatKind.channel : AdminChatKind.group;
String? get description => raw['description'] as String?;
static List<int> _idKeys(Object? source) {
if (source is! Map) return const [];
final out = <int>[];
for (final key in source.keys) {
final id = key is int ? key : int.tryParse(key.toString());
if (id != null) out.add(id);
}
return out;
}
}
+108
View File
@@ -0,0 +1,108 @@
import 'dart:convert';
enum ChatPreviewKind {
photo,
video,
videoNote,
audio,
file,
sticker,
contact,
location,
poll,
share,
call,
missedCall,
videoCall,
missedVideoCall,
control,
other,
}
const Map<ChatPreviewKind, String> _kindToCode = {
ChatPreviewKind.photo: 'photo',
ChatPreviewKind.video: 'video',
ChatPreviewKind.videoNote: 'videoNote',
ChatPreviewKind.audio: 'audio',
ChatPreviewKind.file: 'file',
ChatPreviewKind.sticker: 'sticker',
ChatPreviewKind.contact: 'contact',
ChatPreviewKind.location: 'location',
ChatPreviewKind.poll: 'poll',
ChatPreviewKind.share: 'share',
ChatPreviewKind.call: 'call',
ChatPreviewKind.missedCall: 'missedCall',
ChatPreviewKind.videoCall: 'videoCall',
ChatPreviewKind.missedVideoCall: 'missedVideoCall',
ChatPreviewKind.control: 'control',
ChatPreviewKind.other: 'other',
};
final Map<String, ChatPreviewKind> _codeToKind = {
for (final e in _kindToCode.entries) e.value: e.key,
};
class ChatPreviewThumb {
final String source;
final bool video;
const ChatPreviewThumb({required this.source, this.video = false});
Map<String, dynamic> toMap() => {'s': source, if (video) 'v': true};
static ChatPreviewThumb? fromMap(dynamic raw) {
if (raw is! Map) return null;
final source = raw['s']?.toString();
if (source == null || source.isEmpty) return null;
return ChatPreviewThumb(source: source, video: raw['v'] == true);
}
}
class ChatPreviewMedia {
final ChatPreviewKind kind;
final List<ChatPreviewThumb> thumbs;
final String? label;
final String? detail;
const ChatPreviewMedia({
required this.kind,
this.thumbs = const [],
this.label,
this.detail,
});
bool get captioned => label == null && detail == null;
Map<String, dynamic> toMap() => {
'k': _kindToCode[kind],
if (thumbs.isNotEmpty) 't': [for (final thumb in thumbs) thumb.toMap()],
if (label != null) 'l': label,
if (detail != null) 'd': detail,
};
String encode() => jsonEncode(toMap());
static ChatPreviewMedia? decode(String? raw) {
if (raw == null || raw.isEmpty) return null;
try {
return fromMap(jsonDecode(raw));
} catch (_) {
return null;
}
}
static ChatPreviewMedia? fromMap(dynamic raw) {
if (raw is! Map) return null;
final kind = _codeToKind[raw['k']?.toString()];
if (kind == null) return null;
final thumbsRaw = raw['t'];
return ChatPreviewMedia(
kind: kind,
thumbs: thumbsRaw is List
? [for (final item in thumbsRaw) ?ChatPreviewThumb.fromMap(item)]
: const [],
label: raw['l']?.toString(),
detail: raw['d']?.toString(),
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import '../core/utils/emoji_keyword_index.dart';
class ChatReactionSettings {
static const int maxCount = 8;
final bool isActive;
final int count;
final bool included;
final List<String> reactionIds;
const ChatReactionSettings({
required this.isActive,
required this.count,
required this.included,
required this.reactionIds,
});
static ChatReactionSettings? fromMap(Object? raw) {
if (raw is! Map) return null;
final count = raw['count'];
final ids = raw['reactionIds'];
return ChatReactionSettings(
isActive: raw['isActive'] != false,
count: count is int ? count.clamp(1, maxCount) : maxCount,
included: raw['included'] == true,
reactionIds: ids is List
? ids.map((id) => id.toString()).toList(growable: false)
: const [],
);
}
bool get restricted => included || reactionIds.isNotEmpty;
bool Function(String emoji) filterFor(Iterable<String> present) {
if (!isActive) return (_) => false;
final listed = reactionIds.map(EmojiKeywordIndex.normalize).toSet();
final onMessage = present.map(EmojiKeywordIndex.normalize).toSet();
final full = onMessage.length >= count;
return (emoji) {
final key = EmojiKeywordIndex.normalize(emoji);
if (listed.contains(key) != included) return false;
return !full || onMessage.contains(key);
};
}
static List<String> presentWithoutMine(Object? reactionInfo) {
if (reactionInfo is! Map) return const [];
final counters = reactionInfo['counters'];
if (counters is! List) return const [];
final mine = reactionInfo['yourReaction']?.toString();
final mineKey = mine == null ? null : EmojiKeywordIndex.normalize(mine);
return [
for (final counter in counters)
if (counter is Map && counter['reaction'] != null)
if (EmojiKeywordIndex.normalize(counter['reaction'].toString()) !=
mineKey ||
(counter['count'] is int && (counter['count'] as int) > 1))
counter['reaction'].toString(),
];
}
bool allows(String emoji) => filterFor(const [])(emoji);
bool allowsOn(String emoji, Iterable<String> present) =>
filterFor(present)(emoji);
Set<String> allowedOf(Iterable<String> catalog) {
final listed = reactionIds.map(EmojiKeywordIndex.normalize).toSet();
return {
for (final emoji in catalog)
if (listed.contains(EmojiKeywordIndex.normalize(emoji)) == included)
emoji,
};
}
static List<String> forbiddenOf(
Iterable<String> catalog,
Set<String> allowed,
) => [
for (final emoji in catalog)
if (!allowed.contains(emoji)) emoji,
];
}
+15
View File
@@ -0,0 +1,15 @@
import 'chat_info.dart';
enum ChatRestriction {
forwardDisabled('DISABLE_FORWARD'),
copyDisabled('MESSAGE_COPY_NOT_ALLOWED'),
confirmBeforeSend('CONFIRM_BEFORE_SEND');
const ChatRestriction(this.option);
final String option;
bool enabledIn(ChatInfo info) => info.option(option);
Map<String, bool> optionFor(bool enabled) => {option: enabled};
}
+101
View File
@@ -0,0 +1,101 @@
class ContactName {
final String? type;
final String? name;
final String? firstName;
final String? lastName;
const ContactName({this.type, this.name, this.firstName, this.lastName});
factory ContactName.fromMap(Map map) => ContactName(
type: map['type']?.toString(),
name: map['name']?.toString(),
firstName: map['firstName']?.toString(),
lastName: map['lastName']?.toString(),
);
String? get label {
final n = name;
if (n != null && n.trim().isNotEmpty) return n.trim();
return fullName;
}
String? get fullName {
final combined = [firstName, lastName]
.where((s) => s != null && s.trim().isNotEmpty)
.map((s) => s!.trim())
.join(' ');
if (combined.isNotEmpty) return combined;
final n = name;
return (n != null && n.trim().isNotEmpty) ? n.trim() : null;
}
}
class ContactInfo {
final Map<String, dynamic> raw;
final List<ContactName> names;
const ContactInfo({required this.raw, required this.names});
factory ContactInfo.fromMap(Map<String, dynamic> map) {
final rawNames = map['names'];
final names = <ContactName>[];
if (rawNames is List) {
for (final n in rawNames) {
if (n is Map) names.add(ContactName.fromMap(n));
}
}
return ContactInfo(raw: map, names: names);
}
String? get displayName {
String? firstLabel;
for (final n in names) {
final label = n.label;
if (label == null) continue;
firstLabel ??= label;
if (n.type == 'ONEME') return label;
}
return firstLabel;
}
String? get customFullName => _fullNameOfType('CUSTOM');
String? get onemeFullName => _fullNameOfType('ONEME');
String? get fullName => customFullName ?? onemeFullName ?? displayName;
bool get isSavedContact => customFullName != null;
String? _fullNameOfType(String type) {
for (final n in names) {
if (n.type != type) continue;
final full = n.fullName;
if (full != null) return full;
}
return null;
}
String? get firstName {
for (final n in names) {
final f = n.firstName;
if (f != null && f.trim().isNotEmpty) return f.trim();
}
return null;
}
String? get avatarUrl => raw['baseUrl'] as String?;
List<String> get options {
final o = raw['options'];
return o is List ? o.whereType<String>().toList() : const [];
}
bool get isBot => options.contains('BOT');
bool get isDeleted {
final status = raw['accountStatus'];
return status is int && status != 0;
}
int? get id => raw['id'] as int?;
}
+272
View File
@@ -0,0 +1,272 @@
enum DigitalIdVerification {
valid,
invalid,
photoCreated,
notFound,
deviceMismatch,
liteValid,
unknown;
static DigitalIdVerification fromValue(String? value) {
switch (value) {
case 'valid':
return DigitalIdVerification.valid;
case 'invalid':
return DigitalIdVerification.invalid;
case 'photo_created':
return DigitalIdVerification.photoCreated;
case 'not_found':
return DigitalIdVerification.notFound;
case 'device_mismatch':
return DigitalIdVerification.deviceMismatch;
case 'lite_valid':
return DigitalIdVerification.liteValid;
default:
return DigitalIdVerification.unknown;
}
}
}
enum DigitalIdQrType {
m1('M1', 'max_confirm_age'),
m2('M2', 'max_cert_large_family'),
m3('M3', 'max_student_ticket'),
m4('M4', 'max_sor'),
m5('M5', 'max_single_benefits'),
m7('M7', 'max_invalid_certificate'),
m8('M8', 'max_pension_certificate'),
m11('M11', 'max_identify_verification');
const DigitalIdQrType(this.code, this.alias);
final String code;
final String alias;
}
class DigitalIdAddress {
final String? address;
final String? flat;
final String? frame;
final String? house;
final String? zipCode;
const DigitalIdAddress({
this.address,
this.flat,
this.frame,
this.house,
this.zipCode,
});
factory DigitalIdAddress.fromMap(Map map) {
return DigitalIdAddress(
address: map['address'] as String?,
flat: map['flat'] as String?,
frame: map['frame'] as String?,
house: map['house'] as String?,
zipCode: map['zip_code'] as String?,
);
}
String get formatted {
final parts = <String>[
if (address != null && address!.isNotEmpty) address!,
if (house != null && house!.isNotEmpty) 'д. $house',
if (frame != null && frame!.isNotEmpty) 'к. $frame',
if (flat != null && flat!.isNotEmpty) 'кв. $flat',
];
return parts.join(', ');
}
}
class DigitalIdBiometryStatus {
final bool hasBiometryToken;
final String? deviceId;
final bool hasPhotoHash;
const DigitalIdBiometryStatus({
required this.hasBiometryToken,
required this.deviceId,
required this.hasPhotoHash,
});
factory DigitalIdBiometryStatus.fromMap(Map map) {
return DigitalIdBiometryStatus(
hasBiometryToken: map['has_biometry_token'] == true,
deviceId: map['device_id'] as String?,
hasPhotoHash: map['has_photo_hash'] == true,
);
}
}
class DigitalIdDocument {
final String type;
final Map<String, dynamic> fields;
const DigitalIdDocument({required this.type, required this.fields});
factory DigitalIdDocument.fromMap(Map map) {
final fields = <String, dynamic>{};
for (final entry in map.entries) {
fields[entry.key.toString()] = entry.value;
}
return DigitalIdDocument(
type: (map['type'] as String?) ?? 'unknown',
fields: fields,
);
}
String? get firstName => fields['first_name'] as String?;
String? get lastName => fields['last_name'] as String?;
String? get middleName => fields['middle_name'] as String?;
String? get number => fields['number'] as String?;
String? get series => fields['series'] as String?;
}
class DigitalIdProfile {
final String? firstName;
final String? lastName;
final String? middleName;
final String? birthDate;
final String? birthPlace;
final String? gender;
final String? snils;
final String? inn;
final DigitalIdAddress? registrationAddress;
final List<DigitalIdDocument> documents;
const DigitalIdProfile({
this.firstName,
this.lastName,
this.middleName,
this.birthDate,
this.birthPlace,
this.gender,
this.snils,
this.inn,
this.registrationAddress,
this.documents = const [],
});
factory DigitalIdProfile.fromMap(Map map) {
final rawDocs = map['documents'];
final documents = <DigitalIdDocument>[];
if (rawDocs is List) {
for (final doc in rawDocs) {
if (doc is Map) documents.add(DigitalIdDocument.fromMap(doc));
}
}
final address = map['registration_address'];
return DigitalIdProfile(
firstName: map['first_name'] as String?,
lastName: map['last_name'] as String?,
middleName: map['middle_name'] as String?,
birthDate: map['birth_date'] as String?,
birthPlace: map['birth_place'] as String?,
gender: map['gender'] as String?,
snils: map['snils'] as String?,
inn: map['inn'] as String?,
registrationAddress: address is Map
? DigitalIdAddress.fromMap(address)
: null,
documents: documents,
);
}
String get fullName {
final parts = <String>[
if (lastName != null && lastName!.isNotEmpty) lastName!,
if (firstName != null && firstName!.isNotEmpty) firstName!,
if (middleName != null && middleName!.isNotEmpty) middleName!,
];
return parts.join(' ');
}
}
class DigitalIdUserDocs {
final int userId;
final DigitalIdProfile profile;
const DigitalIdUserDocs({required this.userId, required this.profile});
factory DigitalIdUserDocs.fromMap(Map map) {
final profile = map['digital_profile'];
return DigitalIdUserDocs(
userId: (map['user_id'] as num?)?.toInt() ?? 0,
profile: profile is Map
? DigitalIdProfile.fromMap(profile)
: const DigitalIdProfile(),
);
}
}
class DigitalIdEsiaLink {
final String? state;
final String url;
const DigitalIdEsiaLink({this.state, required this.url});
factory DigitalIdEsiaLink.fromMap(Map map) {
return DigitalIdEsiaLink(
state: map['state'] as String?,
url: map['url'] as String? ?? '',
);
}
}
class DigitalIdQr {
final String qr;
final String? qrGost;
const DigitalIdQr({required this.qr, this.qrGost});
factory DigitalIdQr.fromMap(Map map) {
return DigitalIdQr(
qr: map['qr'] as String? ?? '',
qrGost: map['qr_gost'] as String?,
);
}
}
class DigitalIdUniversalQr {
final String uidHash;
final String? phone;
final String? sessionId;
const DigitalIdUniversalQr({
required this.uidHash,
this.phone,
this.sessionId,
});
factory DigitalIdUniversalQr.fromMap(Map map) {
return DigitalIdUniversalQr(
uidHash: map['uid_hash'] as String? ?? '',
phone: map['phone'] as String?,
sessionId: map['session_id'] as String?,
);
}
}
class DigitalIdAcmsCard {
final String id;
final String inn;
final String companyName;
final String logoImg;
const DigitalIdAcmsCard({
required this.id,
required this.inn,
required this.companyName,
required this.logoImg,
});
factory DigitalIdAcmsCard.fromMap(Map map) {
return DigitalIdAcmsCard(
id: map['id'] as String? ?? '',
inn: map['inn'] as String? ?? '',
companyName: map['company_name'] as String? ?? '',
logoImg: map['logo_img'] as String? ?? '',
);
}
}
+115
View File
@@ -0,0 +1,115 @@
abstract class BannerType {
static const int text = 0;
static const int link = 1;
static const int update = 2;
}
abstract class BannerSettings {
static const int textAnimation = 1;
static const int hideCloseButton = 2;
static const int hideOnClick = 4;
static const int iconThemeColor = 8;
}
class InformerBanner {
final String id;
final String title;
final String description;
final int settings;
final int priority;
final int repeat;
final int rerun;
final int? animojiId;
final String? url;
final int type;
const InformerBanner({
required this.id,
required this.title,
this.description = '',
this.settings = 0,
this.priority = 0,
this.repeat = 0,
this.rerun = 0,
this.animojiId,
this.url,
this.type = BannerType.text,
});
bool get animatesText => settings & BannerSettings.textAnimation != 0;
bool get hidesCloseButton => settings & BannerSettings.hideCloseButton != 0;
bool get closesOnClick => settings & BannerSettings.hideOnClick != 0;
bool get tintsIconWithTheme => settings & BannerSettings.iconThemeColor != 0;
bool get isLink =>
type == BannerType.link && url != null && url!.trim().isNotEmpty;
bool get isUpdate => type == BannerType.update;
bool get isClickable => isLink || isUpdate;
static InformerBanner? fromMap(Map<dynamic, dynamic> map) {
final id = map['id']?.toString();
if (id == null || id.isEmpty) return null;
final title = map['title']?.toString() ?? '';
final description = map['description']?.toString() ?? '';
if (title.isEmpty && description.isEmpty) return null;
return InformerBanner(
id: id,
title: title,
description: description,
settings: _int(map['settings']) ?? 0,
priority: _int(map['priority']) ?? 0,
repeat: _int(map['repeat']) ?? 0,
rerun: _int(map['rerun']) ?? 0,
animojiId: _int(map['animojiId']),
url: map['url']?.toString(),
type: _int(map['type']) ?? BannerType.text,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'description': description,
'settings': settings,
'priority': priority,
'repeat': repeat,
'rerun': rerun,
'animojiId': animojiId,
'url': url,
'type': type,
};
static int? _int(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}
}
class BannerShowState {
final int showCounter;
final int? showAt;
final int? closedAt;
const BannerShowState({this.showCounter = 0, this.showAt, this.closedAt});
BannerShowState copyWith({int? showCounter, int? showAt, int? closedAt}) =>
BannerShowState(
showCounter: showCounter ?? this.showCounter,
showAt: showAt ?? this.showAt,
closedAt: closedAt ?? this.closedAt,
);
static BannerShowState fromJson(Map<dynamic, dynamic> map) => BannerShowState(
showCounter: InformerBanner._int(map['showCounter']) ?? 0,
showAt: InformerBanner._int(map['showAt']),
closedAt: InformerBanner._int(map['closedTime']),
);
Map<String, dynamic> toJson() => {
'showCounter': showCounter,
'showAt': showAt,
'closedTime': closedAt,
};
}
+141
View File
@@ -0,0 +1,141 @@
class LoginInfo {
const LoginInfo._();
static Map<String, dynamic> fromPayload(Map<dynamic, dynamic> payload) {
final profile = _asMap(payload['profile']);
final contact = _asMap(profile?['contact']);
final chats = _asList(payload['chats']);
final config = _asMap(payload['config']);
final server = _asMap(config?['server']);
final user = _asMap(config?['user']);
final experiments = _asMap(config?['experiments']);
final chatSettings = _asMap(config?['chats']);
return {
'registrationTime': contact?['registrationTime'],
'country': contact?['country'],
'videoChatHistory': payload['videoChatHistory'],
'updateTime': contact?['updateTime'],
'id': contact?['id'],
'phone': contact?['phone'],
'photoId': contact?['photoId'],
'accountStatus': contact?['accountStatus'],
'contactOptions': _copyJsonValue(contact?['options']),
'profileOptions': _copyJsonValue(profile?['profileOptions']),
'names': _copyJsonValue(contact?['names']),
'baseUrl': contact?['baseUrl'],
'baseRawUrl': contact?['baseRawUrl'],
'chatMarker': payload['chatMarker'] ?? _latestChatEventTime(chats),
'time': payload['time'],
'updates': payload['updates'],
'messagesCount': _collectionLength(payload['messages']),
'contactsCount': _collectionLength(payload['contacts']),
'presenceCount': _collectionLength(payload['presence']),
'configHash': config?['hash'],
'chats': _buildChatsSummary(chats),
'server': _copyMap(server),
'user': _copyMap(user),
'experiments': _copyMap(experiments),
'chatSettings': _copyMap(chatSettings),
};
}
static Map<String, dynamic> _buildChatsSummary(List<dynamic> chats) {
var active = 0;
var hidden = 0;
var dialogs = 0;
var groups = 0;
var channels = 0;
var unread = 0;
var newMessages = 0;
var messages = 0;
for (final rawChat in chats) {
final chat = _asMap(rawChat);
if (chat == null) continue;
switch (chat['status']) {
case 'ACTIVE':
active++;
case 'HIDDEN':
hidden++;
}
switch (chat['type']) {
case 'DIALOG':
dialogs++;
case 'CHAT':
groups++;
case 'CHANNEL':
channels++;
}
final chatNewMessages = _asInt(chat['newMessages']) ?? 0;
if (chatNewMessages > 0) unread++;
newMessages += chatNewMessages;
messages += _asInt(chat['messagesCount']) ?? 0;
}
return {
'count': chats.length,
'active': active,
'hidden': hidden,
'dialogs': dialogs,
'groups': groups,
'channels': channels,
'unread': unread,
'newMessages': newMessages,
'messages': messages,
};
}
static int? _latestChatEventTime(List<dynamic> chats) {
int? latest;
for (final rawChat in chats) {
final chat = _asMap(rawChat);
final value = _asInt(chat?['lastEventTime']);
if (value != null && (latest == null || value > latest)) {
latest = value;
}
}
return latest;
}
static Map<dynamic, dynamic>? _asMap(dynamic value) {
return value is Map ? value : null;
}
static List<dynamic> _asList(dynamic value) {
return value is List ? value : const [];
}
static int? _asInt(dynamic value) {
return switch (value) {
int number => number,
num number => number.toInt(),
String text => int.tryParse(text),
_ => null,
};
}
static int _collectionLength(dynamic value) {
return switch (value) {
Map items => items.length,
List items => items.length,
_ => 0,
};
}
static Map<String, dynamic>? _copyMap(Map<dynamic, dynamic>? value) {
if (value == null) return null;
return value.map(
(key, item) => MapEntry(key.toString(), _copyJsonValue(item)),
);
}
static dynamic _copyJsonValue(dynamic value) {
if (value is Map) return _copyMap(value);
if (value is List) return value.map(_copyJsonValue).toList();
if (value == null || value is String || value is num || value is bool) {
return value;
}
return value.toString();
}
}
+18
View File
@@ -0,0 +1,18 @@
import 'chat_info.dart';
enum MemberPermission {
editInfo('ONLY_OWNER_CAN_CHANGE_ICON_TITLE', inverted: true),
addMembers('ONLY_ADMIN_CAN_ADD_MEMBER', inverted: true),
pinMessages('ALL_CAN_PIN_MESSAGE'),
inviteByLink('MEMBERS_CAN_SEE_PRIVATE_LINK'),
call('ONLY_ADMIN_CAN_CALL', inverted: true);
const MemberPermission(this.option, {this.inverted = false});
final String option;
final bool inverted;
bool allowedIn(ChatInfo info) => info.option(option) != inverted;
Map<String, bool> optionFor(bool allowed) => {option: allowed != inverted};
}
+140
View File
@@ -0,0 +1,140 @@
class PollAnswer {
final int answerId;
final String text;
final int voteCount;
final double rate;
final List<int> votes;
final bool mine;
const PollAnswer({
required this.answerId,
required this.text,
this.voteCount = 0,
this.rate = 0,
this.votes = const [],
this.mine = false,
});
}
class Poll {
final int pollId;
final String title;
final int settings;
final int version;
final int total;
final List<PollAnswer> answers;
final List<int> voterPreviewIds;
const Poll({
required this.pollId,
required this.title,
this.settings = 0,
this.version = 0,
this.total = 0,
this.answers = const [],
this.voterPreviewIds = const [],
});
bool get isMultiple => settings & 0x1 != 0;
bool get hasMyVote => answers.any((a) => a.mine);
bool votedBy(int userId) =>
answers.any((a) => a.mine || a.votes.contains(userId));
static List<int> _parseVoterIds(dynamic votes) {
if (votes is! List) return const [];
final ids = <int>[];
for (final v in votes) {
if (v is int) {
ids.add(v);
} else if (v is Map && v['userId'] is int) {
ids.add(v['userId'] as int);
}
}
return ids;
}
Poll withStateMap(Map<dynamic, dynamic> stateMap) {
return _buildFromState(
pollId: pollId,
title: title,
settings: settings,
version: version,
answerIdsAndTexts: [for (final a in answers) (a.answerId, a.text)],
stateMap: stateMap,
);
}
factory Poll.fromServerMap(Map<dynamic, dynamic> map) {
final state = map['state'];
final stateMap = state is Map ? state : const {};
final answerIdsAndTexts = <(int, String)>[];
final rawAnswers = map['answers'];
if (rawAnswers is List) {
for (final a in rawAnswers) {
if (a is! Map) continue;
answerIdsAndTexts.add((
a['answerId'] as int? ?? 0,
a['text']?.toString() ?? '',
));
}
}
return _buildFromState(
pollId: map['pollId'] as int? ?? 0,
title: map['title']?.toString() ?? '',
settings: map['settings'] as int? ?? 0,
version: map['version'] as int? ?? 0,
answerIdsAndTexts: answerIdsAndTexts,
stateMap: stateMap,
);
}
static Poll _buildFromState({
required int pollId,
required String title,
required int settings,
required int version,
required List<(int, String)> answerIdsAndTexts,
required Map<dynamic, dynamic> stateMap,
}) {
final resultsById = <int, Map>{};
final result = stateMap['result'];
if (result is List) {
for (final r in result) {
if (r is Map && r['answerId'] is int) {
resultsById[r['answerId'] as int] = r;
}
}
}
final answers = <PollAnswer>[];
for (final (id, text) in answerIdsAndTexts) {
final res = resultsById[id];
answers.add(
PollAnswer(
answerId: id,
text: text,
voteCount: (res?['voteCount'] as num?)?.toInt() ?? 0,
rate: (res?['rate'] as num?)?.toDouble() ?? 0,
votes: _parseVoterIds(res?['votes']),
mine: ((res?['options'] as num?)?.toInt() ?? 0) & 0x1 != 0,
),
);
}
return Poll(
pollId: pollId,
title: title,
settings: settings,
version: version,
total: (stateMap['total'] as num?)?.toInt() ?? 0,
answers: answers,
voterPreviewIds:
(stateMap['voterPreviewIds'] as List?)?.whereType<int>().toList() ??
const [],
);
}
}
+45
View File
@@ -0,0 +1,45 @@
class ReactionCounter {
final String reaction;
final int count;
const ReactionCounter({required this.reaction, required this.count});
}
class ReactionInfo {
final List<ReactionCounter> counters;
final String? yourReaction;
final int totalCount;
const ReactionInfo({
this.counters = const [],
this.yourReaction,
this.totalCount = 0,
});
static ReactionInfo? fromMap(Map? map) {
if (map == null) return null;
final rawCounters = map['counters'];
if (rawCounters is! List || rawCounters.isEmpty) return null;
final counters = <ReactionCounter>[];
for (final c in rawCounters) {
if (c is! Map) continue;
final reaction = c['reaction']?.toString();
if (reaction == null || reaction.isEmpty) continue;
final rawCount = c['count'];
counters.add(
ReactionCounter(
reaction: reaction,
count: rawCount is int ? rawCount : 0,
),
);
}
if (counters.isEmpty) return null;
final total = map['totalCount'];
return ReactionInfo(
counters: counters,
yourReaction: map['yourReaction']?.toString(),
totalCount: total is int ? total : 0,
);
}
bool get isEmpty => counters.isEmpty;
}
+100
View File
@@ -0,0 +1,100 @@
import 'dart:io';
enum SharedFileKind { photo, video, file }
class SharedFile {
final String path;
final String name;
final String mime;
final int size;
const SharedFile({
required this.path,
required this.name,
required this.mime,
required this.size,
});
static SharedFile? fromMap(Map<dynamic, dynamic> map) {
final path = map['path'];
if (path is! String || path.isEmpty) return null;
final name = map['name'];
final mime = map['mime'];
final size = map['size'];
return SharedFile(
path: path,
name: name is String && name.isNotEmpty ? name : _basename(path),
mime: mime is String && mime.isNotEmpty
? mime
: 'application/octet-stream',
size: size is int ? size : 0,
);
}
File get file => File(path);
SharedFileKind get kind {
if (mime.startsWith('image/') && !mime.contains('svg')) {
return SharedFileKind.photo;
}
if (mime.startsWith('video/')) return SharedFileKind.video;
return SharedFileKind.file;
}
static String _basename(String path) {
final idx = path.lastIndexOf(Platform.pathSeparator);
return idx < 0 ? path : path.substring(idx + 1);
}
}
class SharedPayload {
final List<SharedFile> files;
final String? text;
final String? subject;
const SharedPayload({this.files = const [], this.text, this.subject});
static SharedPayload? fromMap(Object? raw) {
if (raw is! Map) return null;
final rawFiles = raw['files'];
final files = <SharedFile>[];
if (rawFiles is List) {
for (final entry in rawFiles) {
if (entry is! Map) continue;
final file = SharedFile.fromMap(entry);
if (file != null && file.file.existsSync()) files.add(file);
}
}
final text = raw['text'];
final subject = raw['subject'];
final payload = SharedPayload(
files: files,
text: text is String && text.trim().isNotEmpty ? text.trim() : null,
subject: subject is String && subject.trim().isNotEmpty
? subject.trim()
: null,
);
return payload.isEmpty ? null : payload;
}
bool get isEmpty => files.isEmpty && text == null;
bool get isTextOnly => files.isEmpty && text != null;
List<SharedFile> get photos =>
files.where((f) => f.kind == SharedFileKind.photo).toList();
List<SharedFile> get videos =>
files.where((f) => f.kind == SharedFileKind.video).toList();
List<SharedFile> get documents =>
files.where((f) => f.kind == SharedFileKind.file).toList();
SharedFileKind? get dominantKind {
if (files.isEmpty) return null;
if (documents.isNotEmpty) return SharedFileKind.file;
if (videos.isNotEmpty && photos.isEmpty) return SharedFileKind.video;
if (photos.isNotEmpty && videos.isEmpty) return SharedFileKind.photo;
return SharedFileKind.photo;
}
}
+111
View File
@@ -0,0 +1,111 @@
class SpoofProfile {
final bool enabled;
final String deviceName;
final String osVersion;
final String screen;
final String timezone;
final String locale;
final String deviceLocale;
final String deviceId;
final String deviceType;
final String arch;
final String appVersion;
final int buildNumber;
final String pushDeviceType;
final String instanceId;
final int? clientSessionId;
final String userAgent;
const SpoofProfile({
required this.enabled,
this.deviceName = '',
this.osVersion = '',
this.screen = '',
this.timezone = '',
this.locale = '',
this.deviceLocale = '',
this.deviceId = '',
this.deviceType = 'ANDROID',
this.arch = 'arm64-v8a',
this.appVersion = '',
this.buildNumber = 0,
this.pushDeviceType = 'GCM',
this.instanceId = '',
this.clientSessionId,
this.userAgent = '',
});
SpoofProfile copyWith({
bool? enabled,
String? deviceName,
String? osVersion,
String? screen,
String? timezone,
String? locale,
String? deviceLocale,
String? deviceId,
String? deviceType,
String? arch,
String? appVersion,
int? buildNumber,
String? pushDeviceType,
String? instanceId,
int? clientSessionId,
String? userAgent,
}) => SpoofProfile(
enabled: enabled ?? this.enabled,
deviceName: deviceName ?? this.deviceName,
osVersion: osVersion ?? this.osVersion,
screen: screen ?? this.screen,
timezone: timezone ?? this.timezone,
locale: locale ?? this.locale,
deviceLocale: deviceLocale ?? this.deviceLocale,
deviceId: deviceId ?? this.deviceId,
deviceType: deviceType ?? this.deviceType,
arch: arch ?? this.arch,
appVersion: appVersion ?? this.appVersion,
buildNumber: buildNumber ?? this.buildNumber,
pushDeviceType: pushDeviceType ?? this.pushDeviceType,
instanceId: instanceId ?? this.instanceId,
clientSessionId: clientSessionId ?? this.clientSessionId,
userAgent: userAgent ?? this.userAgent,
);
Map<String, dynamic> toJson() => {
'enabled': enabled,
'device_name': deviceName,
'os_version': osVersion,
'screen': screen,
'timezone': timezone,
'locale': locale,
'device_locale': deviceLocale,
'device_id': deviceId,
'device_type': deviceType,
'arch': arch,
'app_version': appVersion,
'build_number': buildNumber,
'push_device_type': pushDeviceType,
'instance_id': instanceId,
'client_session_id': clientSessionId,
'user_agent': userAgent,
};
factory SpoofProfile.fromJson(Map<String, dynamic> json) => SpoofProfile(
enabled: json['enabled'] as bool? ?? false,
deviceName: json['device_name'] as String? ?? '',
osVersion: json['os_version'] as String? ?? '',
screen: json['screen'] as String? ?? '',
timezone: json['timezone'] as String? ?? '',
locale: json['locale'] as String? ?? '',
deviceLocale: json['device_locale'] as String? ?? '',
deviceId: json['device_id'] as String? ?? '',
deviceType: json['device_type'] as String? ?? 'ANDROID',
arch: json['arch'] as String? ?? 'arm64-v8a',
appVersion: json['app_version'] as String? ?? '',
buildNumber: (json['build_number'] as num?)?.toInt() ?? 0,
pushDeviceType: json['push_device_type'] as String? ?? 'GCM',
instanceId: json['instance_id'] as String? ?? '',
clientSessionId: (json['client_session_id'] as num?)?.toInt(),
userAgent: json['user_agent'] as String? ?? '',
);
}
+74
View File
@@ -0,0 +1,74 @@
class StickerSet {
final int id;
final String name;
final String iconUrl;
final List<int> stickerIds;
final String? link;
const StickerSet({
required this.id,
required this.name,
required this.iconUrl,
required this.stickerIds,
this.link,
});
factory StickerSet.fromMap(Map<dynamic, dynamic> map) {
final rawStickers = map['stickers'];
final ids = <int>[];
if (rawStickers is List) {
for (final e in rawStickers) {
if (e is int) ids.add(e);
}
}
return StickerSet(
id: map['id'] as int,
name: map['name']?.toString() ?? '',
iconUrl: map['iconUrl']?.toString() ?? '',
stickerIds: ids,
link: map['link']?.toString(),
);
}
}
class StickerItem {
final int id;
final String url;
final String? lottieUrl;
final int? setId;
final int? width;
final int? height;
final List<String> tags;
const StickerItem({
required this.id,
required this.url,
this.lottieUrl,
this.setId,
this.width,
this.height,
this.tags = const [],
});
bool get isAnimated => lottieUrl != null && lottieUrl!.isNotEmpty;
factory StickerItem.fromMap(Map<dynamic, dynamic> map) => StickerItem(
id: map['id'] as int,
url: map['url']?.toString() ?? '',
lottieUrl: map['lottieUrl']?.toString(),
setId: map['setId'] as int?,
width: map['width'] as int?,
height: map['height'] as int?,
tags: _parseTags(map['tags']),
);
static List<String> _parseTags(dynamic raw) {
if (raw is! List) return const [];
final result = <String>[];
for (final e in raw) {
final s = e?.toString();
if (s != null && s.isNotEmpty) result.add(s);
}
return result;
}
}
+326
View File
@@ -0,0 +1,326 @@
import '../core/utils/parse.dart';
import 'attachment.dart';
enum StoryOwnerType { user, chat, channel }
int _ownerTypeToInt(StoryOwnerType type) {
switch (type) {
case StoryOwnerType.user:
return 0;
case StoryOwnerType.chat:
return 1;
case StoryOwnerType.channel:
return 2;
}
}
StoryOwnerType _ownerTypeFromInt(Object? raw) {
switch (parseIntOrNull(raw)) {
case 1:
return StoryOwnerType.chat;
case 2:
return StoryOwnerType.channel;
default:
return StoryOwnerType.user;
}
}
Map<String, dynamic> _asStringMap(Object? raw) {
if (raw is Map<String, dynamic>) return raw;
if (raw is Map) return Map<String, dynamic>.from(raw);
return const {};
}
class StoryOwner {
final int ownerId;
final StoryOwnerType type;
const StoryOwner({required this.ownerId, this.type = StoryOwnerType.user});
bool get isUser => type == StoryOwnerType.user;
static StoryOwner? fromMap(Object? raw) {
final map = _asStringMap(raw);
final id = parseIntOrNull(map['ownerId']);
if (id == null || id == 0) return null;
return StoryOwner(ownerId: id, type: _ownerTypeFromInt(map['type']));
}
Map<String, dynamic> toMap() => {
'ownerId': ownerId,
'type': _ownerTypeToInt(type),
};
@override
bool operator ==(Object other) =>
other is StoryOwner && other.ownerId == ownerId && other.type == type;
@override
int get hashCode => Object.hash(ownerId, type);
}
class StoryReaction {
final int reactionType; // 0 = emoji, 1 = sticker
final String id;
const StoryReaction({this.reactionType = 0, required this.id});
bool get isSticker => reactionType == 1;
static StoryReaction? fromMap(Object? raw) {
final map = _asStringMap(raw);
final id = map['id']?.toString();
if (id == null || id.isEmpty) return null;
return StoryReaction(
reactionType: parseIntOrNull(map['reactionType']) ?? 0,
id: id,
);
}
Map<String, dynamic> toMap() => {'reactionType': reactionType, 'id': id};
}
class StoryMedia {
final AttachmentType type;
final String? url;
final String? thumbnailUrl;
final String? previewData;
final int? width;
final int? height;
final int? durationMs;
const StoryMedia({
required this.type,
this.url,
this.thumbnailUrl,
this.previewData,
this.width,
this.height,
this.durationMs,
});
bool get isVideo => type == AttachmentType.video;
bool get isPhoto => type == AttachmentType.photo;
double get aspectRatio {
final w = width ?? 0;
final h = height ?? 0;
if (w <= 0 || h <= 0) return 9 / 16;
return w / h;
}
static StoryMedia? fromMap(Object? raw) {
final map = _asStringMap(raw);
final typeStr = (map['_type'] as String? ?? '').toUpperCase();
final previewData = decodeAttachPreview(map['previewData']);
final width = parseIntOrNull(map['width']);
final height = parseIntOrNull(map['height']);
switch (typeStr) {
case 'PHOTO':
final url = (map['photoUrl'] ?? map['baseUrl'] ?? map['url'])
?.toString();
return StoryMedia(
type: AttachmentType.photo,
url: url,
previewData: previewData,
width: width,
height: height,
);
case 'VIDEO':
final url =
(map['mp4Url'] ??
map['videoUrl'] ??
map['MP4_1080'] ??
map['baseUrl'])
?.toString();
return StoryMedia(
type: AttachmentType.video,
url: url,
thumbnailUrl: map['thumbnail']?.toString(),
previewData: previewData,
width: width,
height: height,
durationMs: parseIntOrNull(map['duration']),
);
default:
return StoryMedia(
type: AttachmentType.unknown,
previewData: previewData,
width: width,
height: height,
);
}
}
String get _typeName {
switch (type) {
case AttachmentType.photo:
return 'PHOTO';
case AttachmentType.video:
return 'VIDEO';
default:
return 'UNKNOWN';
}
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{
'_type': _typeName,
if (previewData != null) 'previewData': previewData,
if (width != null) 'width': width,
if (height != null) 'height': height,
};
if (isVideo) {
if (url != null) map['mp4Url'] = url;
if (thumbnailUrl != null) map['thumbnail'] = thumbnailUrl;
if (durationMs != null) map['duration'] = durationMs;
} else {
if (url != null) map['photoUrl'] = url;
}
return map;
}
}
class Story {
final int id;
final int cid;
final StoryOwner owner;
final int settings;
final int time;
final int updateTime;
final int expiration;
final StoryMedia? media;
final StoryReaction? reaction;
const Story({
required this.id,
required this.owner,
this.cid = 0,
this.settings = 0,
this.time = 0,
this.updateTime = 0,
this.expiration = 0,
this.media,
this.reaction,
});
Story copyWith({StoryReaction? reaction, bool clearReaction = false}) {
return Story(
id: id,
cid: cid,
owner: owner,
settings: settings,
time: time,
updateTime: updateTime,
expiration: expiration,
media: media,
reaction: clearReaction ? null : (reaction ?? this.reaction),
);
}
static Story? fromMap(Object? raw) {
final map = _asStringMap(raw);
final owner = StoryOwner.fromMap(map['owner']);
if (owner == null) return null;
return Story(
id: parseIntOrNull(map['id']) ?? 0,
cid: parseIntOrNull(map['cid']) ?? 0,
owner: owner,
settings: parseIntOrNull(map['settings']) ?? 0,
time: parseIntOrNull(map['time']) ?? 0,
updateTime: parseIntOrNull(map['updateTime']) ?? 0,
expiration: parseIntOrNull(map['expiration']) ?? 0,
media: StoryMedia.fromMap(map['media']),
reaction: StoryReaction.fromMap(map['reaction']),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'cid': cid,
'owner': owner.toMap(),
'settings': settings,
'time': time,
'updateTime': updateTime,
'expiration': expiration,
if (media != null) 'media': media!.toJson(),
if (reaction != null) 'reaction': reaction!.toMap(),
};
}
class StoryPreview {
final StoryOwner owner;
final int updateTime;
final int totalCount;
final int readCount;
final int lastStoryExpirationTime;
const StoryPreview({
required this.owner,
this.updateTime = 0,
this.totalCount = 0,
this.readCount = 0,
this.lastStoryExpirationTime = 0,
});
int get unreadCount {
final diff = totalCount - readCount;
return diff < 0 ? 0 : diff;
}
bool get hasUnread => unreadCount > 0;
bool get isEmpty => totalCount <= 0;
StoryPreview copyWith({int? readCount, int? totalCount}) => StoryPreview(
owner: owner,
updateTime: updateTime,
totalCount: totalCount ?? this.totalCount,
readCount: readCount ?? this.readCount,
lastStoryExpirationTime: lastStoryExpirationTime,
);
static StoryPreview? fromMap(Object? raw) {
final map = _asStringMap(raw);
final owner = StoryOwner.fromMap(map['owner']);
if (owner == null) return null;
return StoryPreview(
owner: owner,
updateTime: parseIntOrNull(map['updateTime']) ?? 0,
totalCount: parseIntOrNull(map['totalCount']) ?? 0,
readCount: parseIntOrNull(map['readCount']) ?? 0,
lastStoryExpirationTime:
parseIntOrNull(map['lastStoryExpirationTime']) ?? 0,
);
}
Map<String, dynamic> toJson() => {
'owner': owner.toMap(),
'updateTime': updateTime,
'totalCount': totalCount,
'readCount': readCount,
'lastStoryExpirationTime': lastStoryExpirationTime,
};
}
class PeerStories {
final StoryOwner owner;
final List<Story> stories;
const PeerStories({required this.owner, this.stories = const []});
static PeerStories? fromMap(Object? raw) {
final map = _asStringMap(raw);
final owner = StoryOwner.fromMap(map['owner']);
if (owner == null) return null;
final rawStories = map['stories'];
final stories = <Story>[];
if (rawStories is List) {
for (final s in rawStories) {
final story = Story.fromMap(s);
if (story != null) stories.add(story);
}
}
return PeerStories(owner: owner, stories: stories);
}
}