diff --git a/Gemfile b/Gemfile index df09d2e..77421b9 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ gem 'rest-client' gem 'sentry-ruby' gem 'slack-ruby-client' gem 'twitter' +gem 'discordrb' # browser automation gem 'ferrum' diff --git a/Gemfile.lock b/Gemfile.lock index 2753099..3bb8d7f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,9 +7,18 @@ GEM cliver (0.3.2) concurrent-ruby (1.1.8) diff-lcs (1.4.4) + discordrb (3.4.0) + discordrb-webhooks (~> 3.3.0) + ffi (>= 1.9.24) + opus-ruby + rest-client (>= 2.0.0) + websocket-client-simple (>= 0.3.0) + discordrb-webhooks (3.3.0) + rest-client (>= 2.1.0.rc1) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) equalizer (0.0.11) + event_emitter (0.2.6) faraday (1.3.0) faraday-net_http (~> 1.0) multipart-post (>= 1.2, < 3) @@ -23,6 +32,7 @@ GEM concurrent-ruby (~> 1.1) websocket-driver (>= 0.6, < 0.8) ffi (1.15.0) + ffi (1.15.0-x64-mingw32) ffi-compiler (1.0.1) ffi (>= 1.0.0) rake @@ -52,6 +62,10 @@ GEM nokogiri (1.11.2) mini_portile2 (~> 2.5.0) racc (~> 1.4) + nokogiri (1.11.2-x64-mingw32) + racc (~> 1.4) + opus-ruby (1.0.1) + ffi public_suffix (4.0.6) racc (1.5.2) rake (13.0.3) @@ -61,6 +75,12 @@ GEM http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) + rest-client (2.1.0-x64-mingw32) + ffi (~> 1.9) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) rspec (3.10.0) rspec-core (~> 3.10.0) rspec-expectations (~> 3.10.0) @@ -104,14 +124,21 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.7.7) + unf_ext (0.0.7.7-x64-mingw32) + websocket (1.2.9) + websocket-client-simple (0.3.0) + event_emitter + websocket websocket-driver (0.7.3) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) PLATFORMS ruby + x64-mingw32 DEPENDENCIES + discordrb ferrum nokogiri redis @@ -122,4 +149,4 @@ DEPENDENCIES twitter BUNDLED WITH - 2.2.3 + 2.2.16 diff --git a/README.md b/README.md index 95bf911..41a6e46 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ runtime. To enable tweeting, provide the following: * TWITTER_CONSUMER_KEY * TWITTER_CONSUMER_SECRET -This bot can also be configured to send slack notifications to a channel by +This bot can also be configured to send Slack notifications to a channel by setting the following environment variables: * SLACK_API_TOKEN @@ -99,6 +99,11 @@ setting the following environment variables: * SLACK_USERNAME * SLACK_ICON +It can also send Discord notifications to a channel by setting the following +environment variables: + +* DISCORD_TOKEN + Additional configuration can be done with the following: * SENTRY_DSN - sets up error handling with [Sentry](https://sentry.io) diff --git a/lib/discord.rb b/lib/discord.rb new file mode 100644 index 0000000..2bef72c --- /dev/null +++ b/lib/discord.rb @@ -0,0 +1,53 @@ +require 'discordrb/webhooks' + +class FakeDiscord + + def initialize(logger) + @logger = logger + end + + def update(str) + @logger.info "[FakeDiscord]: #{str}" + end + +class DiscordClient + + def initialize(logger) + @logger = logger + @discord = if ENV['ENVIRONMENT'] != 'test' && env_keys_exist? + Discordrb::Webhooks::Client.new(url: ENV['DISCORD_WEBHOOK_URL']).freeze + end + end + + def env_keys_exist? + ENV['DISCORD_WEBHOOK_URL'] + end + + def send(clinic) + @logger.info "[DiscordClient] Sending message for #{clinic.title} (#{clinic.new_appointments} new appointments)" + text = clinic.discord_text + if text.is_a?(Array) + text.each { |t| @discord.execute do |builder| + builder.content = text + end + } + end + @discord.execute do |builder| + builder.content = text + end + end + + rescue => e + @logger.error "[DiscordClient] error: #{e}" + raise e unless ENV['ENVIRONMENT'] == 'production' || ENV['ENVIRONMENT'] == 'staging' + + Sentry.capture_exception(e) + end + + def post(clinics) + clinics.filter(&:should_discord_message?).each do |clinic| + send(clinic) + clinic.save_message_time + end + end +end \ No newline at end of file diff --git a/lib/twitter.rb b/lib/twitter.rb index 233eff4..4324a4e 100644 --- a/lib/twitter.rb +++ b/lib/twitter.rb @@ -41,6 +41,7 @@ def tweet(clinic) else @twitter.update(text) end + end rescue => e @logger.error "[TwitterClient] error: #{e}" @@ -54,5 +55,4 @@ def post(clinics) tweet(clinic) clinic.save_tweet_time end - end -end + end \ No newline at end of file diff --git a/run.rb b/run.rb index 1f93f45..bce1e98 100644 --- a/run.rb +++ b/run.rb @@ -5,6 +5,7 @@ require_relative 'lib/storage' require_relative 'lib/slack' require_relative 'lib/twitter' +require_relative './lib/discord' # Sites require_relative 'lib/sites/ma_immunizations' @@ -98,6 +99,7 @@ def main(opts) storage = Storage.new slack = SlackClient.new(logger) twitter = TwitterClient.new(logger) + discord = DiscordClient.new(logger) logger.info "[Main] Update frequency is set to every #{UPDATE_FREQUENCY} seconds" @@ -115,6 +117,7 @@ def main(opts) all_clinics(storage, logger, **opts) do |clinics| slack.post(clinics) twitter.post(clinics) + discord.post(clinics) clinics.each(&:save_appointments) end diff --git a/spec/discord_spec.rb b/spec/discord_spec.rb new file mode 100644 index 0000000..8fe2d93 --- /dev/null +++ b/spec/discord_spec.rb @@ -0,0 +1,80 @@ +require 'logger' +require_relative '../lib/discord' +require_relative './mock_clinic' + +describe DiscordClient do + let(:discord) { DiscordClient.new(Logger.new('/dev/null')) } + + describe '#discord' do + it 'calls the discord "update" method' do + mock_discord = double('Discord') + mock_clinic = double('Clinic', title: 'Test clinic', new_appointments: 1) + expect(FakeDiscord).to receive(:new).and_return(mock_discord) + expect(mock_discord).to receive(:update).with('test message') + expect(mock_clinic).to receive(:discord_text).and_return('test message') + discord.send(mock_clinic) + end + end + + describe '#should_discord_message?' do + it 'returns true if the clinic has more than 10 new appointments' do + mock_clinic = MockClinic.new(appointments: 100, new_appointments: 100) + expect(mock_clinic.should_discord_message?).to be_truthy + end + + it 'returns false if the clinic has no link' do + mock_clinic = MockClinic.new(appointments: 100, new_appointments: 100, link: nil) + expect(mock_clinic.should_discord_message?).to be_falsy + end + + it 'returns false if the clinic has fewer than 10 appointments' do + mock_clinic = MockClinic.new(appointments: 9, new_appointments: 100) + expect(mock_clinic.should_discord_message?).to be_falsy + end + + it 'returns false if the clinic has fewer than 5 new appointments' do + mock_clinic = MockClinic.new(appointments: 100, new_appointments: 4) + expect(mock_clinic.should_discord_message?).to be_falsy + end + + it 'returns false if the clinic has posted recently' do + mock_clinic = MockClinic.new(appointments: 100, new_appointments: 100, last_posted_time: (Time.now - 60).to_s) + expect(mock_clinic.should_discord_message?).to be_falsy + end + end + + describe '#post' do + it 'only sends about clinics that should post' do + valid_clinic = MockClinic.new(appointments: 100, new_appointments: 100) + invalid_clinic = MockClinic.new(appointments: 0, new_appointments: 0) + expect(discord).to receive(:message).with(valid_clinic) + expect(discord).not_to receive(:message).with(invalid_clinic) + expect(valid_clinic).to receive(:save_message_time) + expect(invalid_clinic).not_to receive(:save_message_time) + discord.post([valid_clinic, invalid_clinic]) + end + + it "doesn't care about the clinic order" do + valid_clinic = MockClinic.new(appointments: 100, new_appointments: 100) + invalid_clinic = MockClinic.new(appointments: 0, new_appointments: 0) + expect(discord).to receive(:message).with(valid_clinic) + expect(discord).not_to receive(:message).with(invalid_clinic) + expect(valid_clinic).to receive(:save_message_time) + expect(invalid_clinic).not_to receive(:save_message_time) + discord.post([invalid_clinic, valid_clinic]) + end + + it 'works with no clinics' do + expect { discord.post([]) }.not_to raise_exception + end + end + + describe '#discord_text' do + it 'posts about appointments with a link' do + mock_clinic = MockClinic.new(title: 'myclinic', appointments: 100, new_appointments: 20) + expect(mock_clinic.discord_text).to eq( + '100 appointments available at myclinic. Check eligibility and sign up at clinicsite.com' + ) + end + end +end