feat: Add command parser

This commit is contained in:
Sorunome 2021-02-03 16:35:36 +01:00
parent 5200764604
commit fcb8d48bd7
No known key found for this signature in database
GPG Key ID: B19471D07FC9BE9C
6 changed files with 485 additions and 6 deletions

View File

@ -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';

View File

@ -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<String, FutureOr<String> 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.

View File

@ -578,15 +578,17 @@ class Room {
Event inReplyTo,
String editEventId,
bool parseMarkdown = true,
Map<String, Map<String, String>> emotePacks}) {
Map<String, Map<String, String>> emotePacks,
bool parseCommands = true,
String msgtype = MessageTypes.Text}) {
if (parseCommands) {
return client.parseAndRunCommand(this, message,
inReplyTo: inReplyTo, editEventId: editEventId, txid: txid);
}
final event = <String, dynamic>{
'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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> 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<String> 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 = <String, dynamic>{
'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});
}

255
test/commands_test.dart Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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 <em>Boop</em>',
});
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 <b>yay</b>');
var sent = getLastMessagePayload();
expect(sent, {
'msgtype': 'm.text',
'body': '<b>yay</b>',
'format': 'org.matrix.custom.html',
'formatted_body': '<b>yay</b>',
});
});
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);
});
});
}

View File

@ -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,