From 9217165304bab21b3bae1ebed8a35b98568be800 Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Sat, 12 Oct 2024 17:36:30 +0200 Subject: [PATCH 1/2] feat: improve command_extension - unify behavior of all message sending related command - add a StringBuffer as stdout-like output buffer for commands - create a typedef for the command function signature - create a common exception type for command execution - enable commands to run on Client-level rather than Room-level - BREAKING: Client.addCommand signature now takes an optional StringBuffer as second parameter Signed-off-by: The one with the braid --- lib/src/client.dart | 2 +- lib/src/room.dart | 37 +-- lib/src/utils/commands_extension.dart | 312 +++++++++++++++++++------- 3 files changed, 253 insertions(+), 98 deletions(-) diff --git a/lib/src/client.dart b/lib/src/client.dart index fc6df4261..2d24fd9e3 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -97,7 +97,7 @@ class Client extends MatrixApi { DateTime? _accessTokenExpiresAt; // For CommandsClientExtension - final Map Function(CommandArgs)> commands = {}; + final Map commands = {}; final Filter syncFilter; final NativeImplementations nativeImplementations; diff --git a/lib/src/room.dart b/lib/src/room.dart index bdef55e4a..e6c5e3297 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -600,22 +600,29 @@ class Room { /// Sends a normal text message to this room. Returns the event ID generated /// by the server for this message. - Future sendTextEvent(String message, - {String? txid, - Event? inReplyTo, - String? editEventId, - bool parseMarkdown = true, - bool parseCommands = true, - String msgtype = MessageTypes.Text, - String? threadRootEventId, - String? threadLastEventId}) { + Future sendTextEvent( + String message, { + String? txid, + Event? inReplyTo, + String? editEventId, + bool parseMarkdown = true, + bool parseCommands = true, + String msgtype = MessageTypes.Text, + String? threadRootEventId, + String? threadLastEventId, + StringBuffer? commandStdout, + }) { if (parseCommands) { - return client.parseAndRunCommand(this, message, - inReplyTo: inReplyTo, - editEventId: editEventId, - txid: txid, - threadRootEventId: threadRootEventId, - threadLastEventId: threadLastEventId); + return client.parseAndRunCommand( + this, + message, + inReplyTo: inReplyTo, + editEventId: editEventId, + txid: txid, + threadRootEventId: threadRootEventId, + threadLastEventId: threadLastEventId, + stdout: commandStdout, + ); } final event = { 'msgtype': msgtype, diff --git a/lib/src/utils/commands_extension.dart b/lib/src/utils/commands_extension.dart index 2f6d2fc55..bac83f6c3 100644 --- a/lib/src/utils/commands_extension.dart +++ b/lib/src/utils/commands_extension.dart @@ -21,29 +21,38 @@ import 'dart:convert'; import 'package:matrix/matrix.dart'; +/// callback taking [CommandArgs] as input and a [StringBuffer] as standard output +/// optionally returns an event ID as in the [Room.sendEvent] syntax. +/// a [CommandException] should be thrown if the specified arguments are considered invalid +typedef CommandExecutionCallback = FutureOr Function( + CommandArgs, + StringBuffer? stdout, +); + extension CommandsClientExtension on Client { /// Add a command to the command handler. `command` is its name, and `callback` is the /// callback to invoke - void addCommand( - String command, FutureOr Function(CommandArgs) callback) { + void addCommand(String command, CommandExecutionCallback callback) { commands[command.toLowerCase()] = callback; } /// Parse and execute a string, `msg` is the input. Optionally `inReplyTo` is the event being /// replied to and `editEventId` is the eventId of the event being replied to Future parseAndRunCommand( - Room room, + Room? room, String msg, { Event? inReplyTo, String? editEventId, String? txid, String? threadRootEventId, String? threadLastEventId, + StringBuffer? stdout, }) async { final args = CommandArgs( inReplyTo: inReplyTo, editEventId: editEventId, msg: '', + client: this, room: room, txid: txid, threadRootEventId: threadRootEventId, @@ -53,7 +62,7 @@ extension CommandsClientExtension on Client { final sendCommand = commands['send']; if (sendCommand != null) { args.msg = msg; - return await sendCommand(args); + return await sendCommand(args, stdout); } return null; } @@ -69,14 +78,14 @@ extension CommandsClientExtension on Client { } final commandOp = commands[command]; if (commandOp != null) { - return await commandOp(args); + return await commandOp(args, stdout); } if (msg.startsWith('/') && commands.containsKey('send')) { // re-set to include the "command" final sendCommand = commands['send']; if (sendCommand != null) { args.msg = msg; - return await sendCommand(args); + return await sendCommand(args, stdout); } } return null; @@ -89,8 +98,12 @@ extension CommandsClientExtension on Client { /// Register all default commands void registerDefaultCommands() { - addCommand('send', (CommandArgs args) async { - return await args.room.sendTextEvent( + addCommand('send', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + return await room.sendTextEvent( args.msg, inReplyTo: args.inReplyTo, editEventId: args.editEventId, @@ -100,8 +113,12 @@ extension CommandsClientExtension on Client { threadLastEventId: args.threadLastEventId, ); }); - addCommand('me', (CommandArgs args) async { - return await args.room.sendTextEvent( + addCommand('me', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + return await room.sendTextEvent( args.msg, inReplyTo: args.inReplyTo, editEventId: args.editEventId, @@ -112,21 +129,34 @@ extension CommandsClientExtension on Client { threadLastEventId: args.threadLastEventId, ); }); - addCommand('dm', (CommandArgs args) async { + addCommand('dm', (args, stdout) async { final parts = args.msg.split(' '); - return await args.room.client.startDirectChat( - parts.first, + final mxid = parts.first; + if (!mxid.isValidMatrixId) { + throw CommandException('You must enter a valid mxid when using /dm'); + } + + return await args.client.startDirectChat( + mxid, enableEncryption: !parts.any((part) => part == '--no-encryption'), ); }); - addCommand('create', (CommandArgs args) async { + addCommand('create', (args, stdout) async { + final groupName = args.msg.replaceFirst('--no-encryption', '').trim(); + final parts = args.msg.split(' '); - return await args.room.client.createGroupChat( + + return await args.client.createGroupChat( + groupName: groupName, enableEncryption: !parts.any((part) => part == '--no-encryption'), ); }); - addCommand('plain', (CommandArgs args) async { - return await args.room.sendTextEvent( + addCommand('plain', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + return await room.sendTextEvent( args.msg, inReplyTo: args.inReplyTo, editEventId: args.editEventId, @@ -137,166 +167,267 @@ extension CommandsClientExtension on Client { threadLastEventId: args.threadLastEventId, ); }); - addCommand('html', (CommandArgs args) async { + addCommand('html', (args, stdout) async { final event = { 'msgtype': 'm.text', 'body': args.msg, 'format': 'org.matrix.custom.html', 'formatted_body': args.msg, }; - return await args.room.sendEvent( + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + return await room.sendEvent( event, inReplyTo: args.inReplyTo, editEventId: args.editEventId, txid: args.txid, ); }); - addCommand('react', (CommandArgs args) async { + addCommand('react', (args, stdout) async { final inReplyTo = args.inReplyTo; if (inReplyTo == null) { return null; } - return await args.room.sendReaction(inReplyTo.eventId, args.msg); + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + final parts = args.msg.split(' '); + final reaction = parts.first.trim(); + if (reaction.isEmpty) { + throw CommandException('You must provide a reaction when using /react'); + } + return await room.sendReaction(inReplyTo.eventId, reaction); }); - addCommand('join', (CommandArgs args) async { - await args.room.client.joinRoom(args.msg); + addCommand('join', (args, stdout) async { + await args.client.joinRoom(args.msg); return null; }); - addCommand('leave', (CommandArgs args) async { - await args.room.leave(); - return ''; + addCommand('leave', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + await room.leave(); + return null; }); - addCommand('op', (CommandArgs args) async { + addCommand('op', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } final parts = args.msg.split(' '); - if (parts.isEmpty) { - return null; + print(parts); + if (parts.isEmpty || !parts.first.isValidMatrixId) { + throw CommandException('You must enter a valid mxid when using /op'); } int? pl; if (parts.length >= 2) { pl = int.tryParse(parts[1]); + if (pl == null) + throw CommandException( + 'Invalid power level ${parts[1]} when using /op'); } final mxid = parts.first; - return await args.room.setPower(mxid, pl ?? 50); + return await room.setPower(mxid, pl ?? 50); }); - addCommand('kick', (CommandArgs args) async { + addCommand('kick', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } final parts = args.msg.split(' '); - await args.room.kick(parts.first); - return ''; + final mxid = parts.first; + if (!mxid.isValidMatrixId) { + throw CommandException('You must enter a valid mxid when using /kick'); + } + await room.kick(mxid); + return null; }); - addCommand('ban', (CommandArgs args) async { + addCommand('ban', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } final parts = args.msg.split(' '); - await args.room.ban(parts.first); - return ''; + final mxid = parts.first; + if (!mxid.isValidMatrixId) { + throw CommandException('You must enter a valid mxid when using /ban'); + } + await room.ban(mxid); + return null; }); - addCommand('unban', (CommandArgs args) async { + addCommand('unban', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } final parts = args.msg.split(' '); - await args.room.unban(parts.first); - return ''; + final mxid = parts.first; + if (!mxid.isValidMatrixId) { + throw CommandException('You must enter a valid mxid when using /unban'); + } + await room.unban(mxid); + return null; }); - addCommand('invite', (CommandArgs args) async { + addCommand('invite', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + final parts = args.msg.split(' '); - await args.room.invite(parts.first); - return ''; + final mxid = parts.first; + if (!mxid.isValidMatrixId) { + throw CommandException( + 'You must enter a valid mxid when using /invite'); + } + await room.invite(mxid); + return null; }); - addCommand('myroomnick', (CommandArgs args) async { - final currentEventJson = args.room - .getState(EventTypes.RoomMember, args.room.client.userID!) + addCommand('myroomnick', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + + final currentEventJson = room + .getState(EventTypes.RoomMember, args.client.userID!) ?.content .copy() ?? {}; currentEventJson['displayname'] = args.msg; - return await args.room.client.setRoomStateWithKey( - args.room.id, + + return await args.client.setRoomStateWithKey( + room.id, EventTypes.RoomMember, - args.room.client.userID!, + args.client.userID!, currentEventJson, ); }); - addCommand('myroomavatar', (CommandArgs args) async { - final currentEventJson = args.room - .getState(EventTypes.RoomMember, args.room.client.userID!) + addCommand('myroomavatar', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + + final currentEventJson = room + .getState(EventTypes.RoomMember, args.client.userID!) ?.content .copy() ?? {}; currentEventJson['avatar_url'] = args.msg; - return await args.room.client.setRoomStateWithKey( - args.room.id, + + return await args.client.setRoomStateWithKey( + room.id, EventTypes.RoomMember, - args.room.client.userID!, + args.client.userID!, currentEventJson, ); }); - addCommand('discardsession', (CommandArgs args) async { + addCommand('discardsession', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } await encryption?.keyManager - .clearOrUseOutboundGroupSession(args.room.id, wipe: true); - return ''; + .clearOrUseOutboundGroupSession(room.id, wipe: true); + return null; }); - addCommand('clearcache', (CommandArgs args) async { + addCommand('clearcache', (args, stdout) async { await clearCache(); - return ''; + return null; }); - addCommand('markasdm', (CommandArgs args) async { - final mxid = args.msg; + addCommand('markasdm', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + + final mxid = args.msg.split(' ').first; if (!mxid.isValidMatrixId) { - throw Exception('You must enter a valid mxid when using /maskasdm'); + throw CommandException( + 'You must enter a valid mxid when using /maskasdm'); } - if (await args.room.requestUser(mxid, requestProfile: false) == null) { - throw Exception('User $mxid is not in this room'); + if (await room.requestUser(mxid, requestProfile: false) == null) { + throw CommandException('User $mxid is not in this room'); } - await args.room.addToDirectChat(args.msg); + await room.addToDirectChat(mxid); return; }); - addCommand('markasgroup', (CommandArgs args) async { - await args.room.removeFromDirectChat(); + addCommand('markasgroup', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + + await room.removeFromDirectChat(); return; }); - addCommand('hug', (CommandArgs args) async { + addCommand('hug', (args, stdout) async { final content = CuteEventContent.hug; - return await args.room.sendEvent( + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + return await room.sendEvent( content, inReplyTo: args.inReplyTo, editEventId: args.editEventId, txid: args.txid, ); }); - addCommand('googly', (CommandArgs args) async { + addCommand('googly', (args, stdout) async { final content = CuteEventContent.googlyEyes; - return await args.room.sendEvent( + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + return await room.sendEvent( content, inReplyTo: args.inReplyTo, editEventId: args.editEventId, txid: args.txid, ); }); - addCommand('cuddle', (CommandArgs args) async { + addCommand('cuddle', (args, stdout) async { final content = CuteEventContent.cuddle; - return await args.room.sendEvent( + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + return await room.sendEvent( content, inReplyTo: args.inReplyTo, editEventId: args.editEventId, txid: args.txid, ); }); - addCommand('sendRaw', (args) async { - await args.room.sendEvent( + addCommand('sendRaw', (args, stdout) async { + final room = args.room; + if (room == null) { + throw RoomCommandException(); + } + return await room.sendEvent( jsonDecode(args.msg), inReplyTo: args.inReplyTo, txid: args.txid, ); - return null; }); - addCommand('ignore', (args) async { + addCommand('ignore', (args, stdout) async { final mxid = args.msg; if (mxid.isEmpty) { - throw 'Please provide a User ID'; + throw CommandException('Please provide a User ID'); } await ignoreUser(mxid); return null; }); - addCommand('unignore', (args) async { + addCommand('unignore', (args, stdout) async { final mxid = args.msg; if (mxid.isEmpty) { - throw 'Please provide a User ID'; + throw CommandException('Please provide a User ID'); } await unignoreUser(mxid); return null; @@ -308,7 +439,8 @@ class CommandArgs { String msg; String? editEventId; Event? inReplyTo; - Room room; + Client client; + Room? room; String? txid; String? threadRootEventId; String? threadLastEventId; @@ -317,8 +449,24 @@ class CommandArgs { {required this.msg, this.editEventId, this.inReplyTo, - required this.room, + required this.client, + this.room, this.txid, this.threadRootEventId, this.threadLastEventId}); } + +class CommandException implements Exception { + final String message; + + const CommandException(this.message); + + @override + String toString() { + return '${super.toString()}: $message'; + } +} + +class RoomCommandException extends CommandException { + const RoomCommandException() : super('This command must run on a room'); +} From b0fab7921c8fd7095702f0bf399789f6b6c8d50a Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Mon, 14 Oct 2024 10:23:49 +0200 Subject: [PATCH 2/2] chore: use stdout for default command output Signed-off-by: The one with the braid --- lib/src/utils/commands_extension.dart | 98 +++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 15 deletions(-) diff --git a/lib/src/utils/commands_extension.dart b/lib/src/utils/commands_extension.dart index bac83f6c3..21134e93d 100644 --- a/lib/src/utils/commands_extension.dart +++ b/lib/src/utils/commands_extension.dart @@ -136,20 +136,27 @@ extension CommandsClientExtension on Client { throw CommandException('You must enter a valid mxid when using /dm'); } - return await args.client.startDirectChat( + final roomId = await args.client.startDirectChat( mxid, enableEncryption: !parts.any((part) => part == '--no-encryption'), ); + stdout?.write(DefaultCommandOutput( + rooms: [roomId], + users: [mxid], + ).toString()); + return null; }); addCommand('create', (args, stdout) async { final groupName = args.msg.replaceFirst('--no-encryption', '').trim(); final parts = args.msg.split(' '); - return await args.client.createGroupChat( + final roomId = await args.client.createGroupChat( groupName: groupName, enableEncryption: !parts.any((part) => part == '--no-encryption'), ); + stdout?.write(DefaultCommandOutput(rooms: [roomId]).toString()); + return null; }); addCommand('plain', (args, stdout) async { final room = args.room; @@ -202,7 +209,8 @@ extension CommandsClientExtension on Client { return await room.sendReaction(inReplyTo.eventId, reaction); }); addCommand('join', (args, stdout) async { - await args.client.joinRoom(args.msg); + final roomId = await args.client.joinRoom(args.msg); + stdout?.write(DefaultCommandOutput(rooms: [roomId]).toString()); return null; }); addCommand('leave', (args, stdout) async { @@ -219,16 +227,16 @@ extension CommandsClientExtension on Client { throw RoomCommandException(); } final parts = args.msg.split(' '); - print(parts); if (parts.isEmpty || !parts.first.isValidMatrixId) { throw CommandException('You must enter a valid mxid when using /op'); } int? pl; if (parts.length >= 2) { pl = int.tryParse(parts[1]); - if (pl == null) + if (pl == null) { throw CommandException( 'Invalid power level ${parts[1]} when using /op'); + } } final mxid = parts.first; return await room.setPower(mxid, pl ?? 50); @@ -244,6 +252,7 @@ extension CommandsClientExtension on Client { throw CommandException('You must enter a valid mxid when using /kick'); } await room.kick(mxid); + stdout?.write(DefaultCommandOutput(users: [mxid]).toString()); return null; }); addCommand('ban', (args, stdout) async { @@ -257,6 +266,7 @@ extension CommandsClientExtension on Client { throw CommandException('You must enter a valid mxid when using /ban'); } await room.ban(mxid); + stdout?.write(DefaultCommandOutput(users: [mxid]).toString()); return null; }); addCommand('unban', (args, stdout) async { @@ -270,6 +280,7 @@ extension CommandsClientExtension on Client { throw CommandException('You must enter a valid mxid when using /unban'); } await room.unban(mxid); + stdout?.write(DefaultCommandOutput(users: [mxid]).toString()); return null; }); addCommand('invite', (args, stdout) async { @@ -285,6 +296,7 @@ extension CommandsClientExtension on Client { 'You must enter a valid mxid when using /invite'); } await room.invite(mxid); + stdout?.write(DefaultCommandOutput(users: [mxid]).toString()); return null; }); addCommand('myroomnick', (args, stdout) async { @@ -355,7 +367,8 @@ extension CommandsClientExtension on Client { throw CommandException('User $mxid is not in this room'); } await room.addToDirectChat(mxid); - return; + stdout?.write(DefaultCommandOutput(users: [mxid]).toString()); + return null; }); addCommand('markasgroup', (args, stdout) async { final room = args.room; @@ -422,6 +435,7 @@ extension CommandsClientExtension on Client { throw CommandException('Please provide a User ID'); } await ignoreUser(mxid); + stdout?.write(DefaultCommandOutput(users: [mxid]).toString()); return null; }); addCommand('unignore', (args, stdout) async { @@ -430,6 +444,7 @@ extension CommandsClientExtension on Client { throw CommandException('Please provide a User ID'); } await unignoreUser(mxid); + stdout?.write(DefaultCommandOutput(users: [mxid]).toString()); return null; }); } @@ -445,15 +460,16 @@ class CommandArgs { String? threadRootEventId; String? threadLastEventId; - CommandArgs( - {required this.msg, - this.editEventId, - this.inReplyTo, - required this.client, - this.room, - this.txid, - this.threadRootEventId, - this.threadLastEventId}); + CommandArgs({ + required this.msg, + this.editEventId, + this.inReplyTo, + required this.client, + this.room, + this.txid, + this.threadRootEventId, + this.threadLastEventId, + }); } class CommandException implements Exception { @@ -470,3 +486,55 @@ class CommandException implements Exception { class RoomCommandException extends CommandException { const RoomCommandException() : super('This command must run on a room'); } + +/// Helper class for normalized command output +/// +/// This class can be used to provide a default, processable output of commands +/// containing some generic data. +class DefaultCommandOutput { + static const format = 'com.famedly.default_command_output'; + final List? rooms; + final List? events; + final List? users; + final List? messages; + final Map? custom; + + const DefaultCommandOutput({ + this.rooms, + this.events, + this.users, + this.messages, + this.custom, + }); + + static DefaultCommandOutput? fromStdout(String stdout) { + final Object? json = jsonDecode(stdout); + if (json is! Map) { + return null; + } + if (json['format'] != format) return null; + return DefaultCommandOutput( + rooms: json['rooms'] as List?, + events: json['events'] as List?, + users: json['users'] as List?, + messages: json['messages'] as List?, + custom: json['custom'] as Map?, + ); + } + + Map toJson() { + return { + 'format': format, + if (rooms != null) 'rooms': rooms, + if (events != null) 'events': events, + if (users != null) 'users': users, + if (messages != null) 'messages': messages, + ...?custom, + }; + } + + @override + String toString() { + return jsonEncode(toJson()); + } +}