Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ ENCRYPTION_KEY_DERIVATION_SALT=16charssalt1234

Comment out the `LOOPS_API_KEY` for the local letter opener, otherwise the app will try to send out a email and fail.

### Slack Activity Digest Bot Token

Daily Slack activity digests require a bot token with `chat:write` access. Add the token to your `.env` file:

```
SLACK_ACTIVITY_DIGEST_BOT_TOKEN=xoxb-...
```

Without this token the digest job will raise an error when it attempts to post to Slack.

## Build & Run the project

```sh
Expand Down
68 changes: 68 additions & 0 deletions app/controllers/my/activity_digest_subscriptions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
module My
class ActivityDigestSubscriptionsController < ApplicationController
before_action :ensure_current_user

def show
@channel_id = current_user.slack_neighborhood_channel
@subscription = find_subscription
@timezones = SlackActivityDigestSubscription::TIMEZONE_NAMES
end

def create
channel_id = current_user.slack_neighborhood_channel
if channel_id.blank?
redirect_to my_activity_digest_subscription_path, alert: "We don't have a Slack team channel on file for you yet." and return
end

subscription = SlackActivityDigestSubscription.find_or_initialize_by(slack_channel_id: channel_id)
subscription.assign_attributes(create_params)
subscription.timezone ||= current_user.timezone
subscription.delivery_hour ||= 10
subscription.enabled = true
subscription.created_by_user ||= current_user
subscription.save!

redirect_to my_activity_digest_subscription_path, notice: "Daily Slack digest enabled for #{subscription.channel_mention}."
end

def update
subscription = find_subscription
unless subscription
redirect_to my_activity_digest_subscription_path, alert: "Digest is not enabled yet." and return
end

subscription.update!(update_params)
redirect_to my_activity_digest_subscription_path, notice: "Digest preferences updated."
end

def destroy
subscription = find_subscription
if subscription
subscription.update!(enabled: false)
end

redirect_to my_activity_digest_subscription_path, notice: "Daily Slack digest disabled."
end

private

def find_subscription
channel_id = current_user.slack_neighborhood_channel
return nil if channel_id.blank?

SlackActivityDigestSubscription.find_by(slack_channel_id: channel_id)
end

def create_params
params.fetch(:slack_activity_digest_subscription, {}).permit(:timezone, :delivery_hour, :slack_team_id)
end

def update_params
params.require(:slack_activity_digest_subscription).permit(:timezone, :delivery_hour)
end

def ensure_current_user
redirect_to root_path, alert: "You must be logged in to manage the digest." unless current_user
end
end
end
47 changes: 47 additions & 0 deletions app/jobs/slack_activity_digest_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
class SlackActivityDigestJob < ApplicationJob
queue_as :latency_5m

include GoodJob::ActiveJobExtensions::Concurrency

good_job_control_concurrency_with(
total_limit: 1,
key: -> { "slack_activity_digest_job_#{arguments.first}" }
)

def perform(subscription_id, as_of_epoch = nil)
subscription = SlackActivityDigestSubscription.find_by(id: subscription_id)
return unless subscription&.enabled?

as_of = as_of_epoch ? Time.at(as_of_epoch).utc : Time.current

result = SlackActivityDigestService.new(subscription: subscription, as_of: as_of).build

deliver(subscription, result)
subscription.mark_delivered!(as_of)
end

private

def deliver(subscription, result)
token = ENV["SLACK_ACTIVITY_DIGEST_BOT_TOKEN"]
raise "Missing SLACK_ACTIVITY_DIGEST_BOT_TOKEN" if token.blank?

payload = {
channel: subscription.slack_channel_id,
text: result.fallback_text,
blocks: result.blocks
}

response = HTTP.auth("Bearer #{token}")
.post("https://slack.com/api/chat.postMessage", json: payload)

body = JSON.parse(response.body)
if body["ok"] != true
Rails.logger.error("SlackActivityDigestJob failed for #{subscription.slack_channel_id}: #{body['error'] || 'unknown error'}")
raise "Slack API error: #{body['error'] || 'unknown error'}"
end
rescue StandardError => e
Rails.logger.error("SlackActivityDigestJob encountered error: #{e.message}")
raise
end
end
18 changes: 18 additions & 0 deletions app/jobs/slack_activity_digest_scheduler_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
class SlackActivityDigestSchedulerJob < ApplicationJob
queue_as :literally_whenever

include GoodJob::ActiveJobExtensions::Concurrency

good_job_control_concurrency_with(
total_limit: 1,
key: "slack_activity_digest_scheduler"
)

def perform(reference_time = Time.current)
SlackActivityDigestSubscription.enabled.find_each do |subscription|
next unless subscription.due_for_delivery?(reference_time)

SlackActivityDigestJob.perform_later(subscription.id, reference_time.to_i)
end
end
end
37 changes: 37 additions & 0 deletions app/models/slack_activity_digest_subscription.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
class SlackActivityDigestSubscription < ApplicationRecord
TIMEZONE_NAMES = ActiveSupport::TimeZone.all.map(&:name).freeze

belongs_to :created_by_user, class_name: "User", optional: true

validates :slack_channel_id, presence: true, uniqueness: true
validates :timezone, presence: true, inclusion: { in: TIMEZONE_NAMES }
validates :delivery_hour, presence: true, inclusion: { in: 0..23 }

scope :enabled, -> { where(enabled: true) }

def due_for_delivery?(reference_time = Time.current)
return false unless enabled?

tz = active_time_zone
local_reference = reference_time.in_time_zone(tz)

return local_reference.hour >= delivery_hour if last_delivered_at.blank?

last_local = last_delivered_at.in_time_zone(tz)
return false if last_local.to_date == local_reference.to_date

local_reference.hour >= delivery_hour
end

def mark_delivered!(delivered_at = Time.current)
update!(last_delivered_at: delivered_at)
end

def channel_mention
"<##{slack_channel_id}>"
end

def active_time_zone
ActiveSupport::TimeZone[timezone] || ActiveSupport::TimeZone["UTC"]
end
end
123 changes: 123 additions & 0 deletions app/services/slack_activity_digest_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
class SlackActivityDigestService
Result = Struct.new(:blocks, :fallback_text, :period_start, :period_end, :total_seconds, :active_user_ids, keyword_init: true)

def initialize(subscription:, as_of: Time.current)
@subscription = subscription
@reference_time = as_of
@helpers = ApplicationController.helpers
end

def build
users = users_in_channel
user_records = users.to_a
user_ids = user_records.map(&:id)
period = digest_period

heartbeat_scope = Heartbeat.where(user_id: user_ids)
.where("time >= ? AND time < ?", period[:start_epoch], period[:end_epoch])

total_seconds = Heartbeat.duration_seconds(heartbeat_scope)

per_user = Heartbeat.duration_seconds(heartbeat_scope.group(:user_id))
per_project = Heartbeat.duration_seconds(heartbeat_scope.where.not(project: [ nil, "" ]).group(:project))

active_user_ids = per_user.keys

Result.new(
blocks: build_blocks(user_records.index_by(&:id), period, total_seconds, per_user, per_project),
fallback_text: fallback_text(period),
period_start: period[:start_time],
period_end: period[:end_time],
total_seconds: total_seconds,
active_user_ids: active_user_ids
)
end

private

def users_in_channel
User.where(slack_neighborhood_channel: @subscription.slack_channel_id)
end

def digest_period
tz = @subscription.active_time_zone
reference_local = @reference_time.in_time_zone(tz)
period_end = reference_local.beginning_of_day
period_start = period_end - 1.day

{
start_time: period_start,
end_time: period_end,
start_epoch: period_start.to_i,
end_epoch: period_end.to_i,
timezone: tz
}
end

def build_blocks(users_map, period, total_seconds, per_user, per_project)
channel_mention = @subscription.channel_mention
day_label = period[:start_time].strftime("%B %d")

blocks = []

header_text = "*Daily coding highlights for #{channel_mention} — #{day_label}*"
blocks << { type: "section", text: { type: "mrkdwn", text: header_text } }

if total_seconds.positive?
active_count = per_user.length
summary_text = "*Team total:* #{@helpers.short_time_detailed(total_seconds)} across #{active_count} #{'hacker'.pluralize(active_count)}"
blocks << { type: "section", text: { type: "mrkdwn", text: summary_text } }
blocks << { type: "divider" }

add_top_users_block(blocks, users_map, per_user)
add_top_projects_block(blocks, per_project)
else
blocks << { type: "section", text: { type: "mrkdwn", text: no_activity_text(period, channel_mention) } }
end

blocks
end

def add_top_users_block(blocks, users_map, per_user)
return if per_user.blank?

lines = per_user.sort_by { |_, seconds| -seconds }.first(5).each_with_index.map do |(user_id, seconds), index|
user = users_map[user_id]
handle = user&.slack_uid.present? ? "<@#{user.slack_uid}>" : user&.display_name || "User ##{user_id}"
"#{index + 1}. #{handle} – #{@helpers.short_time_simple(seconds)}"
end

return if lines.empty?

blocks << {
type: "section",
text: { type: "mrkdwn", text: "*Top hackers yesterday*\n" + lines.join("\n") }
}
end

def add_top_projects_block(blocks, per_project)
return if per_project.blank?

lines = per_project.sort_by { |_, seconds| -seconds }.first(5).map do |project, seconds|
project_name = project.presence || "Untitled"
"• *#{project_name}* – #{@helpers.short_time_simple(seconds)}"
end

return if lines.empty?

blocks << {
type: "section",
text: { type: "mrkdwn", text: "*Focus projects*\n" + lines.join("\n") }
}
end

def no_activity_text(period, channel_mention)
day_label = period[:start_time].strftime("%B %d")
":zzz: No coding activity recorded for #{channel_mention} on #{day_label}. Let’s ship something today!"
end

def fallback_text(period)
day_label = period[:start_time].strftime("%B %d")
"Daily coding highlights for #{day_label}"
end
end
54 changes: 54 additions & 0 deletions app/views/my/activity_digest_subscriptions/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<div class="prose max-w-3xl mx-auto space-y-6">
<h1 class="text-3xl font-semibold">Slack activity digest</h1>

<% if @channel_id.blank? %>
<p class="text-gray-700">We could not detect a Slack neighborhood channel for your account yet. Once your account is linked to a team channel, you can enable the daily digest here.</p>
<% else %>
<section class="space-y-4">
<p class="text-gray-700">
We'll post a daily coding recap for <span class="font-semibold"><%= @subscription&.channel_mention || "<##{@channel_id}>" %></span>
covering the previous day once the local clock hits your chosen delivery hour.
</p>

<% if @subscription&.enabled? %>
<div class="rounded-lg border border-green-200 bg-green-50 p-4">
<p class="font-medium text-green-800">Digest is currently enabled.</p>
<p class="text-sm text-green-700">Last delivered: <%= @subscription.last_delivered_at ? l(@subscription.last_delivered_at, format: :long) : "pending" %></p>
</div>

<%= form_with model: @subscription, url: my_activity_digest_subscription_path, method: :patch, class: "space-y-4" do |f| %>
<div>
<%= f.label :timezone, "Timezone", class: "block text-sm font-medium" %>
<%= f.select :timezone, options_for_select(@timezones, @subscription.timezone), {}, class: "mt-1 block w-full rounded-md border-gray-300" %>
</div>

<div>
<%= f.label :delivery_hour, "Delivery hour (0-23)", class: "block text-sm font-medium" %>
<%= f.number_field :delivery_hour, in: 0..23, class: "mt-1 block w-full rounded-md border-gray-300" %>
</div>

<div class="flex gap-3">
<%= f.submit "Save changes", class: "btn btn-primary" %>
<%= button_to "Disable digest", my_activity_digest_subscription_path, method: :delete, class: "btn btn-secondary" %>
</div>
<% end %>
<% else %>
<%= form_with model: SlackActivityDigestSubscription.new, url: my_activity_digest_subscription_path, method: :post, class: "space-y-4" do |f| %>
<div>
<%= f.label :timezone, "Timezone", class: "block text-sm font-medium" %>
<%= f.select :timezone, options_for_select(@timezones, current_user.timezone), {}, class: "mt-1 block w-full rounded-md border-gray-300" %>
</div>

<div>
<%= f.label :delivery_hour, "Delivery hour (0-23)", class: "block text-sm font-medium" %>
<%= f.number_field :delivery_hour, in: 0..23, value: 10, class: "mt-1 block w-full rounded-md border-gray-300" %>
</div>

<%= f.hidden_field :slack_team_id, value: params[:slack_team_id] %>

<%= f.submit "Enable daily digest", class: "btn btn-primary" %>
<% end %>
<% end %>
</section>
<% end %>
</div>
4 changes: 4 additions & 0 deletions app/views/users/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@
<p class="text-xs text-gray-400">
You can enable notifications for specific channels by running <code class="px-1 py-0.5 bg-gray-800 rounded text-gray-200">/sailorslog on</code> in the Slack channel.
</p>
<div class="mt-3">
<%= link_to "Open daily digest preferences", my_activity_digest_subscription_path,
class: "inline-flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm font-medium rounded transition-colors duration-200" %>
</div>
</div>
</div>
</div>
Expand Down
4 changes: 4 additions & 0 deletions config/initializers/good_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
args: [ :daily ],
kwargs: { force_update: true }
},
slack_activity_digest_scheduler: {
cron: "15 * * * *",
class: "SlackActivityDigestSchedulerJob"
},
last_7_days_leaderboard_update: {
cron: "*/7 * * * *",
class: "LeaderboardUpdateJob",
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def matches?(request)
namespace :my do
resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ }
resource :mailing_address, only: [ :show, :edit ]
resource :activity_digest_subscription, only: [ :show, :create, :update, :destroy ]
get "mailroom", to: "mailroom#index"
resources :heartbeats, only: [] do
collection do
Expand Down
Loading
Loading