Skip to content

Commit 1cd1fb8

Browse files
committed
Sync Discord account during OAuth callback
1 parent b4a5aeb commit 1cd1fb8

11 files changed

Lines changed: 167 additions & 18 deletions

File tree

bin/discord_sync

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,5 @@ require_relative '../lib/polish_open_source_rank'
55

66
configuration = PolishOpenSourceRank::Configuration.load
77
composition = PolishOpenSourceRank::Web::Composition.new(configuration: configuration)
8-
period = PolishOpenSourceRank::Shared::Domain::Period.previous_month.start_date.to_s
98

10-
composition.community.sync_discord_connection.call(period_start: period, limit: Integer(ARGV.fetch(0, 10)))
9+
composition.community.sync_discord_connection.call(period_start: nil, limit: Integer(ARGV.fetch(0, 10)))

docs/deployment.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ before granting additional maintainers deploy permission.
6969
configured explicitly.
7070
- Monthly, package, and resume crawls are started by systemd one-shot services
7171
and use the same mounted `db/` and `log/` directories as the web app.
72+
- Discord account sync runs during the Discord OAuth callback; the Discord bot
73+
remains responsible for gateway invite events.
7274
- `/internal/jobs` reflects SQLite state from that shared app database, so stale
7375
package sections usually mean the package crawl is still running, the process
7476
died and left scans in `processing`, or the last package run failed while work

docs/user-actions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
## Operational Outbox Processing
3737

3838
- `bin/discord_sync [limit]` processes pending and retryable Discord jobs.
39+
- Discord OAuth callbacks sync the connected account immediately after storing
40+
the outbox jobs.
3941
- Jobs finish with status `pending`, `synced`, `failed`, or `retryable`.
4042
- Repeating the Discord connect flow overwrites existing intents for the same
4143
profile and action kind instead of creating duplicates.

lib/polish_open_source_rank/contexts/community/application/sync_discord_connection.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ def call(period_start:, limit: 10)
2525
end
2626
end
2727

28+
def call_for(platform:, source_id:, period_start:)
29+
prepared_roles = role_map.prepare(period_start: period_start)
30+
sync_job_repository.pending_for(platform, source_id).each do |job|
31+
sync_job(SyncContext.new(job: job, period_start: period_start, prepared_roles: prepared_roles))
32+
end
33+
end
34+
2835
private
2936

3037
attr_reader :access_read_model, :member_gateway, :profile_read_model, :role_map, :sync_job_repository

lib/polish_open_source_rank/contexts/community/infrastructure/sqlite/sqlite_discord_sync_job_repository.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ def pending(limit: 10)
5757
SQL
5858
end
5959

60+
def pending_for(platform, source_id)
61+
database.fetch_all(<<~SQL, [platform, source_id])
62+
SELECT #{pending_columns_sql}
63+
FROM discord_sync_jobs
64+
JOIN users
65+
ON users.platform = discord_sync_jobs.platform
66+
AND users.github_id = discord_sync_jobs.user_github_id
67+
WHERE discord_sync_jobs.platform = ?
68+
AND discord_sync_jobs.user_github_id = ?
69+
AND discord_sync_jobs.status IN ('pending', 'retryable')
70+
ORDER BY discord_sync_jobs.updated_at ASC
71+
SQL
72+
end
73+
6074
def mark_synced(job)
6175
update_status(job, terminal_status_attributes(status: 'synced', error: nil, synced_at: timestamp))
6276
end

lib/polish_open_source_rank/web/auth/oauth_login_flow.rb

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,14 @@ def success?
7373
end
7474

7575
def initialize(github_oauth_client:, discord_oauth_client:, public_github_profile:,
76-
register_public_github_profile:, connect_discord_account:)
76+
register_public_github_profile:, connect_discord_account:, sync_discord_connection:)
7777
@dependencies = {
7878
github_oauth_client: github_oauth_client,
7979
discord_oauth_client: discord_oauth_client,
8080
public_github_profile: public_github_profile,
8181
register_public_github_profile: register_public_github_profile,
82-
connect_discord_account: connect_discord_account
82+
connect_discord_account: connect_discord_account,
83+
sync_discord_connection: sync_discord_connection
8384
}
8485
end
8586

@@ -101,11 +102,12 @@ def discord_authorize_url(state:, redirect_uri:)
101102
def finish_discord(login)
102103
token = login.exchange_code(discord_oauth_client)
103104
access_token = token.fetch('access_token')
104-
login.connect_account(
105+
connection = login.connect_account(
105106
use_case: connect_discord_account,
106107
discord_user: discord_oauth_client.user(access_token),
107108
access_token: access_token
108109
)
110+
sync_connected_account(connection)
109111
DiscordResult.success
110112
rescue DiscordOAuthClient::Error
111113
DiscordResult.failure('oauth')
@@ -156,6 +158,19 @@ def register_public_github_profile
156158
def connect_discord_account
157159
dependencies.fetch(:connect_discord_account)
158160
end
161+
162+
def sync_discord_connection
163+
dependencies.fetch(:sync_discord_connection)
164+
end
165+
166+
def sync_connected_account(connection)
167+
profile = connection.profile
168+
sync_discord_connection.call_for(
169+
platform: profile.fetch(:platform),
170+
source_id: profile.fetch(:source_id),
171+
period_start: nil
172+
)
173+
end
159174
end
160175
end
161176
end

lib/polish_open_source_rank/web/routes/auth_flow.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ def login_flow
7373
discord_oauth_client: community.discord_oauth_client,
7474
public_github_profile: method(:public_github_profile),
7575
register_public_github_profile: context_call(:publication).register_public_github_profile,
76-
connect_discord_account: community.connect_discord_account
76+
connect_discord_account: community.connect_discord_account,
77+
sync_discord_connection: community.sync_discord_connection
7778
)
7879
end
7980

spec/polish_open_source_rank/contexts/community/application/sync_discord_connection_spec.rb

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,34 @@ def sync_joined_member(**)
7070
expect(repository.sync_status('github', 1)).to eq('synced')
7171
end
7272

73+
it 'syncs only the requested connected account' do
74+
repository = seeded_repository
75+
gateway = MemberGatewayForSync.new
76+
repository.request_oauth_sync(
77+
platform: 'github',
78+
source_id: 1,
79+
discord_user_id: 'discord-1',
80+
discord_username: 'Alice',
81+
access_token: 'access-token',
82+
welcome_channel_id: nil
83+
)
84+
insert_user(2, 'bob')
85+
repository.request_oauth_sync(
86+
platform: 'github',
87+
source_id: 2,
88+
discord_user_id: 'discord-2',
89+
discord_username: 'Bob',
90+
access_token: 'bob-token',
91+
welcome_channel_id: nil
92+
)
93+
94+
use_case(repository, gateway).call_for(platform: 'github', source_id: 1, period_start: '2026-04-01')
95+
96+
expect(gateway.synced).to include(discord_user_id: 'discord-1', access_token: 'access-token')
97+
expect(repository.sync_status('github', 1)).to eq('synced')
98+
expect(repository.sync_status('github', 2)).to eq('pending')
99+
end
100+
73101
it 'retries invite sync failures and marks repeated failures as failed' do
74102
repository = seeded_repository
75103
repository.request_invite_sync(
@@ -98,14 +126,18 @@ def use_case(repository, gateway)
98126
end
99127

100128
def seeded_repository
101-
database = PolishOpenSourceRank::Shared::Infrastructure::SQLite::Database.open(
129+
@database = PolishOpenSourceRank::Shared::Infrastructure::SQLite::Database.open(
102130
File.join(Dir.mktmpdir, 'rank.sqlite3')
103131
)
104-
database.execute_batch(PolishOpenSourceRank::Infrastructure::SQLiteSchema.sql)
105-
database.execute(
132+
@database.execute_batch(PolishOpenSourceRank::Infrastructure::SQLiteSchema.sql)
133+
insert_user(1, 'alice')
134+
PolishOpenSourceRank::Contexts::Community::Infrastructure::SQLite::SQLiteDiscordSyncJobRepository.new(@database)
135+
end
136+
137+
def insert_user(source_id, login)
138+
@database.execute(
106139
'INSERT INTO users(platform, github_id, login, html_url, updated_at) VALUES (?, ?, ?, ?, ?)',
107-
['github', 1, 'alice', 'https://github.com/alice', '2026-05-01T00:01:00Z']
140+
['github', source_id, login, "https://github.com/#{login}", '2026-05-01T00:01:00Z']
108141
)
109-
PolishOpenSourceRank::Contexts::Community::Infrastructure::SQLite::SQLiteDiscordSyncJobRepository.new(database)
110142
end
111143
end

spec/polish_open_source_rank/contexts/community/infrastructure/sqlite/sqlite_discord_sync_job_repository_spec.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,27 @@
2727
expect(repository.pending(limit: 1).first).to include(discord_username: 'Alice D', login: 'alice', source_id: 1)
2828
end
2929

30+
it 'returns pending jobs scoped to one connected account' do
31+
request_oauth_sync
32+
database.execute(
33+
'INSERT INTO users(platform, github_id, login, html_url, updated_at) VALUES (?, ?, ?, ?, ?)',
34+
['github', 2, 'bob', 'https://github.com/bob', '2026-05-01T00:01:00Z']
35+
)
36+
repository.request_oauth_sync(
37+
platform: 'github',
38+
source_id: 2,
39+
discord_user_id: 'discord-2',
40+
discord_username: 'Bob',
41+
access_token: 'bob-token',
42+
welcome_channel_id: nil
43+
)
44+
45+
jobs = repository.pending_for('github', 1)
46+
47+
expect(jobs.map { |job| job.fetch(:discord_user_id) }).to all(eq('discord-1'))
48+
expect(jobs.map { |job| job.fetch(:action_kind) }).to contain_exactly('member_sync', 'welcome_message')
49+
end
50+
3051
it 'skips welcome messages when OAuth sync has no welcome channel' do
3152
request_oauth_sync(welcome_channel_id: nil)
3253

spec/polish_open_source_rank/web/app_spec.rb

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,40 @@ def sync_joined_member(**attributes)
7676
def post_welcome_message(**attributes)
7777
@welcome = attributes
7878
end
79+
80+
def guild_roles
81+
@guild_roles ||= [
82+
{ 'id' => 'role-top-100', 'name' => 'Polish Elite' },
83+
{ 'id' => 'role-krakow', 'name' => 'Kraków Elite' },
84+
{ 'id' => 'role-gold', 'name' => 'Top 1' }
85+
]
86+
end
87+
88+
def guild_channels
89+
@guild_channels ||= [{ 'id' => 'language-category', 'name' => 'Languages', 'type' => 4 }]
90+
end
91+
92+
def create_role(name:, color: nil)
93+
{ 'id' => "role-#{name.downcase.tr(' ', '-')}", 'name' => name, 'color' => color }.tap do |role|
94+
guild_roles << role
95+
end
96+
end
97+
98+
def create_channel(name:, type:, parent_id: nil, permission_overwrites: nil)
99+
{
100+
'id' => "channel-#{name}",
101+
'name' => name,
102+
'type' => type,
103+
'parent_id' => parent_id,
104+
'permission_overwrites' => permission_overwrites || []
105+
}.tap do |channel|
106+
guild_channels << channel
107+
end
108+
end
109+
110+
def private_channel_overwrites(role_id)
111+
[{ id: role_id, type: 0, allow: '3072', deny: '0' }]
112+
end
79113
end
80114

81115
class FailingDiscordGateway
@@ -444,7 +478,7 @@ def user(_access_token)
444478

445479
discord_callback = finish_discord_auth(request, github_callback)
446480

447-
expect_queued_discord_sync(request, discord_callback)
481+
expect_synced_discord_account(request, discord_callback, discord_gateway)
448482
expect(github_client.exchanged).to eq(['github-code'])
449483
expect(discord_client.exchanged).to eq(['discord-code'])
450484
end
@@ -577,7 +611,7 @@ def user(_access_token)
577611
expect(profile.body).to include('Nie udało się zsynchronizować konta Discord')
578612
end
579613

580-
it 'queues Discord member sync without waiting for the gateway' do
614+
it 'returns to the profile with retry status when immediate Discord member sync fails' do
581615
ENV['DATABASE_URL'] = "sqlite://#{seed_database}"
582616
ENV['DISCORD_GUILD_ID'] = '1505949566229286972'
583617
ENV['DISCORD_INVITE_CHANNEL_ID'] = '1505949566699176050'
@@ -597,7 +631,7 @@ def user(_access_token)
597631

598632
expect(discord_callback.status).to eq(302)
599633
expect(discord_callback.location).to eq('https://discord.com/channels/1505949566229286972/1505949566699176050')
600-
expect(profile.body).to include('Synchronizacja Discord jest w kolejce')
634+
expect(profile.body).to include('Synchronizacja Discord ponawia się po tymczasowym błędzie')
601635
end
602636

603637
it 'rejects Discord sync when the logged-in GitHub profile is no longer ranked' do
@@ -2178,12 +2212,18 @@ def expect_signed_in_profile(github_callback, profile)
21782212
expect(profile.body).not_to include('Discord niepołączony')
21792213
end
21802214

2181-
def expect_queued_discord_sync(request, discord_callback)
2215+
def expect_synced_discord_account(request, discord_callback, discord_gateway)
21822216
expect(discord_callback.status).to eq(302)
21832217
expect(discord_callback.location).to eq('https://discord.com/channels/guild-1/invite-channel')
21842218
profile = request.get('/users/github/alice', 'HTTP_COOKIE' => cookie_header(discord_callback))
21852219
expect(profile.body).to include('Alice Discord')
2186-
expect(profile.body).to include('Synchronizacja Discord jest w kolejce')
2220+
expect(profile.body).not_to include('Synchronizacja Discord jest w kolejce')
2221+
expect(discord_gateway.synced).to include(
2222+
discord_user_id: 'discord-1',
2223+
access_token: 'discord-access',
2224+
github_login: 'alice'
2225+
)
2226+
expect(discord_gateway.welcome).to include(channel_id: 'invite-channel', discord_user_id: 'discord-1')
21872227
end
21882228

21892229
def expect_repository_profile_page(**responses)

0 commit comments

Comments
 (0)