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
45 changes: 30 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ The Discord integration for [reviewGOOSE](https://codegroove.dev/reviewgoose/)
- Creates Discord threads for new PRs (forum channels) or posts in text channels
- Smart notifications: Delays DMs if user already notified in channel
- Channel auto-discovery: repos automatically map to same-named channels
- **Self-service user linking**: Link your GitHub account with `/goose github-user` command
- **Smart user matching**: Automatically matches by username, display name, or server nickname
- Configurable notification settings via YAML
- Activity-based reports when you come online
- Reliable delivery with deduplication
Expand Down Expand Up @@ -44,11 +46,11 @@ Create `.codeGROOVE/discord.yaml`:

```yaml
global:
guild_id: "YOUR_DISCORD_SERVER_ID"
guild_id: YOUR_DISCORD_SERVER_ID

# Optional: Add explicit user mappings if GitHub/Discord usernames differ
# users:
# github-username: "discord-user-id"
# github-username: discord-user-id
```

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

```yaml
global:
guild_id: "1234567890123456789"
guild_id: 1234567890123456789
reminder_dm_delay: 65 # Minutes to wait before sending DM (default: 65, 0 = disabled)

users:
alice: "111111111111111111" # GitHub username → Discord user ID
bob: "222222222222222222"
alice: 111111111111111111 # GitHub username → Discord user ID
bob: discord-bob-username # GitHub username → Discord username
# Unmapped users: bot attempts username match in guild

channels:
Expand Down Expand Up @@ -110,24 +112,33 @@ channels:

## User Mapping

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

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

### 2. Automatic Username Match
Searches the Discord guild for the GitHub username using progressive matching. At each tier, checks both:
### 2. Self-Service Linking
Users can link their own accounts with `/goose github-user <username>`. Mappings are stored persistently and take priority over automatic discovery.

Example:
```
/goose github-user octocat
```

### 3. Automatic Username Match
Searches the Discord guild for the GitHub username using progressive matching. At each tier, checks:
- Discord **Username** (e.g., `@johndoe`)
- Discord **Display Name** (the name shown in the member list)
- Discord **Server Nickname** (the custom name set for this server)

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

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

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

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

**Pro tip**: Set your Discord server nickname to match your GitHub username for automatic matching!

## Slash Commands

- `/goose status` - Show bot connection status
- `/goose report` - Get your personal PR report
- `/goose dashboard` - Link to web dashboard
- `/goose help` - Show help
- `/goose status` - Show bot connection status and statistics
- `/goose dash` - Get your personal PR report and dashboard links
- `/goose github-user <username>` - Link your Discord account to a GitHub username
- `/goose users` - Show all GitHub ↔ Discord user mappings
- `/goose channels` - Show repository to channel mappings
- `/goose help` - Show help information

## Notification Behavior

Expand Down
3 changes: 2 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ func (m *coordinatorManager) startSingleCoordinator(ctx context.Context, org str
m.notifyMgr.RegisterGuild(guildID, discordClient)

// Create user mapper
userMapper := usermapping.New(org, m.configManager, discordClient)
userMapper := usermapping.New(org, m.configManager, discordClient, m.store, guildID)

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

// Register slash commands with Discord
if err := slashHandler.RegisterCommands(guildID); err != nil {
Expand Down
12 changes: 12 additions & 0 deletions cmd/server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,18 @@ func (m *mockStateStore) SaveDailyReportInfo(_ context.Context, userID string, i
return nil
}

func (m *mockStateStore) UserMapping(_ context.Context, _, _ string) (state.UserMappingInfo, bool) {
return state.UserMappingInfo{}, false
}

func (m *mockStateStore) SaveUserMapping(_ context.Context, _ string, _ state.UserMappingInfo) error {
return nil
}

func (m *mockStateStore) ListUserMappings(_ context.Context, _ string) []state.UserMappingInfo {
return nil
}

func (m *mockStateStore) Cleanup(_ context.Context) error {
return nil
}
Expand Down
75 changes: 58 additions & 17 deletions internal/discord/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@

// Client wraps discordgo.Session with a clean interface for bot operations.
type Client struct {
session *discordgo.Session
session session
realSession *discordgo.Session // Keep reference for Session() method
channelCache map[string]string // channel name -> ID
channelTypeCache map[string]discordgo.ChannelType // channel ID -> type
userCache map[string]string // username -> ID
Expand All @@ -42,7 +43,8 @@
discordgo.IntentsMessageContent

return &Client{
session: session,
session: &sessionAdapter{Session: session},
realSession: session,
channelCache: make(map[string]string),
channelTypeCache: make(map[string]discordgo.ChannelType),
userCache: make(map[string]string),
Expand Down Expand Up @@ -94,7 +96,7 @@
return err
case <-time.After(openTimeout):
// Try to close the session to clean up
c.session.Close() //nolint:errcheck,gosec // best-effort close on timeout

Check failure on line 99 in internal/discord/client.go

View workflow job for this annotation

GitHub Actions / Lint

unhandled-error: Unhandled error in call to function discord.session.Close (revive)
return errors.New("timeout waiting for Discord connection")
}
}
Expand All @@ -106,7 +108,7 @@

// Session returns the underlying discordgo session.
func (c *Client) Session() *discordgo.Session {
return c.session
return c.realSession
}

// PostMessage sends a plain text message to a channel with link embeds suppressed.
Expand Down Expand Up @@ -423,7 +425,7 @@
"guild_id", guildID,
"total_members", len(members))

// Tier 1: Exact match (Username takes precedence over GlobalName)
// Tier 1: Exact match (Username takes precedence over GlobalName, then Nick)
for _, member := range members {
if member.User.Username != username {
continue
Expand Down Expand Up @@ -456,8 +458,25 @@

return member.User.ID
}
for _, member := range members {
if member.Nick != username {
continue
}
c.mu.Lock()
c.userCache[username] = member.User.ID
c.mu.Unlock()

slog.Debug("found user by exact nickname match",
"username", username,
"user_id", member.User.ID,
"discord_username", member.User.Username,
"discord_global_name", member.User.GlobalName,
"discord_nick", member.Nick)

return member.User.ID
}

// Tier 2: Case-insensitive match (Username takes precedence over GlobalName)
// Tier 2: Case-insensitive match (Username takes precedence over GlobalName, then Nick)
for _, member := range members {
if !strings.EqualFold(member.User.Username, username) {
continue
Expand Down Expand Up @@ -490,6 +509,23 @@

return member.User.ID
}
for _, member := range members {
if !strings.EqualFold(member.Nick, username) {
continue
}
c.mu.Lock()
c.userCache[username] = member.User.ID
c.mu.Unlock()

slog.Info("found user by case-insensitive nickname match",
"username", username,
"user_id", member.User.ID,
"discord_username", member.User.Username,
"discord_global_name", member.User.GlobalName,
"discord_nick", member.Nick)

return member.User.ID
}

lowerUsername := strings.ToLower(username)

Expand All @@ -503,11 +539,14 @@
for _, member := range members {
usernamePrefix := strings.HasPrefix(strings.ToLower(member.User.Username), lowerUsername)
globalNamePrefix := strings.HasPrefix(strings.ToLower(member.User.GlobalName), lowerUsername)
nickPrefix := strings.HasPrefix(strings.ToLower(member.Nick), lowerUsername)

if usernamePrefix {

Check failure on line 544 in internal/discord/client.go

View workflow job for this annotation

GitHub Actions / Lint

ifElseChain: rewrite if-else to switch statement (gocritic)
matches = append(matches, prefixMatch{member: member, matchType: "username_prefix"})
} else if globalNamePrefix {
matches = append(matches, prefixMatch{member: member, matchType: "global_name_prefix"})
} else if nickPrefix {
matches = append(matches, prefixMatch{member: member, matchType: "nick_prefix"})
}
}

Expand Down Expand Up @@ -551,6 +590,7 @@
"index", i,
"discord_username", member.User.Username,
"discord_global_name", member.User.GlobalName,
"discord_nick", member.Nick,
"user_id", member.User.ID)
}

Expand All @@ -563,11 +603,11 @@

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

perms, err := c.session.UserChannelPermissions(c.session.State.User.ID, channelID)
perms, err := c.session.UserChannelPermissions(c.session.GetState().User.ID, channelID)
if err != nil {
slog.Debug("failed to check channel permissions",
"channel_id", channelID,
Expand Down Expand Up @@ -618,7 +658,7 @@
}

// Get guild from state
guild, err := c.session.State.Guild(guildID)
guild, err := c.session.GetState().Guild(guildID)
if err != nil {
slog.Debug("failed to get guild from state",
"guild_id", guildID,
Expand Down Expand Up @@ -681,13 +721,13 @@

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

return BotInfo{
UserID: c.session.State.User.ID,
Username: c.session.State.User.Username,
UserID: c.session.GetState().User.ID,
Username: c.session.GetState().User.Username,
}, nil
}

Expand All @@ -707,7 +747,7 @@
var threads *discordgo.ThreadsList
err := retryableCtx(ctx, func() error {
var err error
threads, err = c.session.GuildThreadsActive(c.guildID)
threads, err = c.session.ThreadsActive(c.guildID)
return err
})
if err != nil {
Expand All @@ -733,10 +773,11 @@
}

// Also check archived threads (recently archived) with retry
// Use realSession directly since ThreadsArchived is not in the session interface
var archivedThreads *discordgo.ThreadsList
err = retryableCtx(ctx, func() error {
var err error
archivedThreads, err = c.session.ThreadsArchived(forumID, nil, 50)
archivedThreads, err = c.realSession.ThreadsArchived(forumID, nil, 50)
return err
})
if err != nil {
Expand Down Expand Up @@ -776,8 +817,8 @@
}

var botID string
if c.session.State != nil && c.session.State.User != nil {
botID = c.session.State.User.ID
if c.session.GetState() != nil && c.session.GetState().User != nil {
botID = c.session.GetState().User.ID
}

slog.Info("searching for existing channel message",
Expand Down Expand Up @@ -838,8 +879,8 @@
}

var botID string
if c.session.State != nil && c.session.State.User != nil {
botID = c.session.State.User.ID
if c.session.GetState() != nil && c.session.GetState().User != nil {
botID = c.session.GetState().User.ID
}

var messages []*discordgo.Message
Expand Down
Loading