feat: payload для /start в ботах.

This commit is contained in:
Jganenokk
2026-07-30 18:39:31 +07:00
parent 8f42692708
commit f6de41f11d
13 changed files with 483 additions and 38 deletions
+25
View File
@@ -412,6 +412,11 @@ class CachedMessage {
this.editHistory, this.editHistory,
}); });
bool get isBotStartMarker {
final control = attachments?.whereType<ControlAttachment>().firstOrNull;
return control != null && control.isBotStart;
}
CachedMessage copyWith({ CachedMessage copyWith({
String? status, String? status,
bool? deleted, bool? deleted,
@@ -809,6 +814,26 @@ class MessagesModule {
return _api.sendRequest(Opcode.msgSend, payload); return _api.sendRequest(Opcode.msgSend, payload);
} }
Future<Map<String, dynamic>?> 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<String> _sendAndExtractMessageId( Future<String> _sendAndExtractMessageId(
Map<String, dynamic> payload, Map<String, dynamic> payload,
String defaultError, String defaultError,
+31
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../../backend/api.dart'; import '../../backend/api.dart';
import '../../models/bot_info.dart';
import '../../models/chat_info.dart'; import '../../models/chat_info.dart';
import '../../models/contact_info.dart'; import '../../models/contact_info.dart';
import '../protocol/opcode_map.dart'; import '../protocol/opcode_map.dart';
@@ -279,6 +280,36 @@ class PresenceFetch {
} }
} }
class BotInfoFetch {
static final _cache = InfoCache<BotInfo>(
ttl: const Duration(minutes: 30),
fetcher: _fetch,
);
static Future<BotInfo?> get(int botId, {bool forceRefresh = false}) =>
_cache.get(botId, forceRefresh: forceRefresh);
static BotInfo? peek(int botId) => _cache.peek(botId);
static List<BotCommand> commandsOf(int botId) =>
_cache.peek(botId)?.commands ?? const [];
static void invalidate(int botId) => _cache.invalidate(botId);
static void clear() => _cache.clear();
static Future<BotInfo?> _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<String, dynamic>.from(data));
final contact = info.contact;
if (contact != null) ContactInfoFetch.putContact(botId, contact.raw);
return info;
}
}
class ChatInfoFetch { class ChatInfoFetch {
static final _cache = InfoCache<ChatInfo>( static final _cache = InfoCache<ChatInfo>(
ttl: const Duration(minutes: 5), ttl: const Duration(minutes: 5),
+19 -3
View File
@@ -3,8 +3,11 @@ enum MaxLinkKind { call, invite, user, content, public, auth, stickerSet }
class MaxLink { class MaxLink {
final MaxLinkKind kind; final MaxLinkKind kind;
final String url; 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( static final RegExp _host = RegExp(
r'^https?://(?:www\.)?max\.ru/(.+)$', r'^https?://(?:www\.)?max\.ru/(.+)$',
@@ -33,7 +36,8 @@ class MaxLink {
final match = _host.firstMatch(url); final match = _host.firstMatch(url);
if (match == null) return null; 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 final segments = path
.split('/') .split('/')
.where((s) => s.isNotEmpty) .where((s) => s.isNotEmpty)
@@ -59,6 +63,18 @@ class MaxLink {
if (_reserved.contains(segments.first.toLowerCase())) return null; if (_reserved.contains(segments.first.toLowerCase())) return null;
if (!_segment.hasMatch(segments.first)) 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;
} }
} }
@@ -812,12 +812,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
: int.tryParse(phone?.toString() ?? ''); : int.tryParse(phone?.toString() ?? '');
if (phoneInt != null && phoneInt > 0) { if (phoneInt != null && phoneInt > 0) {
items.add( items.add(
_simpleInfoCard( _simpleInfoCard(cs, l10n.loginPhoneNumber, formatPhone(phoneInt)!),
cs,
l10n.loginPhoneNumber,
formatPhone(phoneInt)!,
entities: true,
),
); );
} }
final bio = final bio =
@@ -825,7 +820,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
(_contactData?.raw['about'] as String?); (_contactData?.raw['about'] as String?);
if (bio != null && bio.isNotEmpty) { if (bio != null && bio.isNotEmpty) {
if (items.isNotEmpty) items.add(const SizedBox(height: 8)); 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') { } else if (widget.chatType == 'CHANNEL') {
@@ -852,7 +847,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
String label, String label,
String value, { String value, {
bool isLink = false, bool isLink = false,
bool entities = false,
}) { }) {
return GlossyPill( return GlossyPill(
color: cs.surfaceContainerHigh, color: cs.surfaceContainerHigh,
@@ -869,20 +863,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
if (entities)
FormattedMessageText( FormattedMessageText(
text: value, text: value,
ranges: const [], ranges: const [],
entityMode: TextEntityMode.copy, entityMode: TextEntityMode.copy,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
)
else
Text(
value,
style: TextStyle( style: TextStyle(
color: isLink ? cs.primary : cs.onSurface, color: isLink ? cs.primary : cs.onSurface,
fontSize: 16, fontSize: 16,
@@ -913,7 +897,12 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
), ),
const SizedBox(height: 4), 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),
),
], ],
), ),
), ),
+58 -1
View File
@@ -241,6 +241,7 @@ class ChatScreen extends StatefulWidget {
final int? initialMessageTime; final int? initialMessageTime;
final String? commentPostId; final String? commentPostId;
final CachedMessage? postMessage; final CachedMessage? postMessage;
final String? botStartPayload;
const ChatScreen({ const ChatScreen({
super.key, super.key,
@@ -257,8 +258,21 @@ class ChatScreen extends StatefulWidget {
this.initialMessageTime, this.initialMessageTime,
this.commentPostId, this.commentPostId,
this.postMessage, 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 @override
State<ChatScreen> createState() => _ChatScreenState(); State<ChatScreen> createState() => _ChatScreenState();
} }
@@ -548,6 +562,7 @@ class _ChatScreenState extends State<ChatScreen>
set _myId(int v) => _chatController.myId = v; set _myId(int v) => _chatController.myId = v;
CachedChat? chat; CachedChat? chat;
bool _peerIsBot = false; bool _peerIsBot = false;
bool _botStartRequested = false;
ChatWallpaper? _wallpaper; ChatWallpaper? _wallpaper;
bool get _composerFrosted => bool get _composerFrosted =>
@@ -616,6 +631,7 @@ class _ChatScreenState extends State<ChatScreen>
_previewChat = widget.channelSubscribed == false; _previewChat = widget.channelSubscribed == false;
_chatController.chatId = widget.chatId; _chatController.chatId = widget.chatId;
_chatController.isMounted = () => mounted; _chatController.isMounted = () => mounted;
if (!_commentsMode) ChatScreen._open.add(this);
unawaited(PushService.clearChatNotification(widget.chatId)); unawaited(PushService.clearChatNotification(widget.chatId));
unawaited( unawaited(
animojiModule animojiModule
@@ -779,6 +795,9 @@ class _ChatScreenState extends State<ChatScreen>
if (cached != null) _applyPeerKind(cached.isBot); if (cached != null) _applyPeerKind(cached.isBot);
final info = await ContactInfoFetch.get(peerId); final info = await ContactInfoFetch.get(peerId);
if (info != null) _applyPeerKind(info.isBot); if (info != null) _applyPeerKind(info.isBot);
if ((info ?? cached)?.isBot ?? false) {
unawaited(BotInfoFetch.get(peerId));
}
} }
void _applyPeerKind(bool isBot) { void _applyPeerKind(bool isBot) {
@@ -943,7 +962,44 @@ class _ChatScreenState extends State<ChatScreen>
if (!mounted || !_isLoading) return; if (!mounted || !_isLoading) return;
_shimmerController.repeat(); _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<void> _sendPendingBotStart() async {
final payload = widget.botStartPayload;
if (payload == null || _botStartRequested || !mounted) return;
_botStartRequested = true;
await _sendBotStart(payload);
}
Future<void> _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() { void _onLoadingFinished() {
@@ -1871,6 +1927,7 @@ class _ChatScreenState extends State<ChatScreen>
@override @override
void dispose() { void dispose() {
ChatScreen._open.remove(this);
_chatController.persistSessionCache(); _chatController.persistSessionCache();
if (_previewChat) { if (_previewChat) {
unawaited(chats.subscribeChat(api, widget.chatId, subscribe: false)); unawaited(chats.subscribeChat(api, widget.chatId, subscribe: false));
@@ -92,7 +92,7 @@ class _FormattedMessageTextState extends State<FormattedMessageText> {
if (hasExplicitLink) return ranges; if (hasExplicitLink) return ranges;
for (final match in linkPattern.allMatches(widget.text)) { for (final match in linkPattern.allMatches(widget.text)) {
final raw = match.group(0)!; final raw = match.group(0)!;
final target = raw.startsWith('www.') ? 'https://$raw' : raw; final target = linkTarget(raw);
ranges.add( ranges.add(
FormatRange( FormatRange(
format: TextFormat.link, format: TextFormat.link,
+6 -2
View File
@@ -4,10 +4,14 @@ import 'package:flutter/material.dart';
import '../../core/utils/link_opener.dart'; import '../../core/utils/link_opener.dart';
final RegExp linkPattern = RegExp( final RegExp linkPattern = RegExp(
r'(https?://[^\s<>]+|www\.[^\s<>]+)', r'(https?://[^\s<>]+'
r'|www\.[^\s<>]+'
r'|(?<![\w.@/-])max\.ru(?![\w.-])(?:/[^\s<>]*)?)',
caseSensitive: false, caseSensitive: false,
); );
String linkTarget(String raw) => raw.contains('://') ? raw : 'https://$raw';
class LinkText extends StatefulWidget { class LinkText extends StatefulWidget {
final String text; final String text;
final TextStyle style; final TextStyle style;
@@ -46,7 +50,7 @@ class _LinkTextState extends State<LinkText> {
spans.add(TextSpan(text: widget.text.substring(cursor, match.start))); spans.add(TextSpan(text: widget.text.substring(cursor, match.start)));
} }
final url = match.group(0)!; final url = match.group(0)!;
final target = url.startsWith('www.') ? 'https://$url' : url; final target = linkTarget(url);
final recognizer = TapGestureRecognizer() final recognizer = TapGestureRecognizer()
..onTap = () => openExternalUrl(context, target); ..onTap = () => openExternalUrl(context, target);
_recognizers.add(recognizer); _recognizers.add(recognizer);
+82 -3
View File
@@ -33,7 +33,7 @@ Future<bool> tryHandleMaxLink(BuildContext context, String url) async {
return _openStickerSet(context, link.url); return _openStickerSet(context, link.url);
} }
final resolved = await LinkModule.resolve(api, link.url); final resolved = await _resolve(link);
if (!context.mounted) return true; if (!context.mounted) return true;
switch (resolved) { switch (resolved) {
@@ -43,7 +43,7 @@ Future<bool> tryHandleMaxLink(BuildContext context, String url) async {
showCustomNotification(context, message); showCustomNotification(context, message);
return true; return true;
case ResolvedUser(:final contact): case ResolvedUser(:final contact):
_openContact(context, contact); await _openContact(context, link, contact);
return true; return true;
case ResolvedChat(): case ResolvedChat():
await _openResolvedChat(context, link, resolved); await _openResolvedChat(context, link, resolved);
@@ -51,6 +51,13 @@ Future<bool> tryHandleMaxLink(BuildContext context, String url) async {
} }
} }
Future<ResolvedLink?> _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<bool> _openStickerSet(BuildContext context, String url) async { Future<bool> _openStickerSet(BuildContext context, String url) async {
final path = url final path = url
.replaceFirst( .replaceFirst(
@@ -71,12 +78,24 @@ Future<bool> _openStickerSet(BuildContext context, String url) async {
return true; return true;
} }
void _openContact(BuildContext context, Map<dynamic, dynamic> contact) { Future<void> _openContact(
BuildContext context,
MaxLink link,
Map<dynamic, dynamic> contact,
) async {
final id = contact['id']; final id = contact['id'];
if (id is! int) { if (id is! int) {
showCustomNotification(context, 'Не удалось открыть профиль'); showCustomNotification(context, 'Не удалось открыть профиль');
return; return;
} }
final startPayload = link.startPayload;
if (startPayload != null &&
await _startBotDialog(context, id, contact, startPayload)) {
return;
}
if (!context.mounted) return;
unawaited( unawaited(
openContactDialogProfile( openContactDialogProfile(
context, context,
@@ -87,6 +106,53 @@ void _openContact(BuildContext context, Map<dynamic, dynamic> contact) {
); );
} }
Future<bool> _startBotDialog(
BuildContext context,
int botId,
Map<dynamic, dynamic> 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<void> _openResolvedChat( Future<void> _openResolvedChat(
BuildContext context, BuildContext context,
MaxLink link, MaxLink link,
@@ -131,6 +197,19 @@ Future<void> _openResolvedChat(
if (!context.mounted) return; 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( pushSwipeable(
context, context,
(_) => ChatScreen( (_) => ChatScreen(
+1
View File
@@ -752,6 +752,7 @@ class MessageBubble extends StatelessWidget {
final contentType = _contentType; final contentType = _contentType;
if (message.isControl) { if (message.isControl) {
if (message.isBotStartMarker) return const SizedBox.shrink();
const controlShape = BubbleShape.singleMiddle; const controlShape = BubbleShape.singleMiddle;
return Padding( return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
+4
View File
@@ -429,6 +429,8 @@ class LocationAttachment extends MessageAttachment {
} }
class ControlAttachment extends MessageAttachment { class ControlAttachment extends MessageAttachment {
static const String botStartedEvent = 'botStarted';
final String? event; final String? event;
final String? title; final String? title;
final List<int>? userIds; final List<int>? userIds;
@@ -444,6 +446,8 @@ class ControlAttachment extends MessageAttachment {
this.userId, this.userId,
}) : super(type: AttachmentType.control); }) : super(type: AttachmentType.control);
bool get isBotStart => event == botStartedEvent;
factory ControlAttachment.fromMap(Map<String, dynamic> map) { factory ControlAttachment.fromMap(Map<String, dynamic> map) {
String? title = map['title']?.toString(); String? title = map['title']?.toString();
if ((title == null || title.isEmpty) && map['shortMessage'] != null) { if ((title == null || title.isEmpty) && map['shortMessage'] != null) {
+48
View File
@@ -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<BotCommand> commands;
final ContactInfo? contact;
const BotInfo({required this.botId, required this.commands, this.contact});
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,
);
}
String? get description => contact?.raw['description'] as String?;
String? get link => contact?.raw['link'] as String?;
}
+99
View File
@@ -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<String, dynamic> 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);
});
});
}
+92
View File
@@ -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<String> 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);
});
});
}