add internal gomuks command support

This commit is contained in:
Henry Hiles 2026-11-03 14:11:39 -05:00
commit 8650c44266
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
7 changed files with 168 additions and 79 deletions

2
gomuks

@ -1 +1 @@
Subproject commit 081d5df14be6bf7131e955461207e26da7ad23c9 Subproject commit e745b6dc1baa45ebb62dadf577f54e6ffb2f486f

View file

@ -1,6 +1,7 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/gomuks_commands.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/models/bot_command.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"; import "package:nexus/models/event.dart";
class BotCommandsController(final String roomId) class BotCommandsController(final String roomId)
extends Notifier<IList<BotCommand>> { extends AsyncNotifier<IList<BotCommand>> {
@override @override
IList<BotCommand> build() => ref.watch( Future<IList<BotCommand>> build() async {
RoomsController.provider.select((rooms) { final gomuksCommands = await ref.watch(
final room = rooms[roomId]; GomuksCommandsController.provider.future,
if (room == null) return const .empty(); );
Content? membership(String userId) => final botCommands = ref.watch(
switch (room.state[EventType.membership.type]?[userId]) { RoomsController.provider.select((rooms) {
final rowId? => room.events[rowId]?.content, final room = rooms[roomId];
null => null, if (room == null) return const IList<BotCommand>.empty();
};
return (room.state[EventType.botCommandDescription.type]?.values ?? Content? membership(String userId) =>
const []) switch (room.state[EventType.membership.type]?[userId]) {
.map( final rowId? => room.events[rowId]?.content,
(rowId) => switch (room.events[rowId]) { null => null,
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 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<BotCommandsController, IList<BotCommand>, String>( .family<BotCommandsController, IList<BotCommand>, String>(
BotCommandsController.new, BotCommandsController.new,
); );

View file

@ -13,6 +13,7 @@ import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/settings.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/main.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/account.dart";
import "package:nexus/models/capabilities.dart"; import "package:nexus/models/capabilities.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
@ -240,8 +241,23 @@ class ClientController(final String? accountId) extends AsyncNotifier<int> {
Future<void> redactEvent(RedactEventRequest report) => Future<void> redactEvent(RedactEventRequest report) =>
_sendCommand("redact_event", report.toJson()); _sendCommand("redact_event", report.toJson());
Future<Event> sendMessage(SendMessageRequest request) async => /// Null for gomuks' own commands that don't reply.
Event.fromJson(await _sendCommand("send_message", request.toJson())); Future<Event?> sendMessage(SendMessageRequest request) async =>
switch (await _sendCommand("send_message", request.toJson())) {
final Map<String, dynamic> json => .fromJson(json),
_ => null,
};
/// The MSC4391 definitions of gomuks' own commands.
Future<IList<BotCommandDescriptionContent>> getCommandDefinitions() async =>
switch (await _sendCommand("get_command_definitions")) {
final List definitions => .new(
definitions.whereType<Map<String, dynamic>>().map(
BotCommandDescriptionContent.fromJson,
),
),
_ => const .empty(),
};
Future<Event> sendEvent(SendEventRequest request) async => Future<Event> sendEvent(SendEventRequest request) async =>
Event.fromJson(await _sendCommand("send_event", request.toJson())); Event.fromJson(await _sendCommand("send_event", request.toJson()));

View file

@ -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<IList<BotCommand>> {
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<IList<BotCommand>> 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, IList<BotCommand>>(
GomuksCommandsController.new,
);
}

View file

@ -281,16 +281,23 @@ class RoomChatController(final ChatParams info)
), ),
); );
ref addSent(event);
.read(RoomsController.provider.notifier) }
.update(
.new({ /// Shows [event], which was just sent, until it comes down sync.
info.roomId: .new( void addSent(Event? event) {
events: .new({event.rowId: event}), if (event case final event?) {
clientSticky: .new({event.rowId}), ref
), .read(RoomsController.provider.notifier)
}), .update(
); .new({
info.roomId: .new(
events: .new({event.rowId: event}),
clientSticky: .new({event.rowId}),
),
}),
);
}
} }
Future<void> removeReaction( Future<void> removeReaction(
@ -356,7 +363,6 @@ class RoomChatController(final ChatParams info)
Content content, { Content content, {
bool shouldMention = true, bool shouldMention = true,
int? repliedEventRowId, int? repliedEventRowId,
IList<String> mentionedUserIds = const .empty(),
}) async { }) async {
final repliedEvent = repliedEventRowId == null final repliedEvent = repliedEventRowId == null
? null ? null
@ -375,7 +381,6 @@ class RoomChatController(final ChatParams info)
content: content, content: content,
mentions: Mentions( mentions: Mentions(
userIds: [ userIds: [
...mentionedUserIds,
if (shouldMention && repliedEvent != null) repliedEvent.sender, if (shouldMention && repliedEvent != null) repliedEvent.sender,
].toIList(), ].toIList(),
), ),
@ -383,16 +388,7 @@ class RoomChatController(final ChatParams info)
), ),
); );
ref addSent(event);
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: .new(
events: .new({event.rowId: event}),
clientSticky: .new({event.rowId}),
),
}),
);
} }
/// Sends [image] as a sticker, as a reply to [repliedEventRowId] if set. /// Sends [image] as a sticker, as a reply to [repliedEventRowId] if set.
@ -437,13 +433,32 @@ class RoomChatController(final ChatParams info)
Future<void> sendBotCommand( Future<void> sendBotCommand(
BotCommandDraft draft, { BotCommandDraft draft, {
int? repliedEventRowId, int? repliedEventRowId,
}) => sendWithReply( }) async {
.message, final repliedEvent = repliedEventRowId == null
BotCommandMessageContent(body: draft.body, command: draft.input), ? null
shouldMention: false, : ref
mentionedUserIds: [draft.command.sender].lock, .read(RoomsController.provider)[info.roomId]
repliedEventRowId: repliedEventRowId, ?.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. /// Completes once the event has come down sync.
Future<void> sendPollReference( Future<void> sendPollReference(

View file

@ -24,6 +24,8 @@ class const BotCommand({
"htmlmd", "htmlmd",
}); });
static const gomuksSender = "@gomuks";
String get prefix => "/${description.command}"; String get prefix => "/${description.command}";
String get about => [botName, ?description.description].join(" · "); String get about => [botName, ?description.description].join(" · ");

View file

@ -300,7 +300,8 @@ class const Composer(
final botCommands = isEditing || hasReplacement || attachment != null final botCommands = isEditing || hasReplacement || attachment != null
? const IList<BotCommand>.empty() ? const IList<BotCommand>.empty()
: ref.watch(BotCommandsController.provider(roomId)); : ref.watch(BotCommandsController.provider(roomId)).value ??
const .empty();
void pickBotCommand(BotCommand command) { void pickBotCommand(BotCommand command) {
ref ref