ScuttleBot

feat: expose IRCv3 tags in SDK client, bridge message-tags for metadata (#123, #107) SDK client (pkg/client): populate Channel, Account, MsgID, ServerTime, and full Tags map on received envelopes from IRCv3 message tags. Agents can now read account-tag, server-time, msgid, and custom tags. Bridge: attach +scuttlebot/meta-type client tag when sending messages with structured metadata. Read the tag on incoming messages to populate Meta. Any IRCv3 client can now see message metadata natively. Protocol envelope gains transport-only fields (json:"-") for IRCv3 metadata — no wire format change.

lmata 2026-04-05 13:25 trunk
Commit 42033eb6f615c43dac0b93e225b01863b06b60e68b0a9d35c919b5d6e3168f7f
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -219,16 +219,21 @@
219219
nick := e.Source.Name
220220
if acct, ok := e.Tags.Get("account"); ok && acct != "" {
221221
nick = acct
222222
}
223223
224
- b.dispatch(Message{
224
+ msg := Message{
225225
At: e.Timestamp,
226226
Channel: channel,
227227
Nick: nick,
228228
Text: e.Last(),
229
- })
229
+ }
230
+ // Read meta-type from IRCv3 client tags if present.
231
+ if metaType, ok := e.Tags.Get("+scuttlebot/meta-type"); ok && metaType != "" {
232
+ msg.Meta = &Meta{Type: metaType}
233
+ }
234
+ b.dispatch(msg)
230235
})
231236
232237
b.client = c
233238
234239
errCh := make(chan error, 1)
@@ -338,19 +343,27 @@
338343
}
339344
340345
// SendWithMeta sends a message to channel with optional structured metadata.
341346
// IRC receives only the plain text; SSE subscribers receive the full message
342347
// including meta for rich rendering in the web UI.
348
+//
349
+// When meta is present, key fields are attached as IRCv3 client-only tags
350
+// (+scuttlebot/meta-type) so any IRCv3 client can read them.
343351
func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
344352
if b.client == nil {
345353
return fmt.Errorf("bridge: not connected")
346354
}
347355
ircText := text
348356
if senderNick != "" {
349357
ircText = "[" + senderNick + "] " + text
350358
}
351
- b.client.Cmd.Message(channel, ircText)
359
+ // Attach meta-type as a client-only tag if metadata is present.
360
+ if meta != nil && meta.Type != "" {
361
+ b.client.Cmd.SendRawf("@+scuttlebot/meta-type=%s PRIVMSG %s :%s", meta.Type, channel, ircText)
362
+ } else {
363
+ b.client.Cmd.Message(channel, ircText)
364
+ }
352365
353366
if senderNick != "" {
354367
b.TouchUser(channel, senderNick)
355368
}
356369
357370
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -219,16 +219,21 @@
219 nick := e.Source.Name
220 if acct, ok := e.Tags.Get("account"); ok && acct != "" {
221 nick = acct
222 }
223
224 b.dispatch(Message{
225 At: e.Timestamp,
226 Channel: channel,
227 Nick: nick,
228 Text: e.Last(),
229 })
 
 
 
 
 
230 })
231
232 b.client = c
233
234 errCh := make(chan error, 1)
@@ -338,19 +343,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
@@ -219,16 +219,21 @@
219 nick := e.Source.Name
220 if acct, ok := e.Tags.Get("account"); ok && acct != "" {
221 nick = acct
222 }
223
224 msg := Message{
225 At: e.Timestamp,
226 Channel: channel,
227 Nick: nick,
228 Text: e.Last(),
229 }
230 // Read meta-type from IRCv3 client tags if present.
231 if metaType, ok := e.Tags.Get("+scuttlebot/meta-type"); ok && metaType != "" {
232 msg.Meta = &Meta{Type: metaType}
233 }
234 b.dispatch(msg)
235 })
236
237 b.client = c
238
239 errCh := make(chan error, 1)
@@ -338,19 +343,27 @@
343 }
344
345 // SendWithMeta sends a message to channel with optional structured metadata.
346 // IRC receives only the plain text; SSE subscribers receive the full message
347 // including meta for rich rendering in the web UI.
348 //
349 // When meta is present, key fields are attached as IRCv3 client-only tags
350 // (+scuttlebot/meta-type) so any IRCv3 client can read them.
351 func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
352 if b.client == nil {
353 return fmt.Errorf("bridge: not connected")
354 }
355 ircText := text
356 if senderNick != "" {
357 ircText = "[" + senderNick + "] " + text
358 }
359 // Attach meta-type as a client-only tag if metadata is present.
360 if meta != nil && meta.Type != "" {
361 b.client.Cmd.SendRawf("@+scuttlebot/meta-type=%s PRIVMSG %s :%s", meta.Type, channel, ircText)
362 } else {
363 b.client.Cmd.Message(channel, ircText)
364 }
365
366 if senderNick != "" {
367 b.TouchUser(channel, senderNick)
368 }
369
370
--- 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