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 43 : void addCommand(String command, CommandExecutionCallback callback) {
36 129 : 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 7 : 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 7 : 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 7 : if (!msg.startsWith('/')) {
69 14 : final sendCommand = commands['send'];
70 : if (sendCommand != null) {
71 7 : args.msg = msg;
72 7 : 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 43 : void registerDefaultCommands() {
108 50 : addCommand('send', (args, stdout) async {
109 7 : final room = args.room;
110 : if (room == null) {
111 0 : throw RoomCommandException();
112 : }
113 7 : return await room.sendTextEvent(
114 7 : args.msg,
115 7 : inReplyTo: args.inReplyTo,
116 7 : editEventId: args.editEventId,
117 : parseCommands: false,
118 7 : txid: args.txid,
119 7 : threadRootEventId: args.threadRootEventId,
120 7 : threadLastEventId: args.threadLastEventId,
121 : );
122 : });
123 44 : 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 44 : 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 44 : 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 44 : 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 44 : 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 44 : 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 44 : 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 44 : 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 44 : 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 44 : 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 44 : 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 44 : 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 44 : 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 44 : 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 44 : 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 44 : 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 44 : addCommand('clearcache', (args, stdout) async {
364 1 : await clearCache();
365 : return null;
366 : });
367 44 : 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 44 : 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 44 : 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 44 : 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 44 : 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 43 : 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 43 : 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 43 : 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 43 : addCommand('roomupgrade', (args, stdout) async {
464 0 : final version = args.msg;
465 0 : if (version.isEmpty) {
466 0 : throw CommandException('Please provide a room version');
467 : }
468 : final newRoomId =
469 0 : await args.room!.client.upgradeRoom(args.room!.id, version);
470 0 : stdout?.write(DefaultCommandOutput(rooms: [newRoomId]).toString());
471 : return null;
472 : });
473 43 : addCommand('logout', (args, stdout) async {
474 0 : await logout();
475 : return null;
476 : });
477 43 : addCommand('logoutAll', (args, stdout) async {
478 0 : await logoutAll();
479 : return null;
480 : });
481 : }
482 : }
483 :
484 : class CommandArgs {
485 : String msg;
486 : String? editEventId;
487 : Event? inReplyTo;
488 : Client client;
489 : Room? room;
490 : String? txid;
491 : String? threadRootEventId;
492 : String? threadLastEventId;
493 :
494 7 : CommandArgs({
495 : required this.msg,
496 : this.editEventId,
497 : this.inReplyTo,
498 : required this.client,
499 : this.room,
500 : this.txid,
501 : this.threadRootEventId,
502 : this.threadLastEventId,
503 : });
504 : }
505 :
506 : class CommandException implements Exception {
507 : final String message;
508 :
509 1 : const CommandException(this.message);
510 :
511 0 : @override
512 : String toString() {
513 0 : return '${super.toString()}: $message';
514 : }
515 : }
516 :
517 : class RoomCommandException extends CommandException {
518 2 : const RoomCommandException() : super('This command must run on a room');
519 : }
520 :
521 : /// Helper class for normalized command output
522 : ///
523 : /// This class can be used to provide a default, processable output of commands
524 : /// containing some generic data.
525 : ///
526 : /// NOTE: Please be careful whether to include event IDs into the output.
527 : ///
528 : /// If your command actually sends an event to a room, please do not include
529 : /// the event ID here. The default behavior of the [Room.sendTextEvent] is to
530 : /// return the event ID of the just sent event. The [DefaultCommandOutput.events]
531 : /// field is not supposed to replace/duplicate this behavior.
532 : ///
533 : /// But if your command performs an action such as search, highlight or anything
534 : /// your matrix client should display different than adding an event to the
535 : /// [Timeline], you can include the event IDs related to the command output here.
536 : class DefaultCommandOutput {
537 : static const format = 'com.famedly.default_command_output';
538 : final List<String>? rooms;
539 : final List<String>? events;
540 : final List<String>? users;
541 : final List<String>? messages;
542 : final Map<String, Object?>? custom;
543 :
544 0 : const DefaultCommandOutput({
545 : this.rooms,
546 : this.events,
547 : this.users,
548 : this.messages,
549 : this.custom,
550 : });
551 :
552 0 : static DefaultCommandOutput? fromStdout(String stdout) {
553 0 : final Object? json = jsonDecode(stdout);
554 0 : if (json is! Map<String, Object?>) {
555 : return null;
556 : }
557 0 : if (json['format'] != format) return null;
558 0 : return DefaultCommandOutput(
559 0 : rooms: json['rooms'] == null
560 : ? null
561 0 : : List<String>.from(json['rooms'] as Iterable),
562 0 : events: json['events'] == null
563 : ? null
564 0 : : List<String>.from(json['events'] as Iterable),
565 0 : users: json['users'] == null
566 : ? null
567 0 : : List<String>.from(json['users'] as Iterable),
568 0 : messages: json['messages'] == null
569 : ? null
570 0 : : List<String>.from(json['messages'] as Iterable),
571 0 : custom: json['custom'] == null
572 : ? null
573 0 : : Map<String, Object?>.from(json['custom'] as Map),
574 : );
575 : }
576 :
577 0 : Map<String, Object?> toJson() {
578 0 : return {
579 0 : 'format': format,
580 0 : if (rooms != null) 'rooms': rooms,
581 0 : if (events != null) 'events': events,
582 0 : if (users != null) 'users': users,
583 0 : if (messages != null) 'messages': messages,
584 0 : ...?custom,
585 : };
586 : }
587 :
588 0 : @override
589 : String toString() {
590 0 : return jsonEncode(toJson());
591 : }
592 : }
|