feat(discord): support referenced messages and resolve channel/link references#1047
Conversation
There was a problem hiding this comment.
Pull request overview
Adds richer Discord message context by supporting quoted (referenced) replies and resolving Discord-specific references into human-readable text for LLM prompts.
Changes:
- Prepends replied-to (referenced) message content in a quoted format.
- Adds
resolveDiscordRefsto resolve<#channelId>mentions and expand Discord message links (up to 3). - Applies reference/link resolution to both referenced and primary message content.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
pkg/channels/discord/discord.go
Outdated
| // 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) | ||
| content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s", | ||
| refAuthor, refContent, content) | ||
| } | ||
| } | ||
| content = c.resolveDiscordRefs(s, content) |
There was a problem hiding this comment.
resolveDiscordRefs expands message links by appending linked message content but does not remove/replace the original URLs. Because it’s called on refContent and then again on the full content, any links present in the referenced message will be expanded twice, duplicating appended “[linked message …]” blocks. Consider calling resolveDiscordRefs only once after composing the final content (quote + main), or change link expansion to replace the URL inline / otherwise mark-expanded links so they won’t be expanded again in subsequent passes.
pkg/channels/discord/discord.go
Outdated
| // 2. Expand Discord message links (max 3) | ||
| 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) | ||
| } |
There was a problem hiding this comment.
This will fetch and inject content for any Discord message link a user posts, potentially leaking content from other channels (or even other guilds) that the bot has access to into the current conversation context. To prevent cross-channel/guild data exposure, validate the link’s guild ID (capture group m[1]) against the current event guild, and consider restricting expansions to the current channel (or an explicit allowlist) before calling ChannelMessage.
| // 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 | ||
| } | ||
| return "#" + ch.Name | ||
| }) |
There was a problem hiding this comment.
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.
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.
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.
- 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
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.
3411523 to
3826333
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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) | ||
| } |
There was a problem hiding this comment.
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).
| // 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. |
| // 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 |
There was a problem hiding this comment.
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.
| // 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) |
| // 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) | ||
|
|
There was a problem hiding this comment.
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's review is good, you can address them first |
|
Thanks for the review feedback! Here is how we addressed each point: 1. Duplicate link expansion — Fixed in 2. Unit tests — Added 3. Cross-guild security — Already addressed in 4. 5. Cross-channel expansion within same guild — This is an intentional design decision. Cross-channel link sharing is a common Discord interaction pattern (Discord clients themselves show cross-channel message previews within a guild). Guild-level isolation already prevents cross-server leakage. Per-channel permission checks would require additional API calls per link with high complexity and low benefit, so we consider this a reasonable tradeoff for the current scope. |
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.
0fef5e2 to
e061636
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Resolve Discord refs in main content before concatenation to avoid | ||
| // double-expanding links that appear in the referenced message. |
There was a problem hiding this comment.
The comment about “double-expanding links that appear in the referenced message” is misleading here because refs are resolved separately for main and referenced content, and resolveDiscordRefs itself only expands links once per call. Consider updating the comment to reflect the real reason for ordering (e.g., controlling which links count toward the 3-link cap, or simply that both parts are resolved independently).
| // Resolve Discord refs in main content before concatenation to avoid | |
| // double-expanding links that appear in the referenced message. | |
| // Resolve Discord refs in main content before we optionally prepend the | |
| // referenced message so that main and quoted content are each resolved | |
| // independently (including any link-expansion limits). |
| // 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 | ||
| } |
There was a problem hiding this comment.
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.
|
recheck |
feat(discord): support referenced messages and resolve channel/link references
|
@AaronJny 回复消息上下文的补全和频道/链接引用解析这两个改进都很实用,让bot能拿到完整的对话上下文,回复质量会好很多。resolveDiscordRefs那个方法把channel mention和消息链接都处理了,考虑得很全面。 对了,我们组建了 PicoClaw Dev Group,在Discord上方便大家交流。如果想加入,发邮件到 |
feat(discord): support referenced messages and resolve channel/link references
📝 Description
Two related improvements to Discord message handling:
Referenced message support: When a user replies to a message, the bot now
reads
m.ReferencedMessageand prepends its content as[quoted message from Username]: content, giving the LLM full conversationcontext.
Channel/link resolution: A new
resolveDiscordRefsmethod that:<#id>channel mentions to#channel-nameBoth are applied to the referenced message and the main message content.
🗣️ Type of Change
🤖 AI Code Generation
🔗 Related Issue
Closes #1048
📚 Technical Context (Skip for Docs)
MessageCreateevent includesReferencedMessagefor replies and raw
<#id>format for channel mentions. Without resolvingthese, the LLM receives opaque IDs instead of readable context.
🧪 Test Environment
☑️ Checklist