Merge pull request #1937 from TheOneWithTheBraid/braid/command-runner

feat: improve commands_extension
This commit is contained in:
Karthikeyan S 2025-02-04 16:30:36 +05:30 committed by GitHub
commit 24a0cfb9a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 364 additions and 96 deletions

View File

@ -98,7 +98,7 @@ class Client extends MatrixApi {
DateTime? _accessTokenExpiresAt; DateTime? _accessTokenExpiresAt;
// For CommandsClientExtension // For CommandsClientExtension
final Map<String, FutureOr<String?> Function(CommandArgs)> commands = {}; final Map<String, CommandExecutionCallback> commands = {};
final Filter syncFilter; final Filter syncFilter;
final NativeImplementations nativeImplementations; final NativeImplementations nativeImplementations;

View File

@ -625,6 +625,7 @@ class Room {
String msgtype = MessageTypes.Text, String msgtype = MessageTypes.Text,
String? threadRootEventId, String? threadRootEventId,
String? threadLastEventId, String? threadLastEventId,
StringBuffer? commandStdout,
}) { }) {
if (parseCommands) { if (parseCommands) {
return client.parseAndRunCommand( return client.parseAndRunCommand(
@ -635,6 +636,7 @@ class Room {
txid: txid, txid: txid,
threadRootEventId: threadRootEventId, threadRootEventId: threadRootEventId,
threadLastEventId: threadLastEventId, threadLastEventId: threadLastEventId,
stdout: commandStdout,
); );
} }
final event = <String, dynamic>{ final event = <String, dynamic>{

View File

@ -21,31 +21,45 @@ import 'dart:convert';
import 'package:matrix/matrix.dart'; 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<String?> Function(
CommandArgs,
StringBuffer? stdout,
);
extension CommandsClientExtension on Client { extension CommandsClientExtension on Client {
/// Add a command to the command handler. `command` is its name, and `callback` is the /// Add a command to the command handler. `command` is its name, and `callback` is the
/// callback to invoke /// callback to invoke
void addCommand( void addCommand(String command, CommandExecutionCallback callback) {
String command,
FutureOr<String?> Function(CommandArgs) callback,
) {
commands[command.toLowerCase()] = callback; commands[command.toLowerCase()] = callback;
} }
/// Parse and execute a string, `msg` is the input. Optionally `inReplyTo` is the event being /// Parse and execute a command on Client level
/// replied to and `editEventId` is the eventId of the event being replied to /// - `room`: a [Room] to run the command on. Can be null unless you execute a command strictly requiring a [Room] to run on
/// - `msg`: the complete input to process
/// - `inReplyTo`: an optional [Event] the command is supposed to reply to
/// - `editEventId`: an optional event ID the command is supposed to edit
/// - `txid`: an optional transaction ID
/// - `threadRootEventId`: an optional root event ID of a thread the command is supposed to run on
/// - `threadLastEventId`: an optional most recent event ID of a thread the command is supposed to run on
/// - `stdout`: an optional [StringBuffer] the command can write output to. This is meant as tiny implementation of https://en.wikipedia.org/wiki/Standard_streams in order to process advanced command output to the matrix client. See [DefaultCommandOutput] for a rough idea.
Future<String?> parseAndRunCommand( Future<String?> parseAndRunCommand(
Room room, Room? room,
String msg, { String msg, {
Event? inReplyTo, Event? inReplyTo,
String? editEventId, String? editEventId,
String? txid, String? txid,
String? threadRootEventId, String? threadRootEventId,
String? threadLastEventId, String? threadLastEventId,
StringBuffer? stdout,
}) async { }) async {
final args = CommandArgs( final args = CommandArgs(
inReplyTo: inReplyTo, inReplyTo: inReplyTo,
editEventId: editEventId, editEventId: editEventId,
msg: '', msg: '',
client: this,
room: room, room: room,
txid: txid, txid: txid,
threadRootEventId: threadRootEventId, threadRootEventId: threadRootEventId,
@ -55,7 +69,7 @@ extension CommandsClientExtension on Client {
final sendCommand = commands['send']; final sendCommand = commands['send'];
if (sendCommand != null) { if (sendCommand != null) {
args.msg = msg; args.msg = msg;
return await sendCommand(args); return await sendCommand(args, stdout);
} }
return null; return null;
} }
@ -71,14 +85,14 @@ extension CommandsClientExtension on Client {
} }
final commandOp = commands[command]; final commandOp = commands[command];
if (commandOp != null) { if (commandOp != null) {
return await commandOp(args); return await commandOp(args, stdout);
} }
if (msg.startsWith('/') && commands.containsKey('send')) { if (msg.startsWith('/') && commands.containsKey('send')) {
// re-set to include the "command" // re-set to include the "command"
final sendCommand = commands['send']; final sendCommand = commands['send'];
if (sendCommand != null) { if (sendCommand != null) {
args.msg = msg; args.msg = msg;
return await sendCommand(args); return await sendCommand(args, stdout);
} }
} }
return null; return null;
@ -91,8 +105,12 @@ extension CommandsClientExtension on Client {
/// Register all default commands /// Register all default commands
void registerDefaultCommands() { void registerDefaultCommands() {
addCommand('send', (CommandArgs args) async { addCommand('send', (args, stdout) async {
return await args.room.sendTextEvent( final room = args.room;
if (room == null) {
throw RoomCommandException();
}
return await room.sendTextEvent(
args.msg, args.msg,
inReplyTo: args.inReplyTo, inReplyTo: args.inReplyTo,
editEventId: args.editEventId, editEventId: args.editEventId,
@ -102,8 +120,12 @@ extension CommandsClientExtension on Client {
threadLastEventId: args.threadLastEventId, threadLastEventId: args.threadLastEventId,
); );
}); });
addCommand('me', (CommandArgs args) async { addCommand('me', (args, stdout) async {
return await args.room.sendTextEvent( final room = args.room;
if (room == null) {
throw RoomCommandException();
}
return await room.sendTextEvent(
args.msg, args.msg,
inReplyTo: args.inReplyTo, inReplyTo: args.inReplyTo,
editEventId: args.editEventId, editEventId: args.editEventId,
@ -114,21 +136,44 @@ extension CommandsClientExtension on Client {
threadLastEventId: args.threadLastEventId, threadLastEventId: args.threadLastEventId,
); );
}); });
addCommand('dm', (CommandArgs args) async { addCommand('dm', (args, stdout) async {
final parts = args.msg.split(' '); final parts = args.msg.split(' ');
return await args.room.client.startDirectChat( final mxid = parts.first;
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'), enableEncryption: !parts.any((part) => part == '--no-encryption'),
); );
}); stdout?.write(
addCommand('create', (CommandArgs args) async { DefaultCommandOutput(
final parts = args.msg.split(' '); rooms: [roomId],
return await args.room.client.createGroupChat( users: [mxid],
enableEncryption: !parts.any((part) => part == '--no-encryption'), ).toString(),
); );
return null;
}); });
addCommand('plain', (CommandArgs args) async { addCommand('create', (args, stdout) async {
return await args.room.sendTextEvent( final groupName = args.msg.replaceFirst('--no-encryption', '').trim();
final parts = args.msg.split(' ');
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', (args, stdout) async {
final room = args.room;
if (room == null) {
throw RoomCommandException();
}
return await room.sendTextEvent(
args.msg, args.msg,
inReplyTo: args.inReplyTo, inReplyTo: args.inReplyTo,
editEventId: args.editEventId, editEventId: args.editEventId,
@ -139,168 +184,280 @@ extension CommandsClientExtension on Client {
threadLastEventId: args.threadLastEventId, threadLastEventId: args.threadLastEventId,
); );
}); });
addCommand('html', (CommandArgs args) async { addCommand('html', (args, stdout) async {
final event = <String, dynamic>{ final event = <String, dynamic>{
'msgtype': 'm.text', 'msgtype': 'm.text',
'body': args.msg, 'body': args.msg,
'format': 'org.matrix.custom.html', 'format': 'org.matrix.custom.html',
'formatted_body': args.msg, 'formatted_body': args.msg,
}; };
return await args.room.sendEvent( final room = args.room;
if (room == null) {
throw RoomCommandException();
}
return await room.sendEvent(
event, event,
inReplyTo: args.inReplyTo, inReplyTo: args.inReplyTo,
editEventId: args.editEventId, editEventId: args.editEventId,
txid: args.txid, txid: args.txid,
); );
}); });
addCommand('react', (CommandArgs args) async { addCommand('react', (args, stdout) async {
final inReplyTo = args.inReplyTo; final inReplyTo = args.inReplyTo;
if (inReplyTo == null) { if (inReplyTo == null) {
return 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 { addCommand('join', (args, stdout) async {
await args.room.client.joinRoom(args.msg); final roomId = await args.client.joinRoom(args.msg);
stdout?.write(DefaultCommandOutput(rooms: [roomId]).toString());
return null; return null;
}); });
addCommand('leave', (CommandArgs args) async { addCommand('leave', (args, stdout) async {
await args.room.leave(); final room = args.room;
return ''; 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(' '); final parts = args.msg.split(' ');
if (parts.isEmpty) { if (parts.isEmpty || !parts.first.isValidMatrixId) {
return null; throw CommandException('You must enter a valid mxid when using /op');
} }
int? pl; int? pl;
if (parts.length >= 2) { if (parts.length >= 2) {
pl = int.tryParse(parts[1]); pl = int.tryParse(parts[1]);
if (pl == null) {
throw CommandException(
'Invalid power level ${parts[1]} when using /op',
);
}
} }
final mxid = parts.first; 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(' '); final parts = args.msg.split(' ');
await args.room.kick(parts.first); final mxid = parts.first;
return ''; 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(' '); final parts = args.msg.split(' ');
await args.room.ban(parts.first); final mxid = parts.first;
return ''; 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(' '); final parts = args.msg.split(' ');
await args.room.unban(parts.first); final mxid = parts.first;
return ''; 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(' '); final parts = args.msg.split(' ');
await args.room.invite(parts.first); final mxid = parts.first;
return ''; 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 { addCommand('myroomnick', (args, stdout) async {
final currentEventJson = args.room final room = args.room;
.getState(EventTypes.RoomMember, args.room.client.userID!) if (room == null) {
throw RoomCommandException();
}
final currentEventJson = room
.getState(EventTypes.RoomMember, args.client.userID!)
?.content ?.content
.copy() ?? .copy() ??
{}; {};
currentEventJson['displayname'] = args.msg; currentEventJson['displayname'] = args.msg;
return await args.room.client.setRoomStateWithKey(
args.room.id, return await args.client.setRoomStateWithKey(
room.id,
EventTypes.RoomMember, EventTypes.RoomMember,
args.room.client.userID!, args.client.userID!,
currentEventJson, currentEventJson,
); );
}); });
addCommand('myroomavatar', (CommandArgs args) async { addCommand('myroomavatar', (args, stdout) async {
final currentEventJson = args.room final room = args.room;
.getState(EventTypes.RoomMember, args.room.client.userID!) if (room == null) {
throw RoomCommandException();
}
final currentEventJson = room
.getState(EventTypes.RoomMember, args.client.userID!)
?.content ?.content
.copy() ?? .copy() ??
{}; {};
currentEventJson['avatar_url'] = args.msg; currentEventJson['avatar_url'] = args.msg;
return await args.room.client.setRoomStateWithKey(
args.room.id, return await args.client.setRoomStateWithKey(
room.id,
EventTypes.RoomMember, EventTypes.RoomMember,
args.room.client.userID!, args.client.userID!,
currentEventJson, currentEventJson,
); );
}); });
addCommand('discardsession', (CommandArgs args) async { addCommand('discardsession', (args, stdout) async {
final room = args.room;
if (room == null) {
throw RoomCommandException();
}
await encryption?.keyManager await encryption?.keyManager
.clearOrUseOutboundGroupSession(args.room.id, wipe: true); .clearOrUseOutboundGroupSession(room.id, wipe: true);
return ''; return null;
}); });
addCommand('clearcache', (CommandArgs args) async { addCommand('clearcache', (args, stdout) async {
await clearCache(); await clearCache();
return ''; return null;
}); });
addCommand('markasdm', (CommandArgs args) async { addCommand('markasdm', (args, stdout) async {
final mxid = args.msg; final room = args.room;
if (room == null) {
throw RoomCommandException();
}
final mxid = args.msg.split(' ').first;
if (!mxid.isValidMatrixId) { 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) { if (await room.requestUser(mxid, requestProfile: false) == null) {
throw Exception('User $mxid is not in this room'); throw CommandException('User $mxid is not in this room');
} }
await args.room.addToDirectChat(args.msg); await room.addToDirectChat(mxid);
stdout?.write(DefaultCommandOutput(users: [mxid]).toString());
return null;
});
addCommand('markasgroup', (args, stdout) async {
final room = args.room;
if (room == null) {
throw RoomCommandException();
}
await room.removeFromDirectChat();
return; return;
}); });
addCommand('markasgroup', (CommandArgs args) async { addCommand('hug', (args, stdout) async {
await args.room.removeFromDirectChat();
return;
});
addCommand('hug', (CommandArgs args) async {
final content = CuteEventContent.hug; final content = CuteEventContent.hug;
return await args.room.sendEvent( final room = args.room;
if (room == null) {
throw RoomCommandException();
}
return await room.sendEvent(
content, content,
inReplyTo: args.inReplyTo, inReplyTo: args.inReplyTo,
editEventId: args.editEventId, editEventId: args.editEventId,
txid: args.txid, txid: args.txid,
); );
}); });
addCommand('googly', (CommandArgs args) async { addCommand('googly', (args, stdout) async {
final content = CuteEventContent.googlyEyes; final content = CuteEventContent.googlyEyes;
return await args.room.sendEvent( final room = args.room;
if (room == null) {
throw RoomCommandException();
}
return await room.sendEvent(
content, content,
inReplyTo: args.inReplyTo, inReplyTo: args.inReplyTo,
editEventId: args.editEventId, editEventId: args.editEventId,
txid: args.txid, txid: args.txid,
); );
}); });
addCommand('cuddle', (CommandArgs args) async { addCommand('cuddle', (args, stdout) async {
final content = CuteEventContent.cuddle; final content = CuteEventContent.cuddle;
return await args.room.sendEvent( final room = args.room;
if (room == null) {
throw RoomCommandException();
}
return await room.sendEvent(
content, content,
inReplyTo: args.inReplyTo, inReplyTo: args.inReplyTo,
editEventId: args.editEventId, editEventId: args.editEventId,
txid: args.txid, txid: args.txid,
); );
}); });
addCommand('sendRaw', (args) async { addCommand('sendRaw', (args, stdout) async {
await args.room.sendEvent( final room = args.room;
if (room == null) {
throw RoomCommandException();
}
return await room.sendEvent(
jsonDecode(args.msg), jsonDecode(args.msg),
inReplyTo: args.inReplyTo, inReplyTo: args.inReplyTo,
txid: args.txid, txid: args.txid,
); );
return null;
}); });
addCommand('ignore', (args) async { addCommand('ignore', (args, stdout) async {
final mxid = args.msg; final mxid = args.msg;
if (mxid.isEmpty) { if (mxid.isEmpty) {
throw 'Please provide a User ID'; throw CommandException('Please provide a User ID');
} }
await ignoreUser(mxid); await ignoreUser(mxid);
stdout?.write(DefaultCommandOutput(users: [mxid]).toString());
return null; return null;
}); });
addCommand('unignore', (args) async { addCommand('unignore', (args, stdout) async {
final mxid = args.msg; final mxid = args.msg;
if (mxid.isEmpty) { if (mxid.isEmpty) {
throw 'Please provide a User ID'; throw CommandException('Please provide a User ID');
} }
await unignoreUser(mxid); await unignoreUser(mxid);
stdout?.write(DefaultCommandOutput(users: [mxid]).toString());
return null; return null;
}); });
} }
@ -310,7 +467,8 @@ class CommandArgs {
String msg; String msg;
String? editEventId; String? editEventId;
Event? inReplyTo; Event? inReplyTo;
Room room; Client client;
Room? room;
String? txid; String? txid;
String? threadRootEventId; String? threadRootEventId;
String? threadLastEventId; String? threadLastEventId;
@ -319,9 +477,98 @@ class CommandArgs {
required this.msg, required this.msg,
this.editEventId, this.editEventId,
this.inReplyTo, this.inReplyTo,
required this.room, required this.client,
this.room,
this.txid, this.txid,
this.threadRootEventId, this.threadRootEventId,
this.threadLastEventId, 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.
///
/// NOTE: Please be careful whether to include event IDs into the output.
///
/// If your command actually sends an event to a room, please do not include
/// the event ID here. The default behavior of the [Room.sendTextEvent] is to
/// return the event ID of the just sent event. The [DefaultCommandOutput.events]
/// field is not supposed to replace/duplicate this behavior.
///
/// But if your command performs an action such as search, highlight or anything
/// your matrix client should display different than adding an event to the
/// [Timeline], you can include the event IDs related to the command output here.
class DefaultCommandOutput {
static const format = 'com.famedly.default_command_output';
final List<String>? rooms;
final List<String>? events;
final List<String>? users;
final List<String>? messages;
final Map<String, Object?>? 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<String, Object?>) {
return null;
}
if (json['format'] != format) return null;
return DefaultCommandOutput(
rooms: json['rooms'] == null
? null
: List<String>.from(json['rooms'] as Iterable),
events: json['events'] == null
? null
: List<String>.from(json['events'] as Iterable),
users: json['users'] == null
? null
: List<String>.from(json['users'] as Iterable),
messages: json['messages'] == null
? null
: List<String>.from(json['messages'] as Iterable),
custom: json['custom'] == null
? null
: Map<String, Object?>.from(json['custom'] as Map),
);
}
Map<String, Object?> 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());
}
}

View File

@ -395,7 +395,7 @@ void main() {
await room.sendTextEvent('/dm @alice:example.com --no-encryption'); await room.sendTextEvent('/dm @alice:example.com --no-encryption');
expect( expect(
json.decode( json.decode(
FakeMatrixApi.calledEndpoints['/client/v3/createRoom']?.first, FakeMatrixApi.calledEndpoints['/client/v3/createRoom']?.last,
), ),
{ {
'invite': ['@alice:example.com'], 'invite': ['@alice:example.com'],
@ -406,12 +406,15 @@ void main() {
test('create', () async { test('create', () async {
FakeMatrixApi.calledEndpoints.clear(); FakeMatrixApi.calledEndpoints.clear();
await room.sendTextEvent('/create @alice:example.com --no-encryption'); await room.sendTextEvent('/create New room --no-encryption');
expect( expect(
json.decode( 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); 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 { test('dispose client', () async {
await client.dispose(closeDatabase: true); await client.dispose(closeDatabase: true);
}); });