From 8650c44266b18461e00dc4f4ed36ab3dadc88226 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 3 Nov 2026 14:11:39 -0500 Subject: [PATCH] add internal gomuks command support --- gomuks | 2 +- lib/controllers/bot_commands.dart | 103 +++++++++++++++------------ lib/controllers/client.dart | 20 +++++- lib/controllers/gomuks_commands.dart | 44 ++++++++++++ lib/controllers/room_chat.dart | 73 +++++++++++-------- lib/models/bot_command.dart | 2 + lib/widgets/composer/composer.dart | 3 +- 7 files changed, 168 insertions(+), 79 deletions(-) create mode 100644 lib/controllers/gomuks_commands.dart diff --git a/gomuks b/gomuks index 081d5df..e745b6d 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit 081d5df14be6bf7131e955461207e26da7ad23c9 +Subproject commit e745b6dc1baa45ebb62dadf577f54e6ffb2f486f diff --git a/lib/controllers/bot_commands.dart b/lib/controllers/bot_commands.dart index 0cccf11..49dc414 100644 --- a/lib/controllers/bot_commands.dart +++ b/lib/controllers/bot_commands.dart @@ -1,6 +1,7 @@ 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/gomuks_commands.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/models/bot_command.dart"; @@ -10,56 +11,66 @@ import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; class BotCommandsController(final String roomId) - extends Notifier> { + extends AsyncNotifier> { @override - IList build() => ref.watch( - RoomsController.provider.select((rooms) { - final room = rooms[roomId]; - if (room == null) return const .empty(); + Future> build() async { + final gomuksCommands = await ref.watch( + GomuksCommandsController.provider.future, + ); - Content? membership(String userId) => - switch (room.state[EventType.membership.type]?[userId]) { - final rowId? => room.events[rowId]?.content, - null => null, - }; + final botCommands = ref.watch( + RoomsController.provider.select((rooms) { + final room = rooms[roomId]; + if (room == null) return const IList.empty(); - 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(); - }), - ); + Content? membership(String userId) => + switch (room.state[EventType.membership.type]?[userId]) { + final rowId? => room.events[rowId]?.content, + null => null, + }; - static final provider = NotifierProvider.autoDispose + 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 + .toIList(); + }), + ); + + return gomuksCommands + .addAll(botCommands) + .sortedBy((command) => command.description.command) + .toIList(); + } + + static final provider = AsyncNotifierProvider.autoDispose .family, String>( BotCommandsController.new, ); diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 5bb47c4..db33340 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -13,6 +13,7 @@ import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/main.dart"; +import "package:nexus/models/content/bot_command_description.dart"; import "package:nexus/models/account.dart"; import "package:nexus/models/capabilities.dart"; import "package:nexus/models/content/message.dart"; @@ -240,8 +241,23 @@ class ClientController(final String? accountId) extends AsyncNotifier { Future redactEvent(RedactEventRequest report) => _sendCommand("redact_event", report.toJson()); - Future sendMessage(SendMessageRequest request) async => - Event.fromJson(await _sendCommand("send_message", request.toJson())); + /// Null for gomuks' own commands that don't reply. + Future sendMessage(SendMessageRequest request) async => + switch (await _sendCommand("send_message", request.toJson())) { + final Map json => .fromJson(json), + _ => null, + }; + + /// The MSC4391 definitions of gomuks' own commands. + Future> getCommandDefinitions() async => + switch (await _sendCommand("get_command_definitions")) { + final List definitions => .new( + definitions.whereType>().map( + BotCommandDescriptionContent.fromJson, + ), + ), + _ => const .empty(), + }; Future sendEvent(SendEventRequest request) async => Event.fromJson(await _sendCommand("send_event", request.toJson())); diff --git a/lib/controllers/gomuks_commands.dart b/lib/controllers/gomuks_commands.dart new file mode 100644 index 0000000..b4276eb --- /dev/null +++ b/lib/controllers/gomuks_commands.dart @@ -0,0 +1,44 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/bot_command.dart"; + +class GomuksCommandsController extends AsyncNotifier> { + static const unsupported = ISetConst({ + // Needs gomuks web + "devtools", + "upgraderoom", + + // Needs attachment stuff + "myroomavatar", + "globalavatar", + "roomavatar", + + // Already in Nexus' UI with a better UX + "poll", + }); + + @override + Future> build() async => + (await ref + .read(ClientController.provider.notifier) + .getCommandDefinitions()) + .where( + (description) => + description.isValid && + !unsupported.contains(description.command), + ) + .map( + (description) => BotCommand( + sender: BotCommand.gomuksSender, + botName: "gomuks", + description: description, + ), + ) + .toIList(); + + static final provider = + AsyncNotifierProvider>( + GomuksCommandsController.new, + ); +} diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 83301fd..08e7ea7 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -281,16 +281,23 @@ class RoomChatController(final ChatParams info) ), ); - ref - .read(RoomsController.provider.notifier) - .update( - .new({ - info.roomId: .new( - events: .new({event.rowId: event}), - clientSticky: .new({event.rowId}), - ), - }), - ); + addSent(event); + } + + /// Shows [event], which was just sent, until it comes down sync. + void addSent(Event? event) { + if (event case final event?) { + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + info.roomId: .new( + events: .new({event.rowId: event}), + clientSticky: .new({event.rowId}), + ), + }), + ); + } } Future removeReaction( @@ -356,7 +363,6 @@ class RoomChatController(final ChatParams info) Content content, { bool shouldMention = true, int? repliedEventRowId, - IList mentionedUserIds = const .empty(), }) async { final repliedEvent = repliedEventRowId == null ? null @@ -375,7 +381,6 @@ class RoomChatController(final ChatParams info) content: content, mentions: Mentions( userIds: [ - ...mentionedUserIds, if (shouldMention && repliedEvent != null) repliedEvent.sender, ].toIList(), ), @@ -383,16 +388,7 @@ class RoomChatController(final ChatParams info) ), ); - ref - .read(RoomsController.provider.notifier) - .update( - .new({ - info.roomId: .new( - events: .new({event.rowId: event}), - clientSticky: .new({event.rowId}), - ), - }), - ); + addSent(event); } /// Sends [image] as a sticker, as a reply to [repliedEventRowId] if set. @@ -437,13 +433,32 @@ class RoomChatController(final ChatParams info) Future sendBotCommand( BotCommandDraft draft, { int? repliedEventRowId, - }) => sendWithReply( - .message, - BotCommandMessageContent(body: draft.body, command: draft.input), - shouldMention: false, - mentionedUserIds: [draft.command.sender].lock, - repliedEventRowId: repliedEventRowId, - ); + }) async { + final repliedEvent = repliedEventRowId == null + ? null + : ref + .read(RoomsController.provider)[info.roomId] + ?.events[repliedEventRowId]; + + final event = await ref + .read(ClientController.provider.notifier) + .sendMessage( + .new( + roomId: info.roomId, + text: "", + baseContent: BotCommandMessageContent( + body: draft.body, + command: draft.input, + ), + mentions: .new(userIds: [draft.command.sender].lock), + relation: repliedEvent == null + ? null + : .new(eventId: repliedEvent.eventId, relationType: .reply), + ), + ); + + addSent(event); + } /// Completes once the event has come down sync. Future sendPollReference( diff --git a/lib/models/bot_command.dart b/lib/models/bot_command.dart index 1c5b400..e25f4dd 100644 --- a/lib/models/bot_command.dart +++ b/lib/models/bot_command.dart @@ -24,6 +24,8 @@ class const BotCommand({ "htmlmd", }); + static const gomuksSender = "@gomuks"; + String get prefix => "/${description.command}"; String get about => [botName, ?description.description].join(" ยท "); diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index d5b6602..84e57cc 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -300,7 +300,8 @@ class const Composer( final botCommands = isEditing || hasReplacement || attachment != null ? const IList.empty() - : ref.watch(BotCommandsController.provider(roomId)); + : ref.watch(BotCommandsController.provider(roomId)).value ?? + const .empty(); void pickBotCommand(BotCommand command) { ref