git » sdk » commit 197251c

Basic MUC implementation

author Stephen Paul Weber
2023-10-18 16:22:54 UTC
committer Stephen Paul Weber
2023-10-18 16:23:06 UTC
parent f0bf34df23e0e3f6c9a6f566df2556a0331d2ffe

Basic MUC implementation

xmpp/Caps.hx +1 -1
xmpp/Chat.hx +209 -29
xmpp/ChatMessage.hx +9 -9
xmpp/Client.hx +73 -22
xmpp/persistence/browser.js +1 -1
xmpp/queries/DiscoInfoGet.hx +1 -1

diff --git a/xmpp/Caps.hx b/xmpp/Caps.hx
index 5208627..f1b0b1c 100644
--- a/xmpp/Caps.hx
+++ b/xmpp/Caps.hx
@@ -8,7 +8,7 @@ using Lambda;
 @:expose
 class Caps {
 	private final node: String;
-	private final identities: Array<Identity>;
+	public final identities: Array<Identity>;
 	public final features : Array<String>;
 	// TODO: data forms
 
diff --git a/xmpp/Chat.hx b/xmpp/Chat.hx
index 922c934..d91bd23 100644
--- a/xmpp/Chat.hx
+++ b/xmpp/Chat.hx
@@ -9,6 +9,7 @@ import xmpp.ID;
 import xmpp.MessageSync;
 import xmpp.jingle.PeerConnection;
 import xmpp.jingle.Session;
+import xmpp.queries.DiscoInfoGet;
 import xmpp.queries.MAMQuery;
 using Lambda;
 
@@ -21,22 +22,40 @@ abstract class Chat {
 	private var trusted:Bool = false;
 	public var chatId(default, null):String;
 	public var jingleSessions: Map<String, xmpp.jingle.Session> = [];
+	private var displayName:String;
 
-	private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String) {
+	public function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String) {
 		this.client = client;
 		this.stream = stream;
 		this.persistence = persistence;
 		this.chatId = chatId;
+		this.displayName = chatId;
 	}
 
+	abstract public function prepareIncomingMessage(message:ChatMessage, stanza:Stanza):ChatMessage;
+
 	abstract public function sendMessage(message:ChatMessage):Void;
 
 	abstract public function getMessages(beforeId:Null<String>, beforeTime:Null<String>, handler:(Array<ChatMessage>)->Void):Void;
 
-	abstract public function getDisplayName():String;
-
 	abstract public function getParticipants():Array<String>;
 
+	abstract public function getParticipantDetails(participantId:String, callback:({photoUri:String, displayName:String})->Void):Void;
+
+	abstract public function bookmark():Void;
+
+	public function getPhoto(callback:(String)->Void) {
+		callback(Color.defaultPhoto(chatId, getDisplayName().charAt(0)));
+	}
+
+	public function setDisplayName(fn:String) {
+		this.displayName = fn;
+	}
+
+	public function getDisplayName() {
+		return this.displayName;
+	}
+
 	public function setCaps(resource:String, caps:Caps) {
 		this.caps.set(resource, caps);
 	}
@@ -49,6 +68,10 @@ abstract class Chat {
 		return caps[resource];
 	}
 
+	public function setAvatarSha1(sha1: BytesData) {
+		this.avatarSha1 = sha1;
+	}
+
 	public function setTrusted(trusted:Bool) {
 		this.trusted = trusted;
 	}
@@ -134,26 +157,17 @@ abstract class Chat {
 
 @:expose
 class DirectChat extends Chat {
-	private var displayName:String;
-	public function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String) {
-		super(client, stream, persistence, chatId);
-		this.displayName = chatId;
-	}
-
-	public function setDisplayName(fn:String) {
-		this.displayName = fn;
-	}
-
-	public function getDisplayName() {
-		return this.displayName;
-	}
-
 	public function getParticipants() {
 		return chatId.split("\n");
 	}
 
+	public function getParticipantDetails(participantId:String, callback:({photoUri:String, displayName:String})->Void) {
+		final chat = client.getDirectChat(participantId);
+		chat.getPhoto((photoUri) -> callback({ photoUri: photoUri, displayName: chat.getDisplayName() }));
+	}
+
 	public function getMessages(beforeId:Null<String>, beforeTime:Null<String>, handler:(Array<ChatMessage>)->Void):Void {
-		persistence.getMessages(client.jid, chatId, beforeId, beforeTime, (messages) -> {
+		persistence.getMessages(client.accountId(), chatId, beforeId, beforeTime, (messages) -> {
 			if (messages.length > 0) {
 				handler(messages);
 			} else {
@@ -171,6 +185,10 @@ class DirectChat extends Chat {
 		});
 	}
 
+	public function prepareIncomingMessage(message:ChatMessage, stanza:Stanza) {
+		return message;
+	}
+
 	public function sendMessage(message:ChatMessage):Void {
 		client.chatActivity(this);
 		message.recipients = getParticipants().map((p) -> JID.parse(p));
@@ -182,7 +200,7 @@ class DirectChat extends Chat {
 
 	public function bookmark() {
 		stream.sendIq(
-			new Stanza("iq", { type: "set", id: ID.short() })
+			new Stanza("iq", { type: "set" })
 				.tag("query", { xmlns: "jabber:iq:roster" })
 				.tag("item", { jid: chatId })
 				.up().up(),
@@ -194,23 +212,180 @@ class DirectChat extends Chat {
 		);
 	}
 
-	public function setAvatarSha1(sha1: BytesData) {
-		this.avatarSha1 = sha1;
-	}
-
-	public function getPhoto(callback:(String)->Void) {
+	override public function getPhoto(callback:(String)->Void) {
 		if (avatarSha1 != null) {
 			persistence.getMediaUri("sha-1", avatarSha1, (uri) -> {
 				if (uri != null) {
 					callback(uri);
 				} else {
-					callback(Color.defaultPhoto(chatId, chatId.charAt(0)));
+					callback(Color.defaultPhoto(chatId, getDisplayName().charAt(0)));
+				}
+			});
+		} else {
+			super.getPhoto(callback);
+		}
+	}
+}
+
+@:expose
+class Channel extends Chat {
+	private var disco: Caps = new Caps("", [], ["http://jabber.org/protocol/muc"]); // TODO: persist this
+
+	public function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, ?disco: Caps) {
+		super(client, stream, persistence, chatId);
+		if (disco != null) this.disco = disco;
+		selfPing(disco == null);
+	}
+
+	public function selfPing(shouldRefreshDisco = true) {
+		stream.sendIq(
+			new Stanza("iq", { type: "get", to: getFullJid().asString() })
+				.tag("ping", { xmlns: "urn:xmpp:ping" }).up(),
+			(response) -> {
+				if (response.attr.get("type") == "error") {
+					final err = response.getChild("error")?.getChild(null, "urn:ietf:params:xml:ns:xmpp-stanzas");
+					if (err.name == "service-unavailable" || err.name == "feature-not-implemented") return; // Error, success!
+					if (err.name == "remote-server-not-found" || err.name == "remote-server-timeout") return; // Timeout, retry later
+					if (err.name == "item-not-found") return; // Nick was changed?
+					(shouldRefreshDisco ? refreshDisco : (cb)->cb())(() -> {
+						client.sendPresence(
+							getFullJid().asString(),
+							(stanza) -> {
+								stanza.tag("x", { xmlns: "http://jabber.org/protocol/muc" });
+								if (disco.features.contains("urn:xmpp:mam:2")) stanza.tag("history", { maxchars: "0" }).up();
+								// TODO: else since (last message we know about)
+								stanza.up();
+								return stanza;
+							}
+						);
+					});
 				}
+			}
+		);
+	}
+
+	public function refreshDisco(?callback: ()->Void) {
+		final discoGet = new DiscoInfoGet(chatId);
+		discoGet.onFinished(() -> {
+			if (discoGet.getResult() != null) {
+				disco = discoGet.getResult();
+				persistence.storeCaps(discoGet.getResult());
+				persistence.storeChat(client.accountId(), this);
+			}
+			if (callback != null) callback();
+		});
+		client.sendQuery(discoGet);
+	}
+
+	private function getFullJid() {
+		final jid = JID.parse(chatId);
+		return new JID(jid.node, jid.domain, client.displayName());
+	}
+
+	public function getParticipants() {
+		final jid = JID.parse(chatId);
+		return caps.keys().map((resource) -> new JID(jid.node, jid.domain, resource).asString());
+	}
+
+	public function getParticipantDetails(participantId:String, callback:({photoUri:String, displayName:String})->Void) {
+		if (participantId == getFullJid().asString()) {
+			client.getDirectChat(client.accountId(), false).getPhoto((photoUri) -> {
+				callback({ photoUri: photoUri, displayName: client.displayName() });
 			});
 		} else {
-			callback(Color.defaultPhoto(chatId, chatId.charAt(0)));
+			final nick = JID.parse(participantId).resource;
+			final photoUri = Color.defaultPhoto(participantId, nick.charAt(0));
+			callback({ photoUri: photoUri, displayName: nick });
 		}
 	}
+
+	public function getMessages(beforeId:Null<String>, beforeTime:Null<String>, handler:(Array<ChatMessage>)->Void):Void {
+		persistence.getMessages(client.accountId(), chatId, beforeId, beforeTime, (messages) -> {
+			if (messages.length > 0) {
+				handler(messages);
+			} else {
+				var filter:MAMQueryParams = {};
+				if (beforeId != null) filter.page = { before: beforeId };
+				var sync = new MessageSync(this.client, this.stream, filter, chatId);
+				sync.onMessages((messages) -> {
+					for (message in messages.messages) {
+						message = prepareIncomingMessage(message, new Stanza("message", { from: message.senderId() }));
+						trace("WUT", message);
+						persistence.storeMessage(client.jid, message);
+					}
+					handler(messages.messages.filter((m) -> m.chatId() == chatId));
+				});
+				sync.fetchNext();
+			}
+		});
+	}
+
+	public function prepareIncomingMessage(message:ChatMessage, stanza:Stanza) {
+		// TODO: mark type!=groupchat as whisper somehow
+		message.sender = JID.parse(stanza.attr.get("from")); // MUC always needs full JIDs
+		if (message.senderId() == getFullJid().asString()) {
+			message.recipients = message.replyTo;
+			message.direction = MessageSent;
+		}
+		return message;
+	}
+
+	public function sendMessage(message:ChatMessage):Void {
+		client.chatActivity(this);
+		message.to = JID.parse(chatId);
+		client.sendStanza(message.asStanza("groupchat"));
+	}
+
+	public function bookmark() {
+		// TODO: we should have been created from an existing bookmark if there was one,
+		// and we need to be sure to preserve everything we don't mean to change if this is an update
+		stream.sendIq(
+			new Stanza("iq", { type: "set" })
+				.tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" })
+				.tag("publish", { node: "urn:xmpp:bookmarks:1" })
+				.tag("item", { id: chatId })
+				.tag("conference", { xmlns: "urn:xmpp:bookmarks:1", name: getDisplayName(), autojoin: "true" })
+				.textTag("nick", client.displayName())
+				.up().up()
+				.tag("publish-options")
+				.tag("x", { xmlns: "jabber:x:data", type: "submit" })
+				.tag("field", { "var": "FORM_TYPE", type: "hidden" }).textTag("value", "http://jabber.org/protocol/pubsub#publish-options").up()
+				.tag("field", { "var": "pubsub#persist_items" }).textTag("value", "true").up()
+				.tag("field", { "var": "pubsub#max_items" }).textTag("value", "max").up()
+				.tag("field", { "var": "pubsub#send_last_published_item" }).textTag("value", "never").up()
+				.tag("field", { "var": "pubsub#access_model" }).textTag("value", "whitelist").up()
+				.tag("field", { "var": "pubsub#notify_delete" }).textTag("value", "true").up()
+				.tag("field", { "var": "pubsub#notify_retract" }).textTag("value", "true").up()
+				.up().up().up().up(),
+			(response) -> {
+				if (response.attr.get("type") == "error") {
+					final preconditionError = response.getChild("error")?.getChild("precondition-not-met", "http://jabber.org/protocol/pubsub#errors");
+					if (preconditionError != null) {
+						// publish options failed, so force them to be right, what a silly workflow
+						stream.sendIq(
+							new Stanza("iq", { type: "set" })
+								.tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub#owner" })
+								.tag("configure", { node: "urn:xmpp:bookmarks:1" })
+								.tag("x", { xmlns: "jabber:x:data", type: "submit" })
+								.tag("field", { "var": "FORM_TYPE", type: "hidden" }).textTag("value", "http://jabber.org/protocol/pubsub#publish-options").up()
+								.tag("field", { "var": "pubsub#persist_items" }).textTag("value", "true").up()
+								.tag("field", { "var": "pubsub#max_items" }).textTag("value", "max").up()
+								.tag("field", { "var": "pubsub#send_last_published_item" }).textTag("value", "never").up()
+								.tag("field", { "var": "pubsub#access_model" }).textTag("value", "whitelist").up()
+								.tag("field", { "var": "pubsub#notify_delete" }).textTag("value", "true").up()
+								.tag("field", { "var": "pubsub#notify_retract" }).textTag("value", "true").up()
+								.up().up().up(),
+							(response) -> {
+								if (response.attr.get("type") == "result") {
+									bookmark();
+								}
+							}
+						);
+					}
+				}
+			}
+		);
+	}
 }
 
 @:expose
@@ -231,9 +406,14 @@ class SerializedChat {
 		this.klass = klass;
 	}
 
-	public function toDirectChat(client: Client, stream: GenericStream, persistence: Persistence) {
-		if (klass != "DirectChat") throw "Not a direct chat: " + klass;
-		final chat = new DirectChat(client, stream, persistence, chatId);
+	public function toChat(client: Client, stream: GenericStream, persistence: Persistence) {
+		final chat = if (klass == "DirectChat") {
+			new DirectChat(client, stream, persistence, chatId);
+		} else if (klass == "Channel") {
+			new Channel(client, stream, persistence, chatId);
+		} else {
+			throw "Unknown class: " + klass;
+		}
 		if (displayName != null) chat.setDisplayName(displayName);
 		if (avatarSha1 != null) chat.setAvatarSha1(avatarSha1);
 		chat.setTrusted(trusted);
diff --git a/xmpp/ChatMessage.hx b/xmpp/ChatMessage.hx
index 4ddbc9f..2670d25 100644
--- a/xmpp/ChatMessage.hx
+++ b/xmpp/ChatMessage.hx
@@ -25,9 +25,9 @@ class ChatMessage {
 
 	public var to: Null<JID> = null;
 	private var from: Null<JID> = null;
-	private var sender: Null<JID> = null;
+	public var sender: Null<JID> = null;
 	public var recipients: Array<JID> = [];
-	private var replyTo: Array<JID> = [];
+	public var replyTo: Array<JID> = [];
 
 	var threadId (default, null): Null<String> = null;
 
@@ -36,7 +36,7 @@ class ChatMessage {
 	public var text (default, null): Null<String> = null;
 	public var lang (default, null): Null<String> = null;
 
-	private var direction: MessageDirection = MessageReceived;
+	public var direction: MessageDirection = MessageReceived;
 
 	public function new() { }
 
@@ -51,7 +51,7 @@ class ChatMessage {
 		msg.to = to == null ? null : JID.parse(to);
 		final from = stanza.attr.get("from");
 		msg.from = from == null ? null : JID.parse(from);
-		msg.sender = msg.from;
+		msg.sender = stanza.attr.get("type") == "groupchat" ? msg.from : msg.from?.asBare();
 		final localJid = JID.parse(localJidStr);
 		final localJidBare = localJid.asBare();
 		final domain = localJid.domain;
@@ -82,7 +82,7 @@ class ChatMessage {
 			recipients[msg.to.asBare().asString()] = true;
 		}
 		if (msg.direction == MessageReceived && msg.from != null) {
-			replyTo[msg.from.asString()] = true;
+			replyTo[stanza.attr.get("type") == "groupchat" ? msg.from.asBare().asString() : msg.from.asString()] = true;
 		} else if(msg.to != null) {
 			replyTo[msg.to.asString()] = true;
 		}
@@ -109,7 +109,7 @@ class ChatMessage {
 				} else if (address.attr.get("type") == "ofrom") {
 					if (JID.parse(jid).domain == msg.sender?.domain) {
 						// TODO: check that domain supports extended addressing
-						msg.sender = JID.parse(jid);
+						msg.sender = JID.parse(jid).asBare();
 					}
 				}
 			}
@@ -158,7 +158,7 @@ class ChatMessage {
 	}
 
 	public function senderId():String {
-		return sender?.asBare().asString() ?? throw "sender is null";
+		return sender?.asString() ?? throw "sender is null";
 	}
 
 	public function account():String {
@@ -169,8 +169,8 @@ class ChatMessage {
 		return direction == MessageReceived;
 	}
 
-	public function asStanza():Stanza {
-		var attrs: haxe.DynamicAccess<String> = { type: "chat" };
+	public function asStanza(?type: String):Stanza {
+		var attrs: haxe.DynamicAccess<String> = { type: type ?? "chat" };
 		if (from != null) attrs.set("from", from.asString());
 		if (to != null) attrs.set("to", to.asString());
 		if (localId != null) attrs.set("id", localId);
diff --git a/xmpp/Client.hx b/xmpp/Client.hx
index 6cff23e..f9b874f 100644
--- a/xmpp/Client.hx
+++ b/xmpp/Client.hx
@@ -18,15 +18,14 @@ import xmpp.queries.JabberIqGatewayGet;
 import xmpp.queries.PubsubGet;
 import xmpp.queries.Push2Enable;
 import xmpp.queries.RosterGet;
-
-typedef ChatList = Array<Chat>;
+using Lambda;
 
 @:expose
 class Client extends xmpp.EventEmitter {
 	private var stream:GenericStream;
 	private var chatMessageHandlers: Array<(ChatMessage)->Void> = [];
 	public var jid(default,null):String;
-	private var chats: ChatList = [];
+	private var chats: Array<Chat> = [];
 	private var persistence: Persistence;
 	private final caps = new Caps(
 		"https://sdk.snikket.org",
@@ -57,10 +56,14 @@ class Client extends xmpp.EventEmitter {
 		return JID.parse(jid).asBare().asString();
 	}
 
+	public function displayName() {
+		return JID.parse(jid).node;
+	}
+
 	public function start() {
 		persistence.getChats(jid, (protoChats) -> {
 			for (protoChat in protoChats) {
-				chats.push(protoChat.toDirectChat(this, stream, persistence));
+				chats.push(protoChat.toChat(this, stream, persistence));
 			}
 			this.trigger("chats/update", chats);
 
@@ -132,12 +135,16 @@ class Client extends xmpp.EventEmitter {
 				}
 			}
 
-			final chatMessage = ChatMessage.fromStanza(stanza, jid);
+			var chatMessage = ChatMessage.fromStanza(stanza, jid);
 			if (chatMessage != null) {
-				var chat = getDirectChat(chatMessage.chatId());
-				chatActivity(chat);
-				for (handler in chatMessageHandlers) {
-					handler(chatMessage);
+				var chat = getChat(chatMessage.chatId());
+				if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(chatMessage.chatId());
+				if (chat != null) {
+					chatActivity(chat);
+					chatMessage = chat.prepareIncomingMessage(chatMessage, stanza);
+					for (handler in chatMessageHandlers) {
+						handler(chatMessage);
+					}
 				}
 			}
 
@@ -317,30 +324,72 @@ class Client extends xmpp.EventEmitter {
 	}
 
 	/* Return array of chats, sorted by last activity */
-	public function getChats():ChatList {
+	public function getChats():Array<Chat> {
 		return chats;
 	}
 
+	// We can ask for caps here because presumably they looked this up
+	// via findAvailableChats
+	public function startChat(chatId:String, fn:Null<String>, caps:Caps):Chat {
+		final existingChat = getChat(chatId);
+		if (existingChat != null) return existingChat;
+
+		final chat = if (caps.isChannel(chatId)) {
+			final channel = new Channel(this, this.stream, this.persistence, chatId, caps);
+			chats.unshift(channel);
+			channel;
+		} else {
+			getDirectChat(chatId, false);
+		}
+		if (fn != null) chat.setDisplayName(fn);
+		persistence.storeChat(accountId(), chat);
+		this.trigger("chats/update", [chat]);
+		return chat;
+	}
+
+	public function getChat(chatId:String):Null<Chat> {
+		return chats.find((chat) -> chat.chatId == chatId);
+	}
+
 	public function getDirectChat(chatId:String, triggerIfNew:Bool = true):DirectChat {
 		for (chat in chats) {
 			if (Std.isOfType(chat, DirectChat) && chat.chatId == chatId) {
 				return Std.downcast(chat, DirectChat);
 			}
 		}
-		var chat = new DirectChat(this, this.stream, this.persistence, chatId);
+		final chat = new DirectChat(this, this.stream, this.persistence, chatId);
 		persistence.storeChat(jid, chat);
 		chats.unshift(chat);
 		if (triggerIfNew) this.trigger("chats/update", [chat]);
 		return chat;
 	}
 
-	public function findAvailableChats(q:String, callback:(q:String, chatIds:Array<String>) -> Void) {
+	public function findAvailableChats(q:String, callback:(q:String, results:Array<{ chatId: String, fn: String, note: String, caps: Caps }>) -> Void) {
 		var results = [];
 		final query = StringTools.trim(q);
 		final jid = JID.parse(query);
+		final checkAndAdd = (jid) -> {
+			final discoGet = new DiscoInfoGet(jid.asString());
+			discoGet.onFinished(() -> {
+				final resultCaps = discoGet.getResult();
+				if (resultCaps == null) {
+					final err = discoGet.responseStanza?.getChild("error")?.getChild(null, "urn:ietf:params:xml:ns:xmpp-stanzas");
+					if (err == null || err?.name == "service-unavailable" || err?.name == "feature-not-implemented") {
+						results.push({ chatId: jid.asString(), fn: query, note: jid.asString(), caps: new Caps("", [], []) });
+					}
+				} else {
+					persistence.storeCaps(resultCaps);
+					final identity = resultCaps.identities[0];
+					final fn = identity?.name ?? query;
+					final note = jid.asString() + (identity == null ? "" : " (" + identity.type + ")");
+					results.push({ chatId: jid.asString(), fn: fn, note: note, caps: resultCaps });
+				}
+				callback(q, results);
+			});
+			sendQuery(discoGet);
+		};
 		if (jid.isValid()) {
-			results.push(jid.asBare().asString());
-			callback(q, results); // send some right away
+			checkAndAdd(jid);
 		}
 		for (chat in chats) {
 			if (chat.isTrusted()) {
@@ -359,18 +408,15 @@ class Client extends xmpp.EventEmitter {
 						if (jigGet.getResult() == null) {
 							final caps = chat.getResourceCaps(resource);
 							if (bareJid.isDomain() && caps.features.contains("jid\\20escaping")) {
-								results.push(new JID(query, bareJid.domain).asString());
-								callback(q, results);
+								checkAndAdd(new JID(query, bareJid.domain));
 							} else if (bareJid.isDomain()) {
-								results.push(new JID(StringTools.replace(query, "@", "%"), bareJid.domain).asString());
-								callback(q, results);
+								checkAndAdd(new JID(StringTools.replace(query, "@", "%"), bareJid.domain));
 							}
 						} else {
 							switch (jigGet.getResult()) {
 								case Left(error): return;
 								case Right(result):
-									results.push(result);
-									callback(q, results);
+									checkAndAdd(JID.parse(result));
 							}
 						}
 					});
@@ -398,8 +444,13 @@ class Client extends xmpp.EventEmitter {
 		stream.sendStanza(stanza);
 	}
 
-	public function sendPresence(?to: String) {
-		sendStanza(caps.addC(new Stanza("presence", to == null ? {} : { to: to })));
+	public function sendPresence(?to: String, ?augment: (Stanza)->Stanza) {
+		sendStanza(
+			(augment ?? (s)->s)(
+				caps.addC(new Stanza("presence", to == null ? {} : { to: to }))
+					.textTag("nick", displayName(), { xmlns: "http://jabber.org/protocol/nick" })
+			)
+		);
 	}
 
 	#if js
diff --git a/xmpp/persistence/browser.js b/xmpp/persistence/browser.js
index bf81fbe..200ca7b 100644
--- a/xmpp/persistence/browser.js
+++ b/xmpp/persistence/browser.js
@@ -83,7 +83,7 @@ exports.xmpp.persistence = {
 					avatarSha1: chat.avatarSha1,
 					caps: chat.caps,
 					displayName: chat.displayName,
-					class: chat instanceof xmpp.DirectChat ? "DirectChat" : "Chat"
+					class: chat instanceof xmpp.DirectChat ? "DirectChat" : (chat instanceof xmpp.Channel ? "Channel" : "Chat")
 				});
 			},
 
diff --git a/xmpp/queries/DiscoInfoGet.hx b/xmpp/queries/DiscoInfoGet.hx
index 6fdfab1..904aad3 100644
--- a/xmpp/queries/DiscoInfoGet.hx
+++ b/xmpp/queries/DiscoInfoGet.hx
@@ -14,7 +14,7 @@ class DiscoInfoGet extends GenericQuery {
 	public var xmlns(default, null) = "http://jabber.org/protocol/disco#info";
 	public var queryId:String = null;
 	public var ver:String = null;
-	private var responseStanza:Stanza;
+	public var responseStanza(default, null):Stanza;
 	private var result: Caps;
 
 	public function new(to: String, ?node: String) {