Skip to content

Commit 4d965f2

Browse files
authored
Merge pull request #1047 from AaronJny/feat/discord-reply-context
feat(discord): support referenced messages and resolve channel/link references
2 parents 46201fb + e061636 commit 4d965f2

File tree

2 files changed

+168
-0
lines changed

2 files changed

+168
-0
lines changed

pkg/channels/discord/discord.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http"
77
"net/url"
88
"os"
9+
"regexp"
910
"strings"
1011
"sync"
1112
"time"
@@ -26,6 +27,12 @@ const (
2627
sendTimeout = 10 * time.Second
2728
)
2829

30+
var (
31+
// Pre-compiled regexes for resolveDiscordRefs (avoid re-compiling per call)
32+
channelRefRe = regexp.MustCompile(`<#(\d+)>`)
33+
msgLinkRe = regexp.MustCompile(`https://(?:discord\.com|discordapp\.com)/channels/(\d+)/(\d+)/(\d+)`)
34+
)
35+
2936
type DiscordChannel struct {
3037
*channels.BaseChannel
3138
session *discordgo.Session
@@ -338,6 +345,24 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
338345
content = c.stripBotMention(content)
339346
}
340347

348+
// Resolve Discord refs in main content before concatenation to avoid
349+
// double-expanding links that appear in the referenced message.
350+
content = c.resolveDiscordRefs(s, content, m.GuildID)
351+
352+
// Prepend referenced (quoted) message content if this is a reply
353+
if m.MessageReference != nil && m.ReferencedMessage != nil {
354+
refContent := m.ReferencedMessage.Content
355+
if refContent != "" {
356+
refAuthor := "unknown"
357+
if m.ReferencedMessage.Author != nil {
358+
refAuthor = m.ReferencedMessage.Author.Username
359+
}
360+
refContent = c.resolveDiscordRefs(s, refContent, m.GuildID)
361+
content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s",
362+
refAuthor, refContent, content)
363+
}
364+
}
365+
341366
senderID := m.Author.ID
342367

343368
mediaPaths := make([]string, 0, len(m.Attachments))
@@ -508,6 +533,51 @@ func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error {
508533
return nil
509534
}
510535

536+
// resolveDiscordRefs resolves channel references (<#id> → #channel-name) and
537+
// expands Discord message links to show the linked message content.
538+
// Only links pointing to the same guild are expanded to prevent cross-guild leakage.
539+
func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string, guildID string) string {
540+
// 1. Resolve channel references: <#id> → #channel-name
541+
text = channelRefRe.ReplaceAllStringFunc(text, func(match string) string {
542+
parts := channelRefRe.FindStringSubmatch(match)
543+
if len(parts) < 2 {
544+
return match
545+
}
546+
// Prefer session state cache to avoid API calls
547+
if ch, err := s.State.Channel(parts[1]); err == nil {
548+
return "#" + ch.Name
549+
}
550+
if ch, err := s.Channel(parts[1]); err == nil {
551+
return "#" + ch.Name
552+
}
553+
return match
554+
})
555+
556+
// 2. Expand Discord message links (max 3, same guild only)
557+
matches := msgLinkRe.FindAllStringSubmatch(text, 3)
558+
for _, m := range matches {
559+
if len(m) < 4 {
560+
continue
561+
}
562+
linkGuildID, channelID, messageID := m[1], m[2], m[3]
563+
// Security: only expand links from the same guild
564+
if linkGuildID != guildID {
565+
continue
566+
}
567+
msg, err := s.ChannelMessage(channelID, messageID)
568+
if err != nil || msg == nil || msg.Content == "" {
569+
continue
570+
}
571+
author := "unknown"
572+
if msg.Author != nil {
573+
author = msg.Author.Username
574+
}
575+
text += fmt.Sprintf("\n[linked message from %s]: %s", author, msg.Content)
576+
}
577+
578+
return text
579+
}
580+
511581
// stripBotMention removes the bot mention from the message content.
512582
// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname).
513583
func (c *DiscordChannel) stripBotMention(text string) string {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package discord
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestChannelRefRegex(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
input string
11+
wantID string
12+
wantOK bool
13+
}{
14+
{"basic channel ref", "<#123456789>", "123456789", true},
15+
{"long id", "<#9876543210123456>", "9876543210123456", true},
16+
{"no match plain text", "hello world", "", false},
17+
{"no match partial", "<#>", "", false},
18+
{"no match letters", "<#abc>", "", false},
19+
}
20+
21+
for _, tt := range tests {
22+
t.Run(tt.name, func(t *testing.T) {
23+
matches := channelRefRe.FindStringSubmatch(tt.input)
24+
if tt.wantOK {
25+
if len(matches) < 2 || matches[1] != tt.wantID {
26+
t.Errorf("channelRefRe(%q) = %v, want ID %q", tt.input, matches, tt.wantID)
27+
}
28+
} else {
29+
if len(matches) >= 2 {
30+
t.Errorf("channelRefRe(%q) should not match, got %v", tt.input, matches)
31+
}
32+
}
33+
})
34+
}
35+
}
36+
37+
func TestMsgLinkRegex(t *testing.T) {
38+
tests := []struct {
39+
name string
40+
input string
41+
wantGuild string
42+
wantChan string
43+
wantMsg string
44+
wantOK bool
45+
}{
46+
{
47+
"discord.com link",
48+
"https://discord.com/channels/111/222/333",
49+
"111", "222", "333", true,
50+
},
51+
{
52+
"discordapp.com link",
53+
"https://discordapp.com/channels/111/222/333",
54+
"111", "222", "333", true,
55+
},
56+
{
57+
"real world ids",
58+
"check this https://discord.com/channels/9000000000000001/9000000000000002/9000000000000003 please",
59+
"9000000000000001", "9000000000000002", "9000000000000003", true,
60+
},
61+
{"no match http", "http://discord.com/channels/1/2/3", "", "", "", false},
62+
{"no match missing segment", "https://discord.com/channels/1/2", "", "", "", false},
63+
{"no match plain text", "hello world", "", "", "", false},
64+
}
65+
66+
for _, tt := range tests {
67+
t.Run(tt.name, func(t *testing.T) {
68+
matches := msgLinkRe.FindStringSubmatch(tt.input)
69+
if tt.wantOK {
70+
if len(matches) < 4 {
71+
t.Fatalf("msgLinkRe(%q) didn't match, want guild=%s chan=%s msg=%s",
72+
tt.input, tt.wantGuild, tt.wantChan, tt.wantMsg)
73+
}
74+
if matches[1] != tt.wantGuild || matches[2] != tt.wantChan || matches[3] != tt.wantMsg {
75+
t.Errorf("msgLinkRe(%q) = guild=%s chan=%s msg=%s, want %s/%s/%s",
76+
tt.input, matches[1], matches[2], matches[3],
77+
tt.wantGuild, tt.wantChan, tt.wantMsg)
78+
}
79+
} else {
80+
if len(matches) >= 4 {
81+
t.Errorf("msgLinkRe(%q) should not match, got %v", tt.input, matches)
82+
}
83+
}
84+
})
85+
}
86+
}
87+
88+
func TestMsgLinkRegex_MultipleMatches(t *testing.T) {
89+
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"
90+
matches := msgLinkRe.FindAllStringSubmatch(input, 3)
91+
if len(matches) != 3 {
92+
t.Fatalf("expected 3 matches (capped), got %d", len(matches))
93+
}
94+
// Verify the 3rd match is 7/8/9 (not 10/11/12)
95+
if matches[2][1] != "7" || matches[2][2] != "8" || matches[2][3] != "9" {
96+
t.Errorf("3rd match = %v, want guild=7 chan=8 msg=9", matches[2])
97+
}
98+
}

0 commit comments

Comments
 (0)