feat(xep): Add MUC events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
814f99436b
commit
7ca648c478
@ -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
|
||||
|
@ -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 {}
|
||||
|
35
packages/moxxmpp/lib/src/xeps/xep_0045/events.dart
Normal file
35
packages/moxxmpp/lib/src/xeps/xep_0045/events.dart
Normal file
@ -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;
|
||||
}
|
@ -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<PendingMessage>.empty(growable: true);
|
||||
@ -63,4 +149,7 @@ class RoomState {
|
||||
bool joined;
|
||||
|
||||
late final List<PendingMessage> pendingMessages;
|
||||
|
||||
/// "List" of entities inside the MUC.
|
||||
final Map<String, RoomMember> members = {};
|
||||
}
|
||||
|
@ -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<bool> isSupported() async => true;
|
||||
|
||||
/// Map full JID to RoomState
|
||||
/// Map a room's JID to its RoomState
|
||||
final Map<JID, RoomState> _mucRoomCache = {};
|
||||
|
||||
/// Mapp a room's JID to a completer waiting for the completion of the join process.
|
||||
final Map<JID, Completer<Result<bool, MUCError>>> _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<Completer<Result<bool, MUCError>>>(
|
||||
() {
|
||||
_mucRoomCache[roomJid] = RoomState(
|
||||
roomJid: roomJid,
|
||||
nick: nick,
|
||||
joined: false,
|
||||
);
|
||||
|
||||
final completer = Completer<Result<bool, MUCError>>();
|
||||
_mucRoomJoinCompleter[roomJid] = completer;
|
||||
return completer;
|
||||
},
|
||||
);
|
||||
|
||||
await _sendMucJoin(roomJid, nick, maxHistoryStanzas);
|
||||
return const Result(true);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> _sendMucJoin(
|
||||
@ -222,6 +242,129 @@ class MUCManager extends XmppManagerBase {
|
||||
return const Result(true);
|
||||
}
|
||||
|
||||
Future<RoomState?> getRoomState(JID roomJid) async {
|
||||
return _cacheLock.synchronized(() => _mucRoomCache[roomJid]);
|
||||
}
|
||||
|
||||
Future<StanzaHandlerData> _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<bool, MUCError> 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<StanzaHandlerData> _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');
|
||||
}
|
||||
|
||||
|
@ -162,4 +162,174 @@ void main() {
|
||||
expect(fakeSocket.getState(), 10);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'Test joining a MUC with other members',
|
||||
() async {
|
||||
final fakeSocket = StubTCPSocket([
|
||||
StringExpectation(
|
||||
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
|
||||
'''
|
||||
<stream:stream
|
||||
xmlns="jabber:client"
|
||||
version="1.0"
|
||||
xmlns:stream="http://etherx.jabber.org/streams"
|
||||
from="test.server"
|
||||
xml:lang="en">
|
||||
<stream:features xmlns="http://etherx.jabber.org/streams">
|
||||
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
|
||||
<mechanism>PLAIN</mechanism>
|
||||
</mechanisms>
|
||||
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
|
||||
<required/>
|
||||
</bind>
|
||||
</stream:features>''',
|
||||
),
|
||||
StringExpectation(
|
||||
"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>AHBvbHlub21kaXZpc2lvbgBhYWFh</auth>",
|
||||
'<success xmlns="urn:ietf:params:xml:ns:xmpp-sasl" />',
|
||||
),
|
||||
StringExpectation(
|
||||
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
|
||||
'''
|
||||
<stream:stream
|
||||
xmlns="jabber:client"
|
||||
version="1.0"
|
||||
xmlns:stream="http://etherx.jabber.org/streams"
|
||||
from="test.server"
|
||||
xml:lang="en">
|
||||
<stream:features xmlns="http://etherx.jabber.org/streams">
|
||||
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
|
||||
<required/>
|
||||
</bind>
|
||||
<session xmlns="urn:ietf:params:xml:ns:xmpp-session">
|
||||
<optional/>
|
||||
</session>
|
||||
<csi xmlns="urn:xmpp:csi:0"/>
|
||||
<sm xmlns="urn:xmpp:sm:3"/>
|
||||
</stream:features>
|
||||
''',
|
||||
),
|
||||
StanzaExpectation(
|
||||
'<iq xmlns="jabber:client" type="set" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"/></iq>',
|
||||
'<iq xmlns="jabber:client" type="result" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>polynomdivision@test.server/MU29eEZn</jid></bind></iq>',
|
||||
ignoreId: true,
|
||||
),
|
||||
StanzaExpectation(
|
||||
'<presence to="channel@muc.example.org/test" xmlns="jabber:client"><x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="0"/></x></presence>',
|
||||
'',
|
||||
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>(mucManager)!.joinRoom(
|
||||
roomJid,
|
||||
'test',
|
||||
maxHistoryStanzas: 0,
|
||||
);
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
|
||||
fakeSocket
|
||||
..injectRawXml(
|
||||
'''
|
||||
<presence
|
||||
from='channel@muc.example.org/firstwitch'
|
||||
id='3DCB0401-D7CF-4E31-BE05-EDF8D057BFBD'>
|
||||
<x xmlns='http://jabber.org/protocol/muc#user'>
|
||||
<item affiliation='owner' role='moderator'/>
|
||||
</x>
|
||||
</presence>
|
||||
''',
|
||||
)
|
||||
..injectRawXml(
|
||||
'''
|
||||
<presence
|
||||
from='channel@muc.example.org/secondwitch'
|
||||
id='C2CD9EE3-8421-431E-854A-A2AD0CE2E23D'>
|
||||
<x xmlns='http://jabber.org/protocol/muc#user'>
|
||||
<item affiliation='admin' role='moderator'/>
|
||||
</x>
|
||||
</presence>
|
||||
''',
|
||||
)
|
||||
..injectRawXml(
|
||||
'''
|
||||
<presence
|
||||
from='channel@muc.example.org/test'
|
||||
id='C2CD9EE3-8421-431E-854A-A2AD0CE2E23E'>
|
||||
<x xmlns='http://jabber.org/protocol/muc#user'>
|
||||
<item affiliation='member' role='none'/>
|
||||
<status code='110' />
|
||||
</x>
|
||||
</presence>
|
||||
''',
|
||||
)
|
||||
..injectRawXml(
|
||||
'''
|
||||
<message from="channel@muc.example.org" type="groupchat" xmlns="jabber:client">
|
||||
<subject/>
|
||||
</message>
|
||||
''',
|
||||
);
|
||||
|
||||
await joinResult;
|
||||
expect(fakeSocket.getState(), 5);
|
||||
|
||||
final room = (await conn
|
||||
.getManagerById<MUCManager>(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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user