using Gee; using Xmpp; using Xmpp.Xep; using Dino.Entities; namespace Dino { public class MucManager : StreamInteractionModule, Object { public static ModuleIdentity IDENTITY = new ModuleIdentity("muc_manager"); public string id { get { return IDENTITY.id; } } public signal void left(Account account, Jid jid); public signal void subject_set(Account account, Jid jid, string? subject); public signal void room_info_updated(Account account, Jid muc_jid); public signal void private_room_occupant_updated(Account account, Jid room, Jid occupant); public signal void invite_received(Account account, Jid room_jid, Jid from_jid, string? password, string? reason); public signal void voice_request_received(Account account, Jid room_jid, Jid from_jid, string nick); public signal void received_occupant_role(Account account, Jid jid, Xep.Muc.Role? role); public signal void bookmarks_updated(Account account, Set conferences); public signal void conference_added(Account account, Conference conference); public signal void conference_removed(Account account, Jid jid); private StreamInteractor stream_interactor; private HashMap> mucs_todo = new HashMap>(Account.hash_func, Account.equals_func); private HashMap> mucs_joining = new HashMap>(Account.hash_func, Account.equals_func); private HashMap enter_errors = new HashMap(Jid.hash_func, Jid.equals_func); private ReceivedMessageListener received_message_listener; private HashMap bookmarks_provider = new HashMap(Account.hash_func, Account.equals_func); private HashMap> invites = new HashMap>(Account.hash_func, Account.equals_func); public HashMap default_muc_server = new HashMap(Account.hash_func, Account.equals_func); public static void start(StreamInteractor stream_interactor) { MucManager m = new MucManager(stream_interactor); stream_interactor.add_module(m); } private MucManager(StreamInteractor stream_interactor) { this.stream_interactor = stream_interactor; this.received_message_listener = new ReceivedMessageListener(stream_interactor); stream_interactor.account_added.connect(on_account_added); stream_interactor.stream_negotiated.connect(on_stream_negotiated); stream_interactor.get_module(MessageProcessor.IDENTITY).received_pipeline.connect(received_message_listener); stream_interactor.get_module(ConversationManager.IDENTITY).conversation_deactivated.connect((conversation) => { if (conversation.type_ == Conversation.Type.GROUPCHAT) { part(conversation.account, conversation.counterpart); } }); stream_interactor.stream_resumed.connect((account, stream) => self_ping(account)); Timeout.add_seconds(60 * 3, () => { foreach (Account account in stream_interactor.get_accounts()) { self_ping(account); } return true; }); } // already_autojoin: Without this flag we'd be retrieving bookmarks (to check for autojoin) from the sender on every join public async Muc.JoinResult? join(Account account, Jid jid, string? nick, string? password, bool already_autojoin = false) { XmppStream? stream = stream_interactor.get_stream(account); if (stream == null) return null; string nick_ = (nick ?? account.localpart) ?? account.domainpart; DateTime? history_since = null; Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(jid, account); if (conversation != null) { Entities.Message? last_message = stream_interactor.get_module(MessageStorage.IDENTITY).get_last_message(conversation); if (last_message != null) history_since = last_message.time; } if (!mucs_joining.has_key(account)) { mucs_joining[account] = new HashSet(Jid.hash_bare_func, Jid.equals_bare_func); } mucs_joining[account].add(jid); if (!mucs_todo.has_key(account)) { mucs_todo[account] = new HashSet(Jid.hash_bare_func, Jid.equals_bare_func); } mucs_todo[account].add(jid.with_resource(nick_)); Muc.JoinResult? res = yield stream.get_module(Xep.Muc.Module.IDENTITY).enter(stream, jid.bare_jid, nick_, password, history_since, null); mucs_joining[account].remove(jid); if (res.nick != null) { // Join completed enter_errors.unset(jid); if (!already_autojoin) set_autojoin(account, stream, jid, nick, password); stream_interactor.get_module(MessageProcessor.IDENTITY).send_unsent_muc_messages(account, jid); Conversation joined_conversation = stream_interactor.get_module(ConversationManager.IDENTITY).create_conversation(jid, account, Conversation.Type.GROUPCHAT); joined_conversation.nickname = nick; stream_interactor.get_module(ConversationManager.IDENTITY).start_conversation(joined_conversation); } else if (res.muc_error != null) { // Join failed enter_errors[jid] = res.muc_error; } return res; } public void part(Account account, Jid jid) { if (!mucs_todo.has_key(account) || !mucs_todo[account].contains(jid)) return; mucs_todo[account].remove(jid); XmppStream? stream = stream_interactor.get_stream(account); if (stream == null) return; unset_autojoin(account, stream, jid); stream.get_module(Xep.Muc.Module.IDENTITY).exit(stream, jid.bare_jid); Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(jid, account); if (conversation != null) stream_interactor.get_module(ConversationManager.IDENTITY).close_conversation(conversation); } public async DataForms.DataForm? get_config_form(Account account, Jid jid) { XmppStream? stream = stream_interactor.get_stream(account); if (stream == null) return null; return yield stream.get_module(Xep.Muc.Module.IDENTITY).get_config_form(stream, jid); } public async void set_config_form(Account account, Jid jid, DataForms.DataForm data_form) { XmppStream? stream = stream_interactor.get_stream(account); if (stream == null) return; yield stream.get_module(Xep.Muc.Module.IDENTITY).set_config_form(stream, jid, data_form); } public void change_subject(Account account, Jid jid, string subject) { XmppStream? stream = stream_interactor.get_stream(account); if (stream != null) stream.get_module(Xep.Muc.Module.IDENTITY).change_subject(stream, jid.bare_jid, subject); } public async void change_nick(Conversation conversation, string new_nick) { XmppStream? stream = stream_interactor.get_stream(conversation.account); if (stream == null) return; // Check if this would be a valid nick try { conversation.counterpart.with_resource(new_nick); } catch (InvalidJidError error) { return; } stream.get_module(Xep.Muc.Module.IDENTITY).change_nick(stream, conversation.counterpart, new_nick); conversation.nickname = new_nick; if (mucs_todo.has_key(conversation.account)) { mucs_todo[conversation.account].remove(conversation.counterpart); mucs_todo[conversation.account].add(conversation.counterpart.with_resource(new_nick)); } // Update nick in bookmark Set? conferences = yield bookmarks_provider[conversation.account].get_conferences(stream); if (conferences == null) return; foreach (Conference conference in conferences) { if (conference.jid.equals(conversation.counterpart)) { Conference new_conference = new Conference() { jid=conversation.counterpart, nick=new_nick, name=conference.name, password=conference.password, autojoin=conference.autojoin }; bookmarks_provider[conversation.account].replace_conference.begin(stream, conversation.counterpart, new_conference); break; } } } public void invite(Account account, Jid muc, Jid invitee) { XmppStream? stream = stream_interactor.get_stream(account); if (stream != null) stream.get_module(Xep.Muc.Module.IDENTITY).invite(stream, muc.bare_jid, invitee.bare_jid); } public void kick(Account account, Jid jid, string nick) { XmppStream? stream = stream_interactor.get_stream(account); if (stream != null) stream.get_module(Xep.Muc.Module.IDENTITY).kick(stream, jid.bare_jid, nick); } public void change_affiliation(Account account, Jid jid, string nick, string role) { XmppStream? stream = stream_interactor.get_stream(account); if (stream != null) stream.get_module(Xep.Muc.Module.IDENTITY).change_affiliation.begin(stream, jid.bare_jid, null, nick, role); } public void change_role(Account account, Jid jid, string nick, string role) { XmppStream? stream = stream_interactor.get_stream(account); if (stream != null) stream.get_module(Xep.Muc.Module.IDENTITY).change_role(stream, jid.bare_jid, nick, role); } public void request_voice(Account account, Jid jid) { XmppStream? stream = stream_interactor.get_stream(account); if (stream != null) stream.get_module(Xep.Muc.Module.IDENTITY).request_voice(stream, jid.bare_jid); } public bool kick_possible(Account account, Jid occupant) { XmppStream? stream = stream_interactor.get_stream(account); if (stream != null) return stream.get_module(Xep.Muc.Module.IDENTITY).kick_possible(stream, occupant); return false; } //the term `private room` is a short hand for members-only+non-anonymous rooms public bool is_private_room(Account account, Jid jid) { XmppStream? stream = stream_interactor.get_stream(account); if (stream == null) { return false; } Xep.Muc.Flag? flag = stream.get_flag(Xep.Muc.Flag.IDENTITY); if (flag == null) { return false; } return flag.has_room_feature(jid, Xep.Muc.Feature.NON_ANONYMOUS) && flag.has_room_feature(jid, Xep.Muc.Feature.MEMBERS_ONLY); } public bool is_moderated_room(Account account, Jid jid) { XmppStream? stream = stream_interactor.get_stream(account); if (stream == null) { return false; } Xep.Muc.Flag? flag = stream.get_flag(Xep.Muc.Flag.IDENTITY); if (flag == null) { return false; } return flag.has_room_feature(jid, Xep.Muc.Feature.MODERATED); } public bool is_public_room(Account account, Jid jid) { return is_groupchat(jid, account) && !is_private_room(account, jid); } public Gee.List? get_occupants(Jid jid, Account account) { if (is_groupchat(jid, account)) { Gee.List ret = new ArrayList(Jid.equals_func); Gee.List? full_jids = stream_interactor.get_module(PresenceManager.IDENTITY).get_full_jids(jid, account); if (full_jids != null) { ret.add_all(full_jids); // Remove eventual presence from bare jid ret.remove(jid); } return ret; } return null; } public Gee.List? get_other_occupants(Jid jid, Account account) { Gee.List? occupants = get_occupants(jid, account); Jid? own_jid = get_own_jid(jid, account); if (occupants != null && own_jid != null) { occupants.remove(own_jid); } return occupants; } public bool is_groupchat(Jid jid, Account account) { Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(jid, account, Conversation.Type.GROUPCHAT); return !jid.is_full() && conversation != null; } public bool might_be_groupchat(Jid jid, Account account) { if (mucs_joining.has_key(account) && mucs_joining[account].contains(jid)) return true; return is_groupchat(jid, account); } public bool is_groupchat_occupant(Jid jid, Account account) { return is_groupchat(jid.bare_jid, account) && jid.resourcepart != null; } public async Set? get_bookmarks(Account account) { XmppStream? stream = stream_interactor.get_stream(account); if (stream == null) return null; return yield bookmarks_provider[account].get_conferences(stream); } public void add_bookmark(Account account, Conference conference) { XmppStream? stream = stream_interactor.get_stream(account); if (stream != null) { bookmarks_provider[account].add_conference.begin(stream, conference); } } public void remove_bookmark(Account account, Conference conference) { XmppStream? stream = stream_interactor.get_stream(account); if (stream != null) { bookmarks_provider[account].remove_conference.begin(stream, conference); } } public string? get_room_name(Account account, Jid jid) { Xep.Muc.Flag? flag = get_muc_flag(account); if (flag != null) { return flag.get_room_name(jid); } return null; } public string? get_groupchat_subject(Jid jid, Account account) { Xep.Muc.Flag? flag = get_muc_flag(account); if (flag != null) { return flag.get_muc_subject(jid.bare_jid); } return null; } public Jid? get_real_jid(Jid jid, Account account) { Xep.Muc.Flag? flag = get_muc_flag(account); if (flag != null) { return flag.get_real_jid(jid); } return null; } public Xep.Muc.Role? get_role(Jid jid, Account account) { Xep.Muc.Flag? flag = get_muc_flag(account); if (flag != null) { return flag.get_occupant_role(jid); } return null; } public Xep.Muc.Affiliation? get_affiliation(Jid muc_jid, Jid jid, Account account) { Xep.Muc.Flag? flag = get_muc_flag(account); if (flag != null) { return flag.get_affiliation(muc_jid, jid); } return null; } public Gee.List? get_offline_members(Jid jid, Account account) { Xep.Muc.Flag? flag = get_muc_flag(account); if (flag != null) { return flag.get_offline_members(jid); } return null; } public Gee.List? get_other_offline_members(Jid jid, Account account) { Gee.List? occupants = get_offline_members(jid, account); if (occupants != null) { occupants.remove(account.bare_jid); } return occupants; } public Jid? get_own_jid(Jid muc_jid, Account account) { try { Xep.Muc.Flag? flag = get_muc_flag(account); if (flag != null) { string? nick = flag.get_muc_nick(muc_jid); if (nick != null) return muc_jid.with_resource(nick); } } catch (InvalidJidError e) { warning("Joined MUC with invalid Jid: %s", e.message); } return null; } public bool is_own_muc_jid(Jid full_jid, Account account) { if (!is_groupchat(full_jid.bare_jid, account)) return false; Jid? own_jid = get_own_jid(full_jid, account); return own_jid != null && own_jid.equals(full_jid); } private Xep.Muc.Flag? get_muc_flag(Account account) { XmppStream? stream = stream_interactor.get_stream(account); if (stream != null) { return stream.get_flag(Xep.Muc.Flag.IDENTITY); } return null; } public bool is_joined(Jid jid, Account account) { return get_own_jid(jid, account) != null; } private void on_account_added(Account account) { stream_interactor.module_manager.get_module(account, Xep.Muc.Module.IDENTITY).self_removed_from_room.connect( (stream, jid, code) => { left(account, jid); }); stream_interactor.module_manager.get_module(account, Xep.Muc.Module.IDENTITY).subject_set.connect( (stream, subject, jid) => { subject_set(account, jid, subject); }); stream_interactor.module_manager.get_module(account, Xep.Muc.Module.IDENTITY).invite_received.connect( (stream, room_jid, from_jid, password, reason) => { on_invite_received(account, room_jid, from_jid, password, reason); }); stream_interactor.module_manager.get_module(account, Xep.DirectMucInvitations.Module.IDENTITY).invite_received.connect( (stream, room_jid, from_jid, password, reason) => { on_invite_received(account, room_jid, from_jid, password, reason); }); stream_interactor.module_manager.get_module(account, Xep.Muc.Module.IDENTITY).voice_request_received.connect( (stream, room_jid, from_jid, nick) => { voice_request_received(account, room_jid, from_jid, nick); }); stream_interactor.module_manager.get_module(account, Xep.Muc.Module.IDENTITY).received_occupant_role.connect( (stream, from_jid, role) => { received_occupant_role(account, from_jid, role); }); stream_interactor.module_manager.get_module(account, Xep.Muc.Module.IDENTITY).room_info_updated.connect( (stream, muc_jid) => { room_info_updated(account, muc_jid); }); stream_interactor.module_manager.get_module(account, Xep.Muc.Module.IDENTITY).received_occupant_jid.connect( (stream, room, occupant) => { if (is_private_room(account, room.bare_jid)) { private_room_occupant_updated(account, room, occupant); } }); } private async void search_default_muc_server(Account account) { XmppStream? stream = stream_interactor.get_stream(account); if (stream == null) return; ServiceDiscovery.ItemsResult? items_result = yield stream.get_module(ServiceDiscovery.Module.IDENTITY).request_items(stream, stream.remote_name); if (items_result == null) return; for (int i = 0; i < 2; i++) { foreach (Xep.ServiceDiscovery.Item item in items_result.items) { // First try the promising items and only afterwards all the others bool promising_upload_item = item.jid.to_string().has_prefix("conference") || item.jid.to_string().has_prefix("muc") || item.jid.to_string().has_prefix("chat"); if ((i == 0 && !promising_upload_item) || (i == 1) && promising_upload_item) continue; Gee.Set identities = yield stream_interactor.get_module(EntityInfo.IDENTITY).get_identities(account, item.jid); if (identities == null) return; foreach (Xep.ServiceDiscovery.Identity identity in identities) { if (identity.category == Xep.ServiceDiscovery.Identity.CATEGORY_CONFERENCE) { default_muc_server[account] = item.jid; debug("[%s] Default MUC: %s", account.bare_jid.to_string(), item.jid.to_string()); return; } } } } } private async void on_stream_negotiated(Account account, XmppStream stream) { yield initialize_bookmarks_provider(account); Set? conferences = yield bookmarks_provider[account].get_conferences(stream); if (conferences == null) { join_all_active(account); } else { sync_autojoin_active(account, conferences); } if (!default_muc_server.has_key(account)) { search_default_muc_server.begin(account); } } private async void initialize_bookmarks_provider(Account account) { if (bookmarks_provider.has_key(account)) return; // Use PEP native bookmarks (urn:xmpp:bookmarks:1) if conversion is available, legacy bookmarks (storage:bookmarks) otherwise. bool has_feature = yield stream_interactor.get_module(EntityInfo.IDENTITY).has_feature(account, account.bare_jid, Xep.Bookmarks2.NS_URI_COMPAT); if (has_feature) { debug("[%s] Using PEP native bookmarks (urn:xmpp:bookmarks:1)", account.bare_jid.to_string()); bookmarks_provider[account] = stream_interactor.module_manager.get_module(account, Xep.Bookmarks2.Module.IDENTITY); } else { debug("[%s] Using legacy bookmarks (storage:bookmarks)", account.bare_jid.to_string()); bookmarks_provider[account] = stream_interactor.module_manager.get_module(account, Xep.Bookmarks.Module.IDENTITY); } bookmarks_provider[account].received_conferences.connect( (stream, conferences) => { sync_autojoin_active(account, conferences); bookmarks_updated(account, conferences); }); bookmarks_provider[account].conference_added.connect( (stream, conference) => { on_conference_added(account, conference); }); bookmarks_provider[account].conference_removed.connect( (stream, jid) => { on_conference_removed(account, jid); }); } private void on_invite_received(Account account, Jid room_jid, Jid from_jid, string? password, string? reason) { if (!invites.has_key(account)) { invites[account] = new LinkedList(Jid.equals_func); } if (invites[account].contains(room_jid)) return; invites[account].add(room_jid); invite_received(account, room_jid, from_jid, password, reason); Timeout.add_seconds(5, () => { // We don't want to show the same invite (direct+mediated) twice, but a distinct invite is fine invites[account].remove(room_jid); return false; }); } private void join_all_active(Account account) { Gee.List conversations = stream_interactor.get_module(ConversationManager.IDENTITY).get_active_conversations(account); foreach (Conversation conversation in conversations) { if (conversation.type_ == Conversation.Type.GROUPCHAT && conversation.nickname != null) { join.begin(account, conversation.counterpart, conversation.nickname, null); } } } private void sync_autojoin_active(Account account, Set conferences) { Gee.List active_conversations = stream_interactor.get_module(ConversationManager.IDENTITY).get_active_conversations(account); // Join auto-join MUCs foreach (Conference conference in conferences) { if (!conference.autojoin) continue; bool is_active = false; foreach (Conversation conversation in active_conversations) { if (conference.jid.equals(conversation.counterpart)) { is_active = true; } } if (!is_active || !is_joined(conference.jid, account)) { join.begin(account, conference.jid, conference.nick, conference.password, true); } } // Part MUCs that aren't auto-join (which closes those conversations) foreach (Conversation conversation in active_conversations) { if (conversation.type_ != Conversation.Type.GROUPCHAT) continue; bool should_be_active = false; foreach (Conference conference in conferences) { if (conference.jid.equals(conversation.counterpart) && conference.autojoin) { should_be_active = true; } } if (!should_be_active) { part(conversation.account, conversation.counterpart); } } } private void set_autojoin(Account account, XmppStream stream, Jid jid, string? nick, string? password) { bookmarks_provider[account].get_conferences.begin(stream, (_, res) => { Set? conferences = bookmarks_provider[account].get_conferences.end(res); if (conferences == null) return; foreach (Conference conference in conferences) { if (conference.jid.equals(jid)) { if (!conference.autojoin) { Conference new_conference = new Conference() { jid=jid, nick=nick ?? conference.nick, name=conference.name, password=password ?? conference.password, autojoin=true }; bookmarks_provider[account].replace_conference.begin(stream, jid, new_conference); } return; } } Conference changed = new Xep.Bookmarks.Bookmarks1Conference(jid) { nick=nick, password=password, autojoin=true }; bookmarks_provider[account].add_conference.begin(stream, changed); }); } private void unset_autojoin(Account account, XmppStream stream, Jid jid) { bookmarks_provider[account].get_conferences.begin(stream, (_, res) => { Set? conferences = bookmarks_provider[account].get_conferences.end(res); if (conferences == null) return; foreach (Conference conference in conferences) { if (conference.jid.equals(jid)) { if (conference.autojoin) { Conference new_conference = new Conference() { jid=jid, nick=conference.nick, name=conference.name, password=conference.password, autojoin=false }; bookmarks_provider[account].replace_conference.begin(stream, jid, new_conference); return; } } } }); } private void on_conference_added(Account account, Xmpp.Conference conference) { Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(conference.jid, account, Conversation.Type.GROUPCHAT); if (conversation != null) { if (!conversation.active && conference.autojoin) { join.begin(account, conference.jid, conference.nick, conference.password); } else if (conversation.active && !conference.autojoin) { part(account, conference.jid); } } if (conference.autojoin) { join.begin(account, conference.jid, conference.nick, conference.password); } conference_added(account, conference); } private void on_conference_removed(Account account, Jid jid) { Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(jid, account, Conversation.Type.GROUPCHAT); if (conversation != null && conversation.active) { part(account, jid); } conference_removed(account, jid); } private void self_ping(Account account) { XmppStream? stream = stream_interactor.get_stream(account); if (stream == null) return; if (!mucs_todo.has_key(account)) return; foreach (Jid jid in mucs_todo[account]) { bool joined = false; Xmpp.Xep.MucSelfPing.is_joined.begin(stream, jid, (_, res) => { joined = Xmpp.Xep.MucSelfPing.is_joined.end(res); }); Timeout.add_seconds(10, () => { if (joined || !mucs_todo.has_key(account) || stream_interactor.get_stream(account) != stream) return false; join.begin(account, jid.bare_jid, jid.resourcepart, null, true); return false; }); } } private class ReceivedMessageListener : MessageListener { public string[] after_actions_const = new string[]{ }; public override string action_group { get { return "MUC"; } } public override string[] after_actions { get { return after_actions_const; } } private StreamInteractor stream_interactor; public ReceivedMessageListener(StreamInteractor stream_interactor) { this.stream_interactor = stream_interactor; } public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { if (conversation.type_ != Conversation.Type.GROUPCHAT) return false; XmppStream stream = stream_interactor.get_stream(conversation.account); if (stream == null) return false; if (Xep.DelayedDelivery.MessageFlag.get_flag(stanza) == null) { Jid? real_jid = stream.get_flag(Xep.Muc.Flag.IDENTITY).get_real_jid(message.counterpart); if (real_jid != null && !real_jid.equals(message.counterpart)) { message.real_jid = real_jid.bare_jid; } } Jid? own_muc_jid = stream_interactor.get_module(MucManager.IDENTITY).get_own_jid(message.counterpart.bare_jid, conversation.account); if (stanza.id != null && own_muc_jid != null && message.from.equals(own_muc_jid)) { Entities.Message? m = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_stanza_id(stanza.id, conversation); if (m != null) { // For own messages from this device (msg is a duplicate) m.marked = Message.Marked.RECEIVED; } // For own messages from other devices (msg is not a duplicate msg) message.marked = Message.Marked.RECEIVED; } return false; } } } }