local host = module:get_host(); if module:get_host_type() ~= "component" then error("MIX should be loaded as a component", 0); end local st = require("util.stanza"); local jid = require("util.jid"); local uuid = require("util.uuid"); local id = require("util.id"); local datetime = require("util.datetime"); local time = require("util.time"); local serialization = require("util.serialization"); local pep = module:depends("pep"); -- XML namespaces local mix_core_xmlns = "urn:xmpp:mix:core:1"; -- Persistent data local persistent_channels = module:open_store("mix_channels", "keyval"); local persistent_channel_data = module:open_store("mix_data", "keyval"); -- Configuration local default_channel_description = module:get_option("default_description", "A MIX channel for chatting"); local default_channel_name = module:get_option("default_name", "MIX channel"); local restrict_channel_creation = module:get_option("restrict_local_channels", "local"); module:depends("disco"); -- module:depends("mam"); TODO: Once message sending works module:add_identity("conference", "mix", module:get_option("name", "Prosody MIX service"); module:add_feature("http://jabber.org/protocol/disco#info"); module:add_feature(mix_core_xmlns); Participant = {}; Participant.__index = Participant; function Participant:new(jid, nick) return setmetatable({ jid = jid, nick = nick, }, Participant); end Channel = {} Channel.__index = Channel; function Channel:new(jid, name, description, participants, subscriptions, spid, contacts, adhoc) return setmetatable({ jid = jid, name = name, description = description, participants = participants, subscriptions = subscriptions, spid = spid, contacts = contacts, adhoc = adhoc, }, Channel); end function Channel:from(config) return setmetatable(config, Channel); end function Channel:get_spid(jid) -- Returns the Stable Participant ID for the *BARE* jid return self.spid[jid]; end function Channel:debug_print() module:log("debug", "Channel %s (%s)", self.jid, self.name); module:log("debug", "'%s'", self.description); for _, p in pairs(self.participants) do module:log("debug", "=> %s (%s)", p.jid, p.nick); end module:log("debug", "Contacts:"); for _, c in pairs(self.contacts) do module:log("debug", "=> %s", c); end if self.subscriptions then module:log("debug", "Subscriptions:"); for user, subs in pairs(self.subscriptions) do module:log("debug", "[%s]", user); for _, sub in pairs(self.subscriptions[user]) do module:log("debug", "=> %s", sub); end end end end local channels = {}; function get_channel(jid) -- Return the channel object from the channels array for which the -- JID matches. If none is found, returns -1, nil for i, channel in pairs(channels) do if channel.jid == jid then return i, channel; end end return -1, nil; end function publish_participant(service, spid, participant) service:publish("urn:xmpp:mix:nodes:participants", true, spid, st.stanza("item", { id = spid, xmlns = "http://jabber.org/protocol/pubsub" }) :tag("participant", { xmlns = mix_core_xmlns }) :tag("nick"):text(participant["nick"]):up() :tag("jid"):text(participant["jid"])); end function save_channels() module:log("debug", "Saving channel list..."); local channel_list = {}; for _, channel in pairs(channels) do table.insert(channel_list, channel.jid); persistent_channel_data:set(channel.jid, channel); end persistent_channels:set("channels", channel_list); module:log("debug", "Saving channel list done."); end function Channel:save_state() -- Saving the entire state everything one channel seems stupid, -- so we just save the changed channel module:log("debug", "Saving state of channel %s...", self.jid); persistent_channel_data:set(self.jid, self); module:log("debug", "Saving state done.", self.jid); end function module.load() module:log("info", "Loading MIX channels..."); local channel_list = persistent_channels:get("channels"); if channel_list then for _, channel in pairs(channel_list) do table.insert(channels, Channel:from(persistent_channel_data:get(channel))); module:log("debug", "MIX channel %s loaded", channel); end else module:log("debug", "No MIX channels found."); end module:log("debug", "Loading MIX channels done."); end function channel_not_found(stanza) -- Wrapper for returning a "Channel-not-found" error stanza return st.error_reply(stanza, "cancel", "item-not-found", "The MIX channel was not found"); end module:hook("host-disco-items", function(event) module:log("debug", "host-disco-items called"); local reply = event.reply; for _, channel in pairs(channels) do -- Adhoc channels are supposed to be invisible if not channel.adhoc then reply:tag("item", { jid = channel.jid }):up(); end end end); module:hook("iq/bare/http://jabber.org/protocol/disco#items:query", function(event) module:log("debug", "IQ-GET disco#items"); local origin, stanza = event.origin, event.stanza; local _, channel = get_channel(staza.attr.to); if not channel then origin.send(channel_not_found(stanza)); return; end -- TODO: Maybe check permissions local reply = st.reply(stanza):tag("query", { xmlns = "http://jabber.org/protocol/disco#items", node = "mix" }); for _, node in pairs({"urn:xmpp:mix:nodes:messages", "urn:xmpp:mix:nodes:participants", "urn:xmpp:mix:nodes:info"}) do reply:tag("item", { jid = channel.jid, node = node }):up(); end origin.send(reply); return true; end); function can_create_channels(user) -- Returns true when the jid is allowed to create MIX channels. False otherwise. if restrict_channel_creation == "local" then -- NOTE: Taken from plugins/muc/mod_muc.lua local host_suffix = host:gsub("^[^%.]+%.", ""); module:log("debug", "Comparing %s (Sender) to %s (Host)", jid.host(user), host_suffix); if jid.host(user) == host_suffix then return true; else return false; end end -- TODO: Handle also true/"admin" (See mod_muc) return true; end module:hook("iq-get/host/http://jabber.org/protocol/disco#info:query", function(event) module:log("debug", "IQ-GET host disco#info"); local origin, stanza = event.origin, event.stanza; local reply = st.reply(stanza) :tag("query", { xmlns = "http://jabber.org/protocol/disco#info" }) -- TODO: Name :tag("identity", { category = "conference", type = "mix", name = "" }):up() :tag("feature", { var = "http://jabber.org/protocol/disco#info" }):up() :tag("feature", { var = mix_core_xmlns }):up(); if can_create_channels(stanza.attr.from) then reply:tag("feature", { var = mix_core_xmlns.."#create-channel" }):up(); end end); module:hook("iq-get/bare/http://jabber.org/protocol/disco#info:query", function(event) module:log("debug", "IQ-GET disco#info"); local origin, stanza = event.origin, event.stanza; local _, channel = get_channel(stanza.attr.to); if not channel then origin.send(channel_not_found(stanza)); return; end local reply = st.reply(stanza):tag("query", { xmlns = "http://jabber.org/protocol/disco#info" }); reply:tag("feature", { var = "http://jabber.org/protocol/disco#info" }):up(); reply:tag("identity", { category = "conference", name = channel.name, type = "mix" }):up(); reply:tag("feature", { var = mix_core_xmlns }):up(); -- TODO: Once message sending works, uncomment this -- reply:tag("feature", { var = "urn:xmpp:mam:2" }):up(); origin.send(reply); return true; end); -- TODO: Make this a function of Channel: function find_participant(table, jid) for i, v in pairs(table) do if v.jid == jid then return i; end end return -1; end module:hook("iq-set/bare/"..mix_core_xmlns..":leave", function(event) module:log("debug", "MIX leave received"); local origin, stanza = event.origin, event.stanza; local from = jid.bare(stanza.attr.from); local i, channel = get_channel(stanza.attr.to); if not channel then origin.send(channel_not_found(stanza)); return; end local participant_index = find_participant(channel.participants, from); if participant_index == -1 then origin.send(st.error_reply(stanza, "cancel", "forbidden", "Not a participant")); channel:debug_print(); module:log("debug", "%s is not a participant in %s", from, channel.jid); return; end -- Remove the user as a participant by... -- Unsubscribing local srv = pep.get_pep_service(channel.jid); for _, node in pairs(channel.subscriptions[from]) do srv:remove_subscription(node, true, from); module:log("debug", "Unsubscribed %s from %s on %s", from, node, channel.jid); end channels[i].subscriptions[from] = nil; -- Retracting the participatio) srv:retract("urn:xmpp:mix:nodes:participants", true, channel:get_spid(from), true); -- Removing the user table.remove(channels[i].participants, participant_index); channel:save_state(); origin.send(st.reply(stanza):tag("leave", { xmlns = mix_core_xmlns })); return true; end); module:hook("iq-set/bare/"..mix_core_xmlns..":join", function(event) module:log("debug", "MIX join received"); local origin, stanza = event.origin, event.stanza; local from = jid.bare(stanza.attr.from); local i, channel = get_channel(stanza.attr.to); if not channel then origin.send(channel_not_found(stanza)); return; end -- Prevent the user from joining multiple times local participant_index = find_participant(channel.participants, from); if participant_index ~= -1 then origin.send(st.error_reply(stanza, "cancel", "bad-request", "User already joined")); return; end local spid = channel:get_spid(from) or uuid.generate(); -- Stable Participant ID local reply = st.reply(stanza) :tag("join", { xmlns = mix_core_xmlns, id = spid }); local srv = pep.get_pep_service(jid.node(stanza.attr.to)); local join = stanza:get_child("join", mix_core_xmlns); local nick = join:get_child("nick"); module:log("debug", "User joining as nick %s", nick:get_text()); local nodes = {}; local has_subscribed_once = false; local first_error = nil; for subscribe in join:childtags("subscribe") do module:log("debug", "Subscribing user to node %s", subscribe.attr.node); local ok, err = srv:add_subscription(subscribe.attr.node, true, from); if not ok then module:log("debug", "Error during subscription: %s", err); -- MIX-CORE says that the first error should be returned when -- no of the requested nodes could be subscribed to if first_error ~= nil then first_error = err; end else table.insert(nodes, subscribe.attr.node); reply:tag("subscribe", { node = subscribe.attr.node }):up(); has_subscribed_once = true; end end if not has_subscribed_once then origin.send(st.error_reply(stanza, "cancel", first_error)); return; end local participant = Participant:new(jid.bare(from), nick:get_text()) channels[i].subscriptions[from] = nodes; table.insert(channels[i].participants, participant) channels[i]:get_spid(jid.bare(stanza.attr.from)) = spid; publish_participant(srv, spid, participant); channels[i]:save_state(); reply:add_child(nick); origin.send(reply); return true end); module:hook("iq-set/bare/"..mix_core_xmlns..":setnick", function(event) module:log("debug", "MIX setnick received"); local origin, stanza = event.origin, event.stanza; local from = jid.bare(stanza.attr.from); local i, channel = get_channel(stanza.attr.to); if not channel then origin.send(channel_not_found(stanza)); return; end local participant_index = find_participant(channel.participants, from); if participant_index == -1 then channel:debug_print(); module:log("debug", "%s is not a participant in %s", from, channel.jid); return; end -- NOTE: Prosody should guarantee us that the setnick stanza exists local setnick = stanza:get_child("setnick", mix_core_xmlns); local nick = setnick:get_child("nick"); if nick_stanza == nil then origin.send(st.error_reply(stanza, "cancel", "bad-request", "Missing ")); return; end -- Change the nick channels[i].participants[participant_index].nick = nick:get_text(); -- Inform all other members local srv = pep.get_pep_service(channel.jid); local participant = channel.participants[participant_index]; publish_participant(srv, channel:get_spid(participant.jid), participant); origin.send(st.reply(stanza) :tag("setnick", { xmlns = mix_core_xmlns }) :tag("nick"):text(nick:get_text())); channel:save_state(); return true; end); function Channel:publish_info(srv) srv:publish("urn:xmpp:mix:nodes:info", true, timestamp, st.stanza("item", { id = timestamp, xmlns = "http://jabber.org/protocol/pubsub" }) :tag("x", { xmlns = "jabber:x:data", type = "result" }) :tag("field", { var = "FORM_TYPE", type = "hidden" }) :tag("value"):text(mix_core_xmlns):up():up() :tag("field", { var = "Name" }) :tag("value"):text(self.name):up() :tag("field", { var = "Contact" }) :tag("value"):text(self.contacts)); end function create_channel(node, creator, adhoc) local channel = Channel:new(string.format("%s@%s", node, host), default_channel_name, default_channel_description, {}, {}, {}, { creator }, adhoc); -- Create the PEP nodes local srv = pep.get_pep_service(node); local timestamp = datetime.datetime(time.now()); srv:create("urn:xmpp:mix:nodes:info", true, { ["access_model"] = "open" }); channel:publish_info(srv); -- TODO: This seems bad srv:create("urn:xmpp:mix:nodes:participants", true, { ["access_model"] = "open"}); srv:create("urn:xmpp:mix:nodes:messages", true, { ["access_model"] = "open"}); table.insert(channels, channel); end module:hook("iq-set/host/"..mix_core_xmlns..":create", function(event) module:log("debug", "MIX create received"); local origin, stanza = event.origin, event.stanza; local from = jid.bare(stanza.attr.from); -- Check permissions if not can_create_channels(from) then origin.send(st.error_reply(stanza, "cancel", "forbidden", "Not authorized to create channels")); return; end local create = stanza:get_child("create", mix_core_xmlns); local node; if create.attr.channel ~= nil then -- Create non-adhoc channel node = create.attr.channel; local _, channel = get_channel(stanza.attr.to); if channel then origin.send(st.error_reply(stanza, "cancel", "conflict", "Channel already exists")); return; end create_channel(create.attr.channel, from, false); else -- Create adhoc channel while (true) do node = id.short(); local _, channel = get_channel(string.format("%s@%s", node, host)); if channel == nil then break; end end create_channel(node, from, true); end module:log("debug", "Channel %s created with %s as owner", node, from); origin.send(st.reply(stanza) :tag("create", { xmlns = mix_core_xmlns, channel = node })); save_channels(); return true; end); module:hook("iq-set/host/"..mix_core_xmlns..":destroy", function(event) module:log("debug", "MIX destroy received"); local origin, stanza = event.origin, event.stanza; local from = jid.bare(stanza.attr.from); local destroy = stanza:get_child("create", mix_core_xmlns); local node = destory.attr.channel; local node_jid = string.format("%s@%s", node, host); local i, channel = get_channel(node_jid); if not channel then origin.send(channel_not_found(stanza)); return; end -- TODO: Check permissions: can_create_channels and maybe compare to the contact JIDs -- Remove all registered nodes local srv = pep.get_pep_service(node); for _, pep_node in pairs({ "urn:xmpp:mix:nodes:participants", "urn:xmpp:mix:nodes:info" }) do srv:delete(pep_node, true); end table.remove(channels, i); module:log("debug", "Channel %s destroyed", node); origin.send(st.reply(stanza)); save_channels(); return true; end); module:hook("message/bare", function(event) module:log("debug", "MIX message detected"); local stanza, origin = event.stanza, event.origin; if stanza.attr.type ~= "groupchat" then -- TODO: Is this correct? origin.send(st.error_reply(stanza, "cancel", "bad-request", "Non-groupchat message")); return; end local from = jid.bare(stanza.attr.from); local i, channel = get_channel(stanza.attr.to); if not channel then origin.send(channel_not_found(stanza)); return; end local participant_index = find_participant(channel.participants, from); if participant_index == -1 then origin.send(st.error_reply(stanza, "cancel", "forbidden", "Not a participant")); return; end local participant = channel.participants[participant_index]; local msg = st.clone(stanza); msg:add_child(st.stanza("mix", { xmlns = mix_core_xmlns }) :tag("nick"):text(participant.nick):up() :tag("jid"):text(participant.jid):up()); msg.attr.from = channel.jid.."/"..channel:get_spid(from); for _, p in pairs(channel.participants) do if p.jid ~= participant.jid then msg.attr.to = p.jid; module:send(msg); module:log("debug", msg:pretty_print()); module:log("debug", "Message from %s sent to %s", participant.jid, p.jid); end end return true; end);