Skip to content
Merged
Changes from 4 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
67 changes: 67 additions & 0 deletions pkg/channels/discord/discord.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"os"
"regexp"
"strings"
"sync"
"time"
Expand All @@ -26,6 +27,12 @@ const (
sendTimeout = 10 * time.Second
)

var (
// Pre-compiled regexes for resolveDiscordRefs (avoid re-compiling per call)
channelRefRe = regexp.MustCompile(`<#(\d+)>`)
msgLinkRe = regexp.MustCompile(`https://(?:discord\.com|discordapp\.com)/channels/(\d+)/(\d+)/(\d+)`)
)

type DiscordChannel struct {
*channels.BaseChannel
session *discordgo.Session
Expand Down Expand Up @@ -338,6 +345,21 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
content = c.stripBotMention(content)
}

// Prepend referenced (quoted) message content if this is a reply
if m.MessageReference != nil && m.ReferencedMessage != nil {
refContent := m.ReferencedMessage.Content
if refContent != "" {
refAuthor := "unknown"
if m.ReferencedMessage.Author != nil {
refAuthor = m.ReferencedMessage.Author.Username
}
refContent = c.resolveDiscordRefs(s, refContent, m.GuildID)
content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s",
refAuthor, refContent, content)
}
}
content = c.resolveDiscordRefs(s, content, m.GuildID)

Comment on lines +352 to +365
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleMessage resolves refs on refContent and then calls resolveDiscordRefs again on the full content. Since resolveDiscordRefs appends linked-message expansions but leaves the original URL in place, any message links inside the referenced message will be expanded twice (duplicated appended [linked message …] blocks). Consider resolving refs only once after composing the quoted+main content, or make link expansion idempotent by replacing/removing the matched URL when expanding it.

Copilot uses AI. Check for mistakes.
senderID := m.Author.ID

mediaPaths := make([]string, 0, len(m.Attachments))
Expand Down Expand Up @@ -508,6 +530,51 @@ func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error {
return nil
}

// resolveDiscordRefs resolves channel references (<#id> β†’ #channel-name) and
// expands Discord message links to show the linked message content.
// Only links pointing to the same guild are expanded to prevent cross-guild leakage.
func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string, guildID string) string {
// 1. Resolve channel references: <#id> β†’ #channel-name
text = channelRefRe.ReplaceAllStringFunc(text, func(match string) string {
parts := channelRefRe.FindStringSubmatch(match)
if len(parts) < 2 {
return match
}
// Prefer session state cache to avoid API calls
if ch, err := s.State.Channel(parts[1]); err == nil {
return "#" + ch.Name
}
if ch, err := s.Channel(parts[1]); err == nil {
return "#" + ch.Name
}
return match
})
Comment on lines +540 to +554
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s.Channel(...) can trigger an API call per <#id> occurrence, which may be slow and can increase the risk of hitting Discord rate limits if a message contains many channel refs (or repeated refs). Consider resolving via the session state/cache first (e.g., s.State.Channel(id)), and caching/deduping IDs within a single resolveDiscordRefs call so repeated mentions don’t cause repeated lookups.

Copilot uses AI. Check for mistakes.

// 2. Expand Discord message links (max 3, same guild only)
matches := msgLinkRe.FindAllStringSubmatch(text, 3)
for _, m := range matches {
if len(m) < 4 {
continue
}
linkGuildID, channelID, messageID := m[1], m[2], m[3]
// Security: only expand links from the same guild
if linkGuildID != guildID {
continue
}
Comment on lines +556 to +566
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The β€œmax 3” cap is applied before filtering out cross-guild links (FindAllStringSubmatch(text, 3)), so if the first 3 links are other-guild you may expand 0 even when later same-guild links exist. If the intent is β€œexpand up to 3 eligible (same-guild) links”, collect all matches and stop after expanding 3 that pass the guild check.

Copilot uses AI. Check for mistakes.
msg, err := s.ChannelMessage(channelID, messageID)
if err != nil || msg == nil || msg.Content == "" {
continue
}
author := "unknown"
if msg.Author != nil {
author = msg.Author.Username
}
text += fmt.Sprintf("\n[linked message from %s]: %s", author, msg.Content)
}
Comment on lines +556 to +576
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expanding message links based only on linkGuildID == guildID can leak content across channels: a user can paste a link to a message in another channel within the same guild that they cannot access, but the bot can, and the linked content will be added to the LLM prompt. To prevent privilege escalation, restrict expansion to the current channel, or verify the requesting user has VIEW_CHANNEL/READ_MESSAGE_HISTORY in channelID before fetching/including the message (e.g., via discordgo permission helpers / state member permissions).

Suggested change
// 2. Expand Discord message links (max 3, same guild only)
matches := msgLinkRe.FindAllStringSubmatch(text, 3)
for _, m := range matches {
if len(m) < 4 {
continue
}
linkGuildID, channelID, messageID := m[1], m[2], m[3]
// Security: only expand links from the same guild
if linkGuildID != guildID {
continue
}
msg, err := s.ChannelMessage(channelID, messageID)
if err != nil || msg == nil || msg.Content == "" {
continue
}
author := "unknown"
if msg.Author != nil {
author = msg.Author.Username
}
text += fmt.Sprintf("\n[linked message from %s]: %s", author, msg.Content)
}
// 2. Expand Discord message links
//
// NOTE: Message link expansion has been disabled for security reasons.
// The previous implementation expanded any message links from the same
// guild by calling s.ChannelMessage(channelID, messageID) and appending
// the linked message content to the prompt. Without verifying that the
// requesting user has VIEW_CHANNEL/READ_MESSAGE_HISTORY on the target
// channel, this can leak content from channels the user cannot access.
//
// To re-enable this feature safely, pass the requesting user's ID and the
// current channel ID into this function and check their permissions on
// the linked channel before fetching/including the message content.

Copilot uses AI. Check for mistakes.

return text
Comment on lines +536 to +578
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New resolveDiscordRefs behavior (channel mention replacement + message-link expansion) is untested. Since this package already has unit tests, it would be good to add coverage for the new parsing/expansion logic (including edge cases like multiple links and ensuring no duplicate expansion when the function is called multiple times). If mocking discordgo.Session is awkward, consider factoring out the regex parsing into a pure helper and injecting lookup functions for channels/messages in tests.

Suggested change
// resolveDiscordRefs resolves channel references (<#id> β†’ #channel-name) and
// expands Discord message links to show the linked message content.
// Only links pointing to the same guild are expanded to prevent cross-guild leakage.
func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string, guildID string) string {
// 1. Resolve channel references: <#id> β†’ #channel-name
text = channelRefRe.ReplaceAllStringFunc(text, func(match string) string {
parts := channelRefRe.FindStringSubmatch(match)
if len(parts) < 2 {
return match
}
// Prefer session state cache to avoid API calls
if ch, err := s.State.Channel(parts[1]); err == nil {
return "#" + ch.Name
}
if ch, err := s.Channel(parts[1]); err == nil {
return "#" + ch.Name
}
return match
})
// 2. Expand Discord message links (max 3, same guild only)
matches := msgLinkRe.FindAllStringSubmatch(text, 3)
for _, m := range matches {
if len(m) < 4 {
continue
}
linkGuildID, channelID, messageID := m[1], m[2], m[3]
// Security: only expand links from the same guild
if linkGuildID != guildID {
continue
}
msg, err := s.ChannelMessage(channelID, messageID)
if err != nil || msg == nil || msg.Content == "" {
continue
}
author := "unknown"
if msg.Author != nil {
author = msg.Author.Username
}
text += fmt.Sprintf("\n[linked message from %s]: %s", author, msg.Content)
}
return text
// resolveDiscordRefsText is a pure helper that resolves channel references
// (<#id> β†’ #channel-name) and expands Discord message links to show the linked
// message content. Only links pointing to the same guild are expanded to
// prevent cross-guild leakage.
//
// The channelLookup function should return the channel name for a given ID.
// The messageLookup function should return the author username and content
// for a given message.
func resolveDiscordRefsText(
text string,
guildID string,
channelLookup func(id string) (name string, ok bool),
messageLookup func(channelID, messageID string) (author, content string, ok bool),
) string {
// 1. Resolve channel references: <#id> β†’ #channel-name
if channelLookup != nil {
text = channelRefRe.ReplaceAllStringFunc(text, func(match string) string {
parts := channelRefRe.FindStringSubmatch(match)
if len(parts) < 2 {
return match
}
if name, ok := channelLookup(parts[1]); ok && name != "" {
return "#" + name
}
return match
})
}
// 2. Expand Discord message links (max 3, same guild only)
if messageLookup != nil {
matches := msgLinkRe.FindAllStringSubmatch(text, 3)
for _, m := range matches {
if len(m) < 4 {
continue
}
linkGuildID, channelID, messageID := m[1], m[2], m[3]
// Security: only expand links from the same guild
if linkGuildID != guildID {
continue
}
author, content, ok := messageLookup(channelID, messageID)
if !ok || content == "" {
continue
}
if author == "" {
author = "unknown"
}
text += fmt.Sprintf("\n[linked message from %s]: %s", author, content)
}
}
return text
}
// resolveDiscordRefs resolves channel references (<#id> β†’ #channel-name) and
// expands Discord message links to show the linked message content.
// Only links pointing to the same guild are expanded to prevent cross-guild leakage.
func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string, guildID string) string {
channelLookup := func(id string) (string, bool) {
// Prefer session state cache to avoid API calls
if ch, err := s.State.Channel(id); err == nil && ch != nil {
return ch.Name, true
}
if ch, err := s.Channel(id); err == nil && ch != nil {
return ch.Name, true
}
return "", false
}
messageLookup := func(channelID, messageID string) (string, string, bool) {
msg, err := s.ChannelMessage(channelID, messageID)
if err != nil || msg == nil || msg.Content == "" {
return "", "", false
}
author := "unknown"
if msg.Author != nil && msg.Author.Username != "" {
author = msg.Author.Username
}
return author, msg.Content, true
}
return resolveDiscordRefsText(text, guildID, channelLookup, messageLookup)

Copilot uses AI. Check for mistakes.
}

// stripBotMention removes the bot mention from the message content.
// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname).
func (c *DiscordChannel) stripBotMention(text string) string {
Expand Down
Loading