Skip to content

Commit 229e418

Browse files
authored
Merge pull request #14 from tstromberg/main
implement self-service linking
2 parents 4a5d1bb + 8964b20 commit 229e418

21 files changed

Lines changed: 2608 additions & 118 deletions

README.md

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ The Discord integration for [reviewGOOSE](https://codegroove.dev/reviewgoose/)
99
- Creates Discord threads for new PRs (forum channels) or posts in text channels
1010
- Smart notifications: Delays DMs if user already notified in channel
1111
- Channel auto-discovery: repos automatically map to same-named channels
12+
- **Self-service user linking**: Link your GitHub account with `/goose github-user` command
13+
- **Smart user matching**: Automatically matches by username, display name, or server nickname
1214
- Configurable notification settings via YAML
1315
- Activity-based reports when you come online
1416
- Reliable delivery with deduplication
@@ -44,11 +46,11 @@ Create `.codeGROOVE/discord.yaml`:
4446

4547
```yaml
4648
global:
47-
guild_id: "YOUR_DISCORD_SERVER_ID"
49+
guild_id: YOUR_DISCORD_SERVER_ID
4850

4951
# Optional: Add explicit user mappings if GitHub/Discord usernames differ
5052
# users:
51-
# github-username: "discord-user-id"
53+
# github-username: discord-user-id
5254
```
5355

5456
### 5. Add the Bot to Your Server
@@ -70,12 +72,12 @@ Full configuration options for `.codeGROOVE/discord.yaml`:
7072

7173
```yaml
7274
global:
73-
guild_id: "1234567890123456789"
75+
guild_id: 1234567890123456789
7476
reminder_dm_delay: 65 # Minutes to wait before sending DM (default: 65, 0 = disabled)
7577

7678
users:
77-
alice: "111111111111111111" # GitHub username → Discord user ID
78-
bob: "222222222222222222"
79+
alice: 111111111111111111 # GitHub username → Discord user ID
80+
bob: discord-bob-username # GitHub username → Discord username
7981
# Unmapped users: bot attempts username match in guild
8082

8183
channels:
@@ -110,24 +112,33 @@ channels:
110112

111113
## User Mapping
112114

113-
The bot maps GitHub → Discord users using a 3-tier lookup system:
115+
The bot maps GitHub → Discord users using a 4-tier lookup system:
114116

115-
### 1. Explicit Config Mapping
117+
### 1. Explicit Config Mapping (Highest Priority)
116118
Checks the `users:` section in `discord.yaml`. Values can be:
117119
- Discord numeric ID: `"111111111111111111"`
118120
- Discord username: Bot will look it up in the guild
119121

120-
### 2. Automatic Username Match
121-
Searches the Discord guild for the GitHub username using progressive matching. At each tier, checks both:
122+
### 2. Self-Service Linking
123+
Users can link their own accounts with `/goose github-user <username>`. Mappings are stored persistently and take priority over automatic discovery.
124+
125+
Example:
126+
```
127+
/goose github-user octocat
128+
```
129+
130+
### 3. Automatic Username Match
131+
Searches the Discord guild for the GitHub username using progressive matching. At each tier, checks:
122132
- Discord **Username** (e.g., `@johndoe`)
123133
- Discord **Display Name** (the name shown in the member list)
134+
- Discord **Server Nickname** (the custom name set for this server)
124135

125136
Matching tiers:
126-
- **Tier 1**: Exact match (checks Username first, then Display Name)
137+
- **Tier 1**: Exact match (checks Username, Display Name, then Nickname)
127138
- **Tier 2**: Case-insensitive match (e.g., `JohnDoe` matches `johndoe`)
128139
- **Tier 3**: Prefix match (e.g., `john` matches `johnsmith`) - only if unambiguous (exactly one match)
129140

130-
### 3. Fallback
141+
### 4. Fallback
131142
If no match is found, mentions GitHub username as plain text (e.g., `octocat` instead of `@octocat`)
132143

133144
---
@@ -136,12 +147,16 @@ If no match is found, mentions GitHub username as plain text (e.g., `octocat` in
136147

137148
**How to get Discord User IDs**: With Developer Mode enabled, right-click any username → Copy User ID
138149

150+
**Pro tip**: Set your Discord server nickname to match your GitHub username for automatic matching!
151+
139152
## Slash Commands
140153

141-
- `/goose status` - Show bot connection status
142-
- `/goose report` - Get your personal PR report
143-
- `/goose dashboard` - Link to web dashboard
144-
- `/goose help` - Show help
154+
- `/goose status` - Show bot connection status and statistics
155+
- `/goose dash` - Get your personal PR report and dashboard links
156+
- `/goose github-user <username>` - Link your Discord account to a GitHub username
157+
- `/goose users` - Show all GitHub ↔ Discord user mappings
158+
- `/goose channels` - Show repository to channel mappings
159+
- `/goose help` - Show help information
145160

146161
## Notification Behavior
147162

cmd/server/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ func (m *coordinatorManager) startSingleCoordinator(ctx context.Context, org str
358358
m.notifyMgr.RegisterGuild(guildID, discordClient)
359359

360360
// Create user mapper
361-
userMapper := usermapping.New(org, m.configManager, discordClient)
361+
userMapper := usermapping.New(org, m.configManager, discordClient, m.store, guildID)
362362

363363
// Create Turn client with token provider (will fetch fresh tokens automatically)
364364
turnClient := bot.NewTurnClient(m.cfg.TurnURL, ghClient)
@@ -482,6 +482,7 @@ func (m *coordinatorManager) discordClientForGuild(_ context.Context, guildID st
482482
slashHandler.SetUserMapGetter(m)
483483
slashHandler.SetChannelMapGetter(m)
484484
slashHandler.SetDailyReportGetter(m)
485+
slashHandler.SetStore(m.store)
485486

486487
// Register slash commands with Discord
487488
if err := slashHandler.RegisterCommands(guildID); err != nil {

cmd/server/main_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,18 @@ func (m *mockStateStore) SaveDailyReportInfo(_ context.Context, userID string, i
151151
return nil
152152
}
153153

154+
func (m *mockStateStore) UserMapping(_ context.Context, _, _ string) (state.UserMappingInfo, bool) {
155+
return state.UserMappingInfo{}, false
156+
}
157+
158+
func (m *mockStateStore) SaveUserMapping(_ context.Context, _ string, _ state.UserMappingInfo) error {
159+
return nil
160+
}
161+
162+
func (m *mockStateStore) ListUserMappings(_ context.Context, _ string) []state.UserMappingInfo {
163+
return nil
164+
}
165+
154166
func (m *mockStateStore) Cleanup(_ context.Context) error {
155167
return nil
156168
}

internal/discord/client.go

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import (
1818

1919
// Client wraps discordgo.Session with a clean interface for bot operations.
2020
type Client struct {
21-
session *discordgo.Session
21+
session session
22+
realSession *discordgo.Session // Keep reference for Session() method
2223
channelCache map[string]string // channel name -> ID
2324
channelTypeCache map[string]discordgo.ChannelType // channel ID -> type
2425
userCache map[string]string // username -> ID
@@ -42,7 +43,8 @@ func New(token string) (*Client, error) {
4243
discordgo.IntentsMessageContent
4344

4445
return &Client{
45-
session: session,
46+
session: &sessionAdapter{Session: session},
47+
realSession: session,
4648
channelCache: make(map[string]string),
4749
channelTypeCache: make(map[string]discordgo.ChannelType),
4850
userCache: make(map[string]string),
@@ -106,7 +108,7 @@ func (c *Client) Close() error {
106108

107109
// Session returns the underlying discordgo session.
108110
func (c *Client) Session() *discordgo.Session {
109-
return c.session
111+
return c.realSession
110112
}
111113

112114
// PostMessage sends a plain text message to a channel with link embeds suppressed.
@@ -423,7 +425,7 @@ func (c *Client) LookupUserByUsername(ctx context.Context, username string) stri
423425
"guild_id", guildID,
424426
"total_members", len(members))
425427

426-
// Tier 1: Exact match (Username takes precedence over GlobalName)
428+
// Tier 1: Exact match (Username takes precedence over GlobalName, then Nick)
427429
for _, member := range members {
428430
if member.User.Username != username {
429431
continue
@@ -456,8 +458,25 @@ func (c *Client) LookupUserByUsername(ctx context.Context, username string) stri
456458

457459
return member.User.ID
458460
}
461+
for _, member := range members {
462+
if member.Nick != username {
463+
continue
464+
}
465+
c.mu.Lock()
466+
c.userCache[username] = member.User.ID
467+
c.mu.Unlock()
468+
469+
slog.Debug("found user by exact nickname match",
470+
"username", username,
471+
"user_id", member.User.ID,
472+
"discord_username", member.User.Username,
473+
"discord_global_name", member.User.GlobalName,
474+
"discord_nick", member.Nick)
475+
476+
return member.User.ID
477+
}
459478

460-
// Tier 2: Case-insensitive match (Username takes precedence over GlobalName)
479+
// Tier 2: Case-insensitive match (Username takes precedence over GlobalName, then Nick)
461480
for _, member := range members {
462481
if !strings.EqualFold(member.User.Username, username) {
463482
continue
@@ -490,6 +509,23 @@ func (c *Client) LookupUserByUsername(ctx context.Context, username string) stri
490509

491510
return member.User.ID
492511
}
512+
for _, member := range members {
513+
if !strings.EqualFold(member.Nick, username) {
514+
continue
515+
}
516+
c.mu.Lock()
517+
c.userCache[username] = member.User.ID
518+
c.mu.Unlock()
519+
520+
slog.Info("found user by case-insensitive nickname match",
521+
"username", username,
522+
"user_id", member.User.ID,
523+
"discord_username", member.User.Username,
524+
"discord_global_name", member.User.GlobalName,
525+
"discord_nick", member.Nick)
526+
527+
return member.User.ID
528+
}
493529

494530
lowerUsername := strings.ToLower(username)
495531

@@ -503,11 +539,14 @@ func (c *Client) LookupUserByUsername(ctx context.Context, username string) stri
503539
for _, member := range members {
504540
usernamePrefix := strings.HasPrefix(strings.ToLower(member.User.Username), lowerUsername)
505541
globalNamePrefix := strings.HasPrefix(strings.ToLower(member.User.GlobalName), lowerUsername)
542+
nickPrefix := strings.HasPrefix(strings.ToLower(member.Nick), lowerUsername)
506543

507544
if usernamePrefix {
508545
matches = append(matches, prefixMatch{member: member, matchType: "username_prefix"})
509546
} else if globalNamePrefix {
510547
matches = append(matches, prefixMatch{member: member, matchType: "global_name_prefix"})
548+
} else if nickPrefix {
549+
matches = append(matches, prefixMatch{member: member, matchType: "nick_prefix"})
511550
}
512551
}
513552

@@ -551,6 +590,7 @@ func (c *Client) LookupUserByUsername(ctx context.Context, username string) stri
551590
"index", i,
552591
"discord_username", member.User.Username,
553592
"discord_global_name", member.User.GlobalName,
593+
"discord_nick", member.Nick,
554594
"user_id", member.User.ID)
555595
}
556596

@@ -563,11 +603,11 @@ func (c *Client) LookupUserByUsername(ctx context.Context, username string) stri
563603

564604
// IsBotInChannel checks if the bot has permission to send messages in a channel.
565605
func (c *Client) IsBotInChannel(ctx context.Context, channelID string) bool {
566-
if c.session.State == nil || c.session.State.User == nil {
606+
if c.session.GetState() == nil || c.session.GetState().User == nil {
567607
return false
568608
}
569609

570-
perms, err := c.session.UserChannelPermissions(c.session.State.User.ID, channelID)
610+
perms, err := c.session.UserChannelPermissions(c.session.GetState().User.ID, channelID)
571611
if err != nil {
572612
slog.Debug("failed to check channel permissions",
573613
"channel_id", channelID,
@@ -618,7 +658,7 @@ func (c *Client) IsUserActive(ctx context.Context, userID string) bool {
618658
}
619659

620660
// Get guild from state
621-
guild, err := c.session.State.Guild(guildID)
661+
guild, err := c.session.GetState().Guild(guildID)
622662
if err != nil {
623663
slog.Debug("failed to get guild from state",
624664
"guild_id", guildID,
@@ -681,13 +721,13 @@ type BotInfo struct {
681721

682722
// BotInfo returns the bot's user information.
683723
func (c *Client) BotInfo(ctx context.Context) (BotInfo, error) {
684-
if c.session.State == nil || c.session.State.User == nil {
724+
if c.session.GetState() == nil || c.session.GetState().User == nil {
685725
return BotInfo{}, errors.New("bot user not available")
686726
}
687727

688728
return BotInfo{
689-
UserID: c.session.State.User.ID,
690-
Username: c.session.State.User.Username,
729+
UserID: c.session.GetState().User.ID,
730+
Username: c.session.GetState().User.Username,
691731
}, nil
692732
}
693733

@@ -707,7 +747,7 @@ func (c *Client) FindForumThread(ctx context.Context, forumID, prURL string) (th
707747
var threads *discordgo.ThreadsList
708748
err := retryableCtx(ctx, func() error {
709749
var err error
710-
threads, err = c.session.GuildThreadsActive(c.guildID)
750+
threads, err = c.session.ThreadsActive(c.guildID)
711751
return err
712752
})
713753
if err != nil {
@@ -733,10 +773,11 @@ func (c *Client) FindForumThread(ctx context.Context, forumID, prURL string) (th
733773
}
734774

735775
// Also check archived threads (recently archived) with retry
776+
// Use realSession directly since ThreadsArchived is not in the session interface
736777
var archivedThreads *discordgo.ThreadsList
737778
err = retryableCtx(ctx, func() error {
738779
var err error
739-
archivedThreads, err = c.session.ThreadsArchived(forumID, nil, 50)
780+
archivedThreads, err = c.realSession.ThreadsArchived(forumID, nil, 50)
740781
return err
741782
})
742783
if err != nil {
@@ -776,8 +817,8 @@ func (c *Client) FindChannelMessage(ctx context.Context, channelID, prURL string
776817
}
777818

778819
var botID string
779-
if c.session.State != nil && c.session.State.User != nil {
780-
botID = c.session.State.User.ID
820+
if c.session.GetState() != nil && c.session.GetState().User != nil {
821+
botID = c.session.GetState().User.ID
781822
}
782823

783824
slog.Info("searching for existing channel message",
@@ -838,8 +879,8 @@ func (c *Client) FindDMForPR(ctx context.Context, userID, prURL string) (channel
838879
}
839880

840881
var botID string
841-
if c.session.State != nil && c.session.State.User != nil {
842-
botID = c.session.State.User.ID
882+
if c.session.GetState() != nil && c.session.GetState().User != nil {
883+
botID = c.session.GetState().User.ID
843884
}
844885

845886
var messages []*discordgo.Message

0 commit comments

Comments
 (0)