From 7ca648c478349ffd450d30386a52303340201c3f Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Tue, 26 Sep 2023 23:34:53 +0200 Subject: [PATCH] feat(xep): Add MUC events --- packages/moxxmpp/lib/src/namespaces.dart | 1 + .../moxxmpp/lib/src/xeps/xep_0045/errors.dart | 6 + .../moxxmpp/lib/src/xeps/xep_0045/events.dart | 35 ++++ .../moxxmpp/lib/src/xeps/xep_0045/types.dart | 89 +++++++++ .../lib/src/xeps/xep_0045/xep_0045.dart | 153 +++++++++++++++- packages/moxxmpp/test/xeps/xep_0045_test.dart | 170 ++++++++++++++++++ 6 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 packages/moxxmpp/lib/src/xeps/xep_0045/events.dart diff --git a/packages/moxxmpp/lib/src/namespaces.dart b/packages/moxxmpp/lib/src/namespaces.dart index 2846ba6..b610e58 100644 --- a/packages/moxxmpp/lib/src/namespaces.dart +++ b/packages/moxxmpp/lib/src/namespaces.dart @@ -24,6 +24,7 @@ const extendedAddressingXmlns = 'http://jabber.org/protocol/address'; // XEP-0045 const mucXmlns = 'http://jabber.org/protocol/muc'; +const mucUserXmlns = 'http://jabber.org/protocol/muc#user'; const roomInfoFormType = 'http://jabber.org/protocol/muc#roominfo'; // XEP-0054 diff --git a/packages/moxxmpp/lib/src/xeps/xep_0045/errors.dart b/packages/moxxmpp/lib/src/xeps/xep_0045/errors.dart index 7cc2b8f..72f327a 100644 --- a/packages/moxxmpp/lib/src/xeps/xep_0045/errors.dart +++ b/packages/moxxmpp/lib/src/xeps/xep_0045/errors.dart @@ -17,3 +17,9 @@ class NoNicknameSpecified extends MUCError {} /// them to be a member of a room, but they are not currently joined to /// that room. class RoomNotJoinedError extends MUCError {} + +/// Indicates that the MUC forbids us from joining, i.e. when we're banned. +class JoinForbiddenError extends MUCError {} + +/// Indicates that an unspecific error occurred while joining. +class MUCUnspecificError extends MUCError {} diff --git a/packages/moxxmpp/lib/src/xeps/xep_0045/events.dart b/packages/moxxmpp/lib/src/xeps/xep_0045/events.dart new file mode 100644 index 0000000..1128df8 --- /dev/null +++ b/packages/moxxmpp/lib/src/xeps/xep_0045/events.dart @@ -0,0 +1,35 @@ +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/xeps/xep_0045/types.dart'; + +/// Triggered when the MUC changes our nickname. +class NickChangedByMUCEvent extends XmppEvent { + NickChangedByMUCEvent(this.roomJid, this.nick); + + /// The JID of the room. + final JID roomJid; + + /// The new nickname. + final String nick; +} + +/// Triggered when an entity joins the MUC. +class MemberJoinedEvent extends XmppEvent { + MemberJoinedEvent(this.roomJid, this.member); + + /// The JID of the room. + final JID roomJid; + + /// The new member. + final RoomMember member; +} + +class MemberChangedEvent extends XmppEvent { + MemberChangedEvent(this.roomJid, this.member); + + /// The JID of the room. + final JID roomJid; + + /// The new member. + final RoomMember member; +} diff --git a/packages/moxxmpp/lib/src/xeps/xep_0045/types.dart b/packages/moxxmpp/lib/src/xeps/xep_0045/types.dart index d6b506a..a58866c 100644 --- a/packages/moxxmpp/lib/src/xeps/xep_0045/types.dart +++ b/packages/moxxmpp/lib/src/xeps/xep_0045/types.dart @@ -4,6 +4,66 @@ import 'package:moxxmpp/src/namespaces.dart'; import 'package:moxxmpp/src/xeps/xep_0004.dart'; import 'package:moxxmpp/src/xeps/xep_0030/types.dart'; +class InvalidAffiliationException implements Exception {} +class InvalidRoleException implements Exception {} + +enum Affiliation { + owner('owner'), + admin('admin'), + member('member'), + outcast('outcast'), + none('none'); + + const Affiliation(this.value); + + factory Affiliation.fromString(String value) { + switch (value) { + case 'owner': + return Affiliation.owner; + case 'admin': + return Affiliation.admin; + case 'member': + return Affiliation.member; + case 'outcast': + return Affiliation.outcast; + case 'none': + return Affiliation.none; + default: + throw InvalidAffiliationException(); + } + } + + /// The value to use for an attribute referring to this affiliation. + final String value; +} + +enum Role { + moderator('moderator'), + participant('participant'), + visitor('visitor'), + none('none'); + + const Role(this.value); + + factory Role.fromString(String value) { + switch (value) { + case 'moderator': + return Role.moderator; + case 'participant': + return Role.participant; + case 'visitor': + return Role.visitor; + case 'none': + return Role.none; + default: + throw InvalidRoleException(); + } + } + + /// The value to use for an attribute referring to this role. + final String value; +} + class RoomInformation { /// Represents information about a Multi-User Chat (MUC) room. RoomInformation({ @@ -48,6 +108,32 @@ class RoomInformation { /// The used message-id and an optional origin-id. typedef PendingMessage = (String, String?); +/// An entity inside a MUC room. The name "member" here does not refer to an affiliation of member. +class RoomMember { + const RoomMember(this.nick, this.affiliation, this.role); + + /// The entity's nickname. + final String nick; + + /// The assigned affiliation. + final Affiliation affiliation; + + /// The assigned role. + final Role role; + + RoomMember copyWith({ + String? nick, + Affiliation? affiliation, + Role? role, + }) { + return RoomMember( + nick ?? this.nick, + affiliation ?? this.affiliation, + role ?? this.role, + ); + } +} + class RoomState { RoomState({required this.roomJid, this.nick, required this.joined}) { pendingMessages = List.empty(growable: true); @@ -63,4 +149,7 @@ class RoomState { bool joined; late final List pendingMessages; + + /// "List" of entities inside the MUC. + final Map members = {}; } diff --git a/packages/moxxmpp/lib/src/xeps/xep_0045/xep_0045.dart b/packages/moxxmpp/lib/src/xeps/xep_0045/xep_0045.dart index f5c430f..06fc651 100644 --- a/packages/moxxmpp/lib/src/xeps/xep_0045/xep_0045.dart +++ b/packages/moxxmpp/lib/src/xeps/xep_0045/xep_0045.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:moxlib/moxlib.dart'; import 'package:moxxmpp/src/events.dart'; import 'package:moxxmpp/src/jid.dart'; @@ -6,11 +7,13 @@ import 'package:moxxmpp/src/managers/data.dart'; import 'package:moxxmpp/src/managers/handlers.dart'; import 'package:moxxmpp/src/managers/namespaces.dart'; import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/presence.dart'; import 'package:moxxmpp/src/stanza.dart'; import 'package:moxxmpp/src/stringxml.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/events.dart'; import 'package:moxxmpp/src/xeps/xep_0045/types.dart'; import 'package:moxxmpp/src/xeps/xep_0359.dart'; import 'package:synchronized/extension.dart'; @@ -25,9 +28,12 @@ class MUCManager extends XmppManagerBase { @override Future isSupported() async => true; - /// Map full JID to RoomState + /// Map a room's JID to its RoomState final Map _mucRoomCache = {}; + /// Mapp a room's JID to a completer waiting for the completion of the join process. + final Map>> _mucRoomJoinCompleter = {}; + /// Cache lock final Lock _cacheLock = Lock(); @@ -43,6 +49,14 @@ class MUCManager extends XmppManagerBase { // Before the message handler priority: -99, ), + StanzaHandler( + stanzaTag: 'presence', + callback: _onPresence, + tagName: 'x', + tagXmlns: mucUserXmlns, + // Before the PresenceManager + priority: PresenceManager.presenceHandlerPriority + 1, + ), ]; @override @@ -70,6 +84,7 @@ class MUCManager extends XmppManagerBase { // Mark all groupchats as not joined. for (final jid in _mucRoomCache.keys) { _mucRoomCache[jid]!.joined = false; + _mucRoomJoinCompleter[jid] = Completer(); // Re-join all MUCs. final state = _mucRoomCache[jid]!; @@ -149,18 +164,23 @@ class MUCManager extends XmppManagerBase { return Result(NoNicknameSpecified()); } - await _cacheLock.synchronized( + final completer = + await _cacheLock.synchronized>>( () { _mucRoomCache[roomJid] = RoomState( roomJid: roomJid, nick: nick, joined: false, ); + + final completer = Completer>(); + _mucRoomJoinCompleter[roomJid] = completer; + return completer; }, ); await _sendMucJoin(roomJid, nick, maxHistoryStanzas); - return const Result(true); + return completer.future; } Future _sendMucJoin( @@ -222,6 +242,129 @@ class MUCManager extends XmppManagerBase { return const Result(true); } + Future getRoomState(JID roomJid) async { + return _cacheLock.synchronized(() => _mucRoomCache[roomJid]); + } + + Future _onPresence( + Stanza presence, + StanzaHandlerData state, + ) async { + if (presence.from == null) { + return state; + } + + final from = JID.fromString(presence.from!); + final bareFrom = from.toBare(); + return _cacheLock.synchronized(() { + final room = _mucRoomCache[bareFrom]; + if (room == null) { + return state; + } + + if (from.resource.isEmpty) { + // TODO(Unknown): Handle presence from the room itself. + return state; + } + + if (presence.type == 'error') { + final errorTag = presence.firstTag('error')!; + final error = errorTag.firstTagByXmlns(fullStanzaXmlns)!; + Result result; + if (error.tag == 'forbidden') { + result = Result(JoinForbiddenError()); + } else { + result = Result(MUCUnspecificError()); + } + + _mucRoomCache.remove(bareFrom); + _mucRoomJoinCompleter[bareFrom]!.complete(result); + _mucRoomJoinCompleter.remove(bareFrom); + return StanzaHandlerData( + true, + false, + presence, + state.extensions, + ); + } + + final x = presence.firstTag('x', xmlns: mucUserXmlns)!; + final item = x.firstTag('item')!; + final statuses = x + .findTags('status') + .map((s) => s.attributes['code']! as String) + .toList(); + final role = Role.fromString( + item.attributes['role']! as String, + ); + + if (statuses.contains('110')) { + if (room.nick != from.resource) { + // Notify us of the changed nick. + getAttributes().sendEvent( + NickChangedByMUCEvent( + bareFrom, + from.resource, + ), + ); + } + + // Set the nick to make sure we're in sync with the MUC. + room.nick = from.resource; + return StanzaHandlerData( + true, + false, + presence, + state.extensions, + ); + } + + if (presence.attributes['type'] == 'unavailable' && role == Role.none) { + // Cannot happen while joining, so we assume we are joined + assert( + room.joined, + 'Should not receive unavailable with role="none" while joining', + ); + room.members.remove(from.resource); + } else { + final member = RoomMember( + from.resource, + Affiliation.fromString( + item.attributes['affiliation']! as String, + ), + role, + ); + logger.finest('Got presence from ${from.resource} in $bareFrom'); + if (room.joined) { + if (room.members.containsKey(from.resource)) { + getAttributes().sendEvent( + MemberJoinedEvent( + bareFrom, + member, + ), + ); + } else { + getAttributes().sendEvent( + MemberChangedEvent( + bareFrom, + member, + ), + ); + } + } + + room.members[from.resource] = member; + } + + return StanzaHandlerData( + true, + false, + presence, + state.extensions, + ); + }); + } + Future _onMessageSent( Stanza message, StanzaHandlerData state, @@ -260,6 +403,10 @@ class MUCManager extends XmppManagerBase { if (!roomState.joined) { // Mark the room as joined. _mucRoomCache[roomJid]!.joined = true; + _mucRoomJoinCompleter[roomJid]!.complete( + const Result(true), + ); + _mucRoomJoinCompleter.remove(roomJid); logger.finest('$roomJid is now joined'); } diff --git a/packages/moxxmpp/test/xeps/xep_0045_test.dart b/packages/moxxmpp/test/xeps/xep_0045_test.dart index 4dd8aea..0f92aae 100644 --- a/packages/moxxmpp/test/xeps/xep_0045_test.dart +++ b/packages/moxxmpp/test/xeps/xep_0045_test.dart @@ -162,4 +162,174 @@ void main() { expect(fakeSocket.getState(), 10); }, ); + + test( + 'Test joining a MUC with other members', + () async { + final fakeSocket = StubTCPSocket([ + StringExpectation( + "", + ''' + + + + PLAIN + + + + + ''', + ), + StringExpectation( + "AHBvbHlub21kaXZpc2lvbgBhYWFh", + '', + ), + StringExpectation( + "", + ''' + + + + + + + + + + + +''', + ), + StanzaExpectation( + '', + 'polynomdivision@test.server/MU29eEZn', + ignoreId: true, + ), + StanzaExpectation( + '', + '', + ignoreId: true, + ), + ]); + final conn = XmppConnection( + TestingSleepReconnectionPolicy(1), + AlwaysConnectedConnectivityManager(), + ClientToServerNegotiator(), + fakeSocket, + ) + ..connectionSettings = ConnectionSettings( + jid: JID.fromString('polynomdivision@test.server'), + password: 'aaaa', + ) + ..setResource('test-resource', triggerEvent: false); + await conn.registerManagers([ + DiscoManager([]), + MUCManager(), + ]); + + await conn.registerFeatureNegotiators([ + SaslPlainNegotiator(), + ResourceBindingNegotiator(), + ]); + + await conn.connect( + waitUntilLogin: true, + shouldReconnect: false, + ); + + // Join a groupchat + final roomJid = JID.fromString('channel@muc.example.org'); + final joinResult = conn.getManagerById(mucManager)!.joinRoom( + roomJid, + 'test', + maxHistoryStanzas: 0, + ); + await Future.delayed(const Duration(seconds: 1)); + + fakeSocket + ..injectRawXml( + ''' + + + + + + ''', + ) + ..injectRawXml( + ''' + + + + + + ''', + ) + ..injectRawXml( + ''' + + + + + + + ''', + ) + ..injectRawXml( + ''' + + + + ''', + ); + + await joinResult; + expect(fakeSocket.getState(), 5); + + final room = (await conn + .getManagerById(mucManager)! + .getRoomState(roomJid))!; + expect(room.joined, true); + expect( + room.members.length, + 2, + ); + expect( + room.members['test'], + null, + ); + expect( + room.members['secondwitch']!.role, + Role.moderator, + ); + expect( + room.members['secondwitch']!.affiliation, + Affiliation.admin, + ); + expect( + room.members['firstwitch']!.role, + Role.moderator, + ); + expect( + room.members['firstwitch']!.affiliation, + Affiliation.owner, + ); + }, + ); }