├── config.ru ├── Gemfile ├── cafe_dispatcher.rb ├── Gemfile.lock ├── README.md ├── command_parser.rb ├── tem_cafe.rb ├── command_parser_test.rb ├── tem_cafe_test.rb ├── cafe.rb ├── cafe_dispatcher_test.rb └── cafe_test.rb /config.ru: -------------------------------------------------------------------------------- 1 | require './tem_cafe' 2 | 3 | run TemCafe 4 | 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rack' 4 | gem 'sinatra' 5 | gem 'foreman' 6 | gem 'dalli' 7 | 8 | group :test do 9 | gem 'minitest' 10 | gem 'rack-test' 11 | gem 'timecop' 12 | end 13 | -------------------------------------------------------------------------------- /cafe_dispatcher.rb: -------------------------------------------------------------------------------- 1 | class CafeDispatcher 2 | ActionNotFound = Class.new(StandardError) 3 | 4 | def initialize(cafe, command_parser:) 5 | @cafe = cafe 6 | @command_parser = command_parser 7 | end 8 | 9 | def call(command) 10 | args = @command_parser.call(command) 11 | fail ActionNotFound unless valid_args?(args) 12 | 13 | dispatch_and_return_response(args) 14 | end 15 | 16 | private 17 | 18 | def valid_args?(args) 19 | !args.nil? && @cafe.method(args[0]).arity == args.length - 1 20 | end 21 | 22 | def dispatch_and_return_response(args) 23 | @cafe.public_send(*args) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | dalli (2.7.6) 5 | foreman (0.82.0) 6 | thor (~> 0.19.1) 7 | minitest (5.9.0) 8 | rack (1.6.4) 9 | rack-protection (1.5.3) 10 | rack 11 | rack-test (0.6.3) 12 | rack (>= 1.0) 13 | sinatra (1.4.7) 14 | rack (~> 1.5) 15 | rack-protection (~> 1.4) 16 | tilt (>= 1.3, < 3) 17 | thor (0.19.1) 18 | tilt (2.0.5) 19 | timecop (0.6.3) 20 | 21 | PLATFORMS 22 | ruby 23 | 24 | DEPENDENCIES 25 | dalli 26 | foreman 27 | minitest 28 | rack 29 | rack-test 30 | sinatra 31 | timecop 32 | 33 | BUNDLED WITH 34 | 1.12.5 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tem café? 2 | 3 | bot pra slack que avisa se tem café. 4 | 5 | ## como usa? 6 | 7 | no canal do teu escritório, manda 8 | 9 | /cafe tem 10 | pra saber se tem café 11 | 12 | /cafe fiz 13 | pra avisar que você fez café 14 | 15 | /cafe cabou 16 | pra avisar que acabou o café 17 | 18 | ## como instala? 19 | 20 | 1. faz deploy no heroku 21 | 2. instala addon memcachier 22 | 3. configura no [slack](http://my.slack.com/services/new/slash-commands) 23 | 4. configura variável SLACK_TOKEN pro token que o slack te teu 24 | 5. parte pro abraço 25 | 26 | ## como ajuda? 27 | 28 | manda pr aí. tá faltando: 29 | 30 | - testes 31 | - ~~fazendo (ideia: diz 'fazendo' por x minutos depois do /cafe fiz)~~ valeu [@andrezacm](http://github.com/andrezacm) 32 | - botão ~deploy no heroku~ 33 | - um deploy funcionar pra vários times 34 | - dar um jeito no heroku botando pra dormir (acorda a cada hora durante expediente?) 35 | - ~~um whitelist de métodos no lugar de send (/cafe object_id funciona)~~ valeu [@thiagoa](http://github.com/thiagoa) e [@iagomoreira](http://github.com/iagomoreira) 36 | - e mais e mais coisa. 37 | -------------------------------------------------------------------------------- /command_parser.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | class CommandParser 4 | def initialize(whitelist:) 5 | @whitelist = Whitelist.new(whitelist) 6 | end 7 | 8 | def call(command) 9 | @whitelist.each do |action_definition| 10 | args = decompose_command(*action_definition, command) 11 | return args if args 12 | end 13 | 14 | nil 15 | end 16 | 17 | private 18 | 19 | def decompose_command(action_name, action_regex, command) 20 | return unless command =~ action_regex 21 | 22 | arguments = command.gsub(action_regex, '').strip 23 | arguments = nil if arguments.empty? 24 | 25 | [action_name, *arguments] 26 | end 27 | 28 | class Whitelist 29 | extend Forwardable 30 | 31 | delegate each: :@whitelist 32 | 33 | def initialize(whitelist) 34 | @whitelist = whitelist.map { |action| parse_action(action) } 35 | end 36 | 37 | private 38 | 39 | def parse_action(action_name) 40 | [action_name, /\A#{build_action_regex(action_name)}/] 41 | end 42 | 43 | def build_action_regex(action_name) 44 | Regexp.escape(action_name).to_s.gsub(/[-_\s]/, '[-_\s]') 45 | end 46 | end 47 | 48 | private_constant :Whitelist 49 | end 50 | -------------------------------------------------------------------------------- /tem_cafe.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | require 'dalli' 3 | require 'json' 4 | require_relative 'cafe' 5 | require_relative 'cafe_dispatcher' 6 | require_relative 'command_parser' 7 | 8 | class TemCafe < Sinatra::Base 9 | COMMAND_PARSER = CommandParser.new( 10 | whitelist: %i(fiz tem? tem cabou cabo comofaz :middle_finger: 🖕 caboquejo) 11 | ) 12 | 13 | set :cache, Dalli::Client.new(ENV["MEMCACHIER_SERVERS"], 14 | {:username => ENV["MEMCACHIER_USERNAME"], 15 | :password => ENV["MEMCACHIER_PASSWORD"]}) 16 | 17 | set :token, ENV["SLACK_TOKEN"] 18 | set :show_exceptions, false 19 | 20 | before do 21 | token = settings.token 22 | halt 401, "Opa, também não é assim" if !token || params['token'] != settings.token 23 | end 24 | 25 | error CafeDispatcher::ActionNotFound do 26 | halt 500, "Ih, deu ruim" 27 | end 28 | 29 | post '/' do 30 | @cafe = settings.cache.get(params['channel_id']) || Cafe.new 31 | dispatcher = CafeDispatcher.new(@cafe, command_parser: COMMAND_PARSER) 32 | response = dispatcher.call(params['text']) 33 | 34 | settings.cache.set(params['channel_id'], @cafe) 35 | 36 | content_type "application/json" 37 | 38 | { "response_type": "in_channel", "text": response }.to_json 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /command_parser_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require_relative 'command_parser' 3 | 4 | class CommandParserTest < Minitest::Test 5 | def test_returns_parsed_command_when_main_command_of_raw_command_is_present_in_whitelist 6 | parser = CommandParser.new(whitelist: [:fiz]) 7 | 8 | assert_equal [:fiz], parser.call('fiz') 9 | end 10 | 11 | def test_returns_parsed_command_when_raw_command_matches_whitelist_but_has_spaces 12 | parser = CommandParser.new(whitelist: [:fez_cafe]) 13 | 14 | assert_equal [:fez_cafe], parser.call('fez cafe') 15 | end 16 | 17 | def test_returns_parsed_command_when_raw_command_matches_whitelist_but_has_dashes 18 | parser = CommandParser.new(whitelist: [:fez_cafe]) 19 | 20 | assert_equal [:fez_cafe], parser.call('fez-cafe') 21 | end 22 | 23 | def test_returns_parsed_command_when_main_command_of_raw_command_has_question_mark 24 | parser = CommandParser.new(whitelist: [:fez?]) 25 | 26 | assert_equal [:fez?], parser.call('fez?') 27 | end 28 | 29 | def test_returns_parsed_command_with_arguments_when_raw_command_has_arguments 30 | parser = CommandParser.new(whitelist: [:quem_faz]) 31 | 32 | assert_equal [:quem_faz, 'joao, maria'], parser.call('quem faz joao, maria') 33 | end 34 | 35 | def test_does_not_return_parsed_command_when_main_command_of_raw_command_is_not_at_the_start 36 | parser = CommandParser.new(whitelist: [:fiz]) 37 | 38 | assert_nil parser.call('fez fiz') 39 | assert_nil parser.call('ffiz') 40 | end 41 | 42 | def test_does_not_return_parsed_command_when_main_command_of_raw_command_is_not_present_in_whitelist 43 | parser = CommandParser.new(whitelist: [:fez]) 44 | 45 | assert_nil parser.call('fiz') 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /tem_cafe_test.rb: -------------------------------------------------------------------------------- 1 | ENV['RACK_ENV'] = 'test' 2 | require 'minitest/autorun' 3 | require 'rack/test' 4 | require 'dalli' 5 | 6 | require './tem_cafe' 7 | 8 | class TemCafeTest < Minitest::Test 9 | 10 | include Rack::Test::Methods 11 | 12 | def setup 13 | TemCafe.settings.token = 'my_token' 14 | end 15 | 16 | def teardown 17 | TemCafe.settings.token = nil 18 | end 19 | 20 | def app 21 | TemCafe 22 | end 23 | 24 | def test_nao_permite_acesso_sem_token_configurado 25 | TemCafe.settings.token = nil 26 | 27 | post '/', channel_id: "channel-id", text: "tem" 28 | assert last_response.status == 401 29 | end 30 | 31 | def test_nao_permite_acesso_com_token_errado 32 | TemCafe.settings.token = 'my_token' 33 | 34 | post '/', channel_id: "channel-id", text: "tem", token: "errado" 35 | assert last_response.status == 401 36 | end 37 | 38 | def test_permite_acesso_com_token_correto_e_retorna_corretamente 39 | post '/', channel_id: "channel-id", text: "tem", token: "my_token" 40 | 41 | assert last_response.status == 200 42 | response = JSON.parse(last_response.body) 43 | assert_equal 'in_channel', response['response_type'] 44 | refute response['text'].nil? 45 | end 46 | 47 | def test_integracao 48 | post '/', channel_id: "channel-id", text: "fiz", token: "my_token" 49 | response = JSON.parse(last_response.body) 50 | 51 | respostas = [ 52 | "Opa, café tá fazendo!", 53 | "papai gosta, papai" 54 | ] 55 | 56 | assert respostas.include?(response['text']) 57 | 58 | post '/', channel_id: "channel-id", text: "tem?", token: "my_token" 59 | response = JSON.parse(last_response.body) 60 | 61 | assert_match /\AFazendo/, response['text'] 62 | end 63 | 64 | def test_nao_permite_metodos_fora_da_whitelist 65 | post '/', channel_id: "channel-id", text: "object_id", token: "my_token" 66 | assert last_response.status == 500 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /cafe.rb: -------------------------------------------------------------------------------- 1 | class Cafe 2 | DEMORA_MAIS_OU_MENOS = 4 3 | QUATRO_HORAS = 3_600 * 4 4 | 5 | def fiz 6 | @cabou_em = nil 7 | @feito_em = Time.now 8 | 9 | [ 10 | "Opa, café tá fazendo!", 11 | "papai gosta, papai" 12 | ].sample 13 | end 14 | 15 | def tem?; tem; end 16 | def tem 17 | if @feito_em && !feito_hoje? 18 | cabou 19 | "Não :( Alguém tem que fazer o de hoje" 20 | elsif @feito_em && fazendo? 21 | "Fazendo..." 22 | elsif @feito_em && !velho? 23 | "Tem :) Feito as #{@feito_em.strftime("%H:%M")}" 24 | elsif @feito_em && velho? 25 | "Tem mas tá velho, feito em #{@feito_em.strftime("%H:%M")} :( Vai lá e faz teu nome" 26 | elsif @cabou_em 27 | if cabou_hoje? 28 | "Não :( Cabou as #{@cabou_em.strftime("%H:%M")}" 29 | else 30 | "Não :( Alguém tem que fazer o de hoje" 31 | end 32 | else 33 | "Ixi, nem sei. Veja e me diga" 34 | end 35 | end 36 | 37 | private def feito_hoje? 38 | hoje?(@feito_em) 39 | end 40 | 41 | private def cabou_hoje? 42 | hoje?(@cabou_em) 43 | end 44 | 45 | private def hoje?(time) 46 | Time.now.to_a.slice(4, 3) == time.to_a.slice(4, 3) 47 | end 48 | 49 | private def velho? 50 | (Time.now - @feito_em) >= QUATRO_HORAS 51 | end 52 | 53 | def cabou; cabo; end 54 | def cabo 55 | @feito_em = nil 56 | @cabou_em = Time.now 57 | 58 | "Ih, cabou café :(" 59 | end 60 | 61 | def caboquejo 62 | ["CARACA :O", "alguém levou pra casa só pode"].sample 63 | end 64 | 65 | def comofaz 66 | <<-RECEITA 67 | Pra um cafézinho forte estilo huebr, 1 colher bem cheia pra cada 3 xícaras. 68 | Pra um café mais fraco estilo 'murica, 1 colher bem cheia pra cada 5 xícaras. 69 | Se vai botar açucar então foda-se faz aí de qualquer jeito mesmo. 70 | RECEITA 71 | end 72 | 73 | define_method(':middle_finger:') do 🖕 end 74 | def 🖕 75 | [ 76 | "é o teu", 77 | "sai daí porra", 78 | "vai tu", 79 | "__|__", 80 | "👉👌" 81 | ].sample 82 | end 83 | 84 | private 85 | 86 | def feito_a_quanto_tempo 87 | ((Time.now - @feito_em) / 60).to_i 88 | end 89 | 90 | def fazendo? 91 | feito_a_quanto_tempo < DEMORA_MAIS_OU_MENOS 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /cafe_dispatcher_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'ostruct' 3 | require_relative 'cafe_dispatcher' 4 | 5 | class CafeDispatcherTest < Minitest::Test 6 | class CommandParserFake 7 | def initialize(returns:) 8 | @returns = returns 9 | end 10 | 11 | def call(command) 12 | @returns 13 | end 14 | end 15 | 16 | def test_fails_when_args_returnd_by_command_parser_is_nil 17 | cafe = Object.new 18 | command_parser = CommandParserFake.new(returns: nil) 19 | dispatcher = CafeDispatcher.new(cafe, command_parser: command_parser) 20 | 21 | assert_raises(CafeDispatcher::ActionNotFound) { dispatcher.call('fiz') } 22 | end 23 | 24 | def test_fails_when_args_returned_by_command_parser_have_wrong_arity 25 | cafe = Object.new 26 | cafe.define_singleton_method(:quem_faz) { |one_argument| } 27 | command_parser = CommandParserFake.new(returns: [:quem_faz]) 28 | dispatcher = CafeDispatcher.new(cafe, command_parser: command_parser) 29 | 30 | assert_raises(CafeDispatcher::ActionNotFound) { dispatcher.call('quem_faz') } 31 | end 32 | 33 | def test_dispatches_to_cafe_when_command_parser_returns_args_with_no_options 34 | cafe = Object.new 35 | cafe.define_singleton_method(:quem_faz?) { 'verify_me' } 36 | command_parser = CommandParserFake.new(returns: [:quem_faz?]) 37 | dispatcher = CafeDispatcher.new(cafe, command_parser: command_parser) 38 | 39 | response = dispatcher.call('quem faz?') 40 | 41 | assert_equal 'verify_me', response 42 | end 43 | 44 | def test_dispatches_to_cafe_when_command_parser_returns_args_with_options 45 | cafe = Object.new 46 | cafe.define_singleton_method(:quem_faz) { |args| 'verify_me' } 47 | command_parser = CommandParserFake.new(returns: ['quem_faz', 'joao maria']) 48 | dispatcher = CafeDispatcher.new(cafe, command_parser: command_parser) 49 | 50 | response = dispatcher.call('quem faz') 51 | 52 | assert_equal 'verify_me', response 53 | end 54 | 55 | def test_returns_cafe_response_when_dispatched 56 | cafe = Object.new 57 | cafe.define_singleton_method(:quem_faz) { |names| 'OK, anotado' } 58 | command_parser = CommandParserFake.new(returns: ['quem_faz', 'joao maria']) 59 | dispatcher = CafeDispatcher.new(cafe, command_parser: command_parser) 60 | 61 | response = dispatcher.call('quem faz') 62 | 63 | assert_equal 'OK, anotado', response 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /cafe_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'timecop' 3 | require './cafe' 4 | 5 | class CafeTest < Minitest::Test 6 | def setup 7 | @cafe = Cafe.new 8 | end 9 | 10 | def test_quando_fiz_ta_fazendo_e_demora_4_minutos 11 | agora = Time.now 12 | 13 | Timecop.freeze agora do 14 | respostas = [ 15 | "Opa, café tá fazendo!", 16 | "papai gosta, papai" 17 | ] 18 | 19 | assert respostas.include? @cafe.fiz 20 | end 21 | 22 | tres_minutos_no_futuro = agora + (3 * 60) 23 | 24 | Timecop.freeze tres_minutos_no_futuro do 25 | assert_equal "Fazendo...", @cafe.tem 26 | end 27 | end 28 | 29 | def test_quando_fiz_ta_pronto_depois_de_4_minutos 30 | agora = Time.now 31 | 32 | Timecop.freeze(agora) { @cafe.fiz } 33 | 34 | quatro_minutos_no_futuro = agora + (4 * 60) 35 | 36 | Timecop.freeze quatro_minutos_no_futuro do 37 | assert @cafe.tem.include? agora.strftime('%H:%M') 38 | assert @cafe.tem?.include? agora.strftime('%H:%M') 39 | end 40 | end 41 | 42 | def test_quando_fiz_tem_se_for_no_mesmo_dia 43 | agora = Time.new(2016, 8, 1) 44 | 45 | Timecop.freeze(agora) { @cafe.fiz } 46 | 47 | fim_do_dia = Time.new(2016, 8, 1, 3, 59, 59) 48 | 49 | Timecop.freeze fim_do_dia do 50 | assert @cafe.tem.include? agora.strftime('%H:%M') 51 | assert @cafe.tem?.include? agora.strftime('%H:%M') 52 | refute_includes(@cafe.tem?, 'velho') 53 | end 54 | end 55 | 56 | def test_tem_mas_ta_velho_depois_de_4_horas 57 | agora = Time.new(2016, 8, 1) 58 | 59 | Timecop.freeze(agora) { @cafe.fiz } 60 | 61 | quatro_horas_depois = Time.new(2016, 8, 1, 4, 1, 0) 62 | 63 | Timecop.freeze quatro_horas_depois do 64 | assert_includes(@cafe.tem?, 'velho') 65 | end 66 | end 67 | 68 | def test_quando_cabou_nao_tem 69 | time = Time.now.strftime("%H:%M") 70 | 71 | assert_equal "Ih, cabou café :(", @cafe.cabou 72 | assert_equal "Ih, cabou café :(", @cafe.cabo 73 | assert @cafe.tem.include? time 74 | end 75 | 76 | def test_quando_fez_ontem_nao_tem 77 | ontem = Time.new(2016, 8, 1) 78 | 79 | Timecop.freeze(ontem) { @cafe.fiz } 80 | 81 | hoje = Time.new(2016, 8, 2) 82 | 83 | Timecop.freeze hoje do 84 | assert_match(/Não :\(/, @cafe.tem?) 85 | end 86 | end 87 | 88 | def test_quando_cabou_ontem_alguem_tem_que_fazer_o_de_hoje 89 | ontem = Time.new(2016, 9, 9) 90 | 91 | Timecop.freeze(ontem) do 92 | @cafe.fiz 93 | @cafe.cabou 94 | end 95 | 96 | hoje = Time.new(2016, 9, 10) 97 | 98 | Timecop.freeze hoje do 99 | assert_match(/Não :\(/, @cafe.tem?) 100 | end 101 | end 102 | 103 | def test_quando_fez_mes_passado_no_mesmo_dia_nao_tem 104 | mes_passado = Time.new(2016, 7, 1) 105 | 106 | Timecop.freeze(mes_passado) { @cafe.fiz } 107 | 108 | esse_mes_no_mesmo_dia = Time.new(2016, 8, 1) 109 | 110 | Timecop.freeze esse_mes_no_mesmo_dia do 111 | assert_match(/Não :\(/, @cafe.tem?) 112 | end 113 | end 114 | 115 | def test_quando_fez_ano_passado_no_mesmo_mes_e_dia_nao_tem 116 | ano_passado = Time.new(2015, 8, 1) 117 | 118 | Timecop.freeze(ano_passado) { @cafe.fiz } 119 | 120 | esse_ano_no_mesmo_dia_e_mes = Time.new(2016, 8, 1) 121 | 122 | Timecop.freeze esse_ano_no_mesmo_dia_e_mes do 123 | assert_match(/Não :\(/, @cafe.tem?) 124 | end 125 | end 126 | 127 | def test_quando_nao_sabe_nao_sabe 128 | assert_equal "Ixi, nem sei. Veja e me diga", @cafe.tem 129 | end 130 | 131 | def test_comofaz 132 | receita = <<-RECEITA 133 | Pra um cafézinho forte estilo huebr, 1 colher bem cheia pra cada 3 xícaras. 134 | Pra um café mais fraco estilo 'murica, 1 colher bem cheia pra cada 5 xícaras. 135 | Se vai botar açucar então foda-se faz aí de qualquer jeito mesmo. 136 | RECEITA 137 | 138 | assert_equal receita, @cafe.comofaz 139 | end 140 | 141 | def test_🖕 142 | # sim, tô ligado que isso não é como se testa algo random 143 | xingamentos = [ 144 | "é o teu", 145 | "sai daí porra", 146 | "vai tu", 147 | "__|__", 148 | "👉👌" 149 | ] 150 | assert xingamentos.include? @cafe.public_send("🖕") 151 | assert xingamentos.include? @cafe.public_send(":middle_finger:") 152 | end 153 | end 154 | --------------------------------------------------------------------------------