├── .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 |
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 |
--------------------------------------------------------------------------------