Skip to content
Merged
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
15 changes: 11 additions & 4 deletions app/jobs/code_of_conduct_request_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,24 @@ def perform(user_id, deadline: nil, reminder: false)

# The opening of the message, as plain-text paragraphs. Shared by the Slack
# DM and the email.
def self.paragraphs(user, deadline: nil)
#
# reason is :update for the first request, :demoted for a member who did not
# accept by the deadline and is now a guest, or :admin for a Slack admin who
# can't be demoted (see DemoteForCodeOfConductJob).
def self.paragraphs(user, deadline: nil, reason: :update)
t = ->(key, **args) { I18n.t("code_of_conduct_request.#{key}", **args) }
[
# Capitalized like User#full_name: many Slack-imported names are lowercase.
t.call(:greeting, first_name: user.name_missing? ? "there" : user.first_name.gsub(/\b\p{L}/, &:upcase)),
*t.call(:update).split(/\n{2,}/),
*t.call(reason).split(/\n{2,}/),
(t.call(:name_missing) if user.name_missing?),
(t.call(:deadline, deadline: deadline.strftime("%B %-d, %Y")) if deadline)
(t.call(:deadline, deadline: deadline.strftime("%B %-d, %Y")) if deadline && reason == :update)
].compact
end

# The header of the Slack DM for a reason (see .paragraphs).
def self.slack_title(reason) = I18n.t("code_of_conduct_request.slack_titles.#{reason}")

private

# A Slack failure must not stop the email.
Expand All @@ -49,7 +56,7 @@ def send_slack_dm(user, deadline)

service.post_code_of_conduct(
user.slack_id,
title: I18n.t("code_of_conduct_request.slack_title"),
title: self.class.slack_title(:update),
paragraphs: self.class.paragraphs(user, deadline:),
form: user.name_missing?
)
Expand Down
60 changes: 60 additions & 0 deletions app/jobs/demote_for_code_of_conduct_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# frozen_string_literal: true

# Moves a full member who joined before the code-of-conduct flow, and did not
# accept it by the deadline, back to a single-channel guest in the
# code-of-conduct channel. They then go through the same flow as a new member:
# a DM and an email ask them to accept, and accepting promotes them again (see
# SlackCodeOfConductAcceptedJob).
#
# Slack workspace admins and owners can't be demoted. They get their own
# request instead: a DM whose button opens the form with the box to check,
# and an email. Their apps already need the acceptance (see AppAccess).
#
# Enqueued by `bin/rails slack:coc:demote`. Skips anyone who accepted in the
# meantime, and exempt users.
class DemoteForCodeOfConductJob < ApplicationJob
queue_as :default

def perform(user_id)
user = User.find_by(id: user_id)
return unless user&.slack_member? && user.slack_id.present?
return if user.slack_coc_accepted_at.present? || user.code_of_conduct_exempt

service = SlackService.new
return unless service.configured?

if service.workspace_admin?(user.slack_id)
Rails.logger.info "Not demoting Slack admin #{user.slack_id} for CoC; sending the admin request"
notify(service, user, reason: :admin, form: true)
return
end

result = service.demote_to_guest(user.slack_id)
unless result[:ok]
Rails.logger.error "Failed to demote #{user.slack_id} for CoC: #{result[:error]}"
return
end

user.update_columns(slack_membership: "pending", updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
notify(service, user, reason: :demoted, form: user.name_missing?)
end

private

# A Slack failure must not stop the email.
def notify(service, user, reason:, form:)
begin
service.post_code_of_conduct(
user.slack_id,
title: CodeOfConductRequestJob.slack_title(reason),
paragraphs: CodeOfConductRequestJob.paragraphs(user, reason:),
form: form
)
rescue => e
Rails.logger.error "Failed to DM the CoC (#{reason}) to #{user.slack_id}: #{e.message}"
end
UserMailer.code_of_conduct_request(user, reason:).deliver_later
user.update_columns(slack_coc_requested_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
end

end
4 changes: 2 additions & 2 deletions app/mailers/user_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ def slack_email_address_added(email_address)
# Asks a member who joined the Slack before the code-of-conduct flow to
# accept it (see CodeOfConductRequestJob). Signing in sends them to /slack,
# which shows the accept step.
def code_of_conduct_request(user, deadline: nil)
def code_of_conduct_request(user, deadline: nil, reason: :update)
@user = user
@paragraphs = CodeOfConductRequestJob.paragraphs(user, deadline:)
@paragraphs = CodeOfConductRequestJob.paragraphs(user, deadline:, reason:)
@login_url = login_url
@code_of_conduct_url = SlackService.code_of_conduct_url

Expand Down
30 changes: 30 additions & 0 deletions app/services/slack_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,36 @@ def promote_to_member(slack_user_id)
{ ok: false, error: "request_failed", raw: nil }
end

# Move a full member back to a single-channel guest in the code-of-conduct
# channel, via the undocumented `users.admin.setUltraRestricted` endpoint
# (same xoxc/xoxd auth as #promote_to_member, which undoes it). Used for
# members who joined before the code-of-conduct flow and did not accept it
# by the deadline. Returns { ok:, error:, raw: }.
def demote_to_guest(slack_user_id)
raise ConfigurationError, "Slack code-of-conduct channel (SLACK_COC_CHANNEL) not configured" if coc_channel.blank?

body = admin_api_post("users.admin.setUltraRestricted", {
"user" => slack_user_id,
"channel" => coc_channel,
"team_id" => team_id,
"_x_reason" => "member-set-ultra-restricted"
})

Rails.logger.error "Slack demote failed for #{slack_user_id}: #{body["error"].inspect}" unless body["ok"]
{ ok: !!body["ok"], error: body["error"], raw: body }
rescue Faraday::Error => e
Rails.logger.error "Slack demote request error for #{slack_user_id}: #{e.message}"
{ ok: false, error: "request_failed", raw: nil }
end

# True for workspace admins and owners. They are never demoted.
def workspace_admin?(slack_user_id)
raise ConfigurationError, "Slack client not configured" unless @client

user = @client.users_info(user: slack_user_id)["user"] || {}
user["is_admin"] || user["is_owner"] || user["is_primary_owner"] || false
end

# DM someone the code of conduct with a button to accept it. Uses the bot
# token (needs chat:write + im:write). Returns the Slack API response. The
# blocks come from CodeOfConductSlackViews.request.
Expand Down
11 changes: 10 additions & 1 deletion config/locales/code_of_conduct_request.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,17 @@ en:
Here is a quick update on Patchwork Labs. We set up Weave, our new account system. Your Weave account is linked to your Slack account, and it is how you sign in to Patchwork Labs apps.

New members now join the Slack through Weave, and they accept our Code of Conduct when they join. You joined before we added that step, so we ask you to accept it now. It takes a few seconds.
demoted: |-
We asked you to accept our Code of Conduct, and we have not seen your acceptance yet. Your Slack account is now a guest account, so you can only see the Code of Conduct channel.

Accept the Code of Conduct to get full access back right away.
admin: |-
You are a Slack admin, so your account stays a full member. As an admin, please accept the Code of Conduct now, so that everyone in the Slack has accepted it.
name_missing: "We also don't have your name yet. Please add it when you accept."
deadline: "Please accept by %{deadline}. After that date, members who have not accepted move to guest access in the Slack until they accept."
slack_title: "📜 Please accept our Code of Conduct"
slack_titles:
update: "📜 Please accept our Code of Conduct"
demoted: "📜 Your Slack account is now a guest account"
admin: "📜 Slack admins: please accept our Code of Conduct"
email_action: "Sign in to Weave with %{email} to accept."
sign_off: "Thanks for being part of Patchwork Labs!"
40 changes: 40 additions & 0 deletions docs/CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Code of Conduct acceptance

Every Slack member accepts the Patchwork Labs Code of Conduct. New members accept it when they join (see `User#slack_onboarding_step`). This page is about members who joined before that step existed.

## The flow

```mermaid
flowchart TD
A[Full member, no acceptance] -->|slack:coc:request| B[Slack DM + email]
B -->|DM button or /slack| C[Accepted]
B -->|no answer by the deadline| D[slack:coc:demote]
D --> E[Single-channel guest in the CoC channel, DM + email]
E -->|DM button or /slack| F[Accepted, promoted to full member]
```

- The DM button accepts at once. When the Slack import saved a placeholder name (`NOTSET`, `Unknown`, `User`), the button opens a Slack form instead. The form has a required "I have read and accept" box, and name fields when the name is missing.
- The email links to sign-in. After sign-in, `/` sends the member to `/slack`, which shows the same accept step and name fields.
- The message text is in `config/locales/code_of_conduct_request.en.yml`.

## Runbook

Run these in the production container.

1. Count the members: `bin/rails slack:coc:status`.
2. Preview the message: `bin/rails slack:coc:request DEADLINE=2026-11-01`.
3. Send it: `bin/rails slack:coc:request SEND=1 DEADLINE=2026-11-01`.
4. Optional: send a reminder: `bin/rails slack:coc:request SEND=1 REMIND=1 DEADLINE=2026-11-01`.
5. After the deadline, preview the demotions: `bin/rails slack:coc:demote ASKED_BEFORE=2026-10-15`.
6. Demote: `bin/rails slack:coc:demote CONFIRM=1 ASKED_BEFORE=2026-10-15`. Slack admins and owners cannot be demoted. They get a DM whose button opens the form with the box to check, and an email.

## App access

Every OAuth app requires the code of conduct (see `AppAccess`). A user who has not accepted cannot use the app. The authorize endpoint sends them to `/slack` to accept and then back to the app. Their existing tokens stop working until they accept.

A superadmin can opt out:

- one app, on the app's admin page (`oauth_applications.requires_code_of_conduct`), or
- one user, on the user's admin page (`users.code_of_conduct_exempt`). Exempt users are also not asked to accept and not demoted.

The access policy of the app still applies in both cases.
25 changes: 25 additions & 0 deletions lib/tasks/slack.rake
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,30 @@ namespace :slack do
end
puts "Enqueued #{users.size} requests."
end

# Moves members who were asked and still have not accepted back to
# single-channel guests. Nothing changes without CONFIRM=1.
#
# bin/rails slack:coc:demote # preview
# bin/rails slack:coc:demote CONFIRM=1 # asked 14 or more days ago
# bin/rails slack:coc:demote CONFIRM=1 ASKED_BEFORE=2026-10-15
desc "Move members who did not accept the code of conduct back to guests (CONFIRM=1 to run)"
task demote: :environment do
asked_before = ENV["ASKED_BEFORE"].presence ? Date.iso8601(ENV["ASKED_BEFORE"]).beginning_of_day : 14.days.ago
users = User.code_of_conduct_pending.where(slack_coc_requested_at: ...asked_before).order(:id).to_a

puts "Members asked before #{asked_before.to_date} who have not accepted: #{users.size}"
users.each { |user| puts " #{user.email} (#{user.slack_id}), asked #{user.slack_coc_requested_at.to_date}" }

unless ENV["CONFIRM"] == "1"
puts "Preview only. Run again with CONFIRM=1 to demote them."
next
end

users.each_with_index do |user, index|
DemoteForCodeOfConductJob.set(wait: (index * 3).seconds).perform_later(user.id)
end
puts "Enqueued #{users.size} demotions. Slack admins and owners are not demoted: they get a request with the form instead."
end
end
end
55 changes: 55 additions & 0 deletions spec/jobs/demote_for_code_of_conduct_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe DemoteForCodeOfConductJob do
let(:slack) { instance_double(SlackService, configured?: true, workspace_admin?: false, post_code_of_conduct: true) }
let!(:user) do
create(:user, :verified, slack_id: "U1", slack_membership: "member", slack_coc_requested_at: 3.weeks.ago)
end

before { allow(SlackService).to receive(:new).and_return(slack) }

it "makes the member a guest and asks them again by DM and email" do
allow(slack).to receive(:demote_to_guest).with("U1").and_return({ ok: true })
expect(slack).to receive(:post_code_of_conduct)
.with("U1", title: a_string_including("guest account"), paragraphs: include(a_string_including("now a guest account")), form: false)

expect { described_class.perform_now(user.id) }.to have_enqueued_mail(UserMailer, :code_of_conduct_request)

user.reload
expect(user).to be_slack_pending
expect(user.slack_onboarding_step).to eq(:accept_code_of_conduct)
end

it "keeps the member when Slack refuses" do
allow(slack).to receive(:demote_to_guest).and_return({ ok: false, error: "not_allowed" })

expect { described_class.perform_now(user.id) }.not_to have_enqueued_mail(UserMailer, :code_of_conduct_request)
expect(user.reload).to be_slack_member
end

it "skips someone who accepted in the meantime" do
user.update!(slack_coc_accepted_at: 1.hour.ago)
expect(slack).not_to receive(:demote_to_guest)

described_class.perform_now(user.id)
end

it "skips an exempt user" do
user.update!(code_of_conduct_exempt: true)
expect(slack).not_to receive(:demote_to_guest)

described_class.perform_now(user.id)
end

it "asks Slack admins and owners to accept through the form, without demoting them" do
allow(slack).to receive(:workspace_admin?).with("U1").and_return(true)
expect(slack).not_to receive(:demote_to_guest)
expect(slack).to receive(:post_code_of_conduct)
.with("U1", title: a_string_including("Slack admins"), paragraphs: include(a_string_including("You are a Slack admin")), form: true)

expect { described_class.perform_now(user.id) }.to have_enqueued_mail(UserMailer, :code_of_conduct_request)
expect(user.reload).to be_slack_member
end
end
18 changes: 18 additions & 0 deletions spec/services/slack_service_invite_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,22 @@ def stub_faraday(response_body)
expect(service.promote_to_member("U123")).to include(ok: false, error: "user_not_found")
end
end

describe "#demote_to_guest" do
it "makes the member a single-channel guest in the CoC channel" do
stub_faraday("ok" => true)

result = service.demote_to_guest("U1")

expect(@url).to eq("https://patchworklabs.slack.com/api/users.admin.setUltraRestricted")
expect(@body).to include("U1", "C_COC", "T123")
expect(result[:ok]).to be true
end

it "reports Slack's error" do
stub_faraday("ok" => false, "error" => "not_allowed")

expect(service.demote_to_guest("U1")).to include(ok: false, error: "not_allowed")
end
end
end
Loading