# =XMPP4R - XMPP Library for Ruby
# License:: Ruby's license (see the LICENSE file) or GNU GPL, at your option.
# Website::http://home.gna.org/xmpp4r/
require 'xmpp4r/muc/x/muc'
module Jabber
module MUC
##
# The MUCClient Helper handles low-level stuff of the
# Multi-User Chat (JEP 0045).
#
# Use one instance per room.
#
# Note that one client cannot join a single room multiple
# times. At least the clients' resources must be different.
# This is a protocol design issue. But don't consider it as
# a bug, it is just a clone-preventing feature.
class MUCClient
##
# Sender JID, set this to use MUCClient from Components
# my_jid:: [JID] Defaults to nil
attr_accessor :my_jid
##
# MUC room roster
# roster:: [Hash] of [String] Nick => [Presence]
attr_reader :roster
##
# MUC JID
# jid:: [JID] room@component/nick
attr_reader :jid
##
# Initialize a MUCClient
#
# Call MUCClient#join *after* you have registered your
# callbacks to avoid reception of stanzas after joining
# and before registration of callbacks.
# stream:: [Stream] to operate on
def initialize(stream)
# Attributes initialization
@stream = stream
@my_jid = nil
@jid = nil
@roster = {}
@roster_lock = Mutex.new
@active = false
@join_cbs = CallbackList.new
@leave_cbs = CallbackList.new
@presence_cbs = CallbackList.new
@message_cbs = CallbackList.new
@private_message_cbs = CallbackList.new
end
##
# Join a room
#
# This registers its own callbacks on the stream
# provided to initialize and sends initial presence
# to the room. May throw ErrorException if joining
# fails.
# jid:: [JID] room@component/nick
# password:: [String] Optional password
# return:: [MUCClient] self (chain-able)
def join(jid, password=nil)
if active?
raise "MUCClient already active"
end
@jid = (jid.kind_of?(JID) ? jid : JID.new(jid))
activate
# Joining
pres = Presence.new
pres.to = @jid
pres.from = @my_jid
xmuc = XMUC.new
xmuc.password = password
pres.add(xmuc)
# We don't use Stream#send_with_id here as it's unknown
# if the MUC component *always* uses our stanza id.
error = nil
@stream.send(pres) { |r|
if from_room?(r.from) and r.kind_of?(Presence) and r.type == :error
# Error from room
error = r.error
true
# type='unavailable' may occur when the MUC kills our previous instance,
# but all join-failures should be type='error'
elsif r.from == jid and r.kind_of?(Presence) and r.type != :unavailable
# Our own presence reflected back - success
if r.x(XMUCUser) and (i = r.x(XMUCUser).items.first)
@affiliation = i.affiliation # we're interested in if it's :owner
@role = i.role # :moderator ?
end
handle_presence(r, false)
true
else
# Everything else
false
end
}
if error
deactivate
raise ErrorException.new(error)
end
self
end
##
# Exit the room
#
# * Sends presence with type='unavailable' with an optional
# reason in ,
# * then waits for a reply from the MUC component (will be
# processed by leave-callbacks),
# * then deletes callbacks from the stream.
# reason:: [String] Optional custom exit message
def exit(reason=nil)
unless active?
raise "MUCClient hasn't yet joined"
end
pres = Presence.new
pres.type = :unavailable
pres.to = jid
pres.from = @my_jid
pres.status = reason if reason
@stream.send(pres) { |r|
Jabber::debuglog "exit: #{r.to_s.inspect}"
if r.kind_of?(Presence) and r.type == :unavailable and r.from == jid
@leave_cbs.process(r)
true
else
false
end
}
deactivate
self
end
##
# Is the MUC client active?
#
# This is false after initialization,
# true after joining and
# false after exit/kick
def active?
@active
end
##
# The MUCClient's own nick
# (= resource)
# result:: [String] Nickname
def nick
@jid ? @jid.resource : nil
end
##
# Change nick
#
# Threading is, again, suggested. This method waits for two
# stanzas, one indicating unavailabilty of the old
# transient JID, one indicating availability of the new
# transient JID.
#
# If the service denies nick-change, ErrorException will be raisen.
def nick=(new_nick)
unless active?
raise "MUCClient not active"
end
new_jid = JID.new(@jid.node, @jid.domain, new_nick)
# Joining
pres = Presence.new
pres.to = new_jid
pres.from = @my_jid
error = nil
# Keeping track of the two stanzas enables us to process stanzas
# which don't arrive in the order specified by JEP-0045
presence_unavailable = false
presence_available = false
# We don't use Stream#send_with_id here as it's unknown
# if the MUC component *always* uses our stanza id.
@stream.send(pres) { |r|
if from_room?(r.from) and r.kind_of?(Presence) and r.type == :error
# Error from room
error = r.error
elsif r.from == @jid and r.kind_of?(Presence) and r.type == :unavailable and
r.x and r.x.kind_of?(XMUCUser) and r.x.status_code == 303
# Old JID is offline, but wait for the new JID and let stanza be handled
# by the standard callback
presence_unavailable = true
handle_presence(r)
elsif r.from == new_jid and r.kind_of?(Presence) and r.type != :unavailable
# Our own presence reflected back - success
presence_available = true
handle_presence(r)
end
if error or (presence_available and presence_unavailable)
true
else
false
end
}
if error
raise ErrorException.new(error)
end
# Apply new JID
@jid = new_jid
end
##
# The room name
# (= node)
# result:: [String] Room name
def room
@jid ? @jid.node : nil
end
##
# Send a stanza to the room
#
# If stanza is a Jabber::Message, stanza.type will be
# automatically set to :groupchat if directed to room or :chat
# if directed to participant.
# stanza:: [XMPPStanza] to send
# to:: [String] Stanza destination recipient, or room if +nil+
def send(stanza, to=nil)
if stanza.kind_of? Message
stanza.type = to ? :chat : :groupchat
end
stanza.from = @my_jid
stanza.to = JID::new(jid.node, jid.domain, to)
@stream.send(stanza)
end
##
# Add a callback for stanzas indicating availability
# of a MUC participant
#
# This callback will *not* be called for initial presences when
# a client joins a room, but only for the presences afterwards.
#
# The callback will be called from MUCClient#handle_presence with
# one argument: the stanza.
# Note that this stanza will have been already inserted into
# MUCClient#roster.
def add_join_callback(prio = 0, ref = nil, &block)
@join_cbs.add(prio, ref, block)
end
##
# Add a callback for stanzas indicating unavailability
# of a MUC participant
#
# The callback will be called with one argument: the stanza.
#
# Note that this is called just *before* the stanza is removed from
# MUCClient#roster, so it is still possible to see the last presence
# in the given block.
#
# If the presence's origin is your MUC JID, the MUCClient will be
# deactivated *afterwards*.
def add_leave_callback(prio = 0, ref = nil, &block)
@leave_cbs.add(prio, ref, block)
end
##
# Add a callback for a stanza which is neither a join
# nor a leave. This will be called when a room participant simply
# changes his status.
def add_presence_callback(prio = 0, ref = nil, &block)
@presence_cbs.add(prio, ref, block)
end
##
# Add a callback for stanza directed to the whole room.
#
# See MUCClient#add_private_message_callback for private messages
# between MUC participants.
def add_message_callback(prio = 0, ref = nil, &block)
@message_cbs.add(prio, ref, block)
end
##
# Add a callback for stanza with type='chat'.
#
# These stanza are normally not broadcasted to all room occupants
# but are some sort of private messaging.
def add_private_message_callback(prio = 0, ref = nil, &block)
@private_message_cbs.add(prio, ref, block)
end
##
# Does this JID belong to that room?
# jid:: [JID]
# result:: [true] or [false]
def from_room?(jid)
@jid.strip == jid.strip
end
private
##
# call_join_cbs:: [Bool] Do not call them if we receive initial presences from room
def handle_presence(pres, call_join_cbs=true) # :nodoc:
if pres.type == :unavailable or pres.type == :error
@leave_cbs.process(pres)
@roster_lock.synchronize {
@roster.delete(pres.from.resource)
}
if pres.from == jid and !(pres.x and pres.x.kind_of?(XMUCUser) and pres.x.status_code == 303)
deactivate
end
else
is_join = ! @roster.has_key?(pres.from.resource)
@roster_lock.synchronize {
@roster[pres.from.resource] = pres
}
if is_join
@join_cbs.process(pres) if call_join_cbs
else
@presence_cbs.process(pres)
end
end
end
def handle_message(msg) # :nodoc:
if msg.type == :chat
@private_message_cbs.process(msg)
else # type == :groupchat or anything else
@message_cbs.process(msg)
end
end
def activate # :nodoc:
@active = true
# Callbacks
@stream.add_presence_callback(150, self) { |presence|
if from_room?(presence.from)
handle_presence(presence)
true
else
false
end
}
@stream.add_message_callback(150, self) { |message|
if from_room?(message.from)
handle_message(message)
true
else
false
end
}
end
def deactivate # :nodoc:
@active = false
@jid = nil
# Callbacks
@stream.delete_presence_callback(self)
@stream.delete_message_callback(self)
end
public
def owner?
@affiliation == :owner
end
def configure(options={})
raise 'You are not the owner' unless owner?
iq = Iq.new(:get, jid)
iq.to = @jid
iq.from = @my_jid
iq.add(IqQueryMUCOwner.new)
fields = []
answer = @stream.send_with_id(iq)
raise "Configuration not possible for this room" unless answer.query && answer.query.x(XData)
answer.query.x(XData).fields.each { |field|
if (var = field.attributes['var'])
fields << var
end
}
# fill out the reply form
iq = Iq.new(:set, jid)
iq.to = @jid
iq.from = @my_jid
query = IqQueryMUCOwner.new
form = Dataforms::XData.new
form.type = :submit
options.each do |var, values|
field = Dataforms::XDataField.new
values = [values] unless values.is_a?(Array)
field.var, field.values = var, values
form.add(field)
end
query.add(form)
iq.add(query)
@stream.send_with_id(iq)
end
end
end
end