Add MSC4391 support

Fixes #133
This commit is contained in:
Henry Hiles 2026-11-03 13:12:58 -05:00
commit 3fc33a5ac9
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
4 changed files with 1236 additions and 55 deletions

View 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);
}

View 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,
);
}

View 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,
);
}

View file

@ -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,