LCOV - code coverage report
Current view: top level - lib/src/utils - commands_extension.dart (source / functions) Coverage Total Hit
Test: merged.info Lines: 66.2 % 263 174
Test Date: 2025-02-14 21:16:54 Functions: - 0 0

            Line data    Source code
       1              : /*
       2              :  *   Famedly Matrix SDK
       3              :  *   Copyright (C) 2021 Famedly GmbH
       4              :  *
       5              :  *   This program is free software: you can redistribute it and/or modify
       6              :  *   it under the terms of the GNU Affero General Public License as
       7              :  *   published by the Free Software Foundation, either version 3 of the
       8              :  *   License, or (at your option) any later version.
       9              :  *
      10              :  *   This program is distributed in the hope that it will be useful,
      11              :  *   but WITHOUT ANY WARRANTY; without even the implied warranty of
      12              :  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
      13              :  *   GNU Affero General Public License for more details.
      14              :  *
      15              :  *   You should have received a copy of the GNU Affero General Public License
      16              :  *   along with this program.  If not, see <https://www.gnu.org/licenses/>.
      17              :  */
      18              : 
      19              : import 'dart:async';
      20              : import 'dart:convert';
      21              : 
      22              : import 'package:matrix/matrix.dart';
      23              : 
      24              : /// callback taking [CommandArgs] as input and a [StringBuffer] as standard output
      25              : /// optionally returns an event ID as in the [Room.sendEvent] syntax.
      26              : /// a [CommandException] should be thrown if the specified arguments are considered invalid
      27              : typedef CommandExecutionCallback = FutureOr<String?> Function(
      28              :   CommandArgs,
      29              :   StringBuffer? stdout,
      30              : );
      31              : 
      32              : extension CommandsClientExtension on Client {
      33              :   /// Add a command to the command handler. `command` is its name, and `callback` is the
      34              :   /// callback to invoke
      35           39 :   void addCommand(String command, CommandExecutionCallback callback) {
      36          117 :     commands[command.toLowerCase()] = callback;
      37              :   }
      38              : 
      39              :   /// Parse and execute a command on Client level
      40              :   /// - `room`: a [Room] to run the command on. Can be null unless you execute a command strictly requiring a [Room] to run on
      41              :   /// - `msg`: the complete input to process
      42              :   /// - `inReplyTo`: an optional [Event] the command is supposed to reply to
      43              :   /// - `editEventId`: an optional event ID the command is supposed to edit
      44              :   /// - `txid`: an optional transaction ID
      45              :   /// - `threadRootEventId`: an optional root event ID of a thread the command is supposed to run on
      46              :   /// - `threadLastEventId`: an optional most recent event ID of a thread the command is supposed to run on
      47              :   /// - `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.
      48            5 :   Future<String?> parseAndRunCommand(
      49              :     Room? room,
      50              :     String msg, {
      51              :     Event? inReplyTo,
      52              :     String? editEventId,
      53              :     String? txid,
      54              :     String? threadRootEventId,
      55              :     String? threadLastEventId,
      56              :     StringBuffer? stdout,
      57              :   }) async {
      58            5 :     final args = CommandArgs(
      59              :       inReplyTo: inReplyTo,
      60              :       editEventId: editEventId,
      61              :       msg: '',
      62              :       client: this,
      63              :       room: room,
      64              :       txid: txid,
      65              :       threadRootEventId: threadRootEventId,
      66              :       threadLastEventId: threadLastEventId,
      67              :     );
      68            5 :     if (!msg.startsWith('/')) {
      69           10 :       final sendCommand = commands['send'];
      70              :       if (sendCommand != null) {
      71            5 :         args.msg = msg;
      72            5 :         return await sendCommand(args, stdout);
      73              :       }
      74              :       return null;
      75              :     }
      76              :     // remove the /
      77            1 :     msg = msg.substring(1);
      78              :     var command = msg;
      79            1 :     if (msg.contains(' ')) {
      80            1 :       final idx = msg.indexOf(' ');
      81            2 :       command = msg.substring(0, idx).toLowerCase();
      82            3 :       args.msg = msg.substring(idx + 1);
      83              :     } else {
      84            1 :       command = msg.toLowerCase();
      85              :     }
      86            2 :     final commandOp = commands[command];
      87              :     if (commandOp != null) {
      88            1 :       return await commandOp(args, stdout);
      89              :     }
      90            3 :     if (msg.startsWith('/') && commands.containsKey('send')) {
      91              :       // re-set to include the "command"
      92            2 :       final sendCommand = commands['send'];
      93              :       if (sendCommand != null) {
      94            1 :         args.msg = msg;
      95            1 :         return await sendCommand(args, stdout);
      96              :       }
      97              :     }
      98              :     return null;
      99              :   }
     100              : 
     101              :   /// Unregister all commands
     102            0 :   void unregisterAllCommands() {
     103            0 :     commands.clear();
     104              :   }
     105              : 
     106              :   /// Register all default commands
     107           39 :   void registerDefaultCommands() {
     108           44 :     addCommand('send', (args, stdout) async {
     109            5 :       final room = args.room;
     110              :       if (room == null) {
     111            0 :         throw RoomCommandException();
     112              :       }
     113            5 :       return await room.sendTextEvent(
     114            5 :         args.msg,
     115            5 :         inReplyTo: args.inReplyTo,
     116            5 :         editEventId: args.editEventId,
     117              :         parseCommands: false,
     118            5 :         txid: args.txid,
     119            5 :         threadRootEventId: args.threadRootEventId,
     120            5 :         threadLastEventId: args.threadLastEventId,
     121              :       );
     122              :     });
     123           40 :     addCommand('me', (args, stdout) async {
     124            1 :       final room = args.room;
     125              :       if (room == null) {
     126            0 :         throw RoomCommandException();
     127              :       }
     128            1 :       return await room.sendTextEvent(
     129            1 :         args.msg,
     130            1 :         inReplyTo: args.inReplyTo,
     131            1 :         editEventId: args.editEventId,
     132              :         msgtype: MessageTypes.Emote,
     133              :         parseCommands: false,
     134            1 :         txid: args.txid,
     135            1 :         threadRootEventId: args.threadRootEventId,
     136            1 :         threadLastEventId: args.threadLastEventId,
     137              :       );
     138              :     });
     139           40 :     addCommand('dm', (args, stdout) async {
     140            2 :       final parts = args.msg.split(' ');
     141            1 :       final mxid = parts.first;
     142            1 :       if (!mxid.isValidMatrixId) {
     143            0 :         throw CommandException('You must enter a valid mxid when using /dm');
     144              :       }
     145              : 
     146            2 :       final roomId = await args.client.startDirectChat(
     147              :         mxid,
     148            3 :         enableEncryption: !parts.any((part) => part == '--no-encryption'),
     149              :       );
     150            0 :       stdout?.write(
     151            0 :         DefaultCommandOutput(
     152            0 :           rooms: [roomId],
     153            0 :           users: [mxid],
     154            0 :         ).toString(),
     155              :       );
     156              :       return null;
     157              :     });
     158           40 :     addCommand('create', (args, stdout) async {
     159            3 :       final groupName = args.msg.replaceFirst('--no-encryption', '').trim();
     160              : 
     161            2 :       final parts = args.msg.split(' ');
     162              : 
     163            2 :       final roomId = await args.client.createGroupChat(
     164            1 :         groupName: groupName.isNotEmpty ? groupName : null,
     165            3 :         enableEncryption: !parts.any((part) => part == '--no-encryption'),
     166              :         waitForSync: false,
     167              :       );
     168            0 :       stdout?.write(DefaultCommandOutput(rooms: [roomId]).toString());
     169              :       return null;
     170              :     });
     171           40 :     addCommand('plain', (args, stdout) async {
     172            1 :       final room = args.room;
     173              :       if (room == null) {
     174            0 :         throw RoomCommandException();
     175              :       }
     176            1 :       return await room.sendTextEvent(
     177            1 :         args.msg,
     178            1 :         inReplyTo: args.inReplyTo,
     179            1 :         editEventId: args.editEventId,
     180              :         parseMarkdown: false,
     181              :         parseCommands: false,
     182            1 :         txid: args.txid,
     183            1 :         threadRootEventId: args.threadRootEventId,
     184            1 :         threadLastEventId: args.threadLastEventId,
     185              :       );
     186              :     });
     187           40 :     addCommand('html', (args, stdout) async {
     188            1 :       final event = <String, dynamic>{
     189              :         'msgtype': 'm.text',
     190            1 :         'body': args.msg,
     191              :         'format': 'org.matrix.custom.html',
     192            1 :         'formatted_body': args.msg,
     193              :       };
     194            1 :       final room = args.room;
     195              :       if (room == null) {
     196            0 :         throw RoomCommandException();
     197              :       }
     198            1 :       return await room.sendEvent(
     199              :         event,
     200            1 :         inReplyTo: args.inReplyTo,
     201            1 :         editEventId: args.editEventId,
     202            1 :         txid: args.txid,
     203              :       );
     204              :     });
     205           40 :     addCommand('react', (args, stdout) async {
     206            1 :       final inReplyTo = args.inReplyTo;
     207              :       if (inReplyTo == null) {
     208              :         return null;
     209              :       }
     210            1 :       final room = args.room;
     211              :       if (room == null) {
     212            0 :         throw RoomCommandException();
     213              :       }
     214            2 :       final parts = args.msg.split(' ');
     215            2 :       final reaction = parts.first.trim();
     216            1 :       if (reaction.isEmpty) {
     217            0 :         throw CommandException('You must provide a reaction when using /react');
     218              :       }
     219            2 :       return await room.sendReaction(inReplyTo.eventId, reaction);
     220              :     });
     221           40 :     addCommand('join', (args, stdout) async {
     222            3 :       final roomId = await args.client.joinRoom(args.msg);
     223            0 :       stdout?.write(DefaultCommandOutput(rooms: [roomId]).toString());
     224              :       return null;
     225              :     });
     226           40 :     addCommand('leave', (args, stdout) async {
     227            1 :       final room = args.room;
     228              :       if (room == null) {
     229            0 :         throw RoomCommandException();
     230              :       }
     231            1 :       await room.leave();
     232              :       return null;
     233              :     });
     234           40 :     addCommand('op', (args, stdout) async {
     235            1 :       final room = args.room;
     236              :       if (room == null) {
     237            0 :         throw RoomCommandException();
     238              :       }
     239            2 :       final parts = args.msg.split(' ');
     240            3 :       if (parts.isEmpty || !parts.first.isValidMatrixId) {
     241            0 :         throw CommandException('You must enter a valid mxid when using /op');
     242              :       }
     243              :       int? pl;
     244            2 :       if (parts.length >= 2) {
     245            2 :         pl = int.tryParse(parts[1]);
     246              :         if (pl == null) {
     247            0 :           throw CommandException(
     248            0 :             'Invalid power level ${parts[1]} when using /op',
     249              :           );
     250              :         }
     251              :       }
     252            1 :       final mxid = parts.first;
     253            1 :       return await room.setPower(mxid, pl ?? 50);
     254              :     });
     255           40 :     addCommand('kick', (args, stdout) async {
     256            1 :       final room = args.room;
     257              :       if (room == null) {
     258            0 :         throw RoomCommandException();
     259              :       }
     260            2 :       final parts = args.msg.split(' ');
     261            1 :       final mxid = parts.first;
     262            1 :       if (!mxid.isValidMatrixId) {
     263            0 :         throw CommandException('You must enter a valid mxid when using /kick');
     264              :       }
     265            1 :       await room.kick(mxid);
     266            0 :       stdout?.write(DefaultCommandOutput(users: [mxid]).toString());
     267              :       return null;
     268              :     });
     269           40 :     addCommand('ban', (args, stdout) async {
     270            1 :       final room = args.room;
     271              :       if (room == null) {
     272            0 :         throw RoomCommandException();
     273              :       }
     274            2 :       final parts = args.msg.split(' ');
     275            1 :       final mxid = parts.first;
     276            1 :       if (!mxid.isValidMatrixId) {
     277            0 :         throw CommandException('You must enter a valid mxid when using /ban');
     278              :       }
     279            1 :       await room.ban(mxid);
     280            0 :       stdout?.write(DefaultCommandOutput(users: [mxid]).toString());
     281              :       return null;
     282              :     });
     283           40 :     addCommand('unban', (args, stdout) async {
     284            1 :       final room = args.room;
     285              :       if (room == null) {
     286            0 :         throw RoomCommandException();
     287              :       }
     288            2 :       final parts = args.msg.split(' ');
     289            1 :       final mxid = parts.first;
     290            1 :       if (!mxid.isValidMatrixId) {
     291            0 :         throw CommandException('You must enter a valid mxid when using /unban');
     292              :       }
     293            1 :       await room.unban(mxid);
     294            0 :       stdout?.write(DefaultCommandOutput(users: [mxid]).toString());
     295              :       return null;
     296              :     });
     297           40 :     addCommand('invite', (args, stdout) async {
     298            1 :       final room = args.room;
     299              :       if (room == null) {
     300            0 :         throw RoomCommandException();
     301              :       }
     302              : 
     303            2 :       final parts = args.msg.split(' ');
     304            1 :       final mxid = parts.first;
     305            1 :       if (!mxid.isValidMatrixId) {
     306            0 :         throw CommandException(
     307              :           'You must enter a valid mxid when using /invite',
     308              :         );
     309              :       }
     310            1 :       await room.invite(mxid);
     311            0 :       stdout?.write(DefaultCommandOutput(users: [mxid]).toString());
     312              :       return null;
     313              :     });
     314           40 :     addCommand('myroomnick', (args, stdout) async {
     315            1 :       final room = args.room;
     316              :       if (room == null) {
     317            0 :         throw RoomCommandException();
     318              :       }
     319              : 
     320              :       final currentEventJson = room
     321            3 :               .getState(EventTypes.RoomMember, args.client.userID!)
     322            1 :               ?.content
     323            1 :               .copy() ??
     324            0 :           {};
     325            2 :       currentEventJson['displayname'] = args.msg;
     326              : 
     327            2 :       return await args.client.setRoomStateWithKey(
     328            1 :         room.id,
     329              :         EventTypes.RoomMember,
     330            2 :         args.client.userID!,
     331              :         currentEventJson,
     332              :       );
     333              :     });
     334           40 :     addCommand('myroomavatar', (args, stdout) async {
     335            1 :       final room = args.room;
     336              :       if (room == null) {
     337            0 :         throw RoomCommandException();
     338              :       }
     339              : 
     340              :       final currentEventJson = room
     341            3 :               .getState(EventTypes.RoomMember, args.client.userID!)
     342            1 :               ?.content
     343            1 :               .copy() ??
     344            0 :           {};
     345            2 :       currentEventJson['avatar_url'] = args.msg;
     346              : 
     347            2 :       return await args.client.setRoomStateWithKey(
     348            1 :         room.id,
     349              :         EventTypes.RoomMember,
     350            2 :         args.client.userID!,
     351              :         currentEventJson,
     352              :       );
     353              :     });
     354           40 :     addCommand('discardsession', (args, stdout) async {
     355            1 :       final room = args.room;
     356              :       if (room == null) {
     357            1 :         throw RoomCommandException();
     358              :       }
     359            2 :       await encryption?.keyManager
     360            2 :           .clearOrUseOutboundGroupSession(room.id, wipe: true);
     361              :       return null;
     362              :     });
     363           40 :     addCommand('clearcache', (args, stdout) async {
     364            1 :       await clearCache();
     365              :       return null;
     366              :     });
     367           40 :     addCommand('markasdm', (args, stdout) async {
     368            1 :       final room = args.room;
     369              :       if (room == null) {
     370            0 :         throw RoomCommandException();
     371              :       }
     372              : 
     373            3 :       final mxid = args.msg.split(' ').first;
     374            1 :       if (!mxid.isValidMatrixId) {
     375            0 :         throw CommandException(
     376              :           'You must enter a valid mxid when using /maskasdm',
     377              :         );
     378              :       }
     379            1 :       if (await room.requestUser(mxid, requestProfile: false) == null) {
     380            0 :         throw CommandException('User $mxid is not in this room');
     381              :       }
     382            1 :       await room.addToDirectChat(mxid);
     383            0 :       stdout?.write(DefaultCommandOutput(users: [mxid]).toString());
     384              :       return null;
     385              :     });
     386           40 :     addCommand('markasgroup', (args, stdout) async {
     387            1 :       final room = args.room;
     388              :       if (room == null) {
     389            0 :         throw RoomCommandException();
     390              :       }
     391              : 
     392            1 :       await room.removeFromDirectChat();
     393              :       return;
     394              :     });
     395           40 :     addCommand('hug', (args, stdout) async {
     396            1 :       final content = CuteEventContent.hug;
     397            1 :       final room = args.room;
     398              :       if (room == null) {
     399            0 :         throw RoomCommandException();
     400              :       }
     401            1 :       return await room.sendEvent(
     402              :         content,
     403            1 :         inReplyTo: args.inReplyTo,
     404            1 :         editEventId: args.editEventId,
     405            1 :         txid: args.txid,
     406              :       );
     407              :     });
     408           40 :     addCommand('googly', (args, stdout) async {
     409            1 :       final content = CuteEventContent.googlyEyes;
     410            1 :       final room = args.room;
     411              :       if (room == null) {
     412            0 :         throw RoomCommandException();
     413              :       }
     414            1 :       return await room.sendEvent(
     415              :         content,
     416            1 :         inReplyTo: args.inReplyTo,
     417            1 :         editEventId: args.editEventId,
     418            1 :         txid: args.txid,
     419              :       );
     420              :     });
     421           40 :     addCommand('cuddle', (args, stdout) async {
     422            1 :       final content = CuteEventContent.cuddle;
     423            1 :       final room = args.room;
     424              :       if (room == null) {
     425            0 :         throw RoomCommandException();
     426              :       }
     427            1 :       return await room.sendEvent(
     428              :         content,
     429            1 :         inReplyTo: args.inReplyTo,
     430            1 :         editEventId: args.editEventId,
     431            1 :         txid: args.txid,
     432              :       );
     433              :     });
     434           39 :     addCommand('sendRaw', (args, stdout) async {
     435            0 :       final room = args.room;
     436              :       if (room == null) {
     437            0 :         throw RoomCommandException();
     438              :       }
     439            0 :       return await room.sendEvent(
     440            0 :         jsonDecode(args.msg),
     441            0 :         inReplyTo: args.inReplyTo,
     442            0 :         txid: args.txid,
     443              :       );
     444              :     });
     445           39 :     addCommand('ignore', (args, stdout) async {
     446            0 :       final mxid = args.msg;
     447            0 :       if (mxid.isEmpty) {
     448            0 :         throw CommandException('Please provide a User ID');
     449              :       }
     450            0 :       await ignoreUser(mxid);
     451            0 :       stdout?.write(DefaultCommandOutput(users: [mxid]).toString());
     452              :       return null;
     453              :     });
     454           39 :     addCommand('unignore', (args, stdout) async {
     455            0 :       final mxid = args.msg;
     456            0 :       if (mxid.isEmpty) {
     457            0 :         throw CommandException('Please provide a User ID');
     458              :       }
     459            0 :       await unignoreUser(mxid);
     460            0 :       stdout?.write(DefaultCommandOutput(users: [mxid]).toString());
     461              :       return null;
     462              :     });
     463              :   }
     464              : }
     465              : 
     466              : class CommandArgs {
     467              :   String msg;
     468              :   String? editEventId;
     469              :   Event? inReplyTo;
     470              :   Client client;
     471              :   Room? room;
     472              :   String? txid;
     473              :   String? threadRootEventId;
     474              :   String? threadLastEventId;
     475              : 
     476            5 :   CommandArgs({
     477              :     required this.msg,
     478              :     this.editEventId,
     479              :     this.inReplyTo,
     480              :     required this.client,
     481              :     this.room,
     482              :     this.txid,
     483              :     this.threadRootEventId,
     484              :     this.threadLastEventId,
     485              :   });
     486              : }
     487              : 
     488              : class CommandException implements Exception {
     489              :   final String message;
     490              : 
     491            1 :   const CommandException(this.message);
     492              : 
     493            0 :   @override
     494              :   String toString() {
     495            0 :     return '${super.toString()}: $message';
     496              :   }
     497              : }
     498              : 
     499              : class RoomCommandException extends CommandException {
     500            2 :   const RoomCommandException() : super('This command must run on a room');
     501              : }
     502              : 
     503              : /// Helper class for normalized command output
     504              : ///
     505              : /// This class can be used to provide a default, processable output of commands
     506              : /// containing some generic data.
     507              : ///
     508              : /// NOTE: Please be careful whether to include event IDs into the output.
     509              : ///
     510              : /// If your command actually sends an event to a room, please do not include
     511              : /// the event ID here. The default behavior of the [Room.sendTextEvent] is to
     512              : /// return the event ID of the just sent event. The [DefaultCommandOutput.events]
     513              : /// field is not supposed to replace/duplicate this behavior.
     514              : ///
     515              : /// But if your command performs an action such as search, highlight or anything
     516              : /// your matrix client should display different than adding an event to the
     517              : /// [Timeline], you can include the event IDs related to the command output here.
     518              : class DefaultCommandOutput {
     519              :   static const format = 'com.famedly.default_command_output';
     520              :   final List<String>? rooms;
     521              :   final List<String>? events;
     522              :   final List<String>? users;
     523              :   final List<String>? messages;
     524              :   final Map<String, Object?>? custom;
     525              : 
     526            0 :   const DefaultCommandOutput({
     527              :     this.rooms,
     528              :     this.events,
     529              :     this.users,
     530              :     this.messages,
     531              :     this.custom,
     532              :   });
     533              : 
     534            0 :   static DefaultCommandOutput? fromStdout(String stdout) {
     535            0 :     final Object? json = jsonDecode(stdout);
     536            0 :     if (json is! Map<String, Object?>) {
     537              :       return null;
     538              :     }
     539            0 :     if (json['format'] != format) return null;
     540            0 :     return DefaultCommandOutput(
     541            0 :       rooms: json['rooms'] == null
     542              :           ? null
     543            0 :           : List<String>.from(json['rooms'] as Iterable),
     544            0 :       events: json['events'] == null
     545              :           ? null
     546            0 :           : List<String>.from(json['events'] as Iterable),
     547            0 :       users: json['users'] == null
     548              :           ? null
     549            0 :           : List<String>.from(json['users'] as Iterable),
     550            0 :       messages: json['messages'] == null
     551              :           ? null
     552            0 :           : List<String>.from(json['messages'] as Iterable),
     553            0 :       custom: json['custom'] == null
     554              :           ? null
     555            0 :           : Map<String, Object?>.from(json['custom'] as Map),
     556              :     );
     557              :   }
     558              : 
     559            0 :   Map<String, Object?> toJson() {
     560            0 :     return {
     561            0 :       'format': format,
     562            0 :       if (rooms != null) 'rooms': rooms,
     563            0 :       if (events != null) 'events': events,
     564            0 :       if (users != null) 'users': users,
     565            0 :       if (messages != null) 'messages': messages,
     566            0 :       ...?custom,
     567              :     };
     568              :   }
     569              : 
     570            0 :   @override
     571              :   String toString() {
     572            0 :     return jsonEncode(toJson());
     573              :   }
     574              : }
        

Generated by: LCOV version 2.0-1