diff --git a/.env.example b/.env.example index fb8609f..a15f32f 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,10 @@ PORT=4000 SLACK_CLIENT_ID=your_client_id_here SLACK_CLIENT_SECRET=your_client_secret_here +SLACK_SIGNING_SECRET=your_signing_secret_here SLACK_TEAM_ID=your_slack_workspace_id SLACK_USER_OAUTH_TOKEN=your_user_oauth_token_here +SLACK_BOT_OAUTH_TOKEN=your_bot_oauth_token_here WAKATIME_DATABASE_URL=your_wakatime_database_url_here diff --git a/Gemfile b/Gemfile index f1b6bb5..cd20721 100644 --- a/Gemfile +++ b/Gemfile @@ -35,8 +35,8 @@ gem "solid_cable" gem "good_job" -# Job scheduler -gem "whenever", require: false +# Slack client +gem "slack-ruby-client" # Reduces boot times through caching; required in config/boot.rb gem "bootsnap", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 1210a63..2d2c315 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -115,7 +115,6 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - chronic (0.10.2) concurrent-ruby (1.3.5) connection_pool (2.5.0) crass (1.0.6) @@ -134,6 +133,17 @@ GEM erubi (1.13.1) et-orbi (1.2.11) tzinfo + faraday (2.12.2) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-mashify (0.1.1) + faraday (~> 2.0) + hashie + faraday-multipart (1.1.0) + multipart-post (~> 2.0) + faraday-net_http (3.4.0) + net-http (>= 0.5.0) ffi (1.17.1-aarch64-linux-gnu) ffi (1.17.1-aarch64-linux-musl) ffi (1.17.1-arm-linux-gnu) @@ -147,6 +157,8 @@ GEM fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) + gli (2.22.2) + ostruct globalid (1.2.1) activesupport (>= 6.1) good_job (4.9.0) @@ -156,6 +168,7 @@ GEM fugit (>= 1.11.0) railties (>= 6.1.0) thor (>= 1.0.0) + hashie (5.0.0) http (5.2.0) addressable (~> 2.8) base64 (~> 0.1) @@ -217,6 +230,9 @@ GEM mini_mime (1.1.5) minitest (5.25.4) msgpack (1.8.0) + multipart-post (2.4.1) + net-http (0.6.0) + uri net-imap (0.5.6) date net-protocol @@ -367,6 +383,13 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) + slack-ruby-client (2.5.2) + faraday (>= 2.0) + faraday-mashify + faraday-multipart + gli + hashie + logger solid_cable (3.0.7) actioncable (>= 7.2) activejob (>= 7.2) @@ -426,8 +449,6 @@ GEM base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - whenever (1.0.0) - chronic (>= 0.6.3) xpath (3.2.0) nokogiri (~> 1.8) zeitwerk (2.7.2) @@ -466,6 +487,7 @@ DEPENDENCIES rails (~> 8.0.1) rubocop-rails-omakase selenium-webdriver + slack-ruby-client solid_cable solid_cache sqlite3 (>= 2.1) @@ -475,7 +497,6 @@ DEPENDENCIES turbo-rails tzinfo-data web-console - whenever BUNDLED WITH 2.6.2 diff --git a/app/controllers/sailors_log_controller.rb b/app/controllers/sailors_log_controller.rb new file mode 100644 index 0000000..e7c6586 --- /dev/null +++ b/app/controllers/sailors_log_controller.rb @@ -0,0 +1,68 @@ +class SailorsLogController < ApplicationController + skip_before_action :verify_authenticity_token + before_action :verify_slack_request + + # Handle slack commands + def create + case params[:text].downcase.strip + when "on" + puts "Turning on notifications for #{params[:user_id]} in #{params[:channel_id]}" + SailorsLogSetNotificationPrefJob.perform_later(params[:user_id], params[:channel_id], true) + render json: { + response_type: "in_channel", + text: "@#{params[:user_name]} ran `/sailorslog on` to turn on High Seas notifications in this channel. Every time they code an hour on a project, a short message celebrating will be posted to this channel. They will also show on `/sailorslog leaderboard`." + } + when "off" + SailorsLogSetNotificationPrefJob.perform_later(params[:user_id], params[:channel_id], false) + render json: { + response_type: "ephemeral", + text: ":white_check_mark: Coding notifications have been turned off in this channel." + } + when "leaderboard" + leaderboard = SailorsLog.generate_leaderboard(params[:channel_id]) + message = "*:boat: Sailor's Log - Today*" + medals = [ "first_place_medal", "second_place_medal", "third_place_medal" ] + # ex. + # :first_place_medal: @Irtaza: 2h 6m → Farmworks [C#]: 125m + # :second_place_medal: @Cigan: 1h 33m → Gitracker-1 [JAVA]: 49m + Dupe [JAVA]: 41m + Lovac-Integration [JAVA]: 2m + leaderboard.each_with_index do |entry, index| + medal = medals[index] || "white_small_square" + message += "\n:#{medal}: `<@#{entry[:user_id]}>`: #{entry[:duration]} → " + message += entry[:projects].map do |project| + language = project[:language_emoji] ? "#{project[:language_emoji]} #{project[:language]}" : project[:language] + "#{project[:name]} [#{language}]" + end.join(" + ") + end + + puts message + render json: { + response_type: "in_channel", + text: message + } + else + render json: { + response_type: "ephemeral", + text: "Available commands: `/sailorslog on`, `/sailorslog off`, `/sailorslog leaderboard`" + } + end + end + + private + + def verify_slack_request + timestamp = request.headers["X-Slack-Request-Timestamp"] + signature = request.headers["X-Slack-Signature"] + + # Skip verification in development + return true if Rails.env.development? + + slack_signing_secret = ENV["SLACK_SIGNING_SECRET"] + sig_basestring = "v0:#{timestamp}:#{request.raw_post}" + my_signature = "v0=" + OpenSSL::HMAC.hexdigest("SHA256", slack_signing_secret, sig_basestring) + + unless ActiveSupport::SecurityUtils.secure_compare(my_signature, signature) + head :unauthorized + nil + end + end +end diff --git a/app/jobs/sailors_log_set_notification_pref_job.rb b/app/jobs/sailors_log_set_notification_pref_job.rb new file mode 100644 index 0000000..617a140 --- /dev/null +++ b/app/jobs/sailors_log_set_notification_pref_job.rb @@ -0,0 +1,9 @@ +class SailorsLogSetNotificationPrefJob < ApplicationJob + queue_as :default + + def perform(slack_uid, slack_channel_id, enabled) + slnp = SailorsLogNotificationPreference.find_or_initialize_by(slack_uid: slack_uid, slack_channel_id: slack_channel_id) + slnp.enabled = enabled + slnp.save! + end +end diff --git a/app/models/heartbeat.rb b/app/models/heartbeat.rb index 2b1220a..2c917dc 100644 --- a/app/models/heartbeat.rb +++ b/app/models/heartbeat.rb @@ -16,15 +16,38 @@ class Heartbeat < WakatimeRecord self.ignored_columns += [ "hash" ] def self.duration_seconds(scope = all) - capped_diffs = scope - .select("CASE - WHEN LAG(time) OVER (ORDER BY time) IS NULL THEN 0 - ELSE LEAST(EXTRACT(EPOCH FROM (time - LAG(time) OVER (ORDER BY time))), #{TIMEOUT_DURATION.to_i}) - END as diff") - .where.not(time: nil) - .order(time: :asc) + if scope.group_values.any? + # when grouped, return hash of group key => duration + group_column = scope.group_values.first - connection.select_value("SELECT COALESCE(SUM(diff), 0)::integer FROM (#{capped_diffs.to_sql}) AS diffs").to_i + capped_diffs = scope + .select("#{group_column}, CASE + WHEN LAG(time) OVER (PARTITION BY #{group_column} ORDER BY time) IS NULL THEN 0 + ELSE LEAST(EXTRACT(EPOCH FROM (time - LAG(time) OVER (PARTITION BY #{group_column} ORDER BY time))), #{TIMEOUT_DURATION.to_i}) + END as diff") + .where.not(time: nil) + .order(time: :asc) + .unscope(:group) + + connection.select_all( + "SELECT #{group_column}, COALESCE(SUM(diff), 0)::integer as duration + FROM (#{capped_diffs.to_sql}) AS diffs + GROUP BY #{group_column}" + ).each_with_object({}) do |row, hash| + hash[row[group_column.to_s]] = row["duration"].to_i + end + else + # when not grouped, return a single value + capped_diffs = scope + .select("CASE + WHEN LAG(time) OVER (ORDER BY time) IS NULL THEN 0 + ELSE LEAST(EXTRACT(EPOCH FROM (time - LAG(time) OVER (ORDER BY time))), #{TIMEOUT_DURATION.to_i}) + END as diff") + .where.not(time: nil) + .order(time: :asc) + + connection.select_value("SELECT COALESCE(SUM(diff), 0)::integer FROM (#{capped_diffs.to_sql}) AS diffs").to_i + end end def self.duration_formatted(scope = all) diff --git a/app/models/sailors_log.rb b/app/models/sailors_log.rb index ca3e712..3ca4cad 100644 --- a/app/models/sailors_log.rb +++ b/app/models/sailors_log.rb @@ -14,6 +14,87 @@ class SailorsLog < ApplicationRecord foreign_key: :slack_uid, primary_key: :slack_uid + def self.generate_leaderboard(channel) + # Get all users with enabled preferences in the channel + users_in_channel = SailorsLogNotificationPreference.where(enabled: true, slack_channel_id: channel) + .distinct + .pluck(:slack_uid) + + # Get all durations for users in channel + user_durations = Heartbeat.where(user_id: users_in_channel) + .today + .group(:user_id) + .duration_seconds + + # Sort and take top 10 users + top_user_ids = user_durations.sort_by { |_, duration| -duration }.first(10).map(&:first) + + # Now get detailed project info only for top 10 users + top_user_ids.map do |user_id| + user_heartbeats = Heartbeat.where(user_id: user_id).today + + # Get most common language per project using ActiveRecord + most_common_languages = user_heartbeats + .group(:project, :language) + .count + .group_by { |k, _| k[0] } # Group by project + .transform_values { |langs| langs.max_by { |_, count| count }&.first&.last } # Get most common language + + # Get all project durations in one query + project_durations = user_heartbeats + .group(:project) + .duration_seconds + + projects = project_durations.map do |project, duration| + { + name: project, + duration: duration, + language: most_common_languages[project], + language_emoji: self.language_emoji(most_common_languages[project]) + } + end + + { + user_id: user_id, + duration: user_durations[user_id], + projects: projects + } + end + end + + def self.language_emoji(language) + case language.downcase + when "ruby" + ":ruby:" + when "javascript" + ":js:" + when "typescript" + ":ts:" + when "html" + ":html:" + when "java" + [ ":java:", ":java_duke:" ].sample + when "unity" + [ ":unity:", ":unity_new:" ].sample + when "c++" + ":c++:" + when "c" + [ ":c:", ":c_1:" ].sample + when "rust" + [ ":ferris:", ":crab:", ":ferrisowo:" ].sample + when "python" + [ ":snake:", ":python:", ":pf:", ":tw_snake:" ].sample + when "nix" + [ ":nix:", ":parrot-nix:" ].sample + when "go" + [ ":golang:", ":gopher:", ":gothonk:" ].sample + when "kotlin" + ":kotlin:" + else + nil + end + end + private def initialize_projects_summary diff --git a/config/environments/development.rb b/config/environments/development.rb index d954c45..77166a5 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -73,4 +73,8 @@ Rails.application.configure do # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. # config.generators.apply_rubocop_autocorrect_after_generate! + + # Allow any host in development + # https://www.fngtps.com/2019/rails6-blocked-host/ + config.hosts.clear end diff --git a/config/initializers/slack.rb b/config/initializers/slack.rb new file mode 100644 index 0000000..36cb450 --- /dev/null +++ b/config/initializers/slack.rb @@ -0,0 +1,3 @@ +Slack.configure do |config| + config.token = ENV["SLACK_BOT_OAUTH_TOKEN"] # Using the existing env variable name +end diff --git a/config/routes.rb b/config/routes.rb index 5c9239d..54ef64c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,10 +21,14 @@ Rails.application.routes.draw do # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker - # Defines the root path route ("/") - # root "posts#index" root "static_pages#index" + resources :static_pages, only: [ :index ] do + collection do + get :project_durations + end + end + get "/auth/slack", to: "sessions#new", as: :slack_auth get "/auth/slack/callback", to: "sessions#create" delete "signout", to: "sessions#destroy", as: "signout" @@ -39,4 +43,6 @@ Rails.application.routes.draw do # Namespace for current user actions get "my/settings", to: "users#edit", as: :my_settings patch "my/settings", to: "users#update" + + post "/slack/commands", to: "sailors_log#create" end diff --git a/config/schedule.rb b/config/schedule.rb deleted file mode 100644 index b74d24b..0000000 --- a/config/schedule.rb +++ /dev/null @@ -1,3 +0,0 @@ -every 1.hour do - runner "UpdateLeaderboardJob.perform_later" -end