Skip to content

feat(discord): support referenced messages and resolve channel/link references#1047

Merged
alexhoshina merged 5 commits intosipeed:mainfrom
AaronJny:feat/discord-reply-context
Mar 6, 2026
Merged

feat(discord): support referenced messages and resolve channel/link references#1047
alexhoshina merged 5 commits intosipeed:mainfrom
AaronJny:feat/discord-reply-context

Conversation

@AaronJny
Copy link
Contributor

@AaronJny AaronJny commented Mar 4, 2026

📝 Description

Two related improvements to Discord message handling:

  1. Referenced message support: When a user replies to a message, the bot now
    reads m.ReferencedMessage and prepends its content as
    [quoted message from Username]: content, giving the LLM full conversation
    context.

  2. Channel/link resolution: A new resolveDiscordRefs method that:

    • Resolves <#id> channel mentions to #channel-name
    • Expands Discord message links (up to 3) by fetching linked message content

Both are applied to the referenced message and the main message content.

🗣️ Type of Change

  • ✨ New feature (non-breaking change which adds functionality)

🤖 AI Code Generation

  • 🛠️ Mostly AI-generated (AI draft, Human verified/modified)

🔗 Related Issue

Closes #1048

📚 Technical Context (Skip for Docs)

🧪 Test Environment

  • Hardware: Linux server (runtime), MacBook Apple Silicon (development)
  • OS: Ubuntu Linux (runtime), macOS (development & testing)
  • Model/Provider: GPT-5.2, GPT-5.3-codex
  • Channels: Discord

☑️ Checklist

  • My code/docs follow the style of this project.
  • I have performed a self-review of my own changes.
  • I have updated the documentation accordingly.

Copilot AI review requested due to automatic review settings March 4, 2026 01:53
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 resolveDiscordRefs to 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.

Comment on lines +342 to +355
// 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)
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.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +509 to +525
// 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)
}
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.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +496 to +507
// 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
})
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.
AaronJny added 4 commits March 4, 2026 10:11
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.
@AaronJny AaronJny force-pushed the feat/discord-reply-context branch from 3411523 to 3826333 Compare March 4, 2026 02:12
Copilot AI review requested due to automatic review settings March 4, 2026 02:12
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +553 to +573
// 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)
}
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.
Comment on lines +533 to +575
// 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
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.
Comment on lines +348 to +362
// 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)

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.
@alexhoshina
Copy link
Collaborator

Copilot's review is good, you can address them first

@AaronJny
Copy link
Contributor Author

AaronJny commented Mar 4, 2026

Thanks for the review feedback! Here is how we addressed each point:

1. Duplicate link expansion — Fixed in e061636. resolveDiscordRefs is now called on content and refContent independently before concatenation, preventing links in quoted replies from being expanded twice.

2. Unit tests — Added discord_resolve_test.go in e061636 with coverage for channelRefRe and msgLinkRe regex patterns (valid/invalid inputs, multi-link cap).

3. Cross-guild security — Already addressed in 3826333. resolveDiscordRefs now takes a guildID parameter and skips links pointing to a different guild.

4. s.State.Channel() cache — Already addressed in 3826333. Channel name resolution now tries s.State.Channel() before falling back to s.Channel().

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.
Copilot AI review requested due to automatic review settings March 4, 2026 07:11
@AaronJny AaronJny force-pushed the feat/discord-reply-context branch from 0fef5e2 to e061636 Compare March 4, 2026 07:11
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +348 to +349
// Resolve Discord refs in main content before concatenation to avoid
// double-expanding links that appear in the referenced message.
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 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).

Suggested change
// 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).

Copilot uses AI. Check for mistakes.
Comment on lines +556 to +566
// 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
}
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.
@alexhoshina alexhoshina self-assigned this Mar 5, 2026
@CLAassistant
Copy link

CLAassistant commented Mar 5, 2026

CLA assistant check
All committers have signed the CLA.

@AaronJny
Copy link
Contributor Author

AaronJny commented Mar 6, 2026

recheck

@alexhoshina alexhoshina merged commit 4d965f2 into sipeed:main Mar 6, 2026
6 of 7 checks passed
dj-oyu pushed a commit to dj-oyu/picoclaw that referenced this pull request Mar 8, 2026
feat(discord): support referenced messages and resolve channel/link references
@Orgmar
Copy link
Contributor

Orgmar commented Mar 9, 2026

@AaronJny 回复消息上下文的补全和频道/链接引用解析这两个改进都很实用,让bot能拿到完整的对话上下文,回复质量会好很多。resolveDiscordRefs那个方法把channel mention和消息链接都处理了,考虑得很全面。

对了,我们组建了 PicoClaw Dev Group,在Discord上方便大家交流。如果想加入,发邮件到 [email protected],主题写 [Join PicoClaw Dev Group] AaronJny,我们会发邀请链接给你!

fishtrees pushed a commit to fishtrees/picoclaw that referenced this pull request Mar 12, 2026
feat(discord): support referenced messages and resolve channel/link references
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Discord: LLM lacks context for replied messages and channel references

5 participants