feat: payload для /start в ботах.
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
Vendored
+31
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user