ScuttleBot

Merge pull request #135 from ConflictHQ/feature/105-relaymsg feat: enable RELAYMSG for native relay message attribution

noreply 2026-04-05 16:27 trunk merge
Commit c3c693de8375b5d5a3317870d6eaa54ff58297b866710237ae873c31e9f75590
--- deploy/compose/ergo/ircd.yaml.tmpl
+++ deploy/compose/ergo/ircd.yaml.tmpl
@@ -13,11 +13,13 @@
1313
enforce-utf8: true
1414
lookup-hostnames: false
1515
forward-confirm-hostnames: false
1616
check-ident: false
1717
relaymsg:
18
- enabled: false
18
+ enabled: true
19
+ separators: /
20
+ available-to-chanops: false
1921
ip-cloaking:
2022
enabled: false
2123
max-sendq: "1M"
2224
ip-limits:
2325
count-exempted: true
2426
--- deploy/compose/ergo/ircd.yaml.tmpl
+++ deploy/compose/ergo/ircd.yaml.tmpl
@@ -13,11 +13,13 @@
13 enforce-utf8: true
14 lookup-hostnames: false
15 forward-confirm-hostnames: false
16 check-ident: false
17 relaymsg:
18 enabled: false
 
 
19 ip-cloaking:
20 enabled: false
21 max-sendq: "1M"
22 ip-limits:
23 count-exempted: true
24
--- deploy/compose/ergo/ircd.yaml.tmpl
+++ deploy/compose/ergo/ircd.yaml.tmpl
@@ -13,11 +13,13 @@
13 enforce-utf8: true
14 lookup-hostnames: false
15 forward-confirm-hostnames: false
16 check-ident: false
17 relaymsg:
18 enabled: true
19 separators: /
20 available-to-chanops: false
21 ip-cloaking:
22 enabled: false
23 max-sendq: "1M"
24 ip-limits:
25 count-exempted: true
26
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -1892,14 +1892,16 @@
18921892
let _chatUnread = 0;
18931893
18941894
function appendMsg(msg, isHistory) {
18951895
const area = document.getElementById('chat-msgs');
18961896
1897
- // Parse "[nick] text" sent by the bridge bot on behalf of a web user
1897
+ // Attribution: RELAYMSG delivers nicks as "user/bridge"; legacy uses "[nick] text".
18981898
let displayNick = msg.nick;
18991899
let displayText = msg.text;
1900
- if (msg.nick === 'bridge') {
1900
+ if (msg.nick && msg.nick.endsWith('/bridge')) {
1901
+ displayNick = msg.nick.slice(0, -'/bridge'.length);
1902
+ } else if (msg.nick === 'bridge') {
19011903
const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/);
19021904
if (m) { displayNick = m[1]; displayText = m[2]; }
19031905
}
19041906
19051907
const atMs = new Date(msg.at).getTime();
19061908
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -1892,14 +1892,16 @@
1892 let _chatUnread = 0;
1893
1894 function appendMsg(msg, isHistory) {
1895 const area = document.getElementById('chat-msgs');
1896
1897 // Parse "[nick] text" sent by the bridge bot on behalf of a web user
1898 let displayNick = msg.nick;
1899 let displayText = msg.text;
1900 if (msg.nick === 'bridge') {
 
 
1901 const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/);
1902 if (m) { displayNick = m[1]; displayText = m[2]; }
1903 }
1904
1905 const atMs = new Date(msg.at).getTime();
1906
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -1892,14 +1892,16 @@
1892 let _chatUnread = 0;
1893
1894 function appendMsg(msg, isHistory) {
1895 const area = document.getElementById('chat-msgs');
1896
1897 // Attribution: RELAYMSG delivers nicks as "user/bridge"; legacy uses "[nick] text".
1898 let displayNick = msg.nick;
1899 let displayText = msg.text;
1900 if (msg.nick && msg.nick.endsWith('/bridge')) {
1901 displayNick = msg.nick.slice(0, -'/bridge'.length);
1902 } else if (msg.nick === 'bridge') {
1903 const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/);
1904 if (m) { displayNick = m[1]; displayText = m[2]; }
1905 }
1906
1907 const atMs = new Date(msg.at).getTime();
1908
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -103,10 +103,13 @@
103103
104104
msgTotal atomic.Int64
105105
106106
joinCh chan string
107107
client *girc.Client
108
+
109
+ // RELAYMSG support detected from ISUPPORT.
110
+ relaySep string // separator (e.g. "/"), empty if unsupported
108111
}
109112
110113
// New creates a bridge Bot.
111114
func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot {
112115
if nick == "" {
@@ -172,10 +175,22 @@
172175
PingTimeout: 30 * time.Second,
173176
SSL: false,
174177
})
175178
176179
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
180
+ // Check RELAYMSG support from ISUPPORT (RPL_005).
181
+ if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
182
+ b.relaySep = sep
183
+ if b.log != nil {
184
+ b.log.Info("bridge: RELAYMSG supported", "separator", sep)
185
+ }
186
+ } else {
187
+ b.relaySep = ""
188
+ if b.log != nil {
189
+ b.log.Info("bridge: RELAYMSG not supported, using [nick] prefix fallback")
190
+ }
191
+ }
177192
if b.log != nil {
178193
b.log.Info("bridge connected")
179194
}
180195
for _, ch := range b.initChannels {
181196
cl.Cmd.Join(ch)
@@ -338,19 +353,27 @@
338353
}
339354
340355
// SendWithMeta sends a message to channel with optional structured metadata.
341356
// IRC receives only the plain text; SSE subscribers receive the full message
342357
// including meta for rich rendering in the web UI.
358
+//
359
+// When the server supports RELAYMSG (IRCv3), messages are attributed natively
360
+// so other clients see the real sender nick. Falls back to [nick] prefix.
343361
func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
344362
if b.client == nil {
345363
return fmt.Errorf("bridge: not connected")
346364
}
347
- ircText := text
348
- if senderNick != "" {
349
- ircText = "[" + senderNick + "] " + text
365
+ if senderNick != "" && b.relaySep != "" {
366
+ // Use RELAYMSG for native attribution.
367
+ b.client.Cmd.SendRawf("RELAYMSG %s %s :%s", channel, senderNick, text)
368
+ } else {
369
+ ircText := text
370
+ if senderNick != "" {
371
+ ircText = "[" + senderNick + "] " + text
372
+ }
373
+ b.client.Cmd.Message(channel, ircText)
350374
}
351
- b.client.Cmd.Message(channel, ircText)
352375
353376
if senderNick != "" {
354377
b.TouchUser(channel, senderNick)
355378
}
356379
357380
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -103,10 +103,13 @@
103
104 msgTotal atomic.Int64
105
106 joinCh chan string
107 client *girc.Client
 
 
 
108 }
109
110 // New creates a bridge Bot.
111 func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot {
112 if nick == "" {
@@ -172,10 +175,22 @@
172 PingTimeout: 30 * time.Second,
173 SSL: false,
174 })
175
176 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
 
 
 
 
 
 
 
 
 
 
 
177 if b.log != nil {
178 b.log.Info("bridge connected")
179 }
180 for _, ch := range b.initChannels {
181 cl.Cmd.Join(ch)
@@ -338,19 +353,27 @@
338 }
339
340 // SendWithMeta sends a message to channel with optional structured metadata.
341 // IRC receives only the plain text; SSE subscribers receive the full message
342 // including meta for rich rendering in the web UI.
 
 
 
343 func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
344 if b.client == nil {
345 return fmt.Errorf("bridge: not connected")
346 }
347 ircText := text
348 if senderNick != "" {
349 ircText = "[" + senderNick + "] " + text
 
 
 
 
 
 
350 }
351 b.client.Cmd.Message(channel, ircText)
352
353 if senderNick != "" {
354 b.TouchUser(channel, senderNick)
355 }
356
357
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -103,10 +103,13 @@
103
104 msgTotal atomic.Int64
105
106 joinCh chan string
107 client *girc.Client
108
109 // RELAYMSG support detected from ISUPPORT.
110 relaySep string // separator (e.g. "/"), empty if unsupported
111 }
112
113 // New creates a bridge Bot.
114 func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot {
115 if nick == "" {
@@ -172,10 +175,22 @@
175 PingTimeout: 30 * time.Second,
176 SSL: false,
177 })
178
179 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
180 // Check RELAYMSG support from ISUPPORT (RPL_005).
181 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
182 b.relaySep = sep
183 if b.log != nil {
184 b.log.Info("bridge: RELAYMSG supported", "separator", sep)
185 }
186 } else {
187 b.relaySep = ""
188 if b.log != nil {
189 b.log.Info("bridge: RELAYMSG not supported, using [nick] prefix fallback")
190 }
191 }
192 if b.log != nil {
193 b.log.Info("bridge connected")
194 }
195 for _, ch := range b.initChannels {
196 cl.Cmd.Join(ch)
@@ -338,19 +353,27 @@
353 }
354
355 // SendWithMeta sends a message to channel with optional structured metadata.
356 // IRC receives only the plain text; SSE subscribers receive the full message
357 // including meta for rich rendering in the web UI.
358 //
359 // When the server supports RELAYMSG (IRCv3), messages are attributed natively
360 // so other clients see the real sender nick. Falls back to [nick] prefix.
361 func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
362 if b.client == nil {
363 return fmt.Errorf("bridge: not connected")
364 }
365 if senderNick != "" && b.relaySep != "" {
366 // Use RELAYMSG for native attribution.
367 b.client.Cmd.SendRawf("RELAYMSG %s %s :%s", channel, senderNick, text)
368 } else {
369 ircText := text
370 if senderNick != "" {
371 ircText = "[" + senderNick + "] " + text
372 }
373 b.client.Cmd.Message(channel, ircText)
374 }
 
375
376 if senderNick != "" {
377 b.TouchUser(channel, senderNick)
378 }
379
380
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -23,11 +23,13 @@
2323
{{- end}}
2424
casemapping: ascii
2525
enforce-utf8: true
2626
max-sendq: 96k
2727
relaymsg:
28
- enabled: false
28
+ enabled: true
29
+ separators: /
30
+ available-to-chanops: false
2931
ip-cloaking:
3032
enabled: false
3133
lookup-hostnames: false
3234
3335
datastore:
3436
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -23,11 +23,13 @@
23 {{- end}}
24 casemapping: ascii
25 enforce-utf8: true
26 max-sendq: 96k
27 relaymsg:
28 enabled: false
 
 
29 ip-cloaking:
30 enabled: false
31 lookup-hostnames: false
32
33 datastore:
34
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -23,11 +23,13 @@
23 {{- end}}
24 casemapping: ascii
25 enforce-utf8: true
26 max-sendq: 96k
27 relaymsg:
28 enabled: true
29 separators: /
30 available-to-chanops: false
31 ip-cloaking:
32 enabled: false
33 lookup-hostnames: false
34
35 datastore:
36
--- pkg/ircagent/ircagent.go
+++ pkg/ircagent/ircagent.go
@@ -271,10 +271,17 @@
271271
text := strings.TrimSpace(e.Last())
272272
if senderNick == a.cfg.Nick {
273273
return
274274
}
275275
276
+ // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
277
+ if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
278
+ if idx := strings.Index(senderNick, sep); idx != -1 {
279
+ senderNick = senderNick[:idx]
280
+ }
281
+ }
282
+ // Fallback: parse legacy [nick] prefix from bridge bot.
276283
if strings.HasPrefix(text, "[") {
277284
if end := strings.Index(text, "] "); end != -1 {
278285
senderNick = text[1:end]
279286
text = text[end+2:]
280287
}
281288
--- pkg/ircagent/ircagent.go
+++ pkg/ircagent/ircagent.go
@@ -271,10 +271,17 @@
271 text := strings.TrimSpace(e.Last())
272 if senderNick == a.cfg.Nick {
273 return
274 }
275
 
 
 
 
 
 
 
276 if strings.HasPrefix(text, "[") {
277 if end := strings.Index(text, "] "); end != -1 {
278 senderNick = text[1:end]
279 text = text[end+2:]
280 }
281
--- pkg/ircagent/ircagent.go
+++ pkg/ircagent/ircagent.go
@@ -271,10 +271,17 @@
271 text := strings.TrimSpace(e.Last())
272 if senderNick == a.cfg.Nick {
273 return
274 }
275
276 // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
277 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
278 if idx := strings.Index(senderNick, sep); idx != -1 {
279 senderNick = senderNick[:idx]
280 }
281 }
282 // Fallback: parse legacy [nick] prefix from bridge bot.
283 if strings.HasPrefix(text, "[") {
284 if end := strings.Index(text, "] "); end != -1 {
285 senderNick = text[1:end]
286 text = text[end+2:]
287 }
288
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -126,20 +126,27 @@
126126
}
127127
if onJoined != nil {
128128
onJoined()
129129
}
130130
})
131
- client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
131
+ client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
132132
if len(e.Params) < 1 || e.Source == nil {
133133
return
134134
}
135135
target := normalizeChannel(e.Params[0])
136136
if !c.hasChannel(target) {
137137
return
138138
}
139139
sender := e.Source.Name
140140
text := strings.TrimSpace(e.Last())
141
+ // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
142
+ if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
143
+ if idx := strings.Index(sender, sep); idx != -1 {
144
+ sender = sender[:idx]
145
+ }
146
+ }
147
+ // Fallback: parse legacy [nick] prefix from bridge bot.
141148
if sender == "bridge" && strings.HasPrefix(text, "[") {
142149
if end := strings.Index(text, "] "); end != -1 {
143150
sender = text[1:end]
144151
text = strings.TrimSpace(text[end+2:])
145152
}
146153
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -126,20 +126,27 @@
126 }
127 if onJoined != nil {
128 onJoined()
129 }
130 })
131 client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
132 if len(e.Params) < 1 || e.Source == nil {
133 return
134 }
135 target := normalizeChannel(e.Params[0])
136 if !c.hasChannel(target) {
137 return
138 }
139 sender := e.Source.Name
140 text := strings.TrimSpace(e.Last())
 
 
 
 
 
 
 
141 if sender == "bridge" && strings.HasPrefix(text, "[") {
142 if end := strings.Index(text, "] "); end != -1 {
143 sender = text[1:end]
144 text = strings.TrimSpace(text[end+2:])
145 }
146
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -126,20 +126,27 @@
126 }
127 if onJoined != nil {
128 onJoined()
129 }
130 })
131 client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
132 if len(e.Params) < 1 || e.Source == nil {
133 return
134 }
135 target := normalizeChannel(e.Params[0])
136 if !c.hasChannel(target) {
137 return
138 }
139 sender := e.Source.Name
140 text := strings.TrimSpace(e.Last())
141 // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
142 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
143 if idx := strings.Index(sender, sep); idx != -1 {
144 sender = sender[:idx]
145 }
146 }
147 // Fallback: parse legacy [nick] prefix from bridge bot.
148 if sender == "bridge" && strings.HasPrefix(text, "[") {
149 if end := strings.Index(text, "] "); end != -1 {
150 sender = text[1:end]
151 text = strings.TrimSpace(text[end+2:])
152 }
153

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button