diff --git a/lib/famedlysdk.dart b/lib/famedlysdk.dart index e639154e..aac105e0 100644 --- a/lib/famedlysdk.dart +++ b/lib/famedlysdk.dart @@ -31,6 +31,7 @@ export 'src/utils/states_map.dart'; export 'src/utils/sync_update_extension.dart'; export 'src/utils/to_device_event.dart'; export 'src/utils/uia_request.dart'; +export 'src/utils/commands_extension.dart'; export 'src/client.dart'; export 'src/event.dart'; export 'src/room.dart'; diff --git a/lib/src/client.dart b/lib/src/client.dart index a9aa3d5e..0c7e4bb8 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -32,6 +32,7 @@ import 'database/database.dart' show Database; import 'event.dart'; import 'room.dart'; import 'user.dart'; +import 'utils/commands_extension.dart'; import 'utils/device_keys_list.dart'; import 'utils/event_update.dart'; import 'utils/matrix_file.dart'; @@ -83,6 +84,9 @@ class Client extends MatrixApi { bool mxidLocalPartFallback = true; + // For CommandsClientExtension + final Map Function(CommandArgs)> commands = {}; + /// Create a client /// [clientName] = unique identifier of this client /// [database]: The database instance to use @@ -147,6 +151,9 @@ class Client extends MatrixApi { EventTypes.Sticker, ]); this.httpClient = httpClient ?? http.Client(); + + // register all the default commands + registerDefaultCommands(); } /// The required name for this client. diff --git a/lib/src/room.dart b/lib/src/room.dart index 324c1047..2da9138a 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -578,15 +578,17 @@ class Room { Event inReplyTo, String editEventId, bool parseMarkdown = true, - Map> emotePacks}) { + Map> emotePacks, + bool parseCommands = true, + String msgtype = MessageTypes.Text}) { + if (parseCommands) { + return client.parseAndRunCommand(this, message, + inReplyTo: inReplyTo, editEventId: editEventId, txid: txid); + } final event = { - 'msgtype': 'm.text', + 'msgtype': msgtype, 'body': message, }; - if (message.startsWith('/me ')) { - event['msgtype'] = 'm.emote'; - event['body'] = message.substring(4); - } if (parseMarkdown) { final html = markdown(event['body'], emotePacks ?? this.emotePacks); // if the decoded html is the same as the body, there is no need in sending a formatted message diff --git a/lib/src/utils/commands_extension.dart b/lib/src/utils/commands_extension.dart new file mode 100644 index 00000000..7e89a620 --- /dev/null +++ b/lib/src/utils/commands_extension.dart @@ -0,0 +1,203 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2021 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:async'; + +import '../../famedlysdk.dart'; + +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) { + 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, String msg, + {Event inReplyTo, String editEventId, String txid}) async { + final args = CommandArgs( + inReplyTo: inReplyTo, + editEventId: editEventId, + msg: '', + room: room, + txid: txid, + ); + if (!msg.startsWith('/')) { + if (commands.containsKey('send')) { + args.msg = msg; + return await commands['send'](args); + } + return null; + } + // remove the / + msg = msg.substring(1); + var command = msg; + if (msg.contains(' ')) { + final idx = msg.indexOf(' '); + command = msg.substring(0, idx).toLowerCase(); + args.msg = msg.substring(idx + 1); + } else { + command = msg.toLowerCase(); + } + if (commands.containsKey(command)) { + return await commands[command](args); + } + if (msg.startsWith('/') && commands.containsKey('send')) { + // re-set to include the "command" + args.msg = msg; + return await commands['send'](args); + } + return null; + } + + /// Unregister all commands + void unregisterAllCommands() { + commands.clear(); + } + + /// Register all default commands + void registerDefaultCommands() { + addCommand('send', (CommandArgs args) async { + return await args.room.sendTextEvent( + args.msg, + inReplyTo: args.inReplyTo, + editEventId: args.editEventId, + parseCommands: false, + txid: args.txid, + ); + }); + addCommand('me', (CommandArgs args) async { + return await args.room.sendTextEvent( + args.msg, + inReplyTo: args.inReplyTo, + editEventId: args.editEventId, + msgtype: MessageTypes.Emote, + parseCommands: false, + txid: args.txid, + ); + }); + addCommand('plain', (CommandArgs args) async { + return await args.room.sendTextEvent( + args.msg, + inReplyTo: args.inReplyTo, + editEventId: args.editEventId, + parseMarkdown: false, + parseCommands: false, + txid: args.txid, + ); + }); + addCommand('html', (CommandArgs args) async { + final event = { + 'msgtype': 'm.text', + 'body': args.msg, + 'format': 'org.matrix.custom.html', + 'formatted_body': args.msg, + }; + return await args.room.sendEvent( + event, + inReplyTo: args.inReplyTo, + editEventId: args.editEventId, + txid: args.txid, + ); + }); + addCommand('react', (CommandArgs args) async { + if (args.inReplyTo == null) { + return null; + } + return await args.room.sendReaction(args.inReplyTo.eventId, args.msg); + }); + addCommand('join', (CommandArgs args) async { + await args.room.client.joinRoomOrAlias(args.msg); + return null; + }); + addCommand('leave', (CommandArgs args) async { + await args.room.leave(); + return ''; + }); + addCommand('op', (CommandArgs args) async { + final parts = args.msg.split(' '); + if (parts.isEmpty) { + return null; + } + var pl = 50; + if (parts.length >= 2) { + pl = int.tryParse(parts[1]); + } + final mxid = parts.first; + return await args.room.setPower(mxid, pl); + }); + addCommand('kick', (CommandArgs args) async { + final parts = args.msg.split(' '); + await args.room.kick(parts.first); + return ''; + }); + addCommand('ban', (CommandArgs args) async { + final parts = args.msg.split(' '); + await args.room.ban(parts.first); + return ''; + }); + addCommand('unban', (CommandArgs args) async { + final parts = args.msg.split(' '); + await args.room.unban(parts.first); + return ''; + }); + addCommand('invite', (CommandArgs args) async { + final parts = args.msg.split(' '); + await args.room.invite(parts.first); + return ''; + }); + addCommand('myroomnick', (CommandArgs args) async { + final currentEventJson = args.room + .getState(EventTypes.RoomMember, args.room.client.userID) + .content + .copy(); + currentEventJson['displayname'] = args.msg; + return await args.room.client.sendState( + args.room.id, + EventTypes.RoomMember, + currentEventJson, + args.room.client.userID, + ); + }); + addCommand('myroomavatar', (CommandArgs args) async { + final currentEventJson = args.room + .getState(EventTypes.RoomMember, args.room.client.userID) + .content + .copy(); + currentEventJson['avatar_url'] = args.msg; + return await args.room.client.sendState( + args.room.id, + EventTypes.RoomMember, + currentEventJson, + args.room.client.userID, + ); + }); + } +} + +class CommandArgs { + String msg; + String editEventId; + Event inReplyTo; + Room room; + String txid; + CommandArgs( + {this.msg, this.editEventId, this.inReplyTo, this.room, this.txid}); +} diff --git a/test/commands_test.dart b/test/commands_test.dart new file mode 100644 index 00000000..d44c3875 --- /dev/null +++ b/test/commands_test.dart @@ -0,0 +1,255 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2021 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:famedlysdk/famedlysdk.dart'; +import 'fake_client.dart'; +import 'fake_matrix_api.dart'; + +void main() { + group('Commands', () { + Client client; + Room room; + + final getLastMessagePayload = + ([String type = 'm.room.message', String stateKey]) { + final state = stateKey != null; + return json.decode(FakeMatrixApi.calledEndpoints.entries + .firstWhere((e) => e.key.startsWith( + '/client/r0/rooms/${Uri.encodeComponent(room.id)}/${state ? 'state' : 'send'}/${Uri.encodeComponent(type)}${state && stateKey.isNotEmpty ? '/' + Uri.encodeComponent(stateKey) : ''}')) + .value + .first); + }; + + test('setupClient', () async { + client = await getClient(); + room = Room(id: '!1234:fakeServer.notExisting', client: client); + room.setState(Event( + type: 'm.room.power_levels', + content: {}, + room: room, + stateKey: '', + )); + room.setState(Event( + type: 'm.room.member', + content: {'membership': 'join'}, + room: room, + stateKey: client.userID, + )); + }); + + test('send', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/send Hello World'); + var sent = getLastMessagePayload(); + expect(sent, { + 'msgtype': 'm.text', + 'body': 'Hello World', + }); + + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('Beep Boop'); + sent = getLastMessagePayload(); + expect(sent, { + 'msgtype': 'm.text', + 'body': 'Beep Boop', + }); + + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('Beep *Boop*'); + sent = getLastMessagePayload(); + expect(sent, { + 'msgtype': 'm.text', + 'body': 'Beep *Boop*', + 'format': 'org.matrix.custom.html', + 'formatted_body': 'Beep Boop', + }); + + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('//send Hello World'); + sent = getLastMessagePayload(); + expect(sent, { + 'msgtype': 'm.text', + 'body': '/send Hello World', + }); + }); + + test('me', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/me heya'); + var sent = getLastMessagePayload(); + expect(sent, { + 'msgtype': 'm.emote', + 'body': 'heya', + }); + }); + + test('plain', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/plain *floof*'); + var sent = getLastMessagePayload(); + expect(sent, { + 'msgtype': 'm.text', + 'body': '*floof*', + }); + }); + + test('html', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/html yay'); + var sent = getLastMessagePayload(); + expect(sent, { + 'msgtype': 'm.text', + 'body': 'yay', + 'format': 'org.matrix.custom.html', + 'formatted_body': 'yay', + }); + }); + + test('react', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/react 🦊', + inReplyTo: Event(eventId: '\$event')); + var sent = getLastMessagePayload('m.reaction'); + expect(sent, { + 'm.relates_to': { + 'rel_type': 'm.annotation', + 'event_id': '\$event', + 'key': '🦊', + }, + }); + }); + + test('join', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/join !newroom:example.com'); + expect( + FakeMatrixApi + .calledEndpoints['/client/r0/join/!newroom%3Aexample.com'] + .first != + null, + true); + }); + + test('leave', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/leave'); + expect( + FakeMatrixApi + .calledEndpoints[ + '/client/r0/rooms/!1234%3AfakeServer.notExisting/leave'] + .first != + null, + true); + }); + + test('op', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/op @user:example.org'); + var sent = getLastMessagePayload('m.room.power_levels', ''); + expect(sent, { + 'users': {'@user:example.org': 50} + }); + + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/op @user:example.org 100'); + sent = getLastMessagePayload('m.room.power_levels', ''); + expect(sent, { + 'users': {'@user:example.org': 100} + }); + }); + + test('kick', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/kick @baduser:example.org'); + expect( + json.decode(FakeMatrixApi + .calledEndpoints[ + '/client/r0/rooms/!1234%3AfakeServer.notExisting/kick'] + .first), + { + 'user_id': '@baduser:example.org', + }); + }); + + test('ban', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/ban @baduser:example.org'); + expect( + json.decode(FakeMatrixApi + .calledEndpoints[ + '/client/r0/rooms/!1234%3AfakeServer.notExisting/ban'] + .first), + { + 'user_id': '@baduser:example.org', + }); + }); + + test('unban', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/unban @baduser:example.org'); + expect( + json.decode(FakeMatrixApi + .calledEndpoints[ + '/client/r0/rooms/!1234%3AfakeServer.notExisting/unban'] + .first), + { + 'user_id': '@baduser:example.org', + }); + }); + + test('invite', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/invite @baduser:example.org'); + expect( + json.decode(FakeMatrixApi + .calledEndpoints[ + '/client/r0/rooms/!1234%3AfakeServer.notExisting/invite'] + .first), + { + 'user_id': '@baduser:example.org', + }); + }); + + test('myroomnick', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/myroomnick Foxies~'); + var sent = getLastMessagePayload('m.room.member', client.userID); + expect(sent, { + 'displayname': 'Foxies~', + 'membership': 'join', + }); + }); + + test('myroomavatar', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent('/myroomavatar mxc://beep/boop'); + var sent = getLastMessagePayload('m.room.member', client.userID); + expect(sent, { + 'avatar_url': 'mxc://beep/boop', + 'membership': 'join', + }); + }); + + test('dispose client', () async { + await client.dispose(closeDatabase: true); + }); + }); +} diff --git a/test/fake_matrix_api.dart b/test/fake_matrix_api.dart index d6e2dc0d..a41e5671 100644 --- a/test/fake_matrix_api.dart +++ b/test/fake_matrix_api.dart @@ -104,6 +104,10 @@ class FakeMatrixApi extends MockClient { action.contains( '/client/r0/rooms/!1234%3AfakeServer.notExisting/send/')) { res = {'event_id': '\$event${FakeMatrixApi.eventCounter++}'}; + } else if (method == 'PUT' && + action.contains( + '/client/r0/rooms/!1234%3AfakeServer.notExisting/state/')) { + res = {'event_id': '\$event${FakeMatrixApi.eventCounter++}'}; } else if (action.contains('/client/r0/sync')) { res = { 'next_batch': DateTime.now().millisecondsSinceEpoch.toString @@ -1830,16 +1834,23 @@ class FakeMatrixApi extends MockClient { } }, '/client/r0/rooms/!localpart%3Aexample.com/invite': (var req) => {}, + '/client/r0/rooms/!1234%3AfakeServer.notExisting/invite': (var req) => {}, '/client/r0/rooms/!localpart%3Aexample.com/leave': (var req) => {}, + '/client/r0/rooms/!1234%3AfakeServer.notExisting/leave': (var req) => {}, '/client/r0/rooms/!localpart%3Aexample.com/forget': (var req) => {}, '/client/r0/rooms/!localpart%3Aserver.abc/kick': (var req) => {}, + '/client/r0/rooms/!1234%3AfakeServer.notExisting/kick': (var req) => {}, '/client/r0/rooms/!localpart%3Aexample.com/kick': (var req) => {}, '/client/r0/rooms/!localpart%3Aexample.com/ban': (var req) => {}, + '/client/r0/rooms/!1234%3AfakeServer.notExisting/ban': (var req) => {}, '/client/r0/rooms/!localpart%3Aexample.com/unban': (var req) => {}, + '/client/r0/rooms/!1234%3AfakeServer.notExisting/unban': (var req) => {}, '/client/r0/rooms/!localpart%3Aexample.com/join': (var req) => {'room_id': '!localpart:example.com'}, '/client/r0/join/!localpart%3Aexample.com?server_name=example.com&server_name=example.abc': (var req) => {'room_id': '!localpart:example.com'}, + '/client/r0/join/!newroom%3Aexample.com': (var req) => + {'room_id': '!newroom%3A:example.com'}, '/client/r0/keys/upload': (var req) => { 'one_time_key_counts': { 'curve25519': 10,