├── .gitignore ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── README.md ├── app ├── adapters │ ├── slack_events.rb │ └── slack_socket_mode.rb ├── initialize.rb ├── interaction_support │ ├── checklist.rb │ ├── common_checks.rb │ ├── has_checklist.rb │ ├── interaction.rb │ ├── slack_helpers.rb │ └── slash_command.rb ├── interactions │ ├── README.md │ ├── cdn.rb │ ├── dino_test.rb │ ├── haiku │ │ ├── disable_haiku.rb │ │ ├── enable_haiku.rb │ │ └── notice_poetry.rb │ ├── not_canon.rb │ ├── raise_error.rb │ └── thump_thump.rb ├── lib │ ├── haiku_check.rb │ ├── haiku_check │ │ ├── syllable_counts.txt │ │ └── syllable_estimator.rb │ ├── mock_kv.rb │ └── transcript.rb ├── orpheus.rb └── orpheus │ └── event_handling.rb ├── bin └── rackup ├── boot.rb ├── config.ru ├── cruft ├── errors.rb └── utils.rb ├── honeybadger.yml └── transcript_data.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .env* 2 | mock_kv_store.txt 3 | **/.DS_Store 4 | .DS_Store -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | from 24c02/snarf:latest 2 | workdir /app 3 | 4 | copy Gemfile Gemfile.lock /app/ 5 | run bundle install 6 | 7 | copy . /app 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "zeitwerk", "~> 2.7" 6 | 7 | gem "activesupport", "~> 8.0" 8 | 9 | gem "slack-ruby-client", "~> 2.5" 10 | 11 | gem "sinatra", "~> 4.1" 12 | 13 | gem "puma", "~> 6.6" 14 | 15 | gem "rackup", "~> 2.2" 16 | 17 | gem "dotenv", "~> 3.1", :group => :development 18 | 19 | gem "slack-ruby-socket-mode-bot", "~> 0.3.0", :group => :development 20 | 21 | gem "sinatra-contrib", "~> 4.1" 22 | 23 | gem "awesome_print", "~> 1.9" 24 | 25 | gem "redis", "~> 5.4" 26 | 27 | gem "connection_pool", "~> 2.5" 28 | 29 | gem "syllables" 30 | 31 | gem "humanize" 32 | 33 | 34 | gem "dalli", "~> 3.2" 35 | 36 | gem "honeybadger", "~> 5.28" 37 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (8.0.2) 5 | base64 6 | benchmark (>= 0.3) 7 | bigdecimal 8 | concurrent-ruby (~> 1.0, >= 1.3.1) 9 | connection_pool (>= 2.2.5) 10 | drb 11 | i18n (>= 1.6, < 2) 12 | logger (>= 1.4.2) 13 | minitest (>= 5.1) 14 | securerandom (>= 0.3) 15 | tzinfo (~> 2.0, >= 2.0.5) 16 | uri (>= 0.13.1) 17 | async (2.23.1) 18 | console (~> 1.29) 19 | fiber-annotation 20 | io-event (~> 1.9) 21 | metrics (~> 0.12) 22 | traces (~> 0.15) 23 | async-http (0.88.0) 24 | async (>= 2.10.2) 25 | async-pool (~> 0.9) 26 | io-endpoint (~> 0.14) 27 | io-stream (~> 0.6) 28 | metrics (~> 0.12) 29 | protocol-http (~> 0.49) 30 | protocol-http1 (~> 0.30) 31 | protocol-http2 (~> 0.22) 32 | traces (~> 0.10) 33 | async-pool (0.10.3) 34 | async (>= 1.25) 35 | async-websocket (0.30.0) 36 | async-http (~> 0.76) 37 | protocol-http (~> 0.34) 38 | protocol-rack (~> 0.7) 39 | protocol-websocket (~> 0.17) 40 | awesome_print (1.9.2) 41 | base64 (0.2.0) 42 | benchmark (0.4.0) 43 | bigdecimal (3.1.9) 44 | concurrent-ruby (1.3.5) 45 | connection_pool (2.5.0) 46 | console (1.30.2) 47 | fiber-annotation 48 | fiber-local (~> 1.1) 49 | json 50 | dalli (3.2.8) 51 | dotenv (3.1.7) 52 | drb (2.2.1) 53 | faraday (2.12.2) 54 | faraday-net_http (>= 2.0, < 3.5) 55 | json 56 | logger 57 | faraday-mashify (1.0.0) 58 | faraday (~> 2.0) 59 | hashie 60 | faraday-multipart (1.1.0) 61 | multipart-post (~> 2.0) 62 | faraday-net_http (3.4.0) 63 | net-http (>= 0.5.0) 64 | fiber-annotation (0.2.0) 65 | fiber-local (1.1.0) 66 | fiber-storage 67 | fiber-storage (1.0.0) 68 | gli (2.22.2) 69 | ostruct 70 | hashie (5.0.0) 71 | honeybadger (5.28.0) 72 | logger 73 | ostruct 74 | humanize (3.1.0) 75 | i18n (1.14.7) 76 | concurrent-ruby (~> 1.0) 77 | io-endpoint (0.15.2) 78 | io-event (1.10.0) 79 | io-stream (0.6.1) 80 | json (2.10.2) 81 | logger (1.7.0) 82 | metrics (0.12.2) 83 | minitest (5.25.5) 84 | multi_json (1.15.0) 85 | multipart-post (2.4.1) 86 | mustermann (3.0.3) 87 | ruby2_keywords (~> 0.0.1) 88 | net-http (0.6.0) 89 | uri 90 | nio4r (2.7.4) 91 | ostruct (0.6.1) 92 | protocol-hpack (1.5.1) 93 | protocol-http (0.49.0) 94 | protocol-http1 (0.34.0) 95 | protocol-http (~> 0.22) 96 | protocol-http2 (0.22.1) 97 | protocol-hpack (~> 1.4) 98 | protocol-http (~> 0.47) 99 | protocol-rack (0.11.2) 100 | protocol-http (~> 0.43) 101 | rack (>= 1.0) 102 | protocol-websocket (0.20.1) 103 | protocol-http (~> 0.2) 104 | puma (6.6.0) 105 | nio4r (~> 2.0) 106 | rack (3.1.12) 107 | rack-protection (4.1.1) 108 | base64 (>= 0.1.0) 109 | logger (>= 1.6.0) 110 | rack (>= 3.0.0, < 4) 111 | rack-session (2.1.0) 112 | base64 (>= 0.1.0) 113 | rack (>= 3.0.0) 114 | rackup (2.2.1) 115 | rack (>= 3) 116 | redis (5.4.0) 117 | redis-client (>= 0.22.0) 118 | redis-client (0.24.0) 119 | connection_pool 120 | ruby2_keywords (0.0.5) 121 | securerandom (0.4.1) 122 | sinatra (4.1.1) 123 | logger (>= 1.6.0) 124 | mustermann (~> 3.0) 125 | rack (>= 3.0.0, < 4) 126 | rack-protection (= 4.1.1) 127 | rack-session (>= 2.0.0, < 3) 128 | tilt (~> 2.0) 129 | sinatra-contrib (4.1.1) 130 | multi_json (>= 0.0.2) 131 | mustermann (~> 3.0) 132 | rack-protection (= 4.1.1) 133 | sinatra (= 4.1.1) 134 | tilt (~> 2.0) 135 | slack-ruby-client (2.5.2) 136 | faraday (>= 2.0) 137 | faraday-mashify 138 | faraday-multipart 139 | gli 140 | hashie 141 | logger 142 | slack-ruby-socket-mode-bot (0.3.0) 143 | async 144 | async-http 145 | async-websocket 146 | slack-ruby-client 147 | syllables (0.1.4) 148 | tilt (2.6.0) 149 | traces (0.15.2) 150 | tzinfo (2.0.6) 151 | concurrent-ruby (~> 1.0) 152 | uri (1.0.3) 153 | zeitwerk (2.7.2) 154 | 155 | PLATFORMS 156 | arm64-darwin-24 157 | ruby 158 | 159 | DEPENDENCIES 160 | activesupport (~> 8.0) 161 | awesome_print (~> 1.9) 162 | connection_pool (~> 2.5) 163 | dalli (~> 3.2) 164 | dotenv (~> 3.1) 165 | honeybadger (~> 5.28) 166 | humanize 167 | puma (~> 6.6) 168 | rackup (~> 2.2) 169 | redis (~> 5.4) 170 | sinatra (~> 4.1) 171 | sinatra-contrib (~> 4.1) 172 | slack-ruby-client (~> 2.5) 173 | slack-ruby-socket-mode-bot (~> 0.3.0) 174 | syllables 175 | zeitwerk (~> 2.7) 176 | 177 | BUNDLED WITH 178 | 2.6.2 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Orpheus 2 | a helpful dinosaur. 3 | (now in ruby!) 4 | 5 | if you need some functionality in the slack but can't be bothered to write a slackbot for it, throw it it in here! 6 | we'll end up with a lovely potpourri of nonsense that will surely be maintainable for years to come. 7 | 8 | [here's how to make her do things](app/interactions/README.md) 9 | 10 | ^ go read that if you're doing dev work! -------------------------------------------------------------------------------- /app/adapters/slack_events.rb: -------------------------------------------------------------------------------- 1 | require "sinatra/base" 2 | require "sinatra/custom_logger" 3 | require "json" 4 | require "slack-ruby-client" 5 | require 'honeybadger' 6 | 7 | module Adapters 8 | class SlackEvents 9 | attr_accessor :event_callback, :command_callback, :signing_secret 10 | 11 | def initialize(event_callback, command_callback, signing_secret) 12 | @event_callback = event_callback 13 | @command_callback = command_callback 14 | @signing_secret = signing_secret 15 | end 16 | 17 | def app 18 | @app ||= App.with( 19 | event_callback: event_callback, 20 | command_callback: command_callback, 21 | signing_secret: signing_secret, 22 | ) 23 | end 24 | 25 | class App < Sinatra::Base 26 | set :logger, Orpheus.logger 27 | 28 | # HEALTHCHECK: 29 | # doesn't test anything real, just sees if app booted 30 | get "/u_up?" do 31 | "not now heidi i'm at work" 32 | end 33 | 34 | def self.with(event_callback:, command_callback:, signing_secret:) 35 | Class.new(self) do 36 | set :event_callback, event_callback 37 | set :command_callback, command_callback 38 | set :signing_secret, signing_secret 39 | end 40 | end 41 | 42 | post "/slack_events_go_here" do 43 | verify_slack_request! 44 | data = JSON.parse(request.body.read, symbolize_names: true) 45 | 46 | case data[:type] 47 | when "url_verification" 48 | data[:challenge] 49 | when "event_callback" 50 | Thread.new do 51 | Honeybadger.context( 52 | transaction_name: "slack_event_#{data.dig(:event, :event_ts)}", 53 | ) do 54 | settings.event_callback.call(data) 55 | Honeybadger.context.clear! 56 | end 57 | end 58 | "ok!" 59 | end 60 | end 61 | 62 | post "/slash_commands_go_here" do 63 | verify_slack_request! 64 | Thread.new do 65 | Honeybadger.context( 66 | transaction_name: "slack_command_#{params[:command]}", 67 | ) do 68 | settings.command_callback.call(params) 69 | Honeybadger.context.clear! 70 | end 71 | end 72 | end 73 | 74 | error Slack::Events::Request::MissingSigningSecret do 75 | status 403 76 | "stop it. get some help." 77 | end 78 | 79 | error Slack::Events::Request::InvalidSignature do 80 | status 401 81 | "better luck next time!" 82 | end 83 | 84 | error Slack::Events::Request::TimestampExpired do 85 | status 418 86 | "huh?" 87 | end 88 | 89 | error 404 do 90 | <<~EOH 91 | 92 | 93 | 94 | 95 | *confused dinosaur noises* 96 | 97 | 98 | 111 | 112 | 113 | 114 | dino saur 115 | 116 | 117 | EOH 118 | end 119 | 120 | private 121 | 122 | def verify_slack_request! 123 | slack_request = Slack::Events::Request.new(request) 124 | slack_request.verify! 125 | end 126 | 127 | error do 128 | Honeybadger.notify(env["sinatra.error"]) 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /app/adapters/slack_socket_mode.rb: -------------------------------------------------------------------------------- 1 | require "slack-ruby-socket-mode-bot" 2 | 3 | module Adapters 4 | class SlackSocketMode 5 | attr_reader :app_token, :socket_mode_client, :callback 6 | 7 | def initialize(event_callback, command_callback, app_token) 8 | @event_callback = event_callback 9 | @command_callback = command_callback 10 | @app_token = app_token 11 | @socket_mode_client = SlackSocketModeBot::Transport::SocketModeClient.new( 12 | app_token:, 13 | logger: Orpheus.logger, 14 | callback: proc do |event| 15 | Honeybadger.context( 16 | transaction_name: "slack_socket_mode_#{event.dig(:event, :event_ts)}", 17 | ) do 18 | 19 | Honeybadger.add_breadcrumb( 20 | "Slack socket mode event", 21 | metadata: { 22 | type: event[:type], 23 | event_ts: event.dig(:event, :event_ts), 24 | data: event, 25 | }, 26 | category: "slack", 27 | ) 28 | 29 | if event[:command] 30 | command_callback.call(event) 31 | else 32 | event_callback.call(event) 33 | end 34 | end 35 | Honeybadger.context.clear! 36 | end, 37 | ) 38 | end 39 | 40 | def run! 41 | socket_mode_client.run! 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/initialize.rb: -------------------------------------------------------------------------------- 1 | module Initialize 2 | def self.do_it! 3 | require "redis" 4 | 5 | Orpheus.cache = if Orpheus.production? 6 | ActiveSupport::Cache::MemCacheStore.new(Utils.get_env!("MEMCACHED_URL")) 7 | else 8 | ActiveSupport::Cache::MemoryStore.new 9 | end 10 | 11 | Orpheus.kv ||= if Orpheus.production? 12 | ConnectionPool::Wrapper.new do 13 | Redis.new(url: Utils.get_env!("REDIS_URL")) 14 | end 15 | else 16 | MockKv.new 17 | end 18 | end 19 | 20 | require "honeybadger" 21 | end 22 | -------------------------------------------------------------------------------- /app/interaction_support/checklist.rb: -------------------------------------------------------------------------------- 1 | class Checklist 2 | include CommonChecks 3 | 4 | def initialize 5 | @checks = [] 6 | end 7 | 8 | attr_accessor :checks 9 | 10 | def empty? 11 | checks.empty? 12 | end 13 | 14 | def pass?(event) 15 | return true if empty? 16 | checks.all? { |check| check.call(event) } 17 | end 18 | 19 | def check(&block) 20 | @checks << block 21 | end 22 | end -------------------------------------------------------------------------------- /app/interaction_support/common_checks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CommonChecks 4 | def only_from_users(users) 5 | users = Array(users) 6 | check do |event| 7 | user = SlackHelpers.extract_user(event) 8 | users.include?(user) 9 | end 10 | end 11 | 12 | def only_in_channels(channels) 13 | channels = Array(channels) 14 | check do |event| 15 | channel = event[:channel] || event[:channel_id] 16 | channels.include?(channel) 17 | end 18 | end 19 | 20 | def admin_only 21 | check do |event| 22 | event.dig(:user, :is_admin) 23 | end 24 | end 25 | 26 | def message_text_matches(regex) 27 | check do |event| 28 | text = event[:text] 29 | regex.match?(text) 30 | end 31 | end 32 | 33 | def message_shorter_than(length) 34 | check do |event| 35 | event[:text]&.length&.< length 36 | end 37 | end 38 | 39 | def event_has_user 40 | check do |event| 41 | user = SlackHelpers.extract_user(event) 42 | !user.nil? 43 | end 44 | end 45 | 46 | alias_method :only_from_user, :only_from_users 47 | alias_method :only_in_channel, :only_in_channels 48 | end 49 | -------------------------------------------------------------------------------- /app/interaction_support/has_checklist.rb: -------------------------------------------------------------------------------- 1 | module HasChecklist 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | @_checklist = Checklist.new 6 | extend ClassMethods 7 | self.class.attr_reader :_checklist 8 | end 9 | 10 | module ClassMethods 11 | def checklist(&block) 12 | @_checklist ||= Checklist.new 13 | @_checklist.instance_eval(&block) 14 | end 15 | 16 | def checks_pass?(event) 17 | @_checklist ||= Checklist.new 18 | @_checklist.pass?(event) 19 | end 20 | end 21 | end -------------------------------------------------------------------------------- /app/interaction_support/interaction.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class Interaction 3 | include HasChecklist 4 | 5 | class << self 6 | include SlackHelpers 7 | 8 | def handle(type=nil, message_subtype: nil) 9 | Orpheus::EventHandling.register_interaction(type:, message_subtype:, interaction: self) 10 | end 11 | 12 | def call(event) 13 | Orpheus.logger.error "HEY! #{self.inspect} doesn't implement .call!" 14 | end 15 | end 16 | end 17 | 18 | -------------------------------------------------------------------------------- /app/interaction_support/slack_helpers.rb: -------------------------------------------------------------------------------- 1 | module SlackHelpers 2 | def react(channel, thread_ts, emoji) 3 | Orpheus.client.reactions_add(channel:, name: emoji, timestamp: thread_ts) 4 | end 5 | 6 | def remove_reaction(channel, thread_ts, emoji) 7 | Orpheus.client.reactions_remove(channel:, name: emoji, timestamp: thread_ts) 8 | rescue Slack::Web::Api::Errors::NoReaction 9 | # who cares 10 | end 11 | 12 | def react_to_message(message, emoji) 13 | channel, ts = extract_channel_and_ts(message) 14 | begin 15 | react(channel, ts, emoji) 16 | rescue Slack::Web::Api::Errors::AlreadyReacted => e 17 | # pick up a foot ball 18 | end 19 | end 20 | 21 | def remove_reaction_from_message(message, emoji) 22 | channel, ts = extract_channel_and_ts(message) 23 | remove_reaction(channel, ts, emoji) 24 | end 25 | 26 | # use with caution! 27 | def global_react(message, emoji) 28 | channel, ts = extract_channel_and_ts(message) 29 | begin 30 | react_to_message(message, emoji) 31 | rescue Slack::Web::Api::Errors::NotInChannel => e 32 | Orpheus.client.conversations_join(channel:) 33 | begin 34 | react_to_message(message, emoji) 35 | rescue Slack::Web::Api::Errors::NotInChannel 36 | Orpheus.logger.error("Failed to react to message #{ts} in channel #{channel}: #{e.message}") 37 | end 38 | end 39 | end 40 | 41 | def reply_in_thread(message, content = nil, **kwargs) 42 | channel, ts = extract_channel_and_ts(message) 43 | args = kwargs.merge(channel:, thread_ts: ts) 44 | 45 | if content.is_a?(String) 46 | args[:text] = content 47 | end 48 | 49 | if content.is_a?(Hash) 50 | args.merge!(content) 51 | end 52 | 53 | Orpheus.client.chat_postMessage(args) 54 | end 55 | 56 | def extract_channel_and_ts(event) 57 | [ 58 | event[:channel] || event[:channel_id], 59 | event[:event_ts], 60 | ] 61 | end 62 | 63 | def extract_user(event) 64 | event[:user_id] || 65 | (event[:user].is_a?(String) ? event[:user] : event.dig(:user, :id)) 66 | end 67 | 68 | def reply_ephemerally(message, content = nil, threaded: false, **kwargs) 69 | channel, ts = extract_channel_and_ts(message) 70 | args = kwargs.merge(channel:, user: extract_user(message)) 71 | 72 | if content.is_a?(String) 73 | args[:text] = content 74 | end 75 | 76 | if content.is_a?(Hash) 77 | args.merge!(content) 78 | end 79 | 80 | if threaded 81 | args[:thread_ts] = ts 82 | end 83 | 84 | Orpheus.client.chat_postEphemeral(args) 85 | end 86 | 87 | def respond_to_event(event, content = nil, in_channel: false, threaded: false, **kwargs) 88 | args = kwargs 89 | 90 | if content.is_a?(String) 91 | args[:text] = content 92 | end 93 | 94 | if content.is_a?(Hash) 95 | args.merge!(content) 96 | end 97 | 98 | if in_channel 99 | args[:response_type] = "in_channel" 100 | end 101 | 102 | if threaded 103 | args[:thread_ts] = event[:event_ts] 104 | end 105 | 106 | Faraday.new(url: event[:response_url]).post do |req| 107 | req.headers["Content-Type"] = "application/json" 108 | req.body = args.to_json 109 | end 110 | end 111 | 112 | def users_info_cached(user_id) 113 | Orpheus.cache.fetch("users_info_#{user_id}", expires_in: 1.hour) do 114 | Orpheus.client.users_info(user: user_id) 115 | end 116 | end 117 | 118 | def check_admin(user_id) 119 | users_info_cached(user_id).dig(:user, :is_admin) 120 | end 121 | 122 | module_function :react, :remove_reaction, :react_to_message, :remove_reaction_from_message, :global_react, :reply_in_thread, :extract_channel_and_ts, :extract_user, :reply_ephemerally, :respond_to_event, :users_info_cached, :check_admin 123 | end 124 | -------------------------------------------------------------------------------- /app/interaction_support/slash_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SlashCommand 4 | include HasChecklist 5 | 6 | class << self 7 | include SlackHelpers 8 | 9 | def command(command) 10 | Orpheus::EventHandling.register_command(command: command, interaction: self) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/interactions/README.md: -------------------------------------------------------------------------------- 1 | # writing interactions: 2 | you should do it! 3 | ### how? 4 | read on, but the first thing to do is drop a .rb in this very folder (name it according to Zeitwerk rules, `MostUsefulInteractionEver` should be in `most_useful_interaction_ever.rb`) 5 | if you follow the rest of the steps, everything should magically autoload and register ^_^ 6 | ### event handling: 7 | subclass `Interaction`, register a handler with `handle`: 8 | ```ruby 9 | handle :message # or any other Slack event type 10 | handle message_subtype: :channel_join # for subtypes of Message 11 | ``` 12 | 13 | ### slash commands: 14 | subclass `SlashCommand`, register a command with.... `command`. 15 | ```ruby 16 | class Foo < SlashCommand 17 | command "/bar" 18 | 19 | def self.call(event) 20 | reply_in_thread(event, "baz!!!") 21 | end 22 | end 23 | ``` 24 | ### doing stuff: 25 | both slash commands and interactions need to implement the class method `call(event)` – this gets passed the Slack event body when a relevant event fires. 26 | 27 | there are some helpers for common slack interaction stuff in [SlackHelpers](../interaction_support/slack_helpers.rb) included by default: 28 | ```ruby 29 | reply_in_thread(event, "") 30 | ``` 31 | if you're doing something more complicated, there's a `Slack::Web::Client` available at `Orpheus.client` like so: 32 | ```ruby 33 | Orpheus.client.usergroups_create(name: 'the-chosen-ones') 34 | ``` 35 | ### checklists: 36 | checklists decide whether an interaction runs for an event! 37 | 38 | make sure you add a checklist block... 39 | otherwise your interaction will run on every. single. event (of the type it handles). 40 | common checks live in [interaction_support/common_checks](../interaction_support/common_checks.rb), or you can write more specialized checks with `check`. 41 | the first one to say "no" short-circuits the rest and takes the handler out of the running, so it's smart to put your least expensive checks first for speed (i.e. check you're in the right channel before you check a regex match). 42 | 43 | ```ruby 44 | checklist do 45 | only_in_channel "C07ETBSJA7L" # use common checks, 46 | check do |event| # or write a specific one! 47 | event[:text].length > 50 48 | end 49 | end 50 | ``` 51 | 52 | ### transcript 53 | 54 | when she says things, please have her read them from the [Transcript](../../transcript_data.rb) to facilitate easy flavor rewriting. 55 | 56 | just `Orpheus.transcript(:root_key)` or `Orpheus.transcript "path.with.several.subkeys"`! -------------------------------------------------------------------------------- /app/interactions/cdn.rb: -------------------------------------------------------------------------------- 1 | require "faraday" 2 | require "json" 3 | 4 | class CDN < Interaction 5 | URL = "https://cdn.hackclub.com/api/v3/new" 6 | 7 | handle message_subtype: :file_share 8 | 9 | checklist do 10 | only_in_channel Utils.get_env!("CDN_CHANNEL") 11 | end 12 | 13 | class << self 14 | def upload_to_cdn(files) 15 | Orpheus.logger.info("[cdn] generating links for #{files.length} file(s)") 16 | 17 | file_urls = files.map { |f| f[:url_private] } 18 | 19 | response = Faraday.post(URL) do |req| 20 | req.headers["Content-Type"] = "application/json" 21 | req.headers["Authorization"] = "Bearer beans" 22 | req.headers["X-Download-Authorization"] = "Bearer #{Utils.get_env!("SLACK_BOT_TOKEN")}" 23 | req.body = file_urls.to_json 24 | end 25 | 26 | if response.success? 27 | JSON.parse(response.body) 28 | else 29 | raise "cdn returned error #{response.status}: #{response.body}" 30 | end 31 | end 32 | 33 | def call(event) 34 | files = event[:files] || [] 35 | 36 | begin 37 | ext_flavor_options = [Orpheus.transcript("fileShare.generic")] 38 | files.each do |file| 39 | begin 40 | ext_flavor_options << Orpheus.transcript("fileShare.extensions.#{file[:filetype]}") 41 | rescue TranscriptError 42 | next 43 | end 44 | end 45 | 46 | # Start CDN upload in a separate thread 47 | upload_thread = Thread.new { upload_to_cdn(files) } 48 | 49 | # Start with initial reactions and reply 50 | react_to_message(event, "beachball") 51 | reply_in_thread(event, ext_flavor_options.sample) 52 | 53 | # Wait for upload to complete 54 | cdn_links = upload_thread.value["files"].map { |file| file["deployedUrl"] } 55 | 56 | # Remove beachball and add success reactions 57 | remove_reaction_from_message(event, "beachball") 58 | react_to_message(event, "white_check_mark") 59 | 60 | # Send success message 61 | reply_in_thread( 62 | event, 63 | Orpheus.transcript("fileShare.success", { 64 | links: cdn_links.join("\n"), 65 | user: event[:user], 66 | }), 67 | unfurl_media: false, 68 | unfurl_links: false, 69 | ) 70 | rescue StandardError => err 71 | uuid = Honeybadger.notify(err) 72 | max_file_size = 100_000_000 73 | file_too_big = files.any? { |f| (f[:size] || 0) > max_file_size } 74 | 75 | if file_too_big 76 | reply_in_thread(event, Orpheus.transcript("fileShare.errorTooBig")) 77 | else 78 | reply_in_thread(event, Orpheus.transcript("fileShare.errorGeneric")) 79 | end 80 | 81 | remove_reaction_from_message(event, "beachball") 82 | react_to_message(event, "no_entry") 83 | reply_in_thread(event, Orpheus.transcript("errors.general", { err:, uuid: })) 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /app/interactions/dino_test.rb: -------------------------------------------------------------------------------- 1 | class DinoTest < SlashCommand 2 | command "/dino_test2" 3 | 4 | def self.call(event) 5 | reply_in_thread(event, Orpheus.transcript(:dino_test2)) 6 | end 7 | end -------------------------------------------------------------------------------- /app/interactions/haiku/disable_haiku.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Haiku 4 | class DisableHaiku < SlashCommand 5 | command "/disable-haiku" 6 | 7 | def self.call(event) 8 | Orpheus.kv.set("haiku_disabled_#{event[:user_id]}", true) 9 | respond_to_event(event, Orpheus.transcript("haiku.disabled")) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/interactions/haiku/enable_haiku.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Haiku 4 | class EnableHaiku < SlashCommand 5 | command "/enable-haiku" 6 | 7 | def self.call(event) 8 | Orpheus.kv.del("haiku_disabled_#{event[:user_id]}") 9 | respond_to_event(event, Orpheus.transcript("haiku.enabled")) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/interactions/haiku/notice_poetry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Haiku 4 | class NoticePoetry < Interaction 5 | handle :message 6 | 7 | checklist do 8 | event_has_user 9 | check do |event| 10 | !%w(channel_join).include?(event[:subtype]) 11 | end 12 | message_shorter_than 300 # adjust to taste, but remember that this is slow and runs on every not-opted-out message 13 | check do |event| 14 | !Orpheus.kv.get("haiku_disabled_#{SlackHelpers.extract_user(event)}") 15 | end 16 | end 17 | 18 | def self.call(event) 19 | user = extract_user(event) 20 | haiku = HaikuCheck.test(event[:text]) 21 | 22 | return unless haiku 23 | 24 | global_react(event, "haiku") 25 | begin 26 | reply_in_thread(event, Orpheus.transcript("haiku.template", { haiku:, user: })) 27 | rescue Slack::Web::Api::Errors::NotInChannel 28 | Orpheus.logger.info("[haiku] not in channel: #{event[:channel]}") 29 | return 30 | end 31 | 32 | unless Orpheus.kv.get("haiku_hinted_#{user}") 33 | reply_ephemerally(event, Orpheus.transcript("haiku.optout_hint"), threaded: true) 34 | Orpheus.kv.set("haiku_hinted_#{user}", true) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /app/interactions/not_canon.rb: -------------------------------------------------------------------------------- 1 | class NotCanon < Interaction 2 | handle :message 3 | 4 | checklist do 5 | message_text_matches /:rac_(shy|cute|yap):/ 6 | end 7 | 8 | def self.call(event) 9 | react_to_message event, Orpheus.transcript(:love_emoji) 10 | rescue Slack::Web::Api::Errors::NotInChannel 11 | # who gaf 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/interactions/raise_error.rb: -------------------------------------------------------------------------------- 1 | class RaiseError < SlashCommand 2 | command "/raise_error" 3 | 4 | checklist do 5 | only_from_user "U06QK6AG3RD" 6 | end 7 | 8 | def self.call(event) 9 | raise "frick frack snick snack #{event[:text]}" 10 | end 11 | end -------------------------------------------------------------------------------- /app/interactions/thump_thump.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ThumpThump < Interaction 4 | handle :message 5 | 6 | checklist do 7 | only_in_channel "CLU651WHY" 8 | message_text_matches /thump/ 9 | end 10 | 11 | def self.call(event) 12 | unless check_admin(extract_user(event)) 13 | Orpheus.logger.info 'who do they think they are, playing with my heart like that?' 14 | 15 | react_to_message event, Orpheus.transcript(:heartbreak_emoji) 16 | return 17 | end 18 | 19 | Orpheus.logger.info 'I can hear my heart beat in my chest... it fills me with determination' 20 | react_to_message event, 'heartbeat' 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/lib/haiku_check.rb: -------------------------------------------------------------------------------- 1 | require 'humanize' 2 | 3 | module HaikuCheck 4 | class << self 5 | def initialize_syllable_counts 6 | @syllable_counts ||= begin 7 | counts = {} 8 | filepath = File.join(File.dirname(__FILE__), 'haiku_check', 'syllable_counts.txt') 9 | if File.exist?(filepath) 10 | File.foreach(filepath) do |line| 11 | word, count = line.split 12 | counts[word.downcase] = count.to_i 13 | end 14 | end 15 | counts 16 | end 17 | end 18 | 19 | def get_cmu_syllable_count(word) 20 | @syllable_counts ||= initialize_syllable_counts 21 | @syllable_counts[word.downcase] 22 | end 23 | 24 | def test(text) 25 | return false unless text.is_a?(String) 26 | 27 | # Clean up the text 28 | text = text.downcase 29 | text = text.gsub(/[^a-zA-Z0-9\s\.$]/, '') 30 | text = text.gsub(/\$/, 'dollar ') 31 | text = text.gsub(/\bise\b/, 'ize') # british people... 32 | 33 | # Convert numbers to words 34 | text = text.gsub(/\d+/) { |num| num.to_i.humanize } 35 | 36 | # Split into words 37 | words = text.split(/\s+/).reject(&:empty?) 38 | 39 | # Initialize haiku lines 40 | line1, line2, line3 = [], [], [] 41 | syllable_count1 = syllable_count2 = syllable_count3 = 0 42 | 43 | words.each do |word| 44 | # Try dictionary first, fall back to syllables gem 45 | syllables = get_cmu_syllable_count(word) || SyllableEstimator.estimate(word) 46 | 47 | if syllable_count1 < 5 48 | if syllable_count1 + syllables <= 5 49 | line1 << word 50 | syllable_count1 += syllables 51 | else 52 | return false 53 | end 54 | elsif syllable_count2 < 7 55 | if syllable_count2 + syllables <= 7 56 | line2 << word 57 | syllable_count2 += syllables 58 | else 59 | return false 60 | end 61 | elsif syllable_count3 < 5 62 | if syllable_count3 + syllables <= 5 63 | line3 << word 64 | syllable_count3 += syllables 65 | else 66 | return false 67 | end 68 | else 69 | return false 70 | end 71 | end 72 | 73 | if syllable_count1 == 5 && syllable_count2 == 7 && syllable_count3 == 5 74 | [ 75 | line1.join(' '), 76 | line2.join(' '), 77 | line3.join(' ') 78 | ] 79 | else 80 | nil 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /app/lib/haiku_check/syllable_estimator.rb: -------------------------------------------------------------------------------- 1 | module HaikuCheck 2 | module SyllableEstimator 3 | SUB_SYLLABLES = [ 4 | "cial", 5 | "tia", 6 | "cius", 7 | "cious", 8 | "uiet", 9 | "gious", 10 | "geous", 11 | "priest", 12 | "giu", 13 | "dge", 14 | "ion", 15 | "iou", 16 | "sia$", 17 | ".che$", 18 | ".ched$", 19 | ".abe$", 20 | ".ace$", 21 | ".ade$", 22 | ".age$", 23 | ".aged$", 24 | ".ake$", 25 | ".ale$", 26 | ".aled$", 27 | ".ales$", 28 | ".ane$", 29 | ".ame$", 30 | ".ape$", 31 | ".are$", 32 | ".ase$", 33 | ".ashed$", 34 | ".asque$", 35 | ".ate$", 36 | ".ave$", 37 | ".azed$", 38 | ".awe$", 39 | ".aze$", 40 | ".aped$", 41 | ".athe$", 42 | ".athes$", 43 | ".ece$", 44 | ".ese$", 45 | ".esque$", 46 | ".esques$", 47 | ".eze$", 48 | ".gue$", 49 | ".ibe$", 50 | ".ice$", 51 | ".ide$", 52 | ".ife$", 53 | ".ike$", 54 | ".ile$", 55 | ".ime$", 56 | ".ine$", 57 | ".ipe$", 58 | ".iped$", 59 | ".ire$", 60 | ".ise$", 61 | ".ished$", 62 | ".ite$", 63 | ".ive$", 64 | ".ize$", 65 | ".obe$", 66 | ".ode$", 67 | ".oke$", 68 | ".ole$", 69 | ".ome$", 70 | ".one$", 71 | ".ope$", 72 | ".oque$", 73 | ".ore$", 74 | ".ose$", 75 | ".osque$", 76 | ".osques$", 77 | ".ote$", 78 | ".ove$", 79 | ".pped$", 80 | ".sse$", 81 | ".ssed$", 82 | ".ste$", 83 | ".ube$", 84 | ".uce$", 85 | ".ude$", 86 | ".uge$", 87 | ".uke$", 88 | ".ule$", 89 | ".ules$", 90 | ".uled$", 91 | ".ume$", 92 | ".une$", 93 | ".upe$", 94 | ".ure$", 95 | ".use$", 96 | ".ushed$", 97 | ".ute$", 98 | ".ved$", 99 | ".we$", 100 | ".wes$", 101 | ".wed$", 102 | ".yse$", 103 | ".yze$", 104 | ".rse$", 105 | ".red$", 106 | ".rce$", 107 | ".rde$", 108 | ".ily$", 109 | ".ely$", 110 | ".des$", 111 | ".gged$", 112 | ".kes$", 113 | ".ced$", 114 | ".ked$", 115 | ".med$", 116 | ".mes$", 117 | ".ned$", 118 | ".[sz]ed$", 119 | ".nce$", 120 | ".rles$", 121 | ".nes$", 122 | ".pes$", 123 | ".tes$", 124 | ".res$", 125 | ".ves$", 126 | "ere$" 127 | ].map { |pattern| Regexp.new(pattern) } 128 | 129 | ADD_SYLLABLES = [ 130 | "ia", 131 | "riet", 132 | "dien", 133 | "ien", 134 | "iet", 135 | "iu", 136 | "iest", 137 | "io", 138 | "ii", 139 | "ily", 140 | ".oala$", 141 | ".iara$", 142 | ".ying$", 143 | ".earest", 144 | ".arer", 145 | ".aress", 146 | ".eate$", 147 | ".eation$", 148 | "[aeiouym]bl$", 149 | "[aeiou]{3}", 150 | "^mc", 151 | "ism", 152 | "^mc", 153 | "asm", 154 | "([^aeiouy])1l$", 155 | "[^l]lien", 156 | "^coa[dglx].", 157 | "[^gq]ua[^auieo]", 158 | "dnt$" 159 | ].map { |pattern| Regexp.new(pattern) } 160 | 161 | def self.estimate(word) 162 | return 0 if word.nil? || word.empty? 163 | 164 | word = word.downcase 165 | parts = word.split(/[^aeiouy]+/).reject(&:empty?) 166 | syllables = parts.length 167 | 168 | SUB_SYLLABLES.each do |pattern| 169 | syllables -= 1 if pattern.match?(word) 170 | end 171 | 172 | ADD_SYLLABLES.each do |pattern| 173 | syllables += 1 if pattern.match?(word) 174 | end 175 | 176 | syllables = 1 if syllables <= 0 177 | syllables 178 | end 179 | end 180 | end -------------------------------------------------------------------------------- /app/lib/mock_kv.rb: -------------------------------------------------------------------------------- 1 | class MockKv 2 | def initialize(file_path = 'mock_kv_store.txt') 3 | @file_path = file_path 4 | @store = {} 5 | load_from_file 6 | end 7 | 8 | def get(key) 9 | @store[key] 10 | end 11 | 12 | def set(key, value) 13 | @store[key] = value 14 | save_to_file 15 | value 16 | end 17 | 18 | def del(key) 19 | @store.delete(key) 20 | save_to_file 21 | end 22 | 23 | private 24 | 25 | def load_from_file 26 | return unless File.exist?(@file_path) 27 | 28 | File.foreach(@file_path) do |line| 29 | key, value = line.chomp.split(':', 2) 30 | @store[key] = eval(value) if key && value 31 | end 32 | end 33 | 34 | def save_to_file 35 | File.open(@file_path, 'w') do |file| 36 | @store.each do |key, value| 37 | file.puts("#{key}:#{value.inspect}") 38 | end 39 | end 40 | end 41 | end -------------------------------------------------------------------------------- /app/lib/transcript.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | class TranscriptError < StandardError; end 4 | # this is cursor slop im sorry 5 | class Transcript 6 | class Context 7 | def initialize(transcript, parent = nil, vars = {}) 8 | @transcript = transcript 9 | @parent = parent 10 | @vars = vars 11 | end 12 | 13 | def [](key) 14 | @vars[key] || @vars[key.to_sym] || (@parent&.[](key) if @parent) 15 | end 16 | 17 | def t(path, vars = {}) 18 | @transcript.t(path, vars) 19 | end 20 | 21 | def method_missing(name, *args, &block) 22 | if @vars.key?(name) || @vars.key?(name.to_sym) 23 | @vars[name] || @vars[name.to_sym] 24 | elsif @parent 25 | @parent.send(name, *args, &block) 26 | else 27 | super 28 | end 29 | end 30 | 31 | def respond_to_missing?(name, include_private = false) 32 | @vars.key?(name) || @vars.key?(name.to_sym) || (@parent&.respond_to_missing?(name, include_private) if @parent) || super 33 | end 34 | end 35 | 36 | attr_reader :transcript_data 37 | 38 | def initialize(file = 'transcript_data.rb', logger: Logger.new(STDOUT)) 39 | @logger = logger 40 | @transcript_data = load_transcript(file) 41 | end 42 | 43 | def t(path, vars = {}) 44 | @logger.debug "Looking up '#{path}' with vars: #{vars}" if vars.any? 45 | 46 | # Convert path to array of components 47 | path_array = case path 48 | when Symbol 49 | [path] 50 | when Array 51 | path 52 | else 53 | path.split('.').map(&:to_sym) 54 | end 55 | 56 | target = fetch_path(path_array, @transcript_data) 57 | context = Context.new(self, @current_context, vars) 58 | @current_context = context 59 | evaluate_value(target) 60 | rescue TranscriptError 61 | @logger.error "Transcript path not found: #{path}" 62 | raise 63 | rescue => e 64 | @logger.error "Error processing transcript: #{e.message} #{e.backtrace[0..3].join("\n")}" 65 | "[Error: #{e.message}]" 66 | ensure 67 | @current_context = context&.instance_variable_get(:@parent) 68 | end 69 | 70 | alias_method :call, :t 71 | 72 | def h(&block) 73 | block 74 | end 75 | 76 | private 77 | 78 | def load_transcript(path) 79 | eval(File.read(path)) 80 | rescue => e 81 | @logger.error "Error loading transcript file: #{e}" 82 | {} 83 | end 84 | 85 | def fetch_path(keys, data) 86 | keys.reduce(data) { |obj, key| 87 | if obj.is_a?(Hash) 88 | # Try both string and symbol keys 89 | obj[key] || obj[key.to_sym] || nil 90 | else 91 | nil 92 | end 93 | } || (raise TranscriptError) 94 | end 95 | 96 | def evaluate_value(value) 97 | case value 98 | when Proc 99 | @current_context.instance_exec(&value) 100 | when Array 101 | # If this is a nested array (like in a hash), evaluate each element 102 | if value.any? { |v| v.is_a?(Hash) || (v.is_a?(Array) && v.any? { |vv| vv.is_a?(Proc) || vv.is_a?(Hash) || vv.is_a?(Array) }) } 103 | value.map { |v| evaluate_value(v) } 104 | else 105 | # If this is a final array (like image_url), sample it and then evaluate the result 106 | sampled = value.sample 107 | evaluate_value(sampled) 108 | end 109 | when Hash 110 | value.transform_values { |v| evaluate_value(v) } 111 | else 112 | value 113 | end 114 | end 115 | end -------------------------------------------------------------------------------- /app/orpheus.rb: -------------------------------------------------------------------------------- 1 | module Orpheus 2 | class << self 3 | def logger 4 | @logger ||= Logger.new(STDOUT, level: Logger::INFO) 5 | end 6 | 7 | def client 8 | @client ||= Slack::Web::Client.new(token: Utils.get_env!("SLACK_BOT_TOKEN")) 9 | end 10 | 11 | def _transcript 12 | @transcript ||= Transcript.new('transcript_data.rb') 13 | end 14 | 15 | def transcript(*args, **kwargs, &block) 16 | _transcript.t(*args, **kwargs, &block) 17 | end 18 | 19 | def production? 20 | ENV['ORPHEUS_ENV'] == 'production' 21 | end 22 | 23 | attr_accessor :cache 24 | attr_accessor :kv 25 | 26 | end 27 | 28 | class AbortHandlerChain < Exception; end 29 | 30 | logger.info("loading haiku tables...") 31 | HaikuCheck.initialize_syllable_counts 32 | logger.info("haiku tables loaded!") 33 | end -------------------------------------------------------------------------------- /app/orpheus/event_handling.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Orpheus 4 | class EventHandling 5 | class << self 6 | attr_reader :handlers, :subtype_handlers, :slash_commands 7 | 8 | def register_interaction(type: nil, message_subtype: nil, interaction:, high_pri: false) 9 | raise ArgumentError, "plz provide either type or message_subtype!" unless type || message_subtype 10 | 11 | Orpheus.logger.info "I just learned how to #{interaction.inspect}!" 12 | 13 | target_array = if message_subtype 14 | (@subtype_handlers ||= {})[message_subtype] ||= [] 15 | else 16 | (@handlers ||= {})[type] ||= [] 17 | end 18 | 19 | if high_pri 20 | target_array.unshift(interaction) 21 | else 22 | target_array << interaction 23 | end 24 | end 25 | 26 | def register_command(command:, interaction:) 27 | command = "/#{command}" unless command.start_with?("/") 28 | Orpheus.logger.info "are you /srs or #{command}?" 29 | (@slash_commands ||= {})[command] = interaction 30 | end 31 | 32 | def fire_event!(payload) 33 | event = payload[:event] 34 | type = event[:type].to_sym 35 | 36 | Honeybadger.add_breadcrumb( 37 | "Slack event", 38 | metadata: Utils.step_on_with_dino_hoof(event, :event), 39 | category: "slack", 40 | ) 41 | 42 | handler_queue = [] 43 | 44 | if type == :message 45 | subtype = event[:subtype]&.to_sym 46 | if subtype_handlers&.[](subtype) 47 | # For message subtypes, check handlers registered for that specific subtype 48 | handler_queue += subtype_handlers[subtype].select { |handler| handler.checks_pass? event } 49 | end 50 | end 51 | 52 | # Always check type-specific handlers 53 | if handlers&.[](type) 54 | handler_queue += handlers[type].select { |handler| handler.checks_pass? event } 55 | end 56 | 57 | handler_queue.each do |handler| 58 | begin 59 | handler.call(event) 60 | rescue Orpheus::AbortHandlerChain 61 | Orpheus.logger.debug("#{handler.inspect} aborted handler chain.") 62 | break 63 | rescue StandardError => e 64 | Honeybadger.notify(e) 65 | Orpheus.logger.error(e) unless Orpheus.production? 66 | end 67 | end 68 | end 69 | 70 | def fire_command!(payload) 71 | command = payload[:command] 72 | Honeybadger.add_breadcrumb( 73 | "Slack slash command", 74 | metadata: { 75 | command: command, 76 | data: payload, 77 | }, 78 | category: "slack", 79 | ) 80 | handler = @slash_commands[command] 81 | if handler 82 | begin 83 | handler.call(payload) 84 | rescue StandardError => e 85 | Honeybadger.notify(e) 86 | Orpheus.logger.error(e) unless Orpheus.production? 87 | end 88 | else 89 | Orpheus.logger.warn("uhh i just got asked to #{command} and i'm not really sure what to do about it...") 90 | Honeybadger.notify("unhandled slash command #{command}") 91 | end 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /bin/rackup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rackup' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rackup", "rackup") 28 | -------------------------------------------------------------------------------- /boot.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | require "zeitwerk" 4 | require "active_support/all" 5 | 6 | loader = Zeitwerk::Loader.new 7 | 8 | loader.push_dir('cruft') 9 | loader.push_dir('app') 10 | loader.push_dir('app/interaction_support') 11 | loader.push_dir('app/interactions') 12 | loader.push_dir('app/lib') 13 | 14 | loader.inflector.inflect("cdn" => "CDN") 15 | 16 | 17 | if ENV['ORPHEUS_ENV'] == 'development' 18 | require 'dotenv/load' 19 | loader.enable_reloading 20 | end 21 | 22 | loader.setup 23 | 24 | loader.eager_load if ENV['ORPHEUS_ENV'] == 'production' 25 | loader.eager_load_dir('app/interactions') 26 | 27 | Initialize.do_it! 28 | 29 | include Utils 30 | 31 | event_handler = Orpheus::EventHandling.method(:fire_event!) 32 | command_handler = Orpheus::EventHandling.method(:fire_command!) 33 | 34 | Orpheus.logger.info(Orpheus.transcript(:startup_log)) 35 | if ENV['SOCKET_MODE'] 36 | adapter = Adapters::SlackSocketMode.new( 37 | Orpheus::EventHandling.method(:fire_event!), 38 | Orpheus::EventHandling.method(:fire_command!), 39 | get_env!("SLACK_APP_TOKEN") 40 | ) 41 | adapter.run! 42 | else 43 | def get_app 44 | adapter = Adapters::SlackEvents.new( 45 | Orpheus::EventHandling.method(:fire_event!), 46 | Orpheus::EventHandling.method(:fire_command!), 47 | get_env!("SLACK_SIGNING_SECRET") 48 | ) 49 | adapter.app 50 | end 51 | if __FILE__ == $0 52 | get_app.run! 53 | end 54 | end -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require_relative './boot' 2 | run get_app -------------------------------------------------------------------------------- /cruft/errors.rb: -------------------------------------------------------------------------------- 1 | module Errors 2 | class ConfigError < StandardError; end 3 | end -------------------------------------------------------------------------------- /cruft/utils.rb: -------------------------------------------------------------------------------- 1 | module Utils 2 | def get_env!(key) 3 | res = ENV[key] 4 | raise Errors::ConfigError, "plz set #{key}!" unless res 5 | res 6 | end 7 | 8 | def step_on_with_dino_hoof(hash, prefix = nil) 9 | hash.each_with_object({}) do |(k, v), result| 10 | key = prefix ? "#{prefix}_#{k}".to_sym : k.to_sym 11 | 12 | if v.is_a?(Hash) 13 | result.merge!(step_on_with_dino_hoof(v, key)) 14 | else 15 | result[key] = v 16 | end 17 | end 18 | end 19 | 20 | module_function :get_env!, :step_on_with_dino_hoof 21 | end 22 | -------------------------------------------------------------------------------- /honeybadger.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # For more options, see https://docs.honeybadger.io/lib/ruby/gem-reference/configuration 3 | 4 | api_key: '<%= ENV["HONEYBADGER_API_KEY"] %>' 5 | 6 | # The environment your app is running in. 7 | env: "<%= ENV['RUBY_ENV'] || ENV['RACK_ENV'] %>" 8 | 9 | # The absolute path to your project folder. 10 | root: "<%= Dir.pwd %>" 11 | 12 | # Honeybadger won't report errors in these environments. 13 | development_environments: 14 | - test 15 | - development 16 | - cucumber 17 | 18 | # By default, Honeybadger won't report errors in the development_environments. 19 | # You can override this by explicitly setting report_data to true or false. 20 | # report_data: true 21 | 22 | # The current Git revision of your project. Defaults to the last commit hash. 23 | # revision: null 24 | 25 | # Enable verbose debug logging (useful for troubleshooting). 26 | debug: false 27 | 28 | # Enable Honeybadger Insights 29 | insights: 30 | enabled: false 31 | -------------------------------------------------------------------------------- /transcript_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # hokay so: 3 | # this is where we keep everything orpheus needs to say, so that it's all in one place and easy to manage 4 | # couple of helper methods you should know: 5 | # t("foo.bar") - evaluate the transcript at dig(:foo, :bar) 6 | # h { } – that block will be hydrated when descending the transcript 7 | # ^ this is particularly useful for things like: h { "i'm like if a dinosaur was a #{t('thing')}" } 8 | # k gl cya! 9 | 10 | { 11 | startup_log: [ 12 | "starting!", 13 | "starting... i love my wife...", 14 | ], 15 | dino_test2: [ 16 | "i live. i hunger. i. am. sinister.", 17 | "mrow :3", 18 | "heyo!", 19 | "im literally a dinosaur.", 20 | ], 21 | love_emoji: %w[orpheus-love sparkling_heart aww in_love_orpheus heart-wx 8bit-heart], 22 | heartbreak_emoji: %w[broken_heart broken_heart-wx broken_heart-hole alibaba-heartbreak no-dot-avi], 23 | errors: { 24 | video: h { "This footage was taken moments before the incident #{t("errors.videoURLs")}" }, 25 | videoURLs: [ 26 | "https://www.youtube.com/watch?v=O6WE5C__a70", 27 | "https://youtu.be/8tAu79kB57s", 28 | "https://youtu.be/yZ5AVxKf0EM", 29 | "https://youtu.be/fYM0RuKElIQ", 30 | "https://youtu.be/r1gNHREgecI", 31 | "https://youtu.be/ljbSoFJSn1U", 32 | "https://www.youtube.com/watch?v=lMUSyvTBokc", 33 | ], 34 | general: h { 35 | <<~ERROR 36 | Something went wrong: `#{err.class}: #{err.message}` 37 | #{err.respond_to?(:backtrace) ? t("errors.stack", err: err) : ""} 38 | #{rand < 0.1 ? t("errors.video") : ""} 39 | error ID: #{defined?(uuid) ? uuid : "unknown"} 40 | ERROR 41 | .strip 42 | }, 43 | stack: h { 44 | <<~STACK 45 | ``` 46 | // stack trace: 47 | #{err.backtrace&.first(3)&.join("\n")} 48 | // nora, please go find the full trace 49 | // code available at https://github.com/hackclub/orpheus-bot 50 | ``` 51 | STACK 52 | }, 53 | memory: [ 54 | h { "I think I'm suffering from amnesia... I'm trying to recall what we were talking about, but all that comes to mind is `{#{err.class}: #{err.message}}`" }, 55 | h { "Hmmm... something's on the tip of my tongue, but all I can think is `#{err.class}: #{err.message}`" }, 56 | h { "Do you ever try to remember something, but end up thinking `#{err.class}: #{err.message}` instead? Wait... what were we talking about?" }, 57 | h { "Hmmm... I'm having trouble thinking right now. Whenever I focus, `#{err.class}: #{err.message}` is the only thing that comes to mind" }, 58 | h { "Aw jeez, this is embarrassing. My database just texted me `#{err.class}: #{err.message}`" }, 59 | h { "I just opened my notebook to take a note, but it just says `#{err.class}: #{err.message}` all over the pages" }, 60 | ], 61 | transcript: [ 62 | "Uh oh, I can't read my script. There's a smudge on the next line.", 63 | "Super embarrasing, but I just forgot my next line.", 64 | "I totally forgot what I was talking about.", 65 | ], 66 | }, 67 | haiku: { 68 | template: h do <<~HAIKU 69 | #{haiku[0]} 70 | #{haiku[1]} 71 | #{haiku[2]} 72 | _– a haiku by <@#{user}>, #{Date.today.year}_ 73 | HAIKU 74 | end, 75 | disabled: h { "#{t("haiku.disabled_flavor")} – you can reënable it with `/haiku-enable`" }, 76 | enabled: h { "#{t("haiku.enabled_flavor")} – you can disable it with `/haiku-disable`" }, 77 | disabled_flavor: [ 78 | "okay, no more haiku for you", 79 | "got it, not a poetry person", 80 | "i'll spare you from my poetic wrath", 81 | "your loss, but i respect your choice", 82 | "fine, but i'll still be counting syllables in my head", 83 | "understood, but my inner poet is crying", 84 | "okay, but my muse is giving you the side-eye", 85 | ], 86 | enabled_flavor: [ 87 | "haiku enabled!", 88 | "your syllables shall now be counted", 89 | "poetic justice incoming", 90 | "your inner poet is now free", 91 | "haiku powers: unlocked", 92 | "time to channel your inner poet", 93 | ], 94 | optout_hint: { 95 | "blocks": [ 96 | { 97 | "type": "section", 98 | "text": { 99 | "type": "mrkdwn", 100 | "text": "don't want me\nto notice your poems?\n`/disable-haiku`", 101 | }, 102 | }, 103 | { 104 | "type": "context", 105 | "elements": [ 106 | { 107 | "type": "plain_text", 108 | "text": "(to be clear i mean run the above as a slash command to opt out)", 109 | }, 110 | ], 111 | }, 112 | ], 113 | }, 114 | }, 115 | ping: %w[pong pong! 🏓], 116 | fileShare: { 117 | generic: "thanks, i'm gonna sell these to adfly!", 118 | success: h { "Yeah! Here's yo' links <@#{user}>\n#{links}" }, 119 | errorTooBigImages: [ 120 | "https://cloud-3tq9t10za-hack-club-bot.vercel.app/2too_big_4.png", 121 | "https://cloud-3tq9t10za-hack-club-bot.vercel.app/3too_big_2.png", 122 | "https://cloud-3tq9t10za-hack-club-bot.vercel.app/4too_big_1.png", 123 | "https://cloud-3tq9t10za-hack-club-bot.vercel.app/6too_big_5.png", 124 | "https://cloud-3tq9t10za-hack-club-bot.vercel.app/7too_big_3.png", 125 | ], 126 | errorTooBig: { 127 | blocks: [ 128 | { 129 | type: "image", 130 | title: { 131 | type: "plain_text", 132 | text: "File too big!", 133 | emoji: true, 134 | }, 135 | image_url: h { t("fileShare.errorTooBigImages") }, 136 | alt_text: "file too large", 137 | }, 138 | ], 139 | }, 140 | errorGenericImages: [ 141 | "https://cloud-3tq9t10za-hack-club-bot.vercel.app/0generic_3.png", 142 | "https://cloud-3tq9t10za-hack-club-bot.vercel.app/1generic_2.png", 143 | "https://cloud-3tq9t10za-hack-club-bot.vercel.app/5generic_1.png", 144 | ], 145 | errorGeneric: [ 146 | "_orpheus sneezes and drops the files on the ground before blowing her nose on a blank jpeg._", 147 | "_orpheus trips and your files slip out of her hands and into an inconveniently placed sewer grate._", 148 | "_orpheus accidentally slips the files into a folder in her briefcase labeled \"homework\". she starts sweating profusely._", 149 | h { 150 | { 151 | blocks: [ 152 | { 153 | type: "image", 154 | title: { 155 | type: "plain_text", 156 | text: "Error!", 157 | emoji: true, 158 | }, 159 | image_url: t("fileShare.errorGenericImages"), 160 | alt_text: "Something went wrong", 161 | }, 162 | ], 163 | } 164 | }, 165 | ], 166 | extensions: { 167 | gif: [ 168 | "_gif_ that file to me and i'll upload it", 169 | "_gif_ me all all your files!", 170 | ], 171 | heic: ["What the heic???"], 172 | mov: ["I'll _mov_ that to a permanent link for you"], 173 | html: [ 174 | "Oh, launching a new website?", 175 | "uwu, what's this site?", 176 | "WooOOAAah hey! Are you serving a site?", 177 | "h-t-m-ello :wave:", 178 | ], 179 | rar: [ 180 | ".rawr xD", 181 | "i also go \"rar\" sometimes!", 182 | ], 183 | }, 184 | }, 185 | } 186 | --------------------------------------------------------------------------------