diff --git a/examples_dart/bin/muc_client.dart b/examples_dart/bin/muc_client.dart new file mode 100644 index 0000000..3b874cd --- /dev/null +++ b/examples_dart/bin/muc_client.dart @@ -0,0 +1,80 @@ +import 'package:cli_repl/cli_repl.dart'; +import 'package:example_dart/arguments.dart'; +import 'package:example_dart/socket.dart'; +import 'package:logging/logging.dart'; +import 'package:moxxmpp/moxxmpp.dart'; + +void main(List args) async { + // Set up logging + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + // ignore: avoid_print + print( + '[${record.level.name}] (${record.loggerName}) ${record.time}: ${record.message}', + ); + }); + + final parser = ArgumentParser() + ..parser.addOption('muc', help: 'The MUC to send messages to') + ..parser.addOption('nick', help: 'The nickname with which to join the MUC'); + final options = parser.handleArguments(args); + if (options == null) { + return; + } + + // Connect + final muc = JID.fromString(options['muc']! as String).toBare(); + final nick = options['nick']! as String; + final connection = XmppConnection( + TestingReconnectionPolicy(), + AlwaysConnectedConnectivityManager(), + ClientToServerNegotiator(), + ExampleTCPSocketWrapper(parser.srvRecord), + )..connectionSettings = parser.connectionSettings; + + // Register the managers and negotiators + await connection.registerManagers([ + PresenceManager(), + DiscoManager([]), + PubSubManager(), + MessageManager(), + MUCManager(), + ]); + await connection.registerFeatureNegotiators([ + SaslPlainNegotiator(), + ResourceBindingNegotiator(), + StartTlsNegotiator(), + SaslScramNegotiator(10, '', '', ScramHashType.sha1), + ]); + + // Connect + Logger.root.info('Connecting...'); + final result = + await connection.connect(shouldReconnect: false, waitUntilLogin: true); + if (!result.isType()) { + Logger.root.severe('Authentication failed!'); + return; + } + Logger.root.info('Connected.'); + + // Join room + await connection.getManagerById(mucManager)!.joinRoom(muc, nick); + + final repl = Repl(prompt: '> '); + await for (final line in repl.runAsync()) { + await connection + .getManagerById(messageManager)! + .sendMessage( + muc, + TypedMap.fromList([ + MessageBodyData(line), + ]), + type: 'groupchat'); + } + + // Leave room + await connection.getManagerById(mucManager)!.leaveRoom(muc); + + // Disconnect + await connection.disconnect(); +} diff --git a/packages/moxxmpp/lib/moxxmpp.dart b/packages/moxxmpp/lib/moxxmpp.dart index 221ca5e..55c910c 100644 --- a/packages/moxxmpp/lib/moxxmpp.dart +++ b/packages/moxxmpp/lib/moxxmpp.dart @@ -47,6 +47,9 @@ export 'package:moxxmpp/src/xeps/xep_0030/errors.dart'; export 'package:moxxmpp/src/xeps/xep_0030/helpers.dart'; export 'package:moxxmpp/src/xeps/xep_0030/types.dart'; export 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart'; +export 'package:moxxmpp/src/xeps/xep_0045/errors.dart'; +export 'package:moxxmpp/src/xeps/xep_0045/types.dart'; +export 'package:moxxmpp/src/xeps/xep_0045/xep_0045.dart'; export 'package:moxxmpp/src/xeps/xep_0054.dart'; export 'package:moxxmpp/src/xeps/xep_0060/errors.dart'; export 'package:moxxmpp/src/xeps/xep_0060/helpers.dart'; diff --git a/packages/moxxmpp/lib/src/managers/namespaces.dart b/packages/moxxmpp/lib/src/managers/namespaces.dart index bb2d241..49227ef 100644 --- a/packages/moxxmpp/lib/src/managers/namespaces.dart +++ b/packages/moxxmpp/lib/src/managers/namespaces.dart @@ -33,3 +33,4 @@ const stickersManager = 'org.moxxmpp.stickersmanager'; const entityCapabilitiesManager = 'org.moxxmpp.entitycapabilities'; const messageProcessingHintManager = 'org.moxxmpp.messageprocessinghint'; const occupantIdManager = 'org.moxxmpp.occupantidmanager'; +const mucManager = 'org.moxxmpp.mucmanager'; diff --git a/packages/moxxmpp/lib/src/message.dart b/packages/moxxmpp/lib/src/message.dart index 9700ee6..c6727a7 100644 --- a/packages/moxxmpp/lib/src/message.dart +++ b/packages/moxxmpp/lib/src/message.dart @@ -103,14 +103,15 @@ class MessageManager extends XmppManagerBase { /// data for building the message. Future sendMessage( JID to, - TypedMap extensions, - ) async { + TypedMap extensions, { + String type = 'chat', + }) async { await getAttributes().sendStanza( StanzaDetails( Stanza.message( to: to.toString(), id: extensions.get()?.id, - type: 'chat', + type: type, children: _messageSendingCallbacks .map((c) => c(extensions)) .flattened diff --git a/packages/moxxmpp/lib/src/namespaces.dart b/packages/moxxmpp/lib/src/namespaces.dart index e23e962..2249410 100644 --- a/packages/moxxmpp/lib/src/namespaces.dart +++ b/packages/moxxmpp/lib/src/namespaces.dart @@ -21,6 +21,9 @@ const discoItemsXmlns = 'http://jabber.org/protocol/disco#items'; // XEP-0033 const extendedAddressingXmlns = 'http://jabber.org/protocol/address'; +// XEP-0045 +const mucXmlns = 'http://jabber.org/protocol/muc'; + // XEP-0054 const vCardTempXmlns = 'vcard-temp'; const vCardTempUpdate = 'vcard-temp:x:update'; diff --git a/packages/moxxmpp/lib/src/xeps/xep_0045/errors.dart b/packages/moxxmpp/lib/src/xeps/xep_0045/errors.dart new file mode 100644 index 0000000..7cc2b8f --- /dev/null +++ b/packages/moxxmpp/lib/src/xeps/xep_0045/errors.dart @@ -0,0 +1,19 @@ +/// Represents an error related to Multi-User Chat (MUC). +abstract class MUCError {} + +/// Error indicating an invalid (non-supported) stanza received while going +/// through normal operation/flow of an MUC. +class InvalidStanzaFormat extends MUCError {} + +/// Represents an error indicating an abnormal condition while parsing +/// the DiscoInfo response stanza in Multi-User Chat (MUC). +class InvalidDiscoInfoResponse extends MUCError {} + +/// Returned when no nickname was specified from the client side while trying to +/// perform some actions on the MUC, such as joining the room. +class NoNicknameSpecified extends MUCError {} + +/// This error occurs when a user attempts to perform an action that requires +/// them to be a member of a room, but they are not currently joined to +/// that room. +class RoomNotJoinedError extends MUCError {} diff --git a/packages/moxxmpp/lib/src/xeps/xep_0045/types.dart b/packages/moxxmpp/lib/src/xeps/xep_0045/types.dart new file mode 100644 index 0000000..31e2c80 --- /dev/null +++ b/packages/moxxmpp/lib/src/xeps/xep_0045/types.dart @@ -0,0 +1,43 @@ +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/types.dart'; + +class RoomInformation { + /// Represents information about a Multi-User Chat (MUC) room. + RoomInformation({ + required this.jid, + required this.features, + required this.name, + }); + + /// Constructs a [RoomInformation] object from a [DiscoInfo] object. + /// The [DiscoInfo] object contains the necessary information to populate + /// the [RoomInformation] fields. + factory RoomInformation.fromDiscoInfo({ + required DiscoInfo discoInfo, + }) => + RoomInformation( + jid: discoInfo.jid!, + features: discoInfo.features, + name: discoInfo.identities + .firstWhere((i) => i.category == 'conference') + .name!, + ); + + /// The JID of the Multi-User Chat (MUC) room. + final JID jid; + + /// A list of features supported by the Multi-User Chat (MUC) room. + final List features; + + /// The name or title of the Multi-User Chat (MUC) room. + final String name; +} + +class RoomState { + RoomState({ + required this.roomJid, + this.nick, + }); + final JID roomJid; + String? nick; +} diff --git a/packages/moxxmpp/lib/src/xeps/xep_0045/xep_0045.dart b/packages/moxxmpp/lib/src/xeps/xep_0045/xep_0045.dart new file mode 100644 index 0000000..64a5976 --- /dev/null +++ b/packages/moxxmpp/lib/src/xeps/xep_0045/xep_0045.dart @@ -0,0 +1,112 @@ +import 'package:moxlib/moxlib.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/errors.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/types.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart'; +import 'package:moxxmpp/src/xeps/xep_0045/errors.dart'; +import 'package:moxxmpp/src/xeps/xep_0045/types.dart'; +import 'package:synchronized/synchronized.dart'; + +class MUCManager extends XmppManagerBase { + MUCManager() : super(mucManager); + + @override + Future isSupported() async => true; + + /// Map full JID to RoomState + final Map _mucRoomCache = {}; + + /// Cache lock + final Lock _cacheLock = Lock(); + + /// Queries the information of a Multi-User Chat room. + /// + /// Retrieves the information about the specified MUC room by performing a + /// disco info query. Returns a [Result] with the [RoomInformation] on success + /// or an appropriate [MUCError] on failure. + Future> queryRoomInformation( + JID roomJID, + ) async { + final result = await getAttributes() + .getManagerById(discoManager)! + .discoInfoQuery(roomJID); + if (result.isType()) { + return Result(InvalidStanzaFormat()); + } + try { + final roomInformation = RoomInformation.fromDiscoInfo( + discoInfo: result.get(), + ); + return Result(roomInformation); + } catch (e) { + return Result(InvalidDiscoInfoResponse); + } + } + + /// Joins a Multi-User Chat room. + /// + /// Joins the specified MUC room using the provided nickname. Sends a presence + /// stanza with the appropriate attributes to join the room. Returns a [Result] + /// with a boolean value indicating success or failure, or an [MUCError] + /// if applicable. + Future> joinRoom( + JID roomJid, + String nick, + ) async { + if (nick.isEmpty) { + return Result(NoNicknameSpecified()); + } + await getAttributes().sendStanza( + StanzaDetails( + Stanza.presence( + to: roomJid.withResource(nick).toString(), + children: [ + XMLNode.xmlns( + tag: 'x', + xmlns: mucXmlns, + ) + ], + ), + ), + ); + await _cacheLock.synchronized( + () { + _mucRoomCache[roomJid] = RoomState(roomJid: roomJid, nick: nick); + }, + ); + return const Result(true); + } + + /// Leaves a Multi-User Chat room. + /// + /// Leaves the specified MUC room by sending an 'unavailable' presence stanza. + /// Removes the corresponding room entry from the cache. Returns a [Result] + /// with a boolean value indicating success or failure, or an [MUCError] + /// if applicable. + Future> leaveRoom( + JID roomJid, + ) async { + final nick = await _cacheLock.synchronized(() { + final nick = _mucRoomCache[roomJid]?.nick; + _mucRoomCache.remove(roomJid); + return nick; + }); + if (nick == null) { + return Result(RoomNotJoinedError); + } + await getAttributes().sendStanza( + StanzaDetails( + Stanza.presence( + to: roomJid.withResource(nick).toString(), + type: 'unavailable', + ), + ), + ); + return const Result(true); + } +}