diff --git a/CHANGELOG.md b/CHANGELOG.md index dde1240..4f46548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### 2.3.0 (Next) +* [#191](https://github.com/slack-ruby/slack-ruby-bot-server/pull/191): Refresh workspace names and domains from successful authentication during pings, and expose `Team#update_info!` for standalone metadata refresh without additional scopes - [@dblock](https://github.com/dblock), [@Copilot](https://github.com/apps/copilot-swe-agent). * [#188](https://github.com/slack-ruby/slack-ruby-bot-server/pull/188): Reduce the database CI matrices from 22 to 12 jobs while retaining representative legacy ORM majors and modern Ruby/Grape 3/4 coverage - [@dblock](https://github.com/dblock), [@Copilot](https://github.com/apps/copilot-swe-agent). * [#189](https://github.com/slack-ruby/slack-ruby-bot-server/pull/189): Add a prefixed Team fabricator entry point and preserve application-defined `:team` fabricators in the legacy RSpec helper - [@dblock](https://github.com/dblock), [@Copilot](https://github.com/apps/copilot-swe-agent). * [#186](https://github.com/slack-ruby/slack-ruby-bot-server/pull/186): Document persisted custom Team attributes for both adapters and Rails concern loading/reloading - [@dblock](https://github.com/dblock), [@Copilot](https://github.com/apps/copilot-swe-agent). diff --git a/README.md b/README.md index 24c5d82..554b0fe 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Build a complete Slack bot service with Slack button integration, in Ruby. - [Mongoid Attributes](#mongoid-attributes) - [Active Record Attributes](#active-record-attributes) - [Rails Loading and Reloading](#rails-loading-and-reloading) + - [Workspace Metadata](#workspace-metadata) - [OAuth Version and Scopes](#oauth-version-and-scopes) - [Slack App](#slack-app) - [API](#api) @@ -185,6 +186,20 @@ With Active Record, use the migration above instead of declaring a field in the The framework currently owns the global `Team` constant. An unrelated application model with the same name is a separate [name-collision limitation](https://github.com/slack-ruby/slack-ruby-bot-server/pull/83), not an extension mechanism. +#### Workspace Metadata + +`Team#ping!` refreshes the stored workspace `name` and `domain` from its existing successful [`auth.test`](https://docs.slack.dev/reference/methods/auth.test/) response, without making an additional API request. `domain` is the first hostname label of the workspace URL (for example, `example` from `https://example.slack.com/`). Unchanged metadata is not written to the database. Authentication, malformed-response, and persistence failures propagate instead of returning a successful refresh. + +Call `team.update_info!` to refresh only this information without fetching presence. `auth.test` requires no additional scopes, unlike `team.info`. To refresh when an installation starts, opt into an additional authentication request through a lifecycle callback: + +```ruby +SlackRubyBotServer::Service.instance.on :started do |team| + team.update_info! +end +``` + +This callback is optional; no new authentication request is added to the default startup lifecycle. It refreshes workspace metadata, not transport connection state. + ### OAuth Version and Scopes Configure your app's [OAuth version](https://api.slack.com/authentication/oauth-v2) and [scopes](https://api.slack.com/legacy/oauth-scopes) as needed by your application. diff --git a/UPGRADING.md b/UPGRADING.md index c4b25d8..02fbcb0 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -3,6 +3,10 @@ Upgrading Slack-Ruby-Bot-Server ### Upgrading to >= 2.3.0 +#### Workspace Metadata Refresh + +`Team#ping!` now persists the current workspace `name` and `domain` when they differ from the successful `auth.test` response. This adds no Slack request, but pings (including `/api/status`) can now write to the `teams` collection/table and surface persistence errors. `domain` is derived from the workspace URL's first hostname label. Applications needing a standalone refresh can call `team.update_info!`. + #### Recommended Test Helper Migration If your application defines its own `:team` fabricator, replace `require 'slack-ruby-bot-server/rspec'` with `require 'slack-ruby-bot-server/rspec/fabricators'`. This is recommended, not required: the new entry point registers only `:slack_ruby_bot_server_team`, leaving `:team` available regardless of whether your application's fabricator is loaded before or after the helper. diff --git a/lib/slack-ruby-bot-server/models/team/methods.rb b/lib/slack-ruby-bot-server/models/team/methods.rb index 2b2dc1f..995d06d 100644 --- a/lib/slack-ruby-bot-server/models/team/methods.rb +++ b/lib/slack-ruby-bot-server/models/team/methods.rb @@ -1,3 +1,5 @@ +require 'uri' + module Methods extend ActiveSupport::Concern @@ -34,6 +36,7 @@ def ping! client = Slack::Web::Client.new(token: token) auth = client.auth_test + update_info!(auth) presence = begin client.users_getPresence(user: auth['user_id']) @@ -47,6 +50,21 @@ def ping! } end + def update_info!(auth = nil) + auth ||= Slack::Web::Client.new(token: token).auth_test + raise ArgumentError, 'Slack authentication belongs to a different workspace.' unless auth.fetch('team_id') == team_id + + name = auth.fetch('team') + raise ArgumentError, 'Slack authentication returned an invalid workspace name.' unless name.is_a?(String) && !name.empty? + + url = URI.parse(auth.fetch('url')) + raise ArgumentError, 'Slack authentication returned an invalid workspace URL.' unless url.is_a?(URI::HTTP) && url.host + + attributes = { name: name, domain: url.host.split('.').first } + update!(attributes) if attributes.any? { |key, value| public_send(key) != value } + auth + end + def ping_if_active! return unless active? diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index f3c222d..3c9f08d 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -1,6 +1,76 @@ require 'spec_helper' describe Team do + context 'workspace metadata' do + let(:team) { Fabricate(:team, name: 'Old name', domain: 'old-domain') } + let(:auth) do + Slack::Messages::Message.new( + 'ok' => true, + 'team_id' => team.team_id, + 'team' => 'New workspace name', + 'url' => 'https://new-domain.slack.com/', + 'user_id' => 'UBOT' + ) + end + let(:slack_client) { instance_double(Slack::Web::Client) } + let(:presence) { { 'presence' => 'active' } } + + before do + allow(Slack::Web::Client).to receive(:new).with(token: team.token).and_return(slack_client) + allow(slack_client).to receive(:auth_test).and_return(auth) + allow(slack_client).to receive(:users_getPresence).with(user: 'UBOT').and_return(presence) + end + + it 'persists current workspace info from a ping without an extra API request' do + expect(slack_client).to receive(:auth_test).once.and_return(auth) + expect(team.ping!).to eq(auth: auth, presence: presence) + expect(team.reload.name).to eq 'New workspace name' + expect(team.domain).to eq 'new-domain' + end + + it 'can refresh workspace info without fetching presence' do + expect(slack_client).not_to receive(:users_getPresence) + expect(team.update_info!).to eq auth + expect(team.reload.name).to eq 'New workspace name' + expect(team.domain).to eq 'new-domain' + end + + it 'does not write an unchanged workspace' do + team.update!(name: 'New workspace name', domain: 'new-domain') + expect(team).not_to receive(:update!) + team.ping! + end + + it 'refreshes info even when presence requires an additional scope' do + allow(slack_client).to receive(:users_getPresence).and_raise(Slack::Web::Api::Errors::MissingScope, 'missing_scope') + expect(team.ping!).to eq(auth: auth, presence: nil) + expect(team.reload.domain).to eq 'new-domain' + end + + it 'does not change stored info when Slack authentication fails' do + allow(slack_client).to receive(:auth_test).and_raise(Slack::Web::Api::Errors::SlackError, 'invalid_auth') + expect { team.ping! }.to raise_error(Slack::Web::Api::Errors::SlackError, 'invalid_auth') + expect(team.reload.name).to eq 'Old name' + expect(team.domain).to eq 'old-domain' + end + + it 'rejects a response for another workspace without changing stored info' do + expect do + team.update_info!(auth.merge('team_id' => 'TOTHER')) + end.to raise_error(ArgumentError, 'Slack authentication belongs to a different workspace.') + expect(team.reload.name).to eq 'Old name' + expect(team.domain).to eq 'old-domain' + end + + it 'rejects malformed workspace URLs without partially updating the name' do + expect do + team.update_info!(auth.merge('url' => '/not-a-workspace')) + end.to raise_error(ArgumentError, 'Slack authentication returned an invalid workspace URL.') + expect(team.reload.name).to eq 'Old name' + expect(team.domain).to eq 'old-domain' + end + end + context '#purge!' do let!(:active_team) { Fabricate(:team) } let!(:inactive_team) { Fabricate(:team, active: false) }