add builtin slack integration (#23)

This commit is contained in:
nora 2026-01-30 16:17:02 -05:00 committed by GitHub
parent ae7f38e8bd
commit cd73ee5b57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 351 additions and 1 deletions

View file

@ -71,3 +71,5 @@ gem "blind_index"
gem "sentry-ruby" gem "sentry-ruby"
gem "sentry-rails" gem "sentry-rails"
gem "aws-sdk-s3", require: false gem "aws-sdk-s3", require: false
gem "slack-ruby-client"
gem "slocks", git: "https://github.com/24c02/slocks"

View file

@ -1,3 +1,11 @@
GIT
remote: https://github.com/24c02/slocks
revision: 08f4b98858b30e52ea212ae4ebdca1cea7978b93
specs:
slocks (0.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
@ -140,12 +148,19 @@ GEM
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
faraday-mashify (1.0.2)
faraday (~> 2.0)
hashie
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.2) faraday-net_http (3.4.2)
net-http (~> 0.5) net-http (~> 0.5)
fiddle (1.1.8) fiddle (1.1.8)
fugit (1.12.1) fugit (1.12.1)
et-orbi (~> 1.4) et-orbi (~> 1.4)
raabro (~> 1.4) raabro (~> 1.4)
gli (2.22.2)
ostruct
globalid (1.3.0) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
hashid-rails (1.4.1) hashid-rails (1.4.1)
@ -201,6 +216,7 @@ GEM
msgpack (1.8.0) msgpack (1.8.0)
multi_xml (0.8.1) multi_xml (0.8.1)
bigdecimal (>= 3.1, < 5) bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
mutex_m (0.3.0) mutex_m (0.3.0)
net-http (0.9.1) net-http (0.9.1)
uri (>= 0.11.1) uri (>= 0.11.1)
@ -249,6 +265,7 @@ GEM
omniauth-oauth2 (1.9.0) omniauth-oauth2 (1.9.0)
oauth2 (>= 2.0.2, < 3) oauth2 (>= 2.0.2, < 3)
omniauth (~> 2.0) omniauth (~> 2.0)
ostruct (0.6.3)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.10.1) parser (3.3.10.1)
ast (~> 2.4.1) ast (~> 2.4.1)
@ -400,6 +417,13 @@ GEM
sentry-ruby (6.3.0) sentry-ruby (6.3.0)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
slack-ruby-client (3.1.0)
faraday (>= 2.0.1)
faraday-mashify
faraday-multipart
gli
hashie
logger
snaky_hash (2.0.3) snaky_hash (2.0.3)
hashie (>= 0.1.0, < 6) hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3) version_gem (>= 1.1.8, < 3)
@ -506,6 +530,8 @@ DEPENDENCIES
selenium-webdriver selenium-webdriver
sentry-rails sentry-rails
sentry-ruby sentry-ruby
slack-ruby-client
slocks!
solid_cable solid_cable
solid_cache solid_cache
solid_queue solid_queue

View file

@ -0,0 +1,41 @@
# frozen_string_literal: true
module Slack
class EventsController < ActionController::API
before_action :verify_slack_signature
def create
# Handle Slack URL verification challenge
if params[:type] == "url_verification"
return render json: { challenge: params[:challenge] }
end
# Handle event callbacks
if params[:type] == "event_callback"
event = params[:event]
# Filter to message events with files in monitored channels
if event[:type] == "message" && event[:files].present? && monitored_channel?(event[:channel])
ProcessSlackFileUploadJob.perform_later(event.to_unsafe_h)
end
end
# Respond immediately (Slack requires < 3s response)
head :ok
end
private
def verify_slack_signature
::Slack::Events::Request.new(request).verify!
rescue ::Slack::Events::Request::MissingSigningSecret,
::Slack::Events::Request::InvalidSignature,
::Slack::Events::Request::TimestampExpired
render json: { error: "Invalid signature" }, status: :unauthorized
end
def monitored_channel?(channel_id)
Rails.application.config.slack.cdn_channels.include?(channel_id)
end
end
end

View file

@ -0,0 +1,249 @@
# frozen_string_literal: true
class ProcessSlackFileUploadJob < ApplicationJob
include ActionView::Helpers::NumberHelper
queue_as :default
class QuotaExceededError < StandardError
attr_reader :reason, :details
def initialize(reason, details = nil)
@reason = reason
@details = details
super(details || reason.to_s)
end
end
def perform(event)
@channel_id = event["channel"]
@message_ts = event["ts"]
@slack_user_id = event["user"]
@files = event["files"]
return unless @files.present?
@slack = SlackService.client
@user = nil
begin
@user = find_or_create_user
add_reaction("beachball")
reply_in_thread(pick_flavor_message)
uploads = process_files
notify_success(uploads)
rescue QuotaExceededError => e
notify_quota_exceeded(e)
rescue => e
notify_error(e)
end
end
private
def find_or_create_user
user = User.find_by(slack_id: @slack_user_id)
unless user
profile = @slack.users_info(user: @slack_user_id).user
user = User.create!(
slack_id: @slack_user_id,
email: profile[:profile][:email] || "slack-#{@slack_user_id}@temp.hackclub.com",
name: profile[:real_name] || profile[:name] || "Slack User"
)
end
user
end
def process_files
uploads = []
@files.each do |file|
original_url = file["url_private"]
upload = Upload.create_from_url(
original_url,
user: @user,
provenance: :slack,
original_url: original_url,
authorization: "Bearer #{Rails.application.config.slack.bot_token}"
)
enforce_quota!(upload)
uploads << upload
end
uploads
end
def enforce_quota!(upload)
quota_service = QuotaService.new(@user)
policy = quota_service.current_policy
if upload.byte_size > policy.max_file_size
upload.destroy!
raise QuotaExceededError.new(
:file_too_large,
"File is #{number_to_human_size(upload.byte_size)} but max is #{number_to_human_size(policy.max_file_size)}"
)
end
return unless @user.total_storage_bytes > policy.max_total_storage
upload.destroy!
raise QuotaExceededError.new(
:storage_exceeded,
"You've used #{number_to_human_size(@user.total_storage_bytes)} of your #{number_to_human_size(policy.max_total_storage)} storage"
)
end
def notify_success(uploads)
remove_reaction("beachball")
add_reaction("white_check_mark")
@slack.chat_postMessage(
channel: @channel_id,
thread_ts: @message_ts,
text: "Yeah! Here's yo' links",
**render_slack_template("upload_success", uploads: uploads, slack_user_id: @slack_user_id)
)
end
def notify_quota_exceeded(error)
remove_reaction("beachball")
add_reaction("x")
error_text = case error.reason
when :file_too_large
[
"_cdnpheus tries to pick up the file but it's too heavy. she strains. she sweats. she gives up._ #{error.details}",
"whoa there, that file is THICC. #{error.details} verify at cdn.hackclub.com for chonkier uploads!",
"_cdnpheus attempts to stuff the file into her tiny dinosaur backpack. it does not fit._ #{error.details}",
"i tried to eat this file but it's too big and i'm just a small dinosaur :( #{error.details}"
].sample
when :storage_exceeded
[
"_cdnpheus opens her filing cabinet but papers explode everywhere._ you're out of space! #{error.details}",
"your storage is fuller than my inbox after i mass-DM'd everyone about my soundcloud. #{error.details}",
"no room at the inn! #{error.details} delete some files or verify at cdn.hackclub.com for more space"
].sample
else
[
"quota exceeded! verify at cdn.hackclub.com to unlock your true potential",
"_cdnpheus taps the \"quota exceeded\" sign apologetically_"
].sample
end
reply_in_thread(error_text)
end
def notify_error(error)
Rails.logger.error "Slack file upload failed: #{error.message}\n#{error.backtrace.join("\n")}"
sentry_event = Sentry.capture_exception(error)
sentry_id = sentry_event&.event_id || "unknown"
return unless @slack
begin
remove_reaction("beachball")
add_reaction("x")
@slack.chat_postMessage(
channel: @channel_id,
thread_ts: @message_ts,
text: "Something went wrong uploading your file",
**render_slack_template("upload_error",
flavor_message: pick_error_message,
error_message: error.message,
backtrace: format_backtrace(error.backtrace),
sentry_id: sentry_id)
)
rescue => slack_error
Rails.logger.error "Failed to send Slack error notification: #{slack_error.message}"
end
end
def add_reaction(emoji)
@slack.reactions_add(channel: @channel_id, timestamp: @message_ts, name: emoji)
rescue StandardError
nil
end
def remove_reaction(emoji)
@slack.reactions_remove(channel: @channel_id, timestamp: @message_ts, name: emoji)
rescue StandardError
nil
end
def reply_in_thread(text)
@slack.chat_postMessage(channel: @channel_id, thread_ts: @message_ts, text: text)
end
def render_slack_template(template, locals = {})
json = ApplicationController.render(
template: "slack/#{template}",
formats: [ :slack_message ],
locals:
)
JSON.parse(json, symbolize_names: true)
end
def pick_flavor_message
# Collect all possible flavor messages based on file extensions
flavor_messages = [ "thanks, i'm gonna sell these to adfly!" ] # generic fallback
@files.each do |file|
ext = File.extname(file["name"]).delete_prefix(".").downcase
case ext
when "gif"
flavor_messages += [ "_gif_ that file to me and i'll upload it", "_gif_ me all all your files!" ]
when "heic"
flavor_messages << "What the heic???"
when "mov"
flavor_messages << "I'll _mov_ that to a permanent link for you"
when "html"
flavor_messages += [ "Oh, launching a new website?", "uwu, what's this site?", "WooOOAAah hey! Are you serving a site?", "h-t-m-ello :wave:" ]
when "rar"
flavor_messages += [ ".rawr xD", "i also go \"rar\" sometimes!" ]
end
end
flavor_messages.sample
end
def format_backtrace(backtrace)
return "" if backtrace.blank?
Rails.backtrace_cleaner.clean(backtrace).first(3).map do |line|
if line =~ /^(.+):(\d+):in\s+'(.+)'$/
file, line_num, method_name = $1, $2, $3
url = "https://github.com/hackclub/cdn/blob/main/#{file}#L#{line_num}"
"<#{url}|#{file}:#{line_num}> in `#{method_name}`"
elsif line =~ /^(.+):(\d+)/
file, line_num = $1, $2
url = "https://github.com/hackclub/cdn/blob/main/#{file}#L#{line_num}"
"<#{url}|#{file}:#{line_num}>"
else
line
end
end.join("\n")
end
def pick_error_message
[
"_cdnpheus sneezes and drops the files on the ground before blowing her nose on a blank jpeg._",
"_cdnpheus trips and your files slip out of her hands and into an inconveniently placed sewer grate._",
"_cdnpheus accidentally slips the files into a folder in her briefcase labeled \"homework\". she starts sweating profusely._",
"Hmmm... I'm having trouble thinking right now. Whenever I focus, the only thing that comes to mind is this error",
"Aw jeez, this is embarrassing. My database just texted me this",
"I just opened my notebook to take a note, but it just says this error all over the pages",
"Do you ever try to remember something, but end up thinking about server errors instead? Wait... what were we talking about?",
"Super embarrassing, but I just forgot how to upload files.",
"i live. i hunger. i. fail to upload your file. i. am. sinister.",
"_cdnpheus tries to catch the file but it phases through her claws like a ghost. spooky._"
].sample
end
end

View file

@ -12,7 +12,7 @@ class User < ApplicationRecord
scope :admins, -> { where(is_admin: true) } scope :admins, -> { where(is_admin: true) }
validates :hca_id, presence: true, uniqueness: true validates :hca_id, uniqueness: true, allow_nil: true
validates :quota_policy, inclusion: { in: Quota::ADMIN_ASSIGNABLE.map(&:to_s) }, allow_nil: true validates :quota_policy, inclusion: { in: Quota::ADMIN_ASSIGNABLE.map(&:to_s) }, allow_nil: true
encrypts :hca_access_token encrypts :hca_access_token

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class SlackService
def self.client
@client ||= Slack::Web::Client.new(token: Rails.application.config.slack.bot_token)
end
end

View file

@ -0,0 +1,5 @@
section "#{flavor_message}", markdown: true
section ":warning: `#{error_message}`\n```#{backtrace}```", markdown: true
context [mrkdwn_text("sentry ID: `#{sentry_id}` • <https://cdn.hackclub.com|cdn.hackclub.com>")]

View file

@ -0,0 +1,7 @@
section "Yeah! Here's yo' links <@#{slack_user_id}>!", markdown: true
uploads.each do |upload|
section "#{upload.cdn_url}", markdown: true
end
context [mrkdwn_text("(manage your files at <https://cdn.hackclub.com|cdn.hackclub.com>!)")]

View file

@ -0,0 +1,8 @@
Rails.application.config.slack = ActiveSupport::OrderedOptions.new
Rails.application.config.slack.bot_token = ENV.fetch("SLACK_BOT_TOKEN", nil)
Rails.application.config.slack.signing_secret = ENV.fetch("SLACK_SIGNING_SECRET", nil)
Rails.application.config.slack.cdn_channels = ENV.fetch("CDN_CHANNELS", "").split(",").map(&:strip)
Slack::Events.configure do |config|
config.signing_secret = Rails.application.config.slack.signing_secret
end

View file

@ -44,6 +44,11 @@ Rails.application.routes.draw do
# Rescue endpoint to find uploads by original URL # Rescue endpoint to find uploads by original URL
get "/rescue", to: "external_uploads#rescue", as: :rescue_upload get "/rescue", to: "external_uploads#rescue", as: :rescue_upload
# Slack events webhook
namespace :slack do
post "events", to: "events#create"
end
# External upload redirects (must be last to avoid conflicts) # External upload redirects (must be last to avoid conflicts)
get "/:id/*filename", to: "external_uploads#show", constraints: { id: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ }, as: :external_upload get "/:id/*filename", to: "external_uploads#show", constraints: { id: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ }, as: :external_upload
end end