├── public └── .gitignore ├── config.ru ├── .gitignore ├── CONTRIBUTING.md ├── kaz ├── plugins │ ├── ping.rb │ ├── join_on_invite.rb │ ├── speakerqueue.rb │ └── conference.rb ├── api.rb ├── lib │ ├── config.rb │ ├── redis-send.rb │ └── redis-api.rb ├── README.md └── bot.rb ├── Gemfile ├── README.md ├── lib ├── aliases.rb └── conference.rb ├── Gemfile.lock ├── app.rb └── LICENSE.txt /public/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require './app' 2 | 3 | run App 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | config.yml 3 | *.swp 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | By submitting code to this project, you agree to irrevocably release it under the same license as this project. See README.md for more details. -------------------------------------------------------------------------------- /kaz/plugins/ping.rb: -------------------------------------------------------------------------------- 1 | module Bot 2 | class Ping 3 | include Cinch::Plugin 4 | 5 | match /^ping$/, use_prefix: false 6 | def execute(m) 7 | m.reply "pong" 8 | end 9 | 10 | end 11 | end -------------------------------------------------------------------------------- /kaz/api.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | Bundler.require 4 | 5 | REDIS = Redis.new 6 | 7 | require 'sinatra' 8 | require 'json' 9 | 10 | post '/' do 11 | REDIS.publish 'Kaz:input', params.to_json 12 | end 13 | 14 | -------------------------------------------------------------------------------- /kaz/lib/config.rb: -------------------------------------------------------------------------------- 1 | CONFIG_FILE = File.expand_path '../../../config.yml', __FILE__ 2 | begin 3 | CONFIG = YAML.load_file CONFIG_FILE 4 | rescue => e 5 | STDERR.puts "unable to read: #{CONFIG_FILE}" 6 | STDERR.puts e 7 | exit 1 8 | end 9 | 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org/' 2 | 3 | gem 'shotgun' 4 | 5 | gem 'sinatra', :require => 'sinatra/base' 6 | gem 'mysql2' 7 | gem 'sequel' 8 | gem 'cinch' 9 | gem 'celluloid-io' 10 | gem 'celluloid-redis' 11 | gem 'redis-namespace' 12 | gem 'twilio-ruby' 13 | -------------------------------------------------------------------------------- /kaz/plugins/join_on_invite.rb: -------------------------------------------------------------------------------- 1 | module Bot 2 | class JoinOnInvite 3 | include Cinch::Plugin 4 | 5 | listen_to :invite 6 | def listen(m) 7 | # Works with passworded channels too: 8 | # /invite Kaz #channel password 9 | Bot.Channel(m.target).join 10 | end 11 | 12 | end 13 | end -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Kaz the Telcon Robot 2 | ==================== 3 | 4 | 5 | Web Interface 6 | ------------- 7 | 8 | ```bash 9 | $ bundle exec rackup 10 | ``` 11 | 12 | 13 | IRC Bot 14 | ------- 15 | 16 | ```bash 17 | $ bundle exec ruby kaz/bot.rb 18 | ``` 19 | 20 | 21 | 22 | License 23 | ------- 24 | 25 | Apache 2.0 license 26 | 27 | Please see LICENSE.txt 28 | -------------------------------------------------------------------------------- /kaz/lib/redis-send.rb: -------------------------------------------------------------------------------- 1 | module Bot 2 | 3 | R = Module.new do 4 | class << self 5 | extend Forwardable 6 | def_delegator :redis, :with 7 | def redis &block 8 | @redis ||= ConnectionPool.new do 9 | ::Redis::Namespace.new CONFIG[:irc][:nick], redis: Redis.new(driver: :celluloid) 10 | end 11 | end 12 | def multi &block 13 | with {|_r| _r.multi {|r| block[r]}} 14 | end 15 | def method_missing m, *a 16 | with {|r| r.__send__ m, *a} 17 | end 18 | end 19 | end 20 | 21 | end -------------------------------------------------------------------------------- /lib/aliases.rb: -------------------------------------------------------------------------------- 1 | module Alias 2 | 3 | def self.generate(n) 4 | aliases = [ 5 | 'aaaa', 6 | 'aabc', 7 | 'aabd', 8 | 'aacd', 9 | 'aacf', 10 | 'aacg', 11 | 'aacj', 12 | 'aacn', 13 | 'aacp', 14 | 'aacr', 15 | 'aade', 16 | 'aadf', 17 | 'aadg', 18 | 'aadh', 19 | 'aadj', 20 | 'aadk', 21 | 'aadm', 22 | 'aaef', 23 | 'aafg', 24 | 'aafh', 25 | 'aafk', 26 | 'aafm', 27 | 'aafp', 28 | 'aafq', 29 | 'aafr', 30 | 'aagk', 31 | 'aahj', 32 | 'aajk', 33 | 'aakl', 34 | 'aalm', 35 | 'aamp', 36 | 'aamq', 37 | 'aamt', 38 | 'aamv', 39 | 'aamw', 40 | 'aapq', 41 | 'aaqr', 42 | 'aaru', 43 | 'aast', 44 | 'aatu', 45 | 'aauv', 46 | 'aavw', 47 | 'aawx', 48 | 'aaxy', 49 | 'aayz', 50 | ] 51 | aliases[n % aliases.length] 52 | end 53 | 54 | end -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | builder (3.2.2) 5 | celluloid (0.16.0) 6 | timers (~> 4.0.0) 7 | celluloid-io (0.16.2) 8 | celluloid (>= 0.16.0) 9 | nio4r (>= 1.1.0) 10 | celluloid-redis (0.0.2) 11 | celluloid-io (>= 0.13.0.pre) 12 | redis 13 | cinch (2.2.5) 14 | hitimes (1.2.2) 15 | jwt (1.4.1) 16 | multi_json (1.11.0) 17 | mysql2 (0.3.7) 18 | nio4r (1.1.0) 19 | rack (1.6.0) 20 | rack-protection (1.5.3) 21 | rack 22 | redis (3.2.1) 23 | redis-namespace (1.5.2) 24 | redis (~> 3.0, >= 3.0.4) 25 | sequel (4.19.0) 26 | shotgun (0.9.1) 27 | rack (>= 1.0) 28 | sinatra (1.4.6) 29 | rack (~> 1.4) 30 | rack-protection (~> 1.4) 31 | tilt (>= 1.3, < 3) 32 | tilt (2.0.1) 33 | timers (4.0.1) 34 | hitimes 35 | twilio-ruby (4.0.0) 36 | builder (>= 2.1.2) 37 | jwt (~> 1.0) 38 | multi_json (>= 1.3.0) 39 | 40 | PLATFORMS 41 | ruby 42 | 43 | DEPENDENCIES 44 | celluloid-io 45 | celluloid-redis 46 | cinch 47 | mysql2 48 | redis-namespace 49 | sequel 50 | shotgun 51 | sinatra 52 | twilio-ruby 53 | -------------------------------------------------------------------------------- /kaz/README.md: -------------------------------------------------------------------------------- 1 | IRC Bot 2 | ======= 3 | 4 | Redis Interface 5 | --------------- 6 | 7 | The bot can be controlled by sending messages to the Redis channel it listens on. 8 | By default the bot listens to a channel named "input" with the namespace of its nick 9 | defined in config.yml. This ends up being a string like "BotNick:input" as the redis channel. 10 | 11 | ### Commands 12 | 13 | Commands are sent to the Redis channel as a JSON-encoded string. 14 | 15 | #### Global 16 | 17 | * `{"type":"join","channel":"#bot"}` 18 | * The bot will join the channel "#bot" 19 | * `{"type":"part","channel":"#bot"}` or `{"type":"part","channel":"#bot","text":"bye!"}` 20 | * The bot will part the channel with an optional message 21 | * `{"type":"oper","password":"****"}` or {"type":"oper","password":"****","user":"Bot"}` 22 | * The bot will attempt to become an oper using the given password and optional username 23 | * `{"type":"mode","mode":"****"}` 24 | * Sets a mode on the bot 25 | * `{"type":"unset_mode","mode":"****"}` 26 | * Unsets a mode on the bot 27 | * `{"type":"nick","nick":"NewBot"}` 28 | * Sets the nick of the bot 29 | * `{"type":"raw","cmd":"TOPIC #channel hello"}` 30 | * Sends a raw IRC command, useful in case you need to do something that isn't specifically handled here 31 | 32 | #### Channel Commands 33 | * `{"type":"text","text":"hello","channel":"#bot"}` 34 | * The bot will say "hello" in the channel "#bot" 35 | * `{"type":"action","action":"waves","channel":"#bot"}` 36 | * The bot will send an action "/me waves" to the channel "#bot" 37 | * `{"type":"topic","channel":"#bot","topic":"welcome to the channel"}` 38 | * Sets the topic for the given channel 39 | * `{"type":"op","channel":"#bot","nick":"someuser"}` 40 | * The bot grants ops to the specified nick 41 | * `{"type":"deop","channel":"#bot","nick":"someuser"}` 42 | * The bot de-ops the specified nick 43 | * `{"type":"voice","channel":"#bot","nick":"someuser"}` 44 | * The bot grants voice to the specified nick 45 | * `{"type":"devoice","channel":"#bot","nick":"someuser"}` 46 | * The bot de-voices the specified nick 47 | * `{"type":"kick","channel":"#bot","nick":"someuser"}` 48 | * Kicks the specified nick from the channel 49 | 50 | -------------------------------------------------------------------------------- /kaz/bot.rb: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler.require 3 | require 'forwardable' 4 | require 'yaml' 5 | require 'json' 6 | 7 | class NilClass; alias_method :empty?, :nil?; end 8 | 9 | PLUGINS = [ 10 | {:file => 'ping', :class => 'Ping'}, 11 | {:file => 'conference', :class => 'Conference'}, 12 | {:file => 'speakerqueue', :class => 'SpeakerQueue'}, 13 | {:file => 'join_on_invite', :class => 'JoinOnInvite'}, 14 | ] 15 | 16 | # Load the config file 17 | require File.expand_path "../lib/config", __FILE__ 18 | require File.expand_path "../../lib/conference", __FILE__ 19 | 20 | TwilioClient = Twilio::REST::Client.new CONFIG[:twilio][:sid], CONFIG[:twilio][:token] 21 | 22 | # Need to make a new connection per thread, so provide an easy method to use 23 | def db_connect 24 | Sequel.connect(CONFIG[:db]) 25 | end 26 | 27 | module Bot 28 | 29 | # Load the plugins 30 | PLUGINS.each do |p| 31 | require File.expand_path "../plugins/#{p[:file]}", __FILE__ 32 | end 33 | 34 | # Set up the R object so plugins can use Redis 35 | require File.expand_path "../lib/redis-send", __FILE__ 36 | 37 | # Set up the separate thread for listening on the "input" Redis channel 38 | require File.expand_path "../lib/redis-api", __FILE__ 39 | 40 | class << self 41 | include Cinch::Helpers 42 | 43 | def report msg 44 | Channel(CONFIG[:irc][:admin_channel]).send msg 45 | end 46 | 47 | def bot 48 | unless @bot 49 | @bot = Cinch::Bot.new do 50 | configure do |c| 51 | c.channels = (CONFIG[:irc][:channels] + [CONFIG[:irc][:admin_channel]]).uniq 52 | c.nick = CONFIG[:irc][:nick] 53 | c.plugins.plugins = PLUGINS.map {|p| Bot.const_get p[:class]} 54 | c.server = CONFIG[:irc][:server] 55 | end 56 | 57 | on :connect do 58 | # Subscribe to the Redis channel to set up the API 59 | Bot::SubscriptionReactor.singleton.async.redis_subscribe unless Bot::SubscriptionReactor.singleton.subscribed? 60 | end 61 | end 62 | 63 | # Configure logging 64 | if CONFIG[:irc][:log_file] 65 | root = File.expand_path '../..', __FILE__ 66 | log_file = File.open File.join(root, CONFIG[:irc][:log_file]), "a" 67 | @bot.loggers[0] = Cinch::Logger::FormattedLogger.new(log_file) 68 | Celluloid.logger = Logger.new log_file 69 | end 70 | end 71 | @bot 72 | end 73 | end 74 | 75 | end 76 | 77 | Bot.bot.start 78 | -------------------------------------------------------------------------------- /lib/conference.rb: -------------------------------------------------------------------------------- 1 | class ConfHelper 2 | 3 | # Return the SID of the current conference 4 | # The first time, fetches from Twilio, then caches in the DB 5 | # Returns a Twilio conference object 6 | def self.current_conference(db, room) 7 | begin 8 | if room[:conference_sid] 9 | room[:conference_sid] 10 | conference = TwilioClient.account.conferences.get(room[:conference_sid]) 11 | if conference.status != 'in-progress' 12 | db[:rooms].where(:id => room[:id]).update(:conference_sid => nil) 13 | nil 14 | else 15 | conference 16 | end 17 | else 18 | conferences = TwilioClient.account.conferences.list( 19 | :FriendlyName => room[:dial_code], 20 | :Status => 'in-progress' 21 | ) 22 | if conferences[0] 23 | db[:rooms].where(:id => room[:id]).update(:conference_sid => conferences[0].sid) 24 | conferences[0] 25 | else 26 | db[:rooms].where(:id => room[:id]).update(:conference_sid => nil) 27 | nil 28 | end 29 | end 30 | rescue => e 31 | e 32 | end 33 | end 34 | 35 | # Retrieve a caller record given a name, either a nick or phone ident 36 | def self.caller_for_name(db, room_id, name) 37 | caller = db[:callers] 38 | .where(:room_id => room_id) 39 | .where(:ident => name) 40 | .first 41 | return caller if caller 42 | 43 | caller = db[:callers] 44 | .where(:room_id => room_id) 45 | .where(:nick => name) 46 | .first 47 | return caller if caller 48 | 49 | return nil 50 | end 51 | 52 | # Retrieve the room record given a channel name 53 | def self.room_for_channel(db, m) 54 | room = db[:rooms] 55 | .where(:ircserver_id => CONFIG[:irc][:server_id]) 56 | .where(:irc_channel => m.channel.name) 57 | .first 58 | if !room 59 | m.reply "Sorry, I don't see a conference code configured for this IRC channel" 60 | end 61 | room 62 | end 63 | 64 | def self.mute(db, conference, caller_sid) 65 | begin 66 | participant = conference.participants.get(caller_sid) 67 | participant.mute() 68 | true 69 | rescue => e 70 | e 71 | end 72 | end 73 | 74 | def self.unmute(db, conference, caller_sid) 75 | begin 76 | participant = conference.participants.get(caller_sid) 77 | participant.unmute() 78 | true 79 | rescue => e 80 | e 81 | end 82 | end 83 | 84 | end -------------------------------------------------------------------------------- /kaz/lib/redis-api.rb: -------------------------------------------------------------------------------- 1 | module Bot 2 | 3 | class SubscriptionReactor 4 | include Celluloid::IO 5 | 6 | def self.singleton 7 | @sub ||= SubscriptionReactor.new 8 | end 9 | 10 | def initialize 11 | @redis = ::Redis::Namespace.new CONFIG[:irc][:nick], redis: ::Redis.new(:driver => :celluloid) 12 | @subscribed = false 13 | end 14 | 15 | def subscribed?; @subscribed; end 16 | 17 | def redis_subscribe 18 | @subscribed = true 19 | @redis.subscribe(:input) do |on| 20 | on.subscribe do |channel, subscriptions| 21 | puts "Subscribed to ##{channel} (#{subscriptions} subscriptions)" 22 | end 23 | 24 | # Define the Redis listeners so we can control the bot remotely 25 | on.message do |channel, msg_string| 26 | puts "Incoming from Redis: ##{channel}: #{msg_string}" 27 | message = JSON.parse msg_string 28 | case message['type'] 29 | when 'text' 30 | if message['channel'] and message['text'] 31 | Bot.Channel(message['channel']).send(message['text']) 32 | end 33 | when 'action' 34 | if message['channel'] and message['action'] 35 | Bot.Channel(message['channel']).action(message['action']) 36 | end 37 | when 'topic' 38 | if message['channel'] and message['topic'] 39 | Bot.Channel(message['channel']).topic = message['topic'] 40 | end 41 | when 'op' 42 | if message['channel'] and message['nick'] 43 | Bot.Channel(message['channel']).op(message['nick']) 44 | end 45 | when 'deop' 46 | if message['channel'] and message['nick'] 47 | Bot.Channel(message['channel']).deop(message['nick']) 48 | end 49 | when 'voice' 50 | if message['channel'] and message['nick'] 51 | Bot.Channel(message['channel']).voice(message['nick']) 52 | end 53 | when 'devoice' 54 | if message['channel'] and message['nick'] 55 | Bot.Channel(message['channel']).devoice(message['nick']) 56 | end 57 | when 'kick' 58 | if message['channel'] and message['nick'] 59 | Bot.Channel(message['channel']).kick(message['nick']) 60 | end 61 | when 'join' 62 | if message['channel'] 63 | Bot.bot.join message['channel'] 64 | end 65 | when 'part' 66 | if message['channel'] 67 | Bot.bot.part message['channel'], message['text'] 68 | end 69 | when 'oper' 70 | if message['password'] 71 | Bot.bot.oper message['password'], message['user'] 72 | end 73 | when 'mode' 74 | if message['mode'] 75 | Bot.bot.set_mode message['mode'] 76 | end 77 | when 'unset_mode' 78 | if message['mode'] 79 | Bot.bot.unset_mode message['mode'] 80 | end 81 | when 'nick' 82 | if message['nick'] 83 | Bot.report "Setting nick to #{message['nick']}" 84 | Bot.bot.nick = message['nick'] 85 | end 86 | when 'raw' 87 | if message['cmd'] 88 | Bot.bot.irc.send message['cmd'] 89 | end 90 | end 91 | end 92 | end 93 | rescue => e 94 | puts e.message 95 | ensure 96 | @subscribed = false 97 | end 98 | 99 | end 100 | 101 | end -------------------------------------------------------------------------------- /kaz/plugins/speakerqueue.rb: -------------------------------------------------------------------------------- 1 | module Bot 2 | class SpeakerQueue 3 | include Cinch::Plugin 4 | $queue = {} 5 | 6 | listen_to :channel 7 | 8 | def listen(m) 9 | m.message.match /^(who( is|'s|s) on (the )?)?q(ueue)?\?$/i do 10 | show_queue(m) 11 | end 12 | 13 | m.message.match /^q(ueue)?\+( (?[^ ]+))?( to (?.*))?$/i do |result| 14 | add_to_queue(m, result['nick'], result['topic']) 15 | end 16 | m.message.match /^sees ((?[^ ]+)) raise hand$/i do |result| 17 | add_to_queue(m, result['nick'], nil) 18 | end 19 | m.message.match /^((?[^ ]+)) raises hand$/i do |result| 20 | add_to_queue(m, result['nick'], nil) 21 | end 22 | 23 | m.message.match /^q(ueue)?-( (?[^ ]+))?$/i do |result| 24 | remove_from_queue(m, result['nick']) 25 | end 26 | m.message.match /^sees ((?[^ ]+)) lower hand$/i do |result| 27 | remove_from_queue(m, result['nick']) 28 | end 29 | m.message.match /^((?[^ ]+)) lowers hand$/i do |result| 30 | remove_from_queue(m, result['nick']) 31 | end 32 | 33 | m.message.match /^acks? (?[^ ]+)$/i do |result| 34 | ack_speaker(m, result['nick']) 35 | end 36 | m.message.match /^recognizes? (?[^ ]+)$/i do |result| 37 | ack_speaker(m, result['nick']) 38 | end 39 | 40 | m.message.match /^q=( ?(?[^ ]+(, [^ ]+)*))?$/i do |result| 41 | set_queue(m, result['nicks']) 42 | end 43 | m.message.match /^queue=( ?(?[^ ]+(, [^ ]+)*))?$/i do |result| 44 | set_queue(m, result['nicks'], true) 45 | end 46 | 47 | end 48 | 49 | def show_queue(m) 50 | create_queue_storage(m) 51 | if $queue[m.channel].empty? then 52 | nicklist = 'no one' 53 | else 54 | nicklist = $queue[m.channel].keys.join ', ' 55 | end 56 | m.action_reply "sees #{nicklist} on the speaker queue" 57 | end 58 | 59 | def add_to_queue(m, nick, topic) 60 | create_queue_storage(m) 61 | nick = m.user.nick if nick.nil? or nick.empty? 62 | nick = m.user.nick if nick == 'me' 63 | if $queue[m.channel].keys.member? nick then 64 | m.action_reply "already sees #{nick} on the speaker queue" 65 | else 66 | $queue[m.channel][nick] = topic 67 | show_queue(m) 68 | end 69 | end 70 | 71 | def remove_from_queue(m, nick) 72 | create_queue_storage(m) 73 | nick = m.user.nick if nick.nil? or nick.empty? 74 | nick = m.user.nick if nick == 'me' 75 | if $queue[m.channel].keys.member? nick then 76 | $queue[m.channel].delete nick 77 | show_queue(m) 78 | end 79 | end 80 | 81 | def set_queue(m, nicks, allowEmpty=false) 82 | create_queue_storage(m) 83 | if nicks.nil? or nicks.empty? then 84 | if allowEmpty then 85 | $queue[m.channel] = {} 86 | show_queue(m) 87 | else 88 | m.action_reply "#{m.user.nick}, if you meant to query the queue, please say 'q?'; if you meant to replace the queue, please say 'queue= ..." 89 | end 90 | else 91 | $queue[m.channel] = {} 92 | nicks.split(', ').each do |nick| 93 | $queue[m.channel][nick] = nil 94 | end 95 | show_queue(m) 96 | end 97 | end 98 | 99 | def ack_speaker(m, nick) 100 | create_queue_storage(m) 101 | if $queue[m.channel].keys.member? nick then 102 | 103 | db = db_connect 104 | 105 | # If there is a caller for this nick, unmute them 106 | room = ConfHelper.room_for_channel db, m 107 | if room 108 | caller = ConfHelper.caller_for_name db, room[:id], nick 109 | if caller 110 | conference = ConfHelper.current_conference db, room 111 | if conference and conference.class != SocketError 112 | ConfHelper.unmute db, conference, caller[:call_sid] 113 | Bot.Channel(m.channel.name).voice(nick) if nick 114 | m.reply "#{display_name} should now be unmuted" 115 | end 116 | end 117 | end 118 | 119 | # Remove from queue and respond in the channel 120 | if $queue[m.channel][nick].nil? or $queue[m.channel][nick].empty? then 121 | $queue[m.channel].delete nick 122 | show_queue(m) 123 | else 124 | topic = $queue[m.channel].delete nick 125 | if topic.empty? then 126 | show_queue(m) 127 | else 128 | m.reply "#{nick}, you wanted to #{topic}" 129 | end 130 | end 131 | end 132 | end 133 | 134 | def create_queue_storage(m) 135 | if $queue.nil? then 136 | $queue = {} 137 | end 138 | if $queue[m.channel].nil? then 139 | $queue[m.channel] = {} 140 | end 141 | end 142 | end 143 | end -------------------------------------------------------------------------------- /kaz/plugins/conference.rb: -------------------------------------------------------------------------------- 1 | module Bot 2 | class Conference 3 | include Cinch::Plugin 4 | 5 | # Use a lambda for the prefix so that if the bot nick changes it can still match it 6 | set :prefix, lambda{|m| Regexp.new("^" + Regexp.escape(m.bot.nick) + "[:,] ")} 7 | 8 | match /hello$/, method: :greet 9 | def greet(m) 10 | m.reply "Hello to you, too, #{m.user.nick}." 11 | end 12 | 13 | match /([^ ]+) is ([^ ]+)$/i, method: :set_user 14 | def set_user(m, ident, nick) 15 | db = db_connect 16 | 17 | if nick == 'me' 18 | nick = m.user.nick 19 | end 20 | 21 | server = db[:ircservers].where(:id => CONFIG[:irc][:server_id]).first 22 | caller = db[:callers] 23 | .select_all(:callers) 24 | .join(:rooms, :id => :room_id) 25 | .where(:ircserver_id => server[:id]) 26 | .where(:ident => ident).first 27 | if caller 28 | # First remove the nick from any other records in this room 29 | db[:callers] 30 | .where(:room_id => caller[:room_id]) 31 | .where(:nick => nick) 32 | .update(:nick => nil, :date_nick_set => nil) 33 | 34 | # Add the nick to the current caller 35 | db[:callers] 36 | .where(:id => caller[:id]) 37 | .update(:nick => nick, :date_nick_set => DateTime.now) 38 | m.reply "Okay, #{nick} is on #{ident}" 39 | m.reply "+#{nick}" 40 | 41 | # Give the IRC user voice as well 42 | voice_nick m.channel.name, nick 43 | 44 | # Add the callerid -> nick mapping for the future 45 | remember = db[:remember_me] 46 | .where(:caller_id => caller[:caller_id]) 47 | .first 48 | if remember 49 | db[:remember_me] 50 | .where(:id => remember[:id]) 51 | .update({ 52 | :nick => nick, 53 | :date_lastseen => DateTime.now 54 | }) 55 | else 56 | db[:remember_me] << { 57 | :caller_id => caller[:caller_id], 58 | :nick => nick, 59 | :date_added => DateTime.now, 60 | :date_lastseen => DateTime.now 61 | } 62 | end 63 | 64 | else 65 | m.reply "Sorry, I don't see #{ident} on the call" 66 | end 67 | 68 | end 69 | 70 | match /(?:what is|what's) the code\??$/i, method: :what_is_the_code 71 | def what_is_the_code(m) 72 | db = db_connect 73 | 74 | room = ConfHelper.room_for_channel db, m 75 | return unless room 76 | 77 | m.reply "#{room[:dial_code]}" 78 | end 79 | 80 | match /(?:who is|who's) on the (?:call?|phone)\??$/i, method: :who_is_here 81 | def who_is_here(m) 82 | db = db_connect 83 | 84 | room = ConfHelper.room_for_channel db, m 85 | return unless room 86 | 87 | conference = ConfHelper.current_conference db, room 88 | 89 | if !conference 90 | return m.reply "There is no active call for this conference code" 91 | elsif conference.class == SocketError 92 | return m.reply "Something went wrong trying to reach the phone system" 93 | end 94 | 95 | participants = [] 96 | conference.participants.list.each do |p| 97 | sid = p.uri.match(/Participants\/(.+)\.json/)[1] 98 | caller = db[:callers].where(:room_id => room[:id], :call_sid => sid).first 99 | if caller 100 | string = "#{caller[:nick] ? caller[:nick] : caller[:ident]}" 101 | if p.muted 102 | string += " (muted)" 103 | end 104 | participants << string 105 | else 106 | participants << "#{sid} (not tracked)" 107 | end 108 | end 109 | 110 | m.reply "On the call: #{participants.join(', ')}" 111 | end 112 | 113 | match /this is ([a-z0-9]{4})$/i, method: :set_conference_code 114 | def set_conference_code(m, code) 115 | db = db_connect 116 | 117 | room = ConfHelper.room_for_channel db, m 118 | return unless room 119 | 120 | 121 | end 122 | 123 | match /(mute|unmute) ([^ ]+)$/, method: :mute 124 | def mute(m, action, name) 125 | db = db_connect 126 | 127 | room = ConfHelper.room_for_channel db, m 128 | return unless room 129 | 130 | # Allow users to mute themselves with "mute me" 131 | if name == 'me' 132 | name = m.user.nick 133 | end 134 | 135 | caller = ConfHelper.caller_for_name db, room[:id], name 136 | if !caller 137 | return m.reply "Sorry, I don't see #{name} on the call" 138 | end 139 | 140 | nick = caller[:nick] ? caller[:nick] : nil 141 | display_name = caller[:nick] ? caller[:nick] : caller[:ident] 142 | 143 | conference = ConfHelper.current_conference db, room 144 | if !conference 145 | return m.reply "There is no active call for this conference code" 146 | elsif conference.class == SocketError 147 | return m.reply "Something went wrong trying to reach the phone system" 148 | end 149 | 150 | if action == 'mute' 151 | result = ConfHelper.mute db, conference, caller[:call_sid] 152 | Bot.Channel(m.channel.name).devoice(nick) if nick 153 | m.reply "#{display_name} should now be muted" # TODO: say if already muted 154 | else 155 | ConfHelper.unmute db, conference, caller[:call_sid] 156 | Bot.Channel(m.channel.name).voice(nick) if nick 157 | m.reply "#{display_name} should now be unmuted" 158 | end 159 | 160 | end 161 | 162 | end 163 | end -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | Bundler.require 4 | require 'yaml' 5 | require 'json' 6 | 7 | CONFIG_FILE = File.expand_path '../config.yml', __FILE__ 8 | begin 9 | CONFIG = YAML.load_file CONFIG_FILE 10 | rescue => e 11 | STDERR.puts "unable to read: #{CONFIG_FILE}" 12 | STDERR.puts e 13 | exit 1 14 | end 15 | 16 | REDIS = ::Redis::Namespace.new CONFIG[:irc][:nick], redis: ::Redis.new(:driver => :celluloid) 17 | DB = Sequel.connect(CONFIG[:db]) 18 | TwilioClient = Twilio::REST::Client.new CONFIG[:twilio][:sid], CONFIG[:twilio][:token] 19 | 20 | require File.expand_path "../lib/aliases", __FILE__ 21 | require File.expand_path "../lib/conference", __FILE__ 22 | 23 | class App < Sinatra::Base 24 | 25 | class Config 26 | def self.receive_url 27 | CONFIG[:base_url] + "/call/digits" 28 | end 29 | 30 | def self.conference_callback_url 31 | CONFIG[:base_url] + "/call/callback" 32 | end 33 | end 34 | 35 | def join_conference(call_sid, caller_id, type, gateway, code) 36 | # Look up conference code to find the IRC room 37 | room = DB[:rooms] 38 | .select_all(:rooms) 39 | .join(:ircservers, :id => :ircserver_id) 40 | .join(:gateways, :ircserver_id => :ircservers__id) 41 | .where(:gateways__type => type) 42 | .where(:gateways__value => gateway) 43 | .where(:dial_code => code) 44 | .first 45 | 46 | if room 47 | server = DB[:ircservers].where(:id => room[:ircserver_id]).first 48 | 49 | # if type=phone, generate a random 4-char suffix, show that plus the rest of the number as the display name 50 | if type == 'phone' 51 | # Find the number of callers already on the call 52 | num_callers = DB[:callers] 53 | .where(:room_id => room[:id]) 54 | .count 55 | # Get an alias for this newest caller 56 | ident = Alias::generate num_callers+1 57 | 58 | trunc_caller_id = caller_id[0..-5] 59 | 60 | # If the truncated caller ID matches a US phone number, add dots 61 | if m=trunc_caller_id.match(/^1(\d{3})(\d{3})/ ) 62 | trunc_caller_id = "1.#{m[1]}.#{m[2]}." 63 | end 64 | display_name = trunc_caller_id + ident 65 | 66 | else 67 | ident = caller_id[-4..-1] 68 | display_name = ident 69 | end 70 | 71 | # Log the participant in the room 72 | caller = DB[:callers] 73 | .where(:room_id => room[:id]) 74 | .where(:caller_id => caller_id) 75 | .first 76 | if caller 77 | DB[:callers] 78 | .where(:id => caller[:id]) 79 | .update(:date_joined => DateTime.now, :ident => ident, :call_sid => call_sid) 80 | else 81 | cid = DB[:callers].insert({ 82 | :room_id => room[:id], 83 | :caller_id => caller_id, 84 | :ident => ident, 85 | :call_sid => call_sid, 86 | :date_joined => DateTime.now 87 | }) 88 | caller = DB[:callers].where(:id => cid).first 89 | end 90 | 91 | # if type=phone or sip, look up the caller ID in the nick cache 92 | if type == 'phone' # or sip 93 | remembered = DB[:remember_me] 94 | .where(:caller_id => caller[:caller_id]) 95 | .first 96 | if remembered 97 | display_name = remembered[:nick] 98 | DB[:callers] 99 | .where(:id => caller[:id]) 100 | .update(:nick => remembered[:nick]) 101 | end 102 | end 103 | 104 | # Send a message to IRC that the caller joined 105 | message = "+#{display_name}" 106 | REDIS.publish 'input', {:type => 'text', :channel => room[:irc_channel], :text => message}.to_json 107 | # If the caller corresponds to an IRC user, give them voice 108 | if caller[:nick] 109 | REDIS.publish 'input', {:type => 'voice', :channel => room[:irc_channel], :nick => caller[:nick]}.to_json 110 | end 111 | 112 | xml = Twilio::TwiML::Response.new do |r| 113 | r.Dial({ 114 | :action => Config.conference_callback_url+"?room="+room[:id].to_s, 115 | :method => 'POST' 116 | }) do |d| 117 | d.Conference(code, { 118 | :beep => 'true', 119 | :startConferenceOnEnter => 'true' 120 | }) 121 | end 122 | end.text 123 | puts xml 124 | xml 125 | else 126 | Twilio::TwiML::Response.new do |r| 127 | r.say 'Sorry, that is not a valid code' 128 | end.text 129 | end 130 | end 131 | 132 | def get_caller_id(params) 133 | if params[:To] == '' 134 | params[:CallSid] 135 | else 136 | params[:From] 137 | end 138 | end 139 | 140 | get '/' do 141 | 'Hello world, I am Kaz, your friendly telcon robot!' 142 | end 143 | 144 | get '/call' do 145 | erb :call 146 | end 147 | 148 | post '/irc' do 149 | REDIS.publish 'input', params.to_json 150 | end 151 | 152 | get '/call/participants' do 153 | room = DB[:rooms].where(:id => params[:room]).first 154 | 155 | if room 156 | conference = ConfHelper.current_conference DB, room 157 | if conference 158 | response = [] 159 | conference.participants.list.each do |p| 160 | sid = p.uri.match(/Participants\/(.+)\.json/)[1] 161 | caller = DB[:callers].where(:room_id => room[:id], :call_sid => sid).first 162 | response << { 163 | :date_created => p.date_created, 164 | :muted => p.muted, 165 | :uri => p.uri, 166 | :sid => sid, 167 | :caller => caller 168 | } 169 | end 170 | response.to_json 171 | else 172 | 'no call in progress' 173 | end 174 | end 175 | end 176 | 177 | post '/call/incoming' do 178 | jj params 179 | 180 | # if To is blank, it was from a browser phone 181 | if params[:To] == '' 182 | return join_conference params[:CallSid], params[:CallSid], 'browser', params[:ApplicationSid], params[:code] 183 | else 184 | # TODO: SIP! 185 | gateway = params[:To] 186 | type = 'phone' 187 | end 188 | 189 | Twilio::TwiML::Response.new do |r| 190 | r.Say 'This is Kaz, your friendly telcon robot.' 191 | r.Gather :numDigits => 4, :action => Config.receive_url+'?To='+gateway+'&type='+type, :method => 'post' do |g| 192 | g.Say 'Please enter your four digit conference code.' 193 | end 194 | end.text 195 | end 196 | 197 | post '/call/digits' do 198 | join_conference params[:CallSid], params[:From], params[:type], params[:To], params[:Digits] 199 | end 200 | 201 | post '/call/callback' do 202 | room = DB[:rooms].where(:id => params[:room]).first 203 | 204 | if room 205 | server = DB[:ircservers].where(:id => room[:ircserver_id]).first 206 | jj params 207 | if params[:CallStatus] == 'completed' 208 | caller_id = get_caller_id params 209 | caller = DB[:callers] 210 | .where(:room_id => room[:id]) 211 | .where(:caller_id => caller_id) 212 | 213 | record = caller.first 214 | if !record 215 | puts "Caller disconnected but wasn't already on the call" 216 | puts "#{room[:id]} #{caller_id}" 217 | return 218 | end 219 | 220 | if record[:nick] 221 | display_name = record[:nick] 222 | else 223 | display_name = record[:ident] 224 | end 225 | caller.delete 226 | 227 | message = "-#{display_name}" 228 | REDIS.publish 'input', {:type => 'text', :channel => room[:irc_channel], :text => message}.to_json 229 | 230 | # If the caller is on IRC, devoice them as well 231 | if record[:nick] 232 | REDIS.publish 'input', {:type => 'devoice', :channel => room[:irc_channel], :nick => record[:nick]}.to_json 233 | end 234 | end 235 | end 236 | 237 | Twilio::TwiML::Response.new do |r| 238 | r.Say 'Goodbye' 239 | end.text 240 | end 241 | 242 | end 243 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | --------------------------------------------------------------------------------