Compare commits
10 commits
0b95e32f2f
...
5544cc1a47
Author | SHA1 | Date | |
---|---|---|---|
5544cc1a47 | |||
46099da793 | |||
09d8506f82 | |||
f003d5505d | |||
1ed0e06c2f | |||
4b3975fc0a | |||
58649c9843 | |||
dceabdbb7e | |||
09e925d695 | |||
9d15556045 |
11 changed files with 235 additions and 232 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1 +1,2 @@
|
||||||
*~
|
*~
|
||||||
|
config.rb
|
||||||
|
|
56
bot.rb
56
bot.rb
|
@ -1,53 +1,73 @@
|
||||||
require 'xmpp4r'
|
require 'xmpp4r'
|
||||||
require 'xmpp4r/muc/helper/mucclient'
|
require 'xmpp4r/muc/helper/simplemucclient'
|
||||||
|
|
||||||
require_relative 'sasl'
|
|
||||||
|
|
||||||
require_relative 'config'
|
require_relative 'config'
|
||||||
|
|
||||||
module Lain
|
module Lain
|
||||||
class Bot
|
|
||||||
Version = '0.1'
|
Version = '0.1'
|
||||||
|
|
||||||
|
class Bot
|
||||||
|
|
||||||
def initialize
|
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 = Jabber::Client.new(Jabber::JID.new(Config::JID))
|
||||||
@cl.connect
|
@cl.connect
|
||||||
@cl.auth(Config::Password)
|
@cl.auth(Config::Password)
|
||||||
@cl.send(Jabber::Presence.new)
|
@cl.send(Jabber::Presence.new)
|
||||||
|
|
||||||
@modules = {}
|
$stderr.puts 'connection established.'
|
||||||
|
|
||||||
$stderr.puts 'Loading modules...'
|
|
||||||
|
|
||||||
Config::Modules.each { |mod|
|
|
||||||
require_relative "modules/#{mod}"
|
|
||||||
@modules[mod] = Modules.const_get(mod).new self
|
|
||||||
}
|
|
||||||
|
|
||||||
@mucs = {}
|
@mucs = {}
|
||||||
|
|
||||||
Config::Rooms.each { |r|
|
Config::Rooms.each { |r|
|
||||||
muc = Jabber::MUC::MUCClient.new(@cl)
|
muc = Jabber::MUC::SimpleMUCClient.new(@cl)
|
||||||
@mucs[r] = muc
|
@mucs[r] = muc
|
||||||
|
|
||||||
muc.add_message_callback { |msg|
|
muc.add_message_callback { |msg|
|
||||||
|
unless msg.from == r
|
||||||
@modules.each { | _, mod |
|
@modules.each { | _, mod |
|
||||||
begin
|
begin
|
||||||
mod.on_message muc, msg
|
mod.on_message muc, msg
|
||||||
rescue
|
rescue
|
||||||
end
|
end
|
||||||
}
|
}
|
||||||
|
end
|
||||||
}
|
}
|
||||||
|
|
||||||
$stderr.puts "Joining room `#{r}'..."
|
$stderr.print "Trying to access room `#{r}'... "
|
||||||
|
|
||||||
muc.join("#{r}/#{Config::Nick}")
|
muc.join(r)
|
||||||
muc.configure()
|
|
||||||
|
begin
|
||||||
|
muc.configure
|
||||||
|
rescue
|
||||||
|
end
|
||||||
|
|
||||||
|
$stderr.puts "access granted."
|
||||||
}
|
}
|
||||||
|
|
||||||
$stderr.puts 'Startup finished.'
|
$stderr.puts 'Initialization finished.'
|
||||||
|
end
|
||||||
|
|
||||||
|
def commands
|
||||||
|
@commands
|
||||||
end
|
end
|
||||||
|
|
||||||
def run
|
def run
|
||||||
|
|
16
config.rb.example
Normal file
16
config.rb.example
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
module Lain
|
||||||
|
module Config
|
||||||
|
JID = ''
|
||||||
|
Password = ''
|
||||||
|
|
||||||
|
Rooms = ['']
|
||||||
|
|
||||||
|
Modules = {
|
||||||
|
:Credits => {},
|
||||||
|
:DDate => {},
|
||||||
|
:Fortune => { :command => '/usr/bin/fortune -a' },
|
||||||
|
:Help => {},
|
||||||
|
:Topic => {},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
0
lain.rb
Normal file → Executable file
0
lain.rb
Normal file → Executable file
|
@ -1,11 +1,17 @@
|
||||||
module Lain
|
module Lain
|
||||||
module Modules
|
module Modules
|
||||||
class Base
|
class Base
|
||||||
def initialize(lain)
|
def initialize(lain, config)
|
||||||
|
@lain = lain
|
||||||
|
@config = config
|
||||||
end
|
end
|
||||||
|
|
||||||
def on_message(muc, message)
|
def on_message(muc, message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def commands
|
||||||
|
{}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
30
modules/Credits.rb
Normal file
30
modules/Credits.rb
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
require 'xmpp4r/message'
|
||||||
|
|
||||||
|
require_relative '../module_base'
|
||||||
|
|
||||||
|
module Lain
|
||||||
|
module Modules
|
||||||
|
class Credits < Base
|
||||||
|
CreditText = <<END
|
||||||
|
|
||||||
|
I am Lain (レイン) #{Version}.
|
||||||
|
|
||||||
|
Coded by NeoRaider -- code at http://git.universe-factory.net/lain/
|
||||||
|
END
|
||||||
|
|
||||||
|
def on_message(muc, message)
|
||||||
|
return unless /\A!credits\b/ =~ message.body
|
||||||
|
|
||||||
|
|
||||||
|
muc.say CreditText.chomp
|
||||||
|
end
|
||||||
|
|
||||||
|
def commands
|
||||||
|
{
|
||||||
|
'!credits' => 'show credits'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -6,13 +6,15 @@ module Lain
|
||||||
module Modules
|
module Modules
|
||||||
class DDate < Base
|
class DDate < Base
|
||||||
def on_message(muc, message)
|
def on_message(muc, message)
|
||||||
return unless message.type == :groupchat
|
return unless /\A!ddate\b/ =~ message.body
|
||||||
return unless /!ddate\b/ =~ message.body
|
|
||||||
|
|
||||||
p = IO.popen("ddate")
|
muc.say("DISCORDIAN DATE " + IO.popen("ddate").read.chomp)
|
||||||
while (line = p.gets)
|
end
|
||||||
muc.send(Jabber::Message.new(message.to, line.chomp))
|
|
||||||
end
|
def commands
|
||||||
|
{
|
||||||
|
'!ddate' => 'show Discordian date'
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
21
modules/Fortune.rb
Normal file
21
modules/Fortune.rb
Normal file
|
@ -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
|
21
modules/Help.rb
Normal file
21
modules/Help.rb
Normal file
|
@ -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
|
87
modules/Topic.rb
Normal file
87
modules/Topic.rb
Normal file
|
@ -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] <topic>' => 'add a topic',
|
||||||
|
'!topic d[el[ete]] <index>' => 'remove topic <index>'
|
||||||
|
}
|
||||||
|
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
|
201
sasl.rb
201
sasl.rb
|
@ -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
|
|
Reference in a new issue