diff --git a/.gitignore b/.gitignore index b25c15b..f74e8fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ *~ +config.rb diff --git a/bot.rb b/bot.rb index c6d96ce..1d5c0f6 100644 --- a/bot.rb +++ b/bot.rb @@ -1,53 +1,73 @@ require 'xmpp4r' -require 'xmpp4r/muc/helper/mucclient' - -require_relative 'sasl' +require 'xmpp4r/muc/helper/simplemucclient' require_relative 'config' module Lain + Version = '0.1' + class Bot - Version = '0.1' def initialize - $stderr.puts "Lain #{Version} starting..." + $stderr.puts "Lain #{Version}" + + @modules = {} + + $stderr.print 'Loading modules...' + + Config::Modules.each { |mod, cfg| + $stderr.print " #{mod}" + require_relative "modules/#{mod}" + @modules[mod] = Modules.const_get(mod).new(self, cfg) + } + + $stderr.puts '.' + + @commands = @modules.values.reduce({}) { |c, mod| c.merge mod.commands }.to_a.sort + + $stderr.print 'Connecting... ' @cl = Jabber::Client.new(Jabber::JID.new(Config::JID)) @cl.connect @cl.auth(Config::Password) @cl.send(Jabber::Presence.new) - @modules = {} - - $stderr.puts 'Loading modules...' - - Config::Modules.each { |mod| - require_relative "modules/#{mod}" - @modules[mod] = Modules.const_get(mod).new self - } + $stderr.puts 'connection established.' @mucs = {} Config::Rooms.each { |r| - muc = Jabber::MUC::MUCClient.new(@cl) + muc = Jabber::MUC::SimpleMUCClient.new(@cl) @mucs[r] = muc muc.add_message_callback { |msg| - @modules.each { | _, mod | - begin - mod.on_message muc, msg - rescue - end - } + unless msg.from == r + @modules.each { | _, mod | + begin + mod.on_message muc, msg + rescue + end + } + end } - $stderr.puts "Joining room `#{r}'..." + $stderr.print "Trying to access room `#{r}'... " - muc.join("#{r}/#{Config::Nick}") - muc.configure() + muc.join(r) + + begin + muc.configure + rescue + end + + $stderr.puts "access granted." } - $stderr.puts 'Startup finished.' + $stderr.puts 'Initialization finished.' + end + + def commands + @commands end def run diff --git a/config.rb.example b/config.rb.example new file mode 100644 index 0000000..00242b3 --- /dev/null +++ b/config.rb.example @@ -0,0 +1,16 @@ +module Lain + module Config + JID = '' + Password = '' + + Rooms = [''] + + Modules = { + :Credits => {}, + :DDate => {}, + :Fortune => { :command => '/usr/bin/fortune -a' }, + :Help => {}, + :Topic => {}, + } + end +end diff --git a/lain.rb b/lain.rb old mode 100644 new mode 100755 diff --git a/module_base.rb b/module_base.rb index 500fd20..b209e7b 100644 --- a/module_base.rb +++ b/module_base.rb @@ -1,11 +1,17 @@ module Lain module Modules class Base - def initialize(lain) + def initialize(lain, config) + @lain = lain + @config = config end def on_message(muc, message) end + + def commands + {} + end end end end diff --git a/modules/Credits.rb b/modules/Credits.rb new file mode 100644 index 0000000..1df7d18 --- /dev/null +++ b/modules/Credits.rb @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +require 'xmpp4r/message' + +require_relative '../module_base' + +module Lain + module Modules + class Credits < Base + CreditText = < 'show credits' + } + end + end + end +end diff --git a/modules/DDate.rb b/modules/DDate.rb index 1402648..5f8634e 100644 --- a/modules/DDate.rb +++ b/modules/DDate.rb @@ -6,13 +6,15 @@ module Lain module Modules class DDate < Base def on_message(muc, message) - return unless message.type == :groupchat - return unless /!ddate\b/ =~ message.body + return unless /\A!ddate\b/ =~ message.body - p = IO.popen("ddate") - while (line = p.gets) - muc.send(Jabber::Message.new(message.to, line.chomp)) - end + muc.say("DISCORDIAN DATE " + IO.popen("ddate").read.chomp) + end + + def commands + { + '!ddate' => 'show Discordian date' + } end end end diff --git a/modules/Fortune.rb b/modules/Fortune.rb new file mode 100644 index 0000000..2d4436b --- /dev/null +++ b/modules/Fortune.rb @@ -0,0 +1,21 @@ +require 'xmpp4r/message' + +require_relative '../module_base' + +module Lain + module Modules + class Fortune < Base + def on_message(muc, message) + return unless /\A!fortune\b/ =~ message.body + + muc.say("\nBEGIN FORTUNE COOKIE\n" + IO.popen(@config[:command]).read.chomp + "\nEND FORTUNE COOKIE") + end + + def commands + { + '!fortune' => 'fortune cookies' + } + end + end + end +end diff --git a/modules/Help.rb b/modules/Help.rb new file mode 100644 index 0000000..d4a6d1f --- /dev/null +++ b/modules/Help.rb @@ -0,0 +1,21 @@ +require 'xmpp4r/message' + +require_relative '../module_base' + +module Lain + module Modules + class Help < Base + def on_message(muc, message) + return unless /\A!help\b/ =~ message.body + + muc.say("\nBEGIN COMMAND LIST" + @lain.commands.reduce('') { |s, cmd| s + "\n#{cmd[0]}: #{cmd[1]}" } + "\nEND COMMAND LIST") + end + + def commands + { + '!help' => 'show this help' + } + end + end + end +end diff --git a/modules/Topic.rb b/modules/Topic.rb new file mode 100644 index 0000000..fa2291a --- /dev/null +++ b/modules/Topic.rb @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +require 'xmpp4r/message' + +require_relative '../module_base' + +module Lain + module Modules + class Topic < Base + def on_message(muc, message) + return unless /\A!topic\b/ =~ message.body + + if /\A!topic\s*(?:\ss(?:how)?.*)?\Z/ =~ message.body + show_topic(muc, message) + elsif /\A!topic\s+a(?:dd)?\s+(.+)\Z/ =~ message.body + add_topic(muc, message, $~[1].strip) + elsif /\A!topic\s+d(?:el(?:ete)?)?\s+([[:xdigit:]]+)\Z/ =~ message.body + del_topic(muc, message, $~[1].to_i(16)) + end + end + + def commands + { + '!topic [s[how]]' => 'show topics (with indices)', + '!topic a[dd] ' => 'add a topic', + '!topic d[el[ete]] ' => 'remove topic ' + } + end + + private + + def current_topic(muc) + topic = muc.subject + return [] if topic.nil? + + topic.split('|').map { |s| s.strip } + end + + def set_topic(muc, topic) + muc.subject = topic.join(" | ") + end + + def topic_list(topic) + topic.each_with_index.map { |t, i| "[#{'%02x' % i}] #{t}" }.join("\n") + end + + def show_topic(muc, message) + topic = current_topic muc + if topic.empty? + muc.say "\nTOPIC EMPTY" + return + end + + muc.say("\nBEGIN TOPIC LIST\n" + topic_list(topic) + "\nEND TOPIC LIST") + end + + def add_topic(muc, message, text) + topic = current_topic muc + if text.empty? + pre = "\nERROR: No topic given." + else + topic.unshift text + set_topic(muc, topic) + pre = "\nSUCCESS" + end + + muc.say(pre + "\n\nBEGIN TOPIC LIST\n" + topic_list(topic) + "\nEND TOPIC LIST") + end + + def del_topic(muc, message, index) + topic = current_topic muc + begin + deleted = topic.delete_at index + rescue + deleted = nil + end + if deleted.nil? + pre = "\nERROR: Invalid index [#{'%02x' % index}]." + else + set_topic(muc, topic) + pre = "\nSUCCESS" + end + + muc.say(pre + "\n\nBEGIN TOPIC LIST\n" + topic_list(topic) + "\nEND TOPIC LIST") + end + end + end +end diff --git a/sasl.rb b/sasl.rb deleted file mode 100644 index f871533..0000000 --- a/sasl.rb +++ /dev/null @@ -1,201 +0,0 @@ -require 'digest/sha1' -require 'openssl' -require 'xmpp4r/client' -require 'xmpp4r/sasl' - - -module Jabber - class Client - def auth(password) - #begin - if @stream_mechanisms.include? 'SCRAM-SHA-1' - auth_sasl SASL.new(self, 'SCRAM-SHA-1'), password - elsif @stream_mechanisms.include? 'DIGEST-MD5' - auth_sasl SASL.new(self, 'DIGEST-MD5'), password - elsif @stream_mechanisms.include? 'PLAIN' - auth_sasl SASL.new(self, 'PLAIN'), password - else - auth_nonsasl(password) - end - #rescue - # Jabber::debuglog("#{$!.class}: #{$!}\n#{$!.backtrace.join("\n")}") - # raise ClientAuthenticationFailure.new, $!.to_s - #end - end - end - - module SASL - ## - # Factory function to obtain a SASL helper for the specified mechanism - def SASL.new(stream, mechanism) - case mechanism - when 'SCRAM-SHA-1' - SCRAMSHA1.new(stream) - when 'DIGEST-MD5' - DigestMD5.new(stream) - when 'PLAIN' - Plain.new(stream) - when 'ANONYMOUS' - Anonymous.new(stream) - else - raise "Unknown SASL mechanism: #{mechanism}" - end - end - - ## - # SASL SCRAM-SHA1 authentication helper - class SCRAMSHA1 < Base - ## - # Sends the wished auth mechanism and wait for a challenge - # - # (proceed with SCRAMSHA1#auth) - def initialize(stream) - super - - @nonce = generate_nonce - @client_fm = "n=#{escape @stream.jid.node },r=#{@nonce}" - - challenge = {} - challenge_text = '' - error = nil - @stream.send(generate_auth('SCRAM-SHA-1', text=Base64::strict_encode64('n,,'+@client_fm))) { |reply| - if reply.name == 'challenge' and reply.namespace == NS_SASL - challenge_text = Base64::decode64(reply.text) - challenge = decode_challenge(challenge_text) - else - error = reply.first_element(nil).name - end - true - } - raise error if error - - @server_fm = challenge_text - @cnonce = challenge['r'] - @salt = Base64::decode64(challenge['s']) - @iterations = challenge['i'].to_i - - raise 'SCRAM-SHA-1 protocol error' if @cnonce[0, @nonce.length] != @nonce - end - - def decode_challenge(text) - res = {} - - state = :key - key = '' - value = '' - text.scan(/./) do |ch| - if state == :key - if ch == '=' - state = :value - else - key += ch - end - - elsif state == :value - if ch == ',' - # due to our home-made parsing of the challenge, the key could have - # leading whitespace. strip it, or that would break jabberd2 support. - key = key.strip - res[key] = value - key = '' - value = '' - state = :key - elsif ch == '"' and value == '' - state = :quote - else - value += ch - end - - elsif state == :quote - if ch == '"' - state = :value - else - value += ch - end - end - end - # due to our home-made parsing of the challenge, the key could have - # leading whitespace. strip it, or that would break jabberd2 support. - key = key.strip - res[key] = value unless key == '' - - Jabber::debuglog("SASL SCRAM-SHA-1 challenge:\n#{text}\n#{res.inspect}") - - res - end - - ## - # * Send a response - # * Wait for the server's challenge (which aren't checked) - # * Send a blind response to the server's challenge - def auth(password) - salted_password = hi(password, @salt, @iterations) - client_key = hmac(salted_password, 'Client Key') - stored_key = h(client_key) - - final_message = "c=#{Base64::strict_encode64('n,,')},r=#{@cnonce}" - auth_message = "#{@client_fm},#{@server_fm},#{final_message}" - - client_signature = hmac(stored_key, auth_message) - client_proof = xor(client_key, client_signature) - - - response_text = "#{final_message},p=#{Base64::strict_encode64(client_proof)}" - - Jabber::debuglog("SASL SCRAM-SHA-1 response:\n#{response_text}") - - r = REXML::Element.new('response') - r.add_namespace NS_SASL - r.text = Base64::strict_encode64(response_text) - - error = nil - success = {} - @stream.send(r) { |reply| - if reply.name == 'success' and reply.namespace == NS_SASL - success = decode_challenge(Base64::decode64(reply.text)) - elsif reply.name != 'challenge' - error = reply.first_element(nil).name - end - true - } - - raise error if error - - server_key = hmac(salted_password, 'Server Key') - server_signature = hmac(server_key, auth_message) - - raise "Server authentication failed" if Base64::decode64(success['v']) != server_signature - end - - private - - def xor(a, b) - a.unpack('C*').zip(b.unpack('C*')).collect { | x, y | x ^ y }.pack('C*') - end - - def h(s) - Digest::SHA1.digest(s) - end - - def hmac(key, s) - OpenSSL::HMAC.digest('sha1', key, s) - end - - def hi(s, salt, i) - r = Array.new(size=20, obj=0).pack('C*') - u = salt + [0, 0, 0, 1].pack('C*') - - i.times do |x| - u = hmac(s, u) - r = xor(r, u) - end - - r - end - - def escape(data) - data.gsub(/=/, '=3D').gsub(/,/, '=2C') - end - end - end -end