From 465819e1c66c74b38ab86e2358f0159d1c86027d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=B7=AF=E8=B7=AF?= Date: Wed, 4 Mar 2026 09:13:20 +0800 Subject: [PATCH 1/5] feat(discord): support referenced/quoted messages in replies When a user replies to a message in Discord, the bot now reads m.ReferencedMessage and prepends its content to the incoming message as '[quoted message from Username]: content'. This gives the LLM full context of what message the user is replying to, enabling meaningful follow-up conversations. --- pkg/channels/discord/discord.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 1de910c834..0708afc691 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -338,6 +338,15 @@ 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 != "" { + content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s", + m.ReferencedMessage.Author.Username, refContent, content) + } + } + senderID := m.Author.ID mediaPaths := make([]string, 0, len(m.Attachments)) From 922604fc7eeab69fc0ec5d073dc51573871b47ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=B7=AF=E8=B7=AF?= Date: Wed, 4 Mar 2026 09:14:18 +0800 Subject: [PATCH 2/5] feat(discord): resolve channel references and expand message links Add resolveDiscordRefs method that: 1. Resolves <#id> channel mentions to #channel-name by calling the Discord API to fetch channel info 2. Expands Discord message links (up to 3) by fetching the linked message content and appending it as '[linked message from User]: content' Applied to both quoted/referenced messages and the main message content for full context resolution. --- pkg/channels/discord/discord.go | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 0708afc691..371ec91ad3 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "os" + "regexp" "strings" "sync" "time" @@ -342,10 +343,12 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag if m.MessageReference != nil && m.ReferencedMessage != nil { refContent := m.ReferencedMessage.Content if refContent != "" { + refContent = c.resolveDiscordRefs(s, refContent) content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s", m.ReferencedMessage.Author.Username, refContent, content) } } + content = c.resolveDiscordRefs(s, content) senderID := m.Author.ID @@ -517,6 +520,45 @@ 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. +func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string) string { + // 1. Resolve channel references: <#id> → #channel-name + channelRe := regexp.MustCompile(`<#(\d+)>`) + text = channelRe.ReplaceAllStringFunc(text, func(match string) string { + parts := channelRe.FindStringSubmatch(match) + if len(parts) < 2 { + return match + } + ch, err := s.Channel(parts[1]) + if err != nil { + return match + } + return "#" + ch.Name + }) + + // 2. Expand Discord message links (max 3) + msgLinkRe := regexp.MustCompile(`https://(?:discord\.com|discordapp\.com)/channels/(\d+)/(\d+)/(\d+)`) + matches := msgLinkRe.FindAllStringSubmatch(text, 3) + for _, m := range matches { + if len(m) < 4 { + continue + } + channelID, messageID := m[2], m[3] + 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 +} + // 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 { From c3e029061b1d084394f23a3480430c24c5799b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=B7=AF=E8=B7=AF?= Date: Wed, 4 Mar 2026 09:30:52 +0800 Subject: [PATCH 3/5] refactor(discord): self-review fixes for resolveDiscordRefs - Guard against nil ReferencedMessage.Author to prevent panic - Hoist regexp.MustCompile to package-level vars to avoid re-compilation on every handleMessage call - Both are defensive programming improvements --- pkg/channels/discord/discord.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 371ec91ad3..31af566dc2 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -27,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 @@ -343,9 +349,13 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag 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) content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s", - m.ReferencedMessage.Author.Username, refContent, content) + refAuthor, refContent, content) } } content = c.resolveDiscordRefs(s, content) @@ -524,9 +534,8 @@ func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error { // expands Discord message links to show the linked message content. func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string) string { // 1. Resolve channel references: <#id> → #channel-name - channelRe := regexp.MustCompile(`<#(\d+)>`) - text = channelRe.ReplaceAllStringFunc(text, func(match string) string { - parts := channelRe.FindStringSubmatch(match) + text = channelRefRe.ReplaceAllStringFunc(text, func(match string) string { + parts := channelRefRe.FindStringSubmatch(match) if len(parts) < 2 { return match } @@ -538,7 +547,6 @@ func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string) s }) // 2. Expand Discord message links (max 3) - msgLinkRe := regexp.MustCompile(`https://(?:discord\.com|discordapp\.com)/channels/(\d+)/(\d+)/(\d+)`) matches := msgLinkRe.FindAllStringSubmatch(text, 3) for _, m := range matches { if len(m) < 4 { From 38263333edf231d16cf93ce534cb752a04c28137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=B7=AF=E8=B7=AF?= Date: Wed, 4 Mar 2026 10:08:13 +0800 Subject: [PATCH 4/5] fix(discord): prevent cross-guild message leakage in link expansion Security fix: resolveDiscordRefs now takes a guildID parameter and skips message links pointing to a different guild, preventing the bot from leaking content across guilds. Also uses s.State.Channel() cache before falling back to API calls to reduce Discord API usage and rate limit risk. --- pkg/channels/discord/discord.go | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 31af566dc2..57445a02bf 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -353,12 +353,12 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag if m.ReferencedMessage.Author != nil { refAuthor = m.ReferencedMessage.Author.Username } - refContent = c.resolveDiscordRefs(s, refContent) + 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) + content = c.resolveDiscordRefs(s, content, m.GuildID) senderID := m.Author.ID @@ -532,27 +532,35 @@ func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error { // resolveDiscordRefs resolves channel references (<#id> → #channel-name) and // expands Discord message links to show the linked message content. -func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string) string { +// 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 } - ch, err := s.Channel(parts[1]) - if err != nil { - 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 "#" + ch.Name + return match }) - // 2. Expand Discord message links (max 3) + // 2. Expand Discord message links (max 3, same guild only) matches := msgLinkRe.FindAllStringSubmatch(text, 3) for _, m := range matches { if len(m) < 4 { continue } - channelID, messageID := m[2], m[3] + 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 From e0616362fe65373906634f869fc4288ea82f411d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=B7=AF=E8=B7=AF?= Date: Wed, 4 Mar 2026 15:10:10 +0800 Subject: [PATCH 5/5] fix(discord): prevent duplicate link expansion and add regex tests Address Copilot review feedback: - Move resolveDiscordRefs(content) before the referenced message concatenation to prevent message links in quoted replies from being expanded twice. - Add unit tests for channelRefRe and msgLinkRe regex patterns, covering valid/invalid inputs and the 3-link cap. --- pkg/channels/discord/discord.go | 5 +- pkg/channels/discord/discord_resolve_test.go | 98 ++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 pkg/channels/discord/discord_resolve_test.go diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 57445a02bf..c3bcbff8de 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -345,6 +345,10 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag content = c.stripBotMention(content) } + // Resolve Discord refs in main content before concatenation to avoid + // double-expanding links that appear in the referenced message. + content = c.resolveDiscordRefs(s, content, m.GuildID) + // Prepend referenced (quoted) message content if this is a reply if m.MessageReference != nil && m.ReferencedMessage != nil { refContent := m.ReferencedMessage.Content @@ -358,7 +362,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag refAuthor, refContent, content) } } - content = c.resolveDiscordRefs(s, content, m.GuildID) senderID := m.Author.ID diff --git a/pkg/channels/discord/discord_resolve_test.go b/pkg/channels/discord/discord_resolve_test.go new file mode 100644 index 0000000000..4bc65cc185 --- /dev/null +++ b/pkg/channels/discord/discord_resolve_test.go @@ -0,0 +1,98 @@ +package discord + +import ( + "testing" +) + +func TestChannelRefRegex(t *testing.T) { + tests := []struct { + name string + input string + wantID string + wantOK bool + }{ + {"basic channel ref", "<#123456789>", "123456789", true}, + {"long id", "<#9876543210123456>", "9876543210123456", true}, + {"no match plain text", "hello world", "", false}, + {"no match partial", "<#>", "", false}, + {"no match letters", "<#abc>", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matches := channelRefRe.FindStringSubmatch(tt.input) + if tt.wantOK { + if len(matches) < 2 || matches[1] != tt.wantID { + t.Errorf("channelRefRe(%q) = %v, want ID %q", tt.input, matches, tt.wantID) + } + } else { + if len(matches) >= 2 { + t.Errorf("channelRefRe(%q) should not match, got %v", tt.input, matches) + } + } + }) + } +} + +func TestMsgLinkRegex(t *testing.T) { + tests := []struct { + name string + input string + wantGuild string + wantChan string + wantMsg string + wantOK bool + }{ + { + "discord.com link", + "https://discord.com/channels/111/222/333", + "111", "222", "333", true, + }, + { + "discordapp.com link", + "https://discordapp.com/channels/111/222/333", + "111", "222", "333", true, + }, + { + "real world ids", + "check this https://discord.com/channels/9000000000000001/9000000000000002/9000000000000003 please", + "9000000000000001", "9000000000000002", "9000000000000003", true, + }, + {"no match http", "http://discord.com/channels/1/2/3", "", "", "", false}, + {"no match missing segment", "https://discord.com/channels/1/2", "", "", "", false}, + {"no match plain text", "hello world", "", "", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matches := msgLinkRe.FindStringSubmatch(tt.input) + if tt.wantOK { + if len(matches) < 4 { + t.Fatalf("msgLinkRe(%q) didn't match, want guild=%s chan=%s msg=%s", + tt.input, tt.wantGuild, tt.wantChan, tt.wantMsg) + } + if matches[1] != tt.wantGuild || matches[2] != tt.wantChan || matches[3] != tt.wantMsg { + t.Errorf("msgLinkRe(%q) = guild=%s chan=%s msg=%s, want %s/%s/%s", + tt.input, matches[1], matches[2], matches[3], + tt.wantGuild, tt.wantChan, tt.wantMsg) + } + } else { + if len(matches) >= 4 { + t.Errorf("msgLinkRe(%q) should not match, got %v", tt.input, matches) + } + } + }) + } +} + +func TestMsgLinkRegex_MultipleMatches(t *testing.T) { + input := "see https://discord.com/channels/1/2/3 and https://discord.com/channels/4/5/6 and https://discord.com/channels/7/8/9 and https://discord.com/channels/10/11/12" + matches := msgLinkRe.FindAllStringSubmatch(input, 3) + if len(matches) != 3 { + t.Fatalf("expected 3 matches (capped), got %d", len(matches)) + } + // Verify the 3rd match is 7/8/9 (not 10/11/12) + if matches[2][1] != "7" || matches[2][2] != "8" || matches[2][3] != "9" { + t.Errorf("3rd match = %v, want guild=7 chan=8 msg=9", matches[2]) + } +}