diff --git a/lib/fake_matrix_api.dart b/lib/fake_matrix_api.dart index 34bf8e37..69b9a7e9 100644 --- a/lib/fake_matrix_api.dart +++ b/lib/fake_matrix_api.dart @@ -43,7 +43,9 @@ class FakeMatrixApi extends BaseClient { static Map> get calledEndpoints => currentApi!._calledEndpoints; + static int get eventCounter => currentApi!._eventCounter; + static set eventCounter(int c) { currentApi!._eventCounter = c; } @@ -67,6 +69,8 @@ class FakeMatrixApi extends BaseClient { bool _trace = false; final _apiCallStream = StreamController.broadcast(); + static RoomsUpdate? _pendingRoomsUpdate; + static FakeMatrixApi? currentApi; static Future firstWhereValue(String value) { @@ -105,12 +109,13 @@ class FakeMatrixApi extends BaseClient { '${request.url.path.split('/_matrix').last}?${request.url.query}'; } - // ignore: avoid_print - if (_trace) print('called $action'); - if (action.endsWith('?')) { action = action.substring(0, action.length - 1); } + + // ignore: avoid_print + if (_trace) print('called $action'); + if (action.endsWith('?server_name')) { // This can be removed after matrix_api_lite is released with: // https://gitlab.com/famedly/libraries/matrix_api_lite/-/merge_requests/16 @@ -214,6 +219,8 @@ class FakeMatrixApi extends BaseClient { 'curve25519': 10, 'signed_curve25519': 100, }, + if (_pendingRoomsUpdate != null) + 'rooms': _pendingRoomsUpdate?.toJson(), }; } else if (method == 'PUT' && _client != null && @@ -2536,9 +2543,19 @@ class FakeMatrixApi extends BaseClient { '/client/v3/pushers/set': (var reqI) => {}, '/client/v3/join/1234': (var reqI) => {'room_id': '1234'}, '/client/v3/logout/all': (var reqI) => {}, - '/client/v3/createRoom': (var reqI) => { - 'room_id': '!1234:fakeServer.notExisting', - }, + '/client/v3/createRoom': (var reqI) { + final roomId = '!1234:fakeServer.notExisting'; + unawaited( + Future.delayed(Duration(milliseconds: 100)).then((_) { + _pendingRoomsUpdate = + RoomsUpdate(join: {roomId: JoinedRoomUpdate()}); + }), + ); + + return { + 'room_id': roomId, + }; + }, '/client/v3/rooms/!localpart%3Aserver.abc/read_markers': (var reqI) => {}, '/client/v3/rooms/!localpart:server.abc/kick': (var reqI) => {}, '/client/v3/rooms/!localpart%3Aserver.abc/ban': (var reqI) => {}, diff --git a/lib/src/client.dart b/lib/src/client.dart index c9ed118a..5b58e3f4 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -98,7 +98,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 ef504c87..c4e12567 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -623,6 +623,7 @@ class Room { String msgtype = MessageTypes.Text, String? threadRootEventId, String? threadLastEventId, + StringBuffer? commandStdout, }) { if (parseCommands) { return client.parseAndRunCommand( @@ -633,6 +634,7 @@ class Room { txid: txid, threadRootEventId: threadRootEventId, threadLastEventId: threadLastEventId, + stdout: commandStdout, ); } final event = { diff --git a/lib/src/utils/commands_extension.dart b/lib/src/utils/commands_extension.dart index 6f711e37..2962b156 100644 --- a/lib/src/utils/commands_extension.dart +++ b/lib/src/utils/commands_extension.dart @@ -21,31 +21,40 @@ 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 + /// The [room] parameter can be null unless you execute a command strictly + /// requiring a [Room] to run hon. 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, @@ -55,7 +64,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; } @@ -71,14 +80,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; @@ -91,8 +100,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, @@ -102,8 +115,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, @@ -114,21 +131,44 @@ 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'); + } + + 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', (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( + + final roomId = await args.client.createGroupChat( + groupName: groupName.isNotEmpty ? groupName : null, enableEncryption: !parts.any((part) => part == '--no-encryption'), + waitForSync: false, ); + stdout?.write(DefaultCommandOutput(rooms: [roomId]).toString()); + return null; }); - 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, @@ -139,168 +179,280 @@ 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 { + final roomId = await args.client.joinRoom(args.msg); + stdout?.write(DefaultCommandOutput(rooms: [roomId]).toString()); 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; + 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); + stdout?.write(DefaultCommandOutput(users: [mxid]).toString()); + 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); + stdout?.write(DefaultCommandOutput(users: [mxid]).toString()); + 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); + stdout?.write(DefaultCommandOutput(users: [mxid]).toString()); + 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); + stdout?.write(DefaultCommandOutput(users: [mxid]).toString()); + 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); - return; + await room.addToDirectChat(mxid); + stdout?.write(DefaultCommandOutput(users: [mxid]).toString()); + return null; }); - 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); + stdout?.write(DefaultCommandOutput(users: [mxid]).toString()); 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); + stdout?.write(DefaultCommandOutput(users: [mxid]).toString()); return null; }); } @@ -310,7 +462,8 @@ class CommandArgs { String msg; String? editEventId; Event? inReplyTo; - Room room; + Client client; + Room? room; String? txid; String? threadRootEventId; String? threadLastEventId; @@ -319,9 +472,87 @@ 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'); +} + +/// 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'] == null + ? null + : List.from(json['rooms'] as Iterable), + events: json['events'] == null + ? null + : List.from(json['events'] as Iterable), + users: json['users'] == null + ? null + : List.from(json['users'] as Iterable), + messages: json['messages'] == null + ? null + : List.from(json['messages'] as Iterable), + custom: json['custom'] == null + ? null + : Map.from(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()); + } +} diff --git a/test/commands_test.dart b/test/commands_test.dart index 5528da27..92d0fd6a 100644 --- a/test/commands_test.dart +++ b/test/commands_test.dart @@ -395,7 +395,7 @@ void main() { await room.sendTextEvent('/dm @alice:example.com --no-encryption'); expect( json.decode( - FakeMatrixApi.calledEndpoints['/client/v3/createRoom']?.first, + FakeMatrixApi.calledEndpoints['/client/v3/createRoom']?.last, ), { 'invite': ['@alice:example.com'], @@ -406,12 +406,15 @@ void main() { test('create', () async { FakeMatrixApi.calledEndpoints.clear(); - await room.sendTextEvent('/create @alice:example.com --no-encryption'); + await room.sendTextEvent('/create New room --no-encryption'); expect( json.decode( - FakeMatrixApi.calledEndpoints['/client/v3/createRoom']?.first, + FakeMatrixApi.calledEndpoints['/client/v3/createRoom']?.last, ), - {'preset': 'private_chat'}, + { + 'name': 'New room', + 'preset': 'private_chat', + }, ); }); @@ -527,6 +530,22 @@ void main() { expect(sent, CuteEventContent.cuddle); }); + test('client - clearcache', () async { + await client.parseAndRunCommand(null, '/clearcache'); + expect(client.prevBatch, null); + }); + + test('client - missing room - discardsession', () async { + Object? error; + try { + await client.parseAndRunCommand(null, '/discardsession'); + } catch (e) { + error = e; + } + + expect(error is RoomCommandException, isTrue); + }); + test('dispose client', () async { await client.dispose(closeDatabase: true); });