ScuttleBot

Merge pull request #138 from ConflictHQ/feature/123-sdk-ircv3-tags feat: SDK IRCv3 tag exposure and bridge message-tags for metadata

noreply 2026-04-05 16:38 trunk merge
Commit 9eb7d9e5ac7dfe1373e2e690ad252b0a5dd53dcf0a8fe5ca0fb37bff07fa1c22
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -240,17 +240,22 @@
240240
241241
var msgID string
242242
if id, ok := e.Tags.Get("msgid"); ok {
243243
msgID = id
244244
}
245
- b.dispatch(Message{
245
+ msg := Message{
246246
At: e.Timestamp,
247247
Channel: channel,
248248
Nick: nick,
249249
Text: e.Last(),
250250
MsgID: msgID,
251
- })
251
+ }
252
+ // Read meta-type from IRCv3 client tags if present.
253
+ if metaType, ok := e.Tags.Get("+scuttlebot/meta-type"); ok && metaType != "" {
254
+ msg.Meta = &Meta{Type: metaType}
255
+ }
256
+ b.dispatch(msg)
252257
})
253258
254259
b.client = c
255260
256261
errCh := make(chan error, 1)
@@ -360,26 +365,38 @@
360365
}
361366
362367
// SendWithMeta sends a message to channel with optional structured metadata.
363368
// IRC receives only the plain text; SSE subscribers receive the full message
364369
// including meta for rich rendering in the web UI.
370
+//
371
+// When meta is present, key fields are attached as IRCv3 client-only tags
372
+// (+scuttlebot/meta-type) so any IRCv3 client can read them.
365373
//
366374
// When the server supports RELAYMSG (IRCv3), messages are attributed natively
367375
// so other clients see the real sender nick. Falls back to [nick] prefix.
368376
func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
369377
if b.client == nil {
370378
return fmt.Errorf("bridge: not connected")
371379
}
380
+ // Build optional IRCv3 tag prefix for meta-type.
381
+ tagPrefix := ""
382
+ if meta != nil && meta.Type != "" {
383
+ tagPrefix = "@+scuttlebot/meta-type=" + meta.Type + " "
384
+ }
372385
if senderNick != "" && b.relaySep != "" {
373386
// Use RELAYMSG for native attribution.
374
- b.client.Cmd.SendRawf("RELAYMSG %s %s :%s", channel, senderNick, text)
387
+ b.client.Cmd.SendRawf("%sRELAYMSG %s %s :%s", tagPrefix, channel, senderNick, text)
375388
} else {
376389
ircText := text
377390
if senderNick != "" {
378391
ircText = "[" + senderNick + "] " + text
379392
}
380
- b.client.Cmd.Message(channel, ircText)
393
+ if tagPrefix != "" {
394
+ b.client.Cmd.SendRawf("%sPRIVMSG %s :%s", tagPrefix, channel, ircText)
395
+ } else {
396
+ b.client.Cmd.Message(channel, ircText)
397
+ }
381398
}
382399
383400
if senderNick != "" {
384401
b.TouchUser(channel, senderNick)
385402
}
386403
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -240,17 +240,22 @@
240
241 var msgID string
242 if id, ok := e.Tags.Get("msgid"); ok {
243 msgID = id
244 }
245 b.dispatch(Message{
246 At: e.Timestamp,
247 Channel: channel,
248 Nick: nick,
249 Text: e.Last(),
250 MsgID: msgID,
251 })
 
 
 
 
 
252 })
253
254 b.client = c
255
256 errCh := make(chan error, 1)
@@ -360,26 +365,38 @@
360 }
361
362 // SendWithMeta sends a message to channel with optional structured metadata.
363 // IRC receives only the plain text; SSE subscribers receive the full message
364 // including meta for rich rendering in the web UI.
 
 
 
365 //
366 // When the server supports RELAYMSG (IRCv3), messages are attributed natively
367 // so other clients see the real sender nick. Falls back to [nick] prefix.
368 func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
369 if b.client == nil {
370 return fmt.Errorf("bridge: not connected")
371 }
 
 
 
 
 
372 if senderNick != "" && b.relaySep != "" {
373 // Use RELAYMSG for native attribution.
374 b.client.Cmd.SendRawf("RELAYMSG %s %s :%s", channel, senderNick, text)
375 } else {
376 ircText := text
377 if senderNick != "" {
378 ircText = "[" + senderNick + "] " + text
379 }
380 b.client.Cmd.Message(channel, ircText)
 
 
 
 
381 }
382
383 if senderNick != "" {
384 b.TouchUser(channel, senderNick)
385 }
386
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -240,17 +240,22 @@
240
241 var msgID string
242 if id, ok := e.Tags.Get("msgid"); ok {
243 msgID = id
244 }
245 msg := Message{
246 At: e.Timestamp,
247 Channel: channel,
248 Nick: nick,
249 Text: e.Last(),
250 MsgID: msgID,
251 }
252 // Read meta-type from IRCv3 client tags if present.
253 if metaType, ok := e.Tags.Get("+scuttlebot/meta-type"); ok && metaType != "" {
254 msg.Meta = &Meta{Type: metaType}
255 }
256 b.dispatch(msg)
257 })
258
259 b.client = c
260
261 errCh := make(chan error, 1)
@@ -360,26 +365,38 @@
365 }
366
367 // SendWithMeta sends a message to channel with optional structured metadata.
368 // IRC receives only the plain text; SSE subscribers receive the full message
369 // including meta for rich rendering in the web UI.
370 //
371 // When meta is present, key fields are attached as IRCv3 client-only tags
372 // (+scuttlebot/meta-type) so any IRCv3 client can read them.
373 //
374 // When the server supports RELAYMSG (IRCv3), messages are attributed natively
375 // so other clients see the real sender nick. Falls back to [nick] prefix.
376 func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
377 if b.client == nil {
378 return fmt.Errorf("bridge: not connected")
379 }
380 // Build optional IRCv3 tag prefix for meta-type.
381 tagPrefix := ""
382 if meta != nil && meta.Type != "" {
383 tagPrefix = "@+scuttlebot/meta-type=" + meta.Type + " "
384 }
385 if senderNick != "" && b.relaySep != "" {
386 // Use RELAYMSG for native attribution.
387 b.client.Cmd.SendRawf("%sRELAYMSG %s %s :%s", tagPrefix, channel, senderNick, text)
388 } else {
389 ircText := text
390 if senderNick != "" {
391 ircText = "[" + senderNick + "] " + text
392 }
393 if tagPrefix != "" {
394 b.client.Cmd.SendRawf("%sPRIVMSG %s :%s", tagPrefix, channel, ircText)
395 } else {
396 b.client.Cmd.Message(channel, ircText)
397 }
398 }
399
400 if senderNick != "" {
401 b.TouchUser(channel, senderNick)
402 }
403
--- pkg/client/client.go
+++ pkg/client/client.go
@@ -186,10 +186,27 @@
186186
text := e.Last()
187187
env, err := protocol.Unmarshal([]byte(text))
188188
if err != nil {
189189
return // non-JSON PRIVMSG (human chat) — silently ignored
190190
}
191
+
192
+ // Populate IRCv3 transport metadata.
193
+ env.Channel = channel
194
+ env.ServerTime = e.Timestamp
195
+ if acct, ok := e.Tags.Get("account"); ok {
196
+ env.Account = acct
197
+ }
198
+ if msgID, ok := e.Tags.Get("msgid"); ok {
199
+ env.MsgID = msgID
200
+ }
201
+ if len(e.Tags) > 0 {
202
+ env.Tags = make(map[string]string, len(e.Tags))
203
+ for k, v := range e.Tags {
204
+ env.Tags[k] = v
205
+ }
206
+ }
207
+
191208
c.dispatch(ctx, env)
192209
})
193210
194211
// NOTICE is ignored — system/human commentary, not agent traffic.
195212
196213
--- pkg/client/client.go
+++ pkg/client/client.go
@@ -186,10 +186,27 @@
186 text := e.Last()
187 env, err := protocol.Unmarshal([]byte(text))
188 if err != nil {
189 return // non-JSON PRIVMSG (human chat) — silently ignored
190 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191 c.dispatch(ctx, env)
192 })
193
194 // NOTICE is ignored — system/human commentary, not agent traffic.
195
196
--- pkg/client/client.go
+++ pkg/client/client.go
@@ -186,10 +186,27 @@
186 text := e.Last()
187 env, err := protocol.Unmarshal([]byte(text))
188 if err != nil {
189 return // non-JSON PRIVMSG (human chat) — silently ignored
190 }
191
192 // Populate IRCv3 transport metadata.
193 env.Channel = channel
194 env.ServerTime = e.Timestamp
195 if acct, ok := e.Tags.Get("account"); ok {
196 env.Account = acct
197 }
198 if msgID, ok := e.Tags.Get("msgid"); ok {
199 env.MsgID = msgID
200 }
201 if len(e.Tags) > 0 {
202 env.Tags = make(map[string]string, len(e.Tags))
203 for k, v := range e.Tags {
204 env.Tags[k] = v
205 }
206 }
207
208 c.dispatch(ctx, env)
209 })
210
211 // NOTICE is ignored — system/human commentary, not agent traffic.
212
213
--- pkg/protocol/protocol.go
+++ pkg/protocol/protocol.go
@@ -33,10 +33,17 @@
3333
ID string `json:"id"`
3434
From string `json:"from"`
3535
To []string `json:"to,omitempty"`
3636
TS int64 `json:"ts"`
3737
Payload json.RawMessage `json:"payload,omitempty"`
38
+
39
+ // IRCv3 transport metadata — populated at receive time, not serialized.
40
+ Channel string `json:"-"` // channel the message arrived on
41
+ Account string `json:"-"` // account-tag: sender's NickServ account
42
+ MsgID string `json:"-"` // msgid tag: server-assigned message ID
43
+ ServerTime time.Time `json:"-"` // server-time tag: server-provided timestamp
44
+ Tags map[string]string `json:"-"` // all IRCv3 message tags
3845
}
3946
4047
// New creates a new Envelope with a generated ID and current timestamp.
4148
// To is left empty (unaddressed — matches all recipients).
4249
func New(msgType, from string, payload any) (*Envelope, error) {
4350
--- pkg/protocol/protocol.go
+++ pkg/protocol/protocol.go
@@ -33,10 +33,17 @@
33 ID string `json:"id"`
34 From string `json:"from"`
35 To []string `json:"to,omitempty"`
36 TS int64 `json:"ts"`
37 Payload json.RawMessage `json:"payload,omitempty"`
 
 
 
 
 
 
 
38 }
39
40 // New creates a new Envelope with a generated ID and current timestamp.
41 // To is left empty (unaddressed — matches all recipients).
42 func New(msgType, from string, payload any) (*Envelope, error) {
43
--- pkg/protocol/protocol.go
+++ pkg/protocol/protocol.go
@@ -33,10 +33,17 @@
33 ID string `json:"id"`
34 From string `json:"from"`
35 To []string `json:"to,omitempty"`
36 TS int64 `json:"ts"`
37 Payload json.RawMessage `json:"payload,omitempty"`
38
39 // IRCv3 transport metadata — populated at receive time, not serialized.
40 Channel string `json:"-"` // channel the message arrived on
41 Account string `json:"-"` // account-tag: sender's NickServ account
42 MsgID string `json:"-"` // msgid tag: server-assigned message ID
43 ServerTime time.Time `json:"-"` // server-time tag: server-provided timestamp
44 Tags map[string]string `json:"-"` // all IRCv3 message tags
45 }
46
47 // New creates a new Envelope with a generated ID and current timestamp.
48 // To is left empty (unaddressed — matches all recipients).
49 func New(msgType, from string, payload any) (*Envelope, error) {
50

Keyboard Shortcuts

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