parent
f35f715b81
commit
3fc33a5ac9
4 changed files with 1236 additions and 55 deletions
132
lib/controllers/bot_argument_suggestions.dart
Normal file
132
lib/controllers/bot_argument_suggestions.dart
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/bot_command_draft.dart";
|
||||
import "package:nexus/controllers/members_by_status.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/via.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/models/bot_argument_suggestion.dart";
|
||||
import "package:nexus/models/bot_command_draft.dart";
|
||||
import "package:nexus/models/content/bot_command_description.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
|
||||
class BotArgumentSuggestionsController(final String roomId)
|
||||
extends Notifier<IList<BotArgumentSuggestion>> {
|
||||
@override
|
||||
IList<BotArgumentSuggestion> build() {
|
||||
final draft = ref.watch(BotCommandDraftController.provider(roomId));
|
||||
final (parameter, text) = switch (draft) {
|
||||
BotCommandDraft(focusedParameter: final parameter?, :final texts) => (
|
||||
parameter,
|
||||
texts[parameter.key] ?? "",
|
||||
),
|
||||
_ => (null, ""),
|
||||
};
|
||||
if (parameter == null) return const .empty();
|
||||
|
||||
final schema = parameter.schema;
|
||||
final parts = schema is ArraySchema
|
||||
? text.split(BotParameterSchema.separator)
|
||||
: [text];
|
||||
final query = parts.last.trim().toLowerCase();
|
||||
final picked = parts.take(parts.length - 1).toISet();
|
||||
|
||||
final members = schema.allows(.userId)
|
||||
? ref
|
||||
.watch(
|
||||
MembersByStatusController.provider(
|
||||
.new(roomId: roomId, status: .join),
|
||||
),
|
||||
)
|
||||
.value ??
|
||||
const .empty()
|
||||
: const ISet<Event>.empty();
|
||||
final rooms = schema.allows(.roomId)
|
||||
? ref.watch(RoomsController.provider).values
|
||||
: const <Room>[];
|
||||
|
||||
return {
|
||||
...schema.literals.map((literal) => "$literal"),
|
||||
if (schema.allows(.boolean)) ...["True", "False"],
|
||||
}
|
||||
.map((value) => BotArgumentSuggestion(title: value, text: value))
|
||||
.followedBy(
|
||||
members
|
||||
.map(
|
||||
(member) => switch (member) {
|
||||
Event(
|
||||
stateKey: final userId?,
|
||||
content: MembershipContent(
|
||||
:final displayName,
|
||||
:final avatarUrl,
|
||||
),
|
||||
) =>
|
||||
BotArgumentSuggestion(
|
||||
title: displayName ?? userId.localpart,
|
||||
subtitle: userId,
|
||||
avatar: avatarUrl,
|
||||
text: userId,
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
)
|
||||
.nonNulls,
|
||||
)
|
||||
.followedBy(
|
||||
rooms
|
||||
.map(
|
||||
(room) => switch (room.metadata) {
|
||||
final metadata? => BotArgumentSuggestion(
|
||||
title:
|
||||
metadata.name ?? metadata.canonicalAlias ?? metadata.id,
|
||||
subtitle: metadata.canonicalAlias ?? metadata.id,
|
||||
avatar: metadata.avatar,
|
||||
text: metadata.id,
|
||||
room: room,
|
||||
),
|
||||
null => null,
|
||||
},
|
||||
)
|
||||
.nonNulls,
|
||||
)
|
||||
.where(
|
||||
(suggestion) =>
|
||||
!picked.contains(suggestion.text) &&
|
||||
[
|
||||
suggestion.title,
|
||||
suggestion.subtitle,
|
||||
].any((text) => text?.toLowerCase().contains(query) == true),
|
||||
)
|
||||
.take(8)
|
||||
.toIList();
|
||||
}
|
||||
|
||||
void pick(BotArgumentSuggestion suggestion) => ref
|
||||
.read(BotCommandDraftController.provider(roomId).notifier)
|
||||
.pick(
|
||||
suggestion,
|
||||
via: switch (suggestion.room) {
|
||||
final room? => ref.read(ViaController.provider(room)),
|
||||
null => const .empty(),
|
||||
},
|
||||
);
|
||||
|
||||
bool pickTop() {
|
||||
final draft = ref.read(BotCommandDraftController.provider(roomId));
|
||||
if (state.firstOrNull case final suggestion?
|
||||
when draft != null && !draft.arguments.containsKey(draft.focused)) {
|
||||
pick(suggestion);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider.autoDispose
|
||||
.family<
|
||||
BotArgumentSuggestionsController,
|
||||
IList<BotArgumentSuggestion>,
|
||||
String
|
||||
>(BotArgumentSuggestionsController.new);
|
||||
}
|
||||
103
lib/controllers/bot_command_draft.dart
Normal file
103
lib/controllers/bot_command_draft.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/models/bot_argument_suggestion.dart";
|
||||
import "package:nexus/models/bot_command.dart";
|
||||
import "package:nexus/models/bot_command_draft.dart";
|
||||
import "package:nexus/models/content/bot_command_description.dart";
|
||||
|
||||
class BotCommandDraftController(final String roomId)
|
||||
extends Notifier<BotCommandDraft?> {
|
||||
@override
|
||||
BotCommandDraft? build() => null;
|
||||
|
||||
void start(BotCommand command, {String text = ""}) {
|
||||
state = BotCommandDraft(
|
||||
command: command,
|
||||
roomId: roomId,
|
||||
texts: .fromEntries(
|
||||
command.description.parameters
|
||||
.where((parameter) => !parameter.optional)
|
||||
.map(
|
||||
(parameter) => switch (parameter.schema) {
|
||||
LiteralSchema(:final value) => MapEntry(
|
||||
parameter.key,
|
||||
"$value",
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
)
|
||||
.nonNulls,
|
||||
),
|
||||
);
|
||||
focusNext();
|
||||
if (state?.focused case final key? when text.isNotEmpty) setText(key, text);
|
||||
}
|
||||
|
||||
void setText(String key, String text) {
|
||||
if (state case final draft?) {
|
||||
state = draft.copyWith(texts: draft.texts.add(key, text));
|
||||
}
|
||||
}
|
||||
|
||||
void focus(String key) => state = state?.copyWith(focused: key);
|
||||
|
||||
void focusNext() {
|
||||
if (state case final draft?) {
|
||||
state = draft.copyWith(
|
||||
focused: draft.missing.firstOrNull?.key ?? draft.focused,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void addOption(String key) {
|
||||
if (state case final draft?) {
|
||||
state = draft.copyWith(options: draft.options.add(key), focused: key);
|
||||
}
|
||||
}
|
||||
|
||||
void removeOption(String key) {
|
||||
if (state case final draft?) {
|
||||
state = draft.copyWith(
|
||||
options: draft.options.remove(key),
|
||||
texts: draft.texts.remove(key),
|
||||
// Focus moves back a parameter if none are missing, so it isn't lost
|
||||
focused: draft.focused == key
|
||||
? draft.missing.firstOrNull?.key ??
|
||||
draft.shown
|
||||
.takeWhile((parameter) => parameter.key != key)
|
||||
.lastOrNull
|
||||
?.key
|
||||
: draft.focused,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void pick(BotArgumentSuggestion suggestion, {required ISet<String> via}) {
|
||||
if (state case BotCommandDraft(
|
||||
focusedParameter: final parameter?,
|
||||
:final texts,
|
||||
:final vias,
|
||||
)) {
|
||||
final text = texts[parameter.key] ?? "";
|
||||
final isArray = parameter.schema is ArraySchema;
|
||||
state = state?.copyWith(
|
||||
texts: texts.add(
|
||||
parameter.key,
|
||||
isArray
|
||||
? "${text.substring(0, text.lastIndexOf(BotParameterSchema.separator) + 1)}${suggestion.text}, "
|
||||
: suggestion.text,
|
||||
),
|
||||
vias: via.isEmpty ? vias : vias.add(suggestion.text, via.toIList()),
|
||||
);
|
||||
if (!isArray) focusNext();
|
||||
}
|
||||
}
|
||||
|
||||
void clear() => state = null;
|
||||
|
||||
static final provider = NotifierProvider.autoDispose
|
||||
.family<BotCommandDraftController, BotCommandDraft?, String>(
|
||||
BotCommandDraftController.new,
|
||||
);
|
||||
}
|
||||
66
lib/controllers/bot_commands.dart
Normal file
66
lib/controllers/bot_commands.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/models/bot_command.dart";
|
||||
import "package:nexus/models/content/bot_command_description.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
|
||||
class BotCommandsController(final String roomId)
|
||||
extends Notifier<IList<BotCommand>> {
|
||||
@override
|
||||
IList<BotCommand> build() => ref.watch(
|
||||
RoomsController.provider.select((rooms) {
|
||||
final room = rooms[roomId];
|
||||
if (room == null) return const .empty();
|
||||
|
||||
Content? membership(String userId) =>
|
||||
switch (room.state[EventType.membership.type]?[userId]) {
|
||||
final rowId? => room.events[rowId]?.content,
|
||||
null => null,
|
||||
};
|
||||
|
||||
return (room.state[EventType.botCommandDescription.type]?.values ??
|
||||
const [])
|
||||
.map(
|
||||
(rowId) => switch (room.events[rowId]) {
|
||||
Event(
|
||||
redactedBy: null,
|
||||
:final sender,
|
||||
content: final BotCommandDescriptionContent description,
|
||||
)
|
||||
when description.isValid &&
|
||||
!BotCommand.builtIn.contains(
|
||||
description.command.split(" ").first,
|
||||
) =>
|
||||
switch (membership(sender)) {
|
||||
MembershipContent(
|
||||
status: .join,
|
||||
:final displayName,
|
||||
:final avatarUrl,
|
||||
) =>
|
||||
BotCommand(
|
||||
sender: sender,
|
||||
botName: displayName ?? sender.localpart,
|
||||
botAvatar: avatarUrl,
|
||||
description: description,
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
_ => null,
|
||||
},
|
||||
)
|
||||
.nonNulls
|
||||
.sortedBy((command) => command.description.command)
|
||||
.toIList();
|
||||
}),
|
||||
);
|
||||
|
||||
static final provider = NotifierProvider.autoDispose
|
||||
.family<BotCommandsController, IList<BotCommand>, String>(
|
||||
BotCommandsController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import "package:nexus/controllers/client.dart";
|
|||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/helpers/extensions/is_media.dart";
|
||||
import "package:nexus/helpers/extensions/send_failed.dart";
|
||||
import "package:nexus/models/bot_command_draft.dart";
|
||||
import "package:nexus/models/content/bot_command.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/image_pack.dart";
|
||||
import "package:nexus/models/content/poll_end.dart";
|
||||
|
|
@ -354,6 +356,7 @@ class RoomChatController(final ChatParams info)
|
|||
Content content, {
|
||||
bool shouldMention = true,
|
||||
int? repliedEventRowId,
|
||||
IList<String> mentionedUserIds = const .empty(),
|
||||
}) async {
|
||||
final repliedEvent = repliedEventRowId == null
|
||||
? null
|
||||
|
|
@ -372,6 +375,7 @@ class RoomChatController(final ChatParams info)
|
|||
content: content,
|
||||
mentions: Mentions(
|
||||
userIds: [
|
||||
...mentionedUserIds,
|
||||
if (shouldMention && repliedEvent != null) repliedEvent.sender,
|
||||
].toIList(),
|
||||
),
|
||||
|
|
@ -430,6 +434,17 @@ class RoomChatController(final ChatParams info)
|
|||
repliedEventRowId: repliedEventRowId,
|
||||
);
|
||||
|
||||
Future<void> sendBotCommand(
|
||||
BotCommandDraft draft, {
|
||||
int? repliedEventRowId,
|
||||
}) => sendWithReply(
|
||||
.message,
|
||||
BotCommandMessageContent(body: draft.body, command: draft.input),
|
||||
shouldMention: false,
|
||||
mentionedUserIds: [draft.command.sender].lock,
|
||||
repliedEventRowId: repliedEventRowId,
|
||||
);
|
||||
|
||||
/// Completes once the event has come down sync.
|
||||
Future<void> sendPollReference(
|
||||
Event poll,
|
||||
|
|
|
|||
Loading…
Reference in a new issue