From f6de41f11d12d796264697a0e4bf44e7ef3d3667 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Thu, 30 Jul 2026 18:39:31 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20payload=20=D0=B4=D0=BB=D1=8F=20/start?= =?UTF-8?q?=20=D0=B2=20=D0=B1=D0=BE=D1=82=D0=B0=D1=85.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 25 +++++ lib/core/cache/info_cache.dart | 31 ++++++ lib/core/links/max_link.dart | 22 ++++- .../screens/chats/chat_info_screen.dart | 45 ++++----- lib/frontend/screens/chats/chat_screen.dart | 59 ++++++++++- .../widgets/formatted_message_text.dart | 2 +- lib/frontend/widgets/link_text.dart | 8 +- lib/frontend/widgets/max_link_handler.dart | 85 +++++++++++++++- lib/frontend/widgets/message_bubble.dart | 1 + lib/models/attachment.dart | 4 + lib/models/bot_info.dart | 48 +++++++++ test/bot_start_control_test.dart | 99 +++++++++++++++++++ test/max_link_test.dart | 92 +++++++++++++++++ 13 files changed, 483 insertions(+), 38 deletions(-) create mode 100644 lib/models/bot_info.dart create mode 100644 test/bot_start_control_test.dart create mode 100644 test/max_link_test.dart diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index c39312d..9ca17a4 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -412,6 +412,11 @@ class CachedMessage { this.editHistory, }); + bool get isBotStartMarker { + final control = attachments?.whereType().firstOrNull; + return control != null && control.isBotStart; + } + CachedMessage copyWith({ String? status, bool? deleted, @@ -809,6 +814,26 @@ class MessagesModule { return _api.sendRequest(Opcode.msgSend, payload); } + Future?> sendBotStart( + int chatId, + String startPayload, + ) async { + final response = await _api.sendRequest(Opcode.msgSend, { + 'chatId': chatId, + 'message': { + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'attaches': [ + { + '_type': 'CONTROL', + 'event': ControlAttachment.botStartedEvent, + 'startPayload': startPayload, + }, + ], + }, + }); + return _sentMessageMap(response); + } + Future _sendAndExtractMessageId( Map payload, String defaultError, diff --git a/lib/core/cache/info_cache.dart b/lib/core/cache/info_cache.dart index 2725490..640600f 100644 --- a/lib/core/cache/info_cache.dart +++ b/lib/core/cache/info_cache.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../backend/api.dart'; +import '../../models/bot_info.dart'; import '../../models/chat_info.dart'; import '../../models/contact_info.dart'; import '../protocol/opcode_map.dart'; @@ -279,6 +280,36 @@ class PresenceFetch { } } +class BotInfoFetch { + static final _cache = InfoCache( + ttl: const Duration(minutes: 30), + fetcher: _fetch, + ); + + static Future get(int botId, {bool forceRefresh = false}) => + _cache.get(botId, forceRefresh: forceRefresh); + + static BotInfo? peek(int botId) => _cache.peek(botId); + + static List commandsOf(int botId) => + _cache.peek(botId)?.commands ?? const []; + + static void invalidate(int botId) => _cache.invalidate(botId); + static void clear() => _cache.clear(); + + static Future _fetch(int botId) async { + final api = _api; + if (api == null || api.state != SessionState.online) return null; + final resp = await api.sendRequest(Opcode.botInfo, {'botId': botId}); + final data = resp.payload; + if (data is! Map) return null; + final info = BotInfo.fromPayload(botId, Map.from(data)); + final contact = info.contact; + if (contact != null) ContactInfoFetch.putContact(botId, contact.raw); + return info; + } +} + class ChatInfoFetch { static final _cache = InfoCache( ttl: const Duration(minutes: 5), diff --git a/lib/core/links/max_link.dart b/lib/core/links/max_link.dart index 01f9168..3539737 100644 --- a/lib/core/links/max_link.dart +++ b/lib/core/links/max_link.dart @@ -3,8 +3,11 @@ enum MaxLinkKind { call, invite, user, content, public, auth, stickerSet } class MaxLink { final MaxLinkKind kind; final String url; + final String baseUrl; + final String? startPayload; - const MaxLink(this.kind, this.url); + const MaxLink(this.kind, this.url, {String? baseUrl, this.startPayload}) + : baseUrl = baseUrl ?? url; static final RegExp _host = RegExp( r'^https?://(?:www\.)?max\.ru/(.+)$', @@ -33,7 +36,8 @@ class MaxLink { final match = _host.firstMatch(url); if (match == null) return null; - final path = match.group(1)!.split('?').first.split('#').first; + final rest = match.group(1)!; + final path = rest.split('?').first.split('#').first; final segments = path .split('/') .where((s) => s.isNotEmpty) @@ -59,6 +63,18 @@ class MaxLink { if (_reserved.contains(segments.first.toLowerCase())) return null; if (!_segment.hasMatch(segments.first)) return null; - return MaxLink(MaxLinkKind.public, url); + return MaxLink( + MaxLinkKind.public, + url, + baseUrl: 'https://max.ru/${segments.join('/')}', + startPayload: _startPayload(rest), + ); + } + + static String? _startPayload(String rest) { + final parts = rest.split('#').first.split('?'); + if (parts.length < 2) return null; + final value = Uri.splitQueryString(parts[1])['start']?.trim(); + return (value == null || value.isEmpty) ? null : value; } } diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 15181cc..32ac049 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -812,12 +812,7 @@ class _ChatInfoScreenState extends State : int.tryParse(phone?.toString() ?? ''); if (phoneInt != null && phoneInt > 0) { items.add( - _simpleInfoCard( - cs, - l10n.loginPhoneNumber, - formatPhone(phoneInt)!, - entities: true, - ), + _simpleInfoCard(cs, l10n.loginPhoneNumber, formatPhone(phoneInt)!), ); } final bio = @@ -825,7 +820,7 @@ class _ChatInfoScreenState extends State (_contactData?.raw['about'] as String?); if (bio != null && bio.isNotEmpty) { if (items.isNotEmpty) items.add(const SizedBox(height: 8)); - items.add(_simpleInfoCard(cs, l10n.chatInfoBio, bio, entities: true)); + items.add(_simpleInfoCard(cs, l10n.chatInfoBio, bio)); } } } else if (widget.chatType == 'CHANNEL') { @@ -852,7 +847,6 @@ class _ChatInfoScreenState extends State String label, String value, { bool isLink = false, - bool entities = false, }) { return GlossyPill( color: cs.surfaceContainerHigh, @@ -869,26 +863,16 @@ class _ChatInfoScreenState extends State style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 4), - if (entities) - FormattedMessageText( - text: value, - ranges: const [], - entityMode: TextEntityMode.copy, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ) - else - Text( - value, - style: TextStyle( - color: isLink ? cs.primary : cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), + FormattedMessageText( + text: value, + ranges: const [], + entityMode: TextEntityMode.copy, + style: TextStyle( + color: isLink ? cs.primary : cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, ), + ), ], ), ), @@ -913,7 +897,12 @@ class _ChatInfoScreenState extends State style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 4), - Text(link, style: TextStyle(color: cs.primary, fontSize: 15)), + FormattedMessageText( + text: link, + ranges: const [], + entityMode: TextEntityMode.copy, + style: TextStyle(color: cs.primary, fontSize: 15), + ), ], ), ), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 50b856e..e034795 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -241,6 +241,7 @@ class ChatScreen extends StatefulWidget { final int? initialMessageTime; final String? commentPostId; final CachedMessage? postMessage; + final String? botStartPayload; const ChatScreen({ super.key, @@ -257,8 +258,21 @@ class ChatScreen extends StatefulWidget { this.initialMessageTime, this.commentPostId, this.postMessage, + this.botStartPayload, }); + static final List<_ChatScreenState> _open = []; + + static bool startBotInVisibleChat(int chatId, String startPayload) { + for (final screen in _open.reversed) { + if (screen.widget.chatId != chatId) continue; + if (!screen.mounted || !screen._isRouteCurrent) continue; + unawaited(screen._sendBotStart(startPayload)); + return true; + } + return false; + } + @override State createState() => _ChatScreenState(); } @@ -548,6 +562,7 @@ class _ChatScreenState extends State set _myId(int v) => _chatController.myId = v; CachedChat? chat; bool _peerIsBot = false; + bool _botStartRequested = false; ChatWallpaper? _wallpaper; bool get _composerFrosted => @@ -616,6 +631,7 @@ class _ChatScreenState extends State _previewChat = widget.channelSubscribed == false; _chatController.chatId = widget.chatId; _chatController.isMounted = () => mounted; + if (!_commentsMode) ChatScreen._open.add(this); unawaited(PushService.clearChatNotification(widget.chatId)); unawaited( animojiModule @@ -779,6 +795,9 @@ class _ChatScreenState extends State if (cached != null) _applyPeerKind(cached.isBot); final info = await ContactInfoFetch.get(peerId); if (info != null) _applyPeerKind(info.isBot); + if ((info ?? cached)?.isBot ?? false) { + unawaited(BotInfoFetch.get(peerId)); + } } void _applyPeerKind(bool isBot) { @@ -943,7 +962,44 @@ class _ChatScreenState extends State if (!mounted || !_isLoading) return; _shimmerController.repeat(); }); - _loadHistory(); + unawaited(_loadHistory().then((_) => _sendPendingBotStart())); + } + + bool get _isRouteCurrent { + if (!mounted) return false; + final route = ModalRoute.of(context); + return route == null || route.isCurrent; + } + + Future _sendPendingBotStart() async { + final payload = widget.botStartPayload; + if (payload == null || _botStartRequested || !mounted) return; + _botStartRequested = true; + await _sendBotStart(payload); + } + + Future _sendBotStart(String startPayload) async { + if (_myId == 0) { + final profile = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + _myId = profile?.id ?? 0; + } + try { + final sent = await messagesModule.sendBotStart( + widget.chatId, + startPayload, + ); + if (!mounted) return; + if (sent == null) { + showCustomNotification(context, 'Не удалось запустить бота'); + return; + } + await _persistOutgoing( + CachedMessage.fromPushPayload(_myId, widget.chatId, sent), + ); + } catch (_) { + if (mounted) showCustomNotification(context, 'Не удалось запустить бота'); + } } void _onLoadingFinished() { @@ -1871,6 +1927,7 @@ class _ChatScreenState extends State @override void dispose() { + ChatScreen._open.remove(this); _chatController.persistSessionCache(); if (_previewChat) { unawaited(chats.subscribeChat(api, widget.chatId, subscribe: false)); diff --git a/lib/frontend/widgets/formatted_message_text.dart b/lib/frontend/widgets/formatted_message_text.dart index b044be1..03d8a62 100644 --- a/lib/frontend/widgets/formatted_message_text.dart +++ b/lib/frontend/widgets/formatted_message_text.dart @@ -92,7 +92,7 @@ class _FormattedMessageTextState extends State { if (hasExplicitLink) return ranges; for (final match in linkPattern.allMatches(widget.text)) { final raw = match.group(0)!; - final target = raw.startsWith('www.') ? 'https://$raw' : raw; + final target = linkTarget(raw); ranges.add( FormatRange( format: TextFormat.link, diff --git a/lib/frontend/widgets/link_text.dart b/lib/frontend/widgets/link_text.dart index f0bbff3..a9b3a0a 100644 --- a/lib/frontend/widgets/link_text.dart +++ b/lib/frontend/widgets/link_text.dart @@ -4,10 +4,14 @@ import 'package:flutter/material.dart'; import '../../core/utils/link_opener.dart'; final RegExp linkPattern = RegExp( - r'(https?://[^\s<>]+|www\.[^\s<>]+)', + r'(https?://[^\s<>]+' + r'|www\.[^\s<>]+' + r'|(?]*)?)', caseSensitive: false, ); +String linkTarget(String raw) => raw.contains('://') ? raw : 'https://$raw'; + class LinkText extends StatefulWidget { final String text; final TextStyle style; @@ -46,7 +50,7 @@ class _LinkTextState extends State { spans.add(TextSpan(text: widget.text.substring(cursor, match.start))); } final url = match.group(0)!; - final target = url.startsWith('www.') ? 'https://$url' : url; + final target = linkTarget(url); final recognizer = TapGestureRecognizer() ..onTap = () => openExternalUrl(context, target); _recognizers.add(recognizer); diff --git a/lib/frontend/widgets/max_link_handler.dart b/lib/frontend/widgets/max_link_handler.dart index de3a07c..2d32ffa 100644 --- a/lib/frontend/widgets/max_link_handler.dart +++ b/lib/frontend/widgets/max_link_handler.dart @@ -33,7 +33,7 @@ Future tryHandleMaxLink(BuildContext context, String url) async { return _openStickerSet(context, link.url); } - final resolved = await LinkModule.resolve(api, link.url); + final resolved = await _resolve(link); if (!context.mounted) return true; switch (resolved) { @@ -43,7 +43,7 @@ Future tryHandleMaxLink(BuildContext context, String url) async { showCustomNotification(context, message); return true; case ResolvedUser(:final contact): - _openContact(context, contact); + await _openContact(context, link, contact); return true; case ResolvedChat(): await _openResolvedChat(context, link, resolved); @@ -51,6 +51,13 @@ Future tryHandleMaxLink(BuildContext context, String url) async { } } +Future _resolve(MaxLink link) async { + final resolved = await LinkModule.resolve(api, link.url); + if (link.startPayload == null || link.baseUrl == link.url) return resolved; + if (resolved is ResolvedChat || resolved is ResolvedUser) return resolved; + return LinkModule.resolve(api, link.baseUrl); +} + Future _openStickerSet(BuildContext context, String url) async { final path = url .replaceFirst( @@ -71,12 +78,24 @@ Future _openStickerSet(BuildContext context, String url) async { return true; } -void _openContact(BuildContext context, Map contact) { +Future _openContact( + BuildContext context, + MaxLink link, + Map contact, +) async { final id = contact['id']; if (id is! int) { showCustomNotification(context, 'Не удалось открыть профиль'); return; } + + final startPayload = link.startPayload; + if (startPayload != null && + await _startBotDialog(context, id, contact, startPayload)) { + return; + } + if (!context.mounted) return; + unawaited( openContactDialogProfile( context, @@ -87,6 +106,53 @@ void _openContact(BuildContext context, Map contact) { ); } +Future _startBotDialog( + BuildContext context, + int botId, + Map contact, + String startPayload, +) async { + final profile = await AppDatabase.loadActiveProfile(); + final myId = profile?.id ?? 0; + if (myId == 0) return false; + + final chatId = + await AppDatabase.findDialogChatByParticipant(myId, botId) ?? + (myId ^ botId); + if (chatId <= 0 || !context.mounted) return false; + + _openChatAndStartBot( + context, + chatId: chatId, + name: _contactName(contact), + imageUrl: (contact['baseUrl'] as String?) ?? '', + chatType: 'DIALOG', + startPayload: startPayload, + ); + return true; +} + +void _openChatAndStartBot( + BuildContext context, { + required int chatId, + required String name, + required String imageUrl, + required String chatType, + required String startPayload, +}) { + if (ChatScreen.startBotInVisibleChat(chatId, startPayload)) return; + pushSwipeable( + context, + (_) => ChatScreen( + chatId: chatId, + name: name, + imageUrl: imageUrl, + chatType: chatType, + botStartPayload: startPayload, + ), + ); +} + Future _openResolvedChat( BuildContext context, MaxLink link, @@ -131,6 +197,19 @@ Future _openResolvedChat( if (!context.mounted) return; } + final startPayload = link.startPayload; + if (startPayload != null && type == 'DIALOG') { + _openChatAndStartBot( + context, + chatId: id, + name: title, + imageUrl: icon, + chatType: type, + startPayload: startPayload, + ); + return; + } + pushSwipeable( context, (_) => ChatScreen( diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 8e88134..238036b 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -752,6 +752,7 @@ class MessageBubble extends StatelessWidget { final contentType = _contentType; if (message.isControl) { + if (message.isBotStartMarker) return const SizedBox.shrink(); const controlShape = BubbleShape.singleMiddle; return Padding( padding: EdgeInsets.only( diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index fd1f3e4..b1287ce 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -429,6 +429,8 @@ class LocationAttachment extends MessageAttachment { } class ControlAttachment extends MessageAttachment { + static const String botStartedEvent = 'botStarted'; + final String? event; final String? title; final List? userIds; @@ -444,6 +446,8 @@ class ControlAttachment extends MessageAttachment { this.userId, }) : super(type: AttachmentType.control); + bool get isBotStart => event == botStartedEvent; + factory ControlAttachment.fromMap(Map map) { String? title = map['title']?.toString(); if ((title == null || title.isEmpty) && map['shortMessage'] != null) { diff --git a/lib/models/bot_info.dart b/lib/models/bot_info.dart new file mode 100644 index 0000000..3ab0d62 --- /dev/null +++ b/lib/models/bot_info.dart @@ -0,0 +1,48 @@ +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 BotInfo { + final int botId; + final List commands; + final ContactInfo? contact; + + const BotInfo({required this.botId, required this.commands, this.contact}); + + factory BotInfo.fromPayload(int botId, Map payload) { + final rawCommands = payload['commands']; + final commands = []; + if (rawCommands is List) { + for (final c in rawCommands.whereType()) { + 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.from(rawContact)) + : null, + ); + } + + String? get description => contact?.raw['description'] as String?; + + String? get link => contact?.raw['link'] as String?; +} diff --git a/test/bot_start_control_test.dart b/test/bot_start_control_test.dart new file mode 100644 index 0000000..9c56502 --- /dev/null +++ b/test/bot_start_control_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/models/attachment.dart'; +import 'package:komet/models/bot_info.dart'; + +void main() { + group('botStarted control message', () { + CachedMessage parse(Map message) => + CachedMessage.fromPushPayload(1001, 2002, message); + + test('is a control message and a start marker', () { + final message = parse({ + 'id': '3003', + 'time': 1700000000000, + 'type': 'USER', + 'sender': 1001, + 'text': 'abc123', + 'attaches': [ + {'_type': 'CONTROL', 'event': 'botStarted'}, + ], + }); + + expect(message.isControl, isTrue); + expect(message.isBotStartMarker, isTrue); + }); + + test('other control events are not start markers', () { + final message = parse({ + 'id': '3004', + 'time': 1700000000000, + 'type': 'USER', + 'sender': 1001, + 'attaches': [ + {'_type': 'CONTROL', 'event': 'add', 'userIds': [1002]}, + ], + }); + + expect(message.isControl, isTrue); + expect(message.isBotStartMarker, isFalse); + }); + + test('a plain message is neither', () { + final message = parse({ + 'id': '3005', + 'time': 1700000000000, + 'type': 'USER', + 'sender': 1001, + 'text': 'привет', + 'attaches': const [], + }); + + expect(message.isControl, isFalse); + expect(message.isBotStartMarker, isFalse); + }); + + test('the event name used on the wire stays stable', () { + expect(ControlAttachment.botStartedEvent, 'botStarted'); + }); + }); + + group('BotInfo', () { + test('parses commands and the bot contact', () { + final info = BotInfo.fromPayload(4004, { + 'commands': [ + {'botId': 4004, 'name': 'start', 'description': 'Главное меню'}, + {'botId': 4004, 'name': 'stats', 'description': ' '}, + {'botId': 4004, 'description': 'без имени'}, + ], + 'contact': { + 'id': 4004, + 'names': [ + {'name': 'Тестовый бот', 'type': 'ONEME'}, + ], + 'options': ['BOT'], + 'description': 'Описание бота', + 'link': 'https://max.ru/id100000000001_bot', + }, + }); + + expect(info.botId, 4004); + expect(info.commands.map((c) => c.name), ['start', 'stats']); + expect(info.commands.first.slash, '/start'); + expect(info.commands.first.description, 'Главное меню'); + expect(info.commands.last.description, isNull); + expect(info.contact?.isBot, isTrue); + expect(info.contact?.displayName, 'Тестовый бот'); + expect(info.description, 'Описание бота'); + expect(info.link, 'https://max.ru/id100000000001_bot'); + }); + + test('tolerates a payload without commands or contact', () { + final info = BotInfo.fromPayload(4005, const {}); + + expect(info.commands, isEmpty); + expect(info.contact, isNull); + expect(info.link, isNull); + }); + }); +} diff --git a/test/max_link_test.dart b/test/max_link_test.dart new file mode 100644 index 0000000..d5dcbb3 --- /dev/null +++ b/test/max_link_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/links/max_link.dart'; +import 'package:komet/frontend/widgets/link_text.dart'; + +void main() { + group('linkPattern', () { + List matches(String text) => + linkPattern.allMatches(text).map((m) => m.group(0)!).toList(); + + test('picks up a bare max.ru link inside plain text', () { + expect( + matches('Ваша ссылка 👇\nmax.ru/id100000000001_bot?start=abc123\n'), + ['max.ru/id100000000001_bot?start=abc123'], + ); + expect(matches('зайди на max.ru и посмотри'), ['max.ru']); + }); + + test('still picks up schemed and www links', () { + expect(matches('https://max.ru/somebot?start=x'), [ + 'https://max.ru/somebot?start=x', + ]); + expect(matches('www.max.ru/somebot'), ['www.max.ru/somebot']); + }); + + test('does not match look-alike hosts or emails', () { + expect(matches('evil.max.ru/phish'), isEmpty); + expect(matches('max.ru.evil.com/phish'), isEmpty); + expect(matches('bot@max.ru'), isEmpty); + expect(matches('max.rules/somebot'), isEmpty); + }); + + test('linkTarget adds a scheme only when missing', () { + expect(linkTarget('max.ru/somebot'), 'https://max.ru/somebot'); + expect(linkTarget('www.max.ru/somebot'), 'https://www.max.ru/somebot'); + expect(linkTarget('http://max.ru/somebot'), 'http://max.ru/somebot'); + expect(linkTarget('https://max.ru/somebot'), 'https://max.ru/somebot'); + }); + + test('a bare link is parsed as a max link once normalized', () { + final link = MaxLink.parse(linkTarget('max.ru/somebot?start=abc123')); + + expect(link!.kind, MaxLinkKind.public); + expect(link.startPayload, 'abc123'); + }); + }); + + group('MaxLink start payload', () { + test('parses a bot start link over http', () { + final link = MaxLink.parse('http://max.ru/id100000000001bot?start=abc123'); + + expect(link, isNotNull); + expect(link!.kind, MaxLinkKind.public); + expect(link.startPayload, 'abc123'); + expect(link.baseUrl, 'https://max.ru/id100000000001bot'); + expect(link.url, 'http://max.ru/id100000000001bot?start=abc123'); + }); + + test('decodes the payload and ignores a trailing fragment', () { + final link = MaxLink.parse( + 'https://www.max.ru/somebot?start=a%20b&ref=x#top', + ); + + expect(link!.startPayload, 'a b'); + expect(link.baseUrl, 'https://max.ru/somebot'); + }); + + test('keeps the payload empty for links without one', () { + expect(MaxLink.parse('https://max.ru/somebot')!.startPayload, isNull); + expect( + MaxLink.parse('https://max.ru/somebot?start=')!.startPayload, + isNull, + ); + expect( + MaxLink.parse('https://max.ru/somebot?other=1')!.startPayload, + isNull, + ); + }); + + test('leaves other link kinds untouched', () { + final invite = MaxLink.parse('https://max.ru/join/AbCdEf?start=x'); + + expect(invite!.kind, MaxLinkKind.invite); + expect(invite.startPayload, isNull); + expect(invite.baseUrl, invite.url); + }); + + test('still rejects non-max links', () { + expect(MaxLink.parse('https://example.com/somebot?start=x'), isNull); + expect(MaxLink.parse('https://max.ru/?start=x'), isNull); + }); + }); +}