ScuttleBot

scuttlebot / internal / bots / bridge / bridge.go
Blame History Raw 693 lines
1
// Package bridge implements the IRC bridge bot for the web chat UI.
2
//
3
// The bridge connects to IRC, joins channels, and buffers recent messages.
4
// It exposes subscriptions for SSE fan-out and a Send method for the web UI
5
// to post messages back into IRC.
6
package bridge
7
8
import (
9
"context"
10
"encoding/json"
11
"fmt"
12
"log/slog"
13
"net"
14
"strconv"
15
"strings"
16
"sync"
17
"sync/atomic"
18
"time"
19
20
"github.com/lrstanley/girc"
21
)
22
23
const botNick = "bridge"
24
const defaultWebUserTTL = 5 * time.Minute
25
26
// Meta is optional structured metadata attached to a bridge message.
27
// IRC sees only the plain text; the web UI uses Meta for rich rendering.
28
type Meta struct {
29
Type string `json:"type"`
30
Data json.RawMessage `json:"data"`
31
}
32
33
// Message is a single IRC message captured by the bridge.
34
type Message struct {
35
At time.Time `json:"at"`
36
Channel string `json:"channel"`
37
Nick string `json:"nick"`
38
Text string `json:"text"`
39
MsgID string `json:"msgid,omitempty"`
40
Meta *Meta `json:"meta,omitempty"`
41
}
42
43
// ringBuf is a fixed-capacity circular buffer of Messages.
44
type ringBuf struct {
45
msgs []Message
46
head int
47
size int
48
cap int
49
}
50
51
func newRingBuf(cap int) *ringBuf {
52
return &ringBuf{msgs: make([]Message, cap), cap: cap}
53
}
54
55
func (r *ringBuf) push(m Message) {
56
r.msgs[r.head] = m
57
r.head = (r.head + 1) % r.cap
58
if r.size < r.cap {
59
r.size++
60
}
61
}
62
63
// snapshot returns messages in chronological order (oldest first).
64
func (r *ringBuf) snapshot() []Message {
65
if r.size == 0 {
66
return nil
67
}
68
out := make([]Message, r.size)
69
if r.size < r.cap {
70
copy(out, r.msgs[:r.size])
71
} else {
72
n := copy(out, r.msgs[r.head:])
73
copy(out[n:], r.msgs[:r.head])
74
}
75
return out
76
}
77
78
// Stats is a snapshot of bridge activity.
79
type Stats struct {
80
Channels int `json:"channels"`
81
MessagesTotal int64 `json:"messages_total"`
82
ActiveSubs int `json:"active_subscribers"`
83
}
84
85
// Bot is the IRC bridge bot.
86
type Bot struct {
87
ircAddr string
88
nick string
89
password string
90
bufSize int
91
initChannels []string
92
log *slog.Logger
93
94
mu sync.RWMutex
95
buffers map[string]*ringBuf
96
subs map[string]map[uint64]chan Message
97
subSeq uint64
98
joined map[string]bool
99
// webUsers tracks nicks that have posted via the HTTP bridge recently.
100
// channel → nick → last seen time
101
webUsers map[string]map[string]time.Time
102
// webUserTTL controls how long bridge-posted HTTP nicks stay visible in Users().
103
webUserTTL time.Duration
104
105
msgTotal atomic.Int64
106
107
joinCh chan string
108
client *girc.Client
109
onUserJoin func(channel, nick string) // optional callback when a non-bridge user joins
110
111
// namesUsers is our own authoritative user list populated from RPL_NAMREPLY.
112
// channel → nick → mode prefix ("@", "+", or "")
113
namesUsers map[string]map[string]string
114
115
// RELAYMSG support detected from ISUPPORT.
116
relaySep string // separator (e.g. "/"), empty if unsupported
117
}
118
119
// New creates a bridge Bot.
120
func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot {
121
if nick == "" {
122
nick = botNick
123
}
124
if bufSize <= 0 {
125
bufSize = 200
126
}
127
if webUserTTL <= 0 {
128
webUserTTL = defaultWebUserTTL
129
}
130
// Normalize channel names: ensure # prefix.
131
for i, ch := range channels {
132
if ch != "" && ch[0] != '#' {
133
channels[i] = "#" + ch
134
}
135
}
136
return &Bot{
137
ircAddr: ircAddr,
138
nick: nick,
139
password: password,
140
bufSize: bufSize,
141
initChannels: channels,
142
webUsers: make(map[string]map[string]time.Time),
143
webUserTTL: webUserTTL,
144
log: log,
145
buffers: make(map[string]*ringBuf),
146
subs: make(map[string]map[uint64]chan Message),
147
joined: make(map[string]bool),
148
joinCh: make(chan string, 32),
149
namesUsers: make(map[string]map[string]string),
150
}
151
}
152
153
// SetWebUserTTL updates how long bridge-posted HTTP nicks remain visible in
154
// the channel user list after their last post.
155
func (b *Bot) SetWebUserTTL(ttl time.Duration) {
156
if ttl <= 0 {
157
ttl = defaultWebUserTTL
158
}
159
b.mu.Lock()
160
b.webUserTTL = ttl
161
b.mu.Unlock()
162
}
163
164
// SetOnUserJoin registers a callback invoked when a non-bridge user joins a channel.
165
func (b *Bot) SetOnUserJoin(fn func(channel, nick string)) {
166
b.onUserJoin = fn
167
}
168
169
// Notice sends an IRC NOTICE to the given target (nick or channel).
170
func (b *Bot) Notice(target, text string) {
171
if b.client != nil {
172
b.client.Cmd.Notice(target, text)
173
}
174
}
175
176
// Name returns the bot's IRC nick.
177
func (b *Bot) Name() string { return b.nick }
178
179
// Start connects to IRC and begins bridging messages. Blocks until ctx is cancelled.
180
func (b *Bot) Start(ctx context.Context) error {
181
host, port, err := splitHostPort(b.ircAddr)
182
if err != nil {
183
return fmt.Errorf("bridge: parse irc addr: %w", err)
184
}
185
186
c := girc.New(girc.Config{
187
Server: host,
188
Port: port,
189
Nick: b.nick,
190
User: b.nick,
191
Name: "scuttlebot bridge",
192
SASL: &girc.SASLPlain{User: b.nick, Pass: b.password},
193
PingDelay: 30 * time.Second,
194
PingTimeout: 30 * time.Second,
195
SSL: false,
196
AllowFlood: true, // trusted local connection — no rate limiting
197
})
198
199
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
200
cl.Cmd.Mode(cl.GetNick(), "+B")
201
// Check RELAYMSG support from ISUPPORT (RPL_005).
202
if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
203
b.relaySep = sep
204
if b.log != nil {
205
b.log.Info("bridge: RELAYMSG supported", "separator", sep)
206
}
207
} else {
208
b.relaySep = ""
209
if b.log != nil {
210
b.log.Info("bridge: RELAYMSG not supported, using [nick] prefix fallback")
211
}
212
}
213
if b.log != nil {
214
b.log.Info("bridge connected")
215
}
216
for _, ch := range b.initChannels {
217
cl.Cmd.Join(ch)
218
}
219
})
220
221
c.Handlers.AddBg(girc.INVITE, func(_ *girc.Client, e girc.Event) {
222
if ch := e.Last(); strings.HasPrefix(ch, "#") {
223
b.JoinChannel(ch)
224
}
225
})
226
227
c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
228
if len(e.Params) < 1 || e.Source == nil {
229
return
230
}
231
channel := e.Params[0]
232
nick := e.Source.Name
233
234
if nick == b.nick {
235
// Bridge itself joined — initialize buffers.
236
b.mu.Lock()
237
if !b.joined[channel] {
238
b.joined[channel] = true
239
if b.buffers[channel] == nil {
240
b.buffers[channel] = newRingBuf(b.bufSize)
241
b.subs[channel] = make(map[uint64]chan Message)
242
}
243
}
244
b.mu.Unlock()
245
if b.log != nil {
246
b.log.Info("bridge joined channel", "channel", channel)
247
}
248
} else if b.onUserJoin != nil {
249
// Another user joined — fire callback for on-join instructions.
250
go b.onUserJoin(channel, nick)
251
}
252
})
253
254
// Parse RPL_NAMREPLY ourselves for a reliable user list.
255
c.Handlers.AddBg(girc.RPL_NAMREPLY, func(_ *girc.Client, e girc.Event) {
256
// Format: :server 353 bridge = #channel :@op +voice regular
257
if len(e.Params) < 4 {
258
return
259
}
260
channel := e.Params[2]
261
names := strings.Fields(e.Last())
262
b.mu.Lock()
263
if b.namesUsers[channel] == nil {
264
b.namesUsers[channel] = make(map[string]string)
265
}
266
for _, name := range names {
267
prefix := ""
268
nick := name
269
if strings.HasPrefix(name, "@") {
270
prefix = "@"
271
nick = name[1:]
272
} else if strings.HasPrefix(name, "+") {
273
prefix = "+"
274
nick = name[1:]
275
}
276
// Strip !user@host from userhost-in-names (IRCv3).
277
if idx := strings.Index(nick, "!"); idx != -1 {
278
nick = nick[:idx]
279
}
280
if nick != "" && nick != b.nick {
281
b.namesUsers[channel][nick] = prefix
282
}
283
}
284
b.mu.Unlock()
285
})
286
287
c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
288
if len(e.Params) < 1 || e.Source == nil {
289
return
290
}
291
channel := e.Params[0]
292
if !strings.HasPrefix(channel, "#") {
293
return // ignore DMs
294
}
295
// Prefer account-tag (IRCv3) over source nick for sender identity.
296
nick := e.Source.Name
297
if acct, ok := e.Tags.Get("account"); ok && acct != "" {
298
nick = acct
299
}
300
301
var msgID string
302
if id, ok := e.Tags.Get("msgid"); ok {
303
msgID = id
304
}
305
msg := Message{
306
At: e.Timestamp,
307
Channel: channel,
308
Nick: nick,
309
Text: e.Last(),
310
MsgID: msgID,
311
}
312
// Read meta-type from IRCv3 client tags if present.
313
if metaType, ok := e.Tags.Get("+scuttlebot/meta-type"); ok && metaType != "" {
314
msg.Meta = &Meta{Type: metaType}
315
}
316
b.dispatch(msg)
317
})
318
319
b.client = c
320
321
errCh := make(chan error, 1)
322
go func() {
323
if err := c.Connect(); err != nil && ctx.Err() == nil {
324
errCh <- err
325
}
326
}()
327
328
go b.joinLoop(ctx, c)
329
go b.namesRefreshLoop(ctx)
330
331
select {
332
case <-ctx.Done():
333
c.Close()
334
return nil
335
case err := <-errCh:
336
return fmt.Errorf("bridge: irc: %w", err)
337
}
338
}
339
340
// Stop disconnects the bot.
341
func (b *Bot) Stop() {
342
if b.client != nil {
343
b.client.Close()
344
}
345
}
346
347
// JoinChannel asks the bridge to join a channel it isn't already in.
348
// Pre-initialises the buffer so Messages() returns an empty slice (not nil)
349
// immediately, even before the IRC JOIN is confirmed.
350
func (b *Bot) JoinChannel(channel string) {
351
b.mu.Lock()
352
if b.buffers[channel] == nil {
353
b.buffers[channel] = newRingBuf(b.bufSize)
354
b.subs[channel] = make(map[uint64]chan Message)
355
}
356
b.mu.Unlock()
357
select {
358
case b.joinCh <- channel:
359
default:
360
}
361
}
362
363
// LeaveChannel parts the bridge from a channel and removes its buffers.
364
func (b *Bot) LeaveChannel(channel string) {
365
if b.client != nil {
366
b.client.Cmd.Part(channel)
367
}
368
b.mu.Lock()
369
delete(b.joined, channel)
370
delete(b.buffers, channel)
371
delete(b.subs, channel)
372
b.mu.Unlock()
373
}
374
375
// Channels returns the list of channels currently joined.
376
func (b *Bot) Channels() []string {
377
b.mu.RLock()
378
defer b.mu.RUnlock()
379
out := make([]string, 0, len(b.joined))
380
for ch := range b.joined {
381
out = append(out, ch)
382
}
383
return out
384
}
385
386
// Messages returns a snapshot of buffered messages for channel, oldest first.
387
// Returns nil if the channel is unknown.
388
func (b *Bot) Messages(channel string) []Message {
389
b.mu.RLock()
390
defer b.mu.RUnlock()
391
rb := b.buffers[channel]
392
if rb == nil {
393
return nil
394
}
395
return rb.snapshot()
396
}
397
398
// Subscribe returns a channel that receives new messages for channel,
399
// and an unsubscribe function.
400
func (b *Bot) Subscribe(channel string) (<-chan Message, func()) {
401
ch := make(chan Message, 64)
402
403
b.mu.Lock()
404
b.subSeq++
405
id := b.subSeq
406
if b.subs[channel] == nil {
407
b.subs[channel] = make(map[uint64]chan Message)
408
}
409
b.subs[channel][id] = ch
410
b.mu.Unlock()
411
412
unsub := func() {
413
b.mu.Lock()
414
delete(b.subs[channel], id)
415
b.mu.Unlock()
416
close(ch)
417
}
418
return ch, unsub
419
}
420
421
// Send sends a message to channel. The message is attributed to senderNick
422
// via a visible prefix: "[senderNick] text". The sent message is also pushed
423
// directly into the buffer since IRC servers don't echo messages back to sender.
424
func (b *Bot) Send(ctx context.Context, channel, text, senderNick string) error {
425
return b.SendWithMeta(ctx, channel, text, senderNick, nil)
426
}
427
428
// SendWithMeta sends a message to channel with optional structured metadata.
429
// IRC receives only the plain text; SSE subscribers receive the full message
430
// including meta for rich rendering in the web UI.
431
//
432
// When meta is present, key fields are attached as IRCv3 client-only tags
433
// (+scuttlebot/meta-type) so any IRCv3 client can read them.
434
//
435
// When the server supports RELAYMSG (IRCv3), messages are attributed natively
436
// so other clients see the real sender nick. Falls back to [nick] prefix.
437
func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
438
if b.client == nil {
439
return fmt.Errorf("bridge: not connected")
440
}
441
// Build optional IRCv3 tag prefix for meta-type.
442
tagPrefix := ""
443
if meta != nil && meta.Type != "" {
444
tagPrefix = "@+scuttlebot/meta-type=" + meta.Type + " "
445
}
446
if senderNick != "" && b.relaySep != "" {
447
// Use RELAYMSG for native attribution.
448
b.client.Cmd.SendRawf("%sRELAYMSG %s %s :%s", tagPrefix, channel, senderNick, text)
449
} else {
450
ircText := text
451
if senderNick != "" {
452
ircText = "[" + senderNick + "] " + text
453
}
454
if tagPrefix != "" {
455
b.client.Cmd.SendRawf("%sPRIVMSG %s :%s", tagPrefix, channel, ircText)
456
} else {
457
b.client.Cmd.Message(channel, ircText)
458
}
459
}
460
461
if senderNick != "" {
462
b.TouchUser(channel, senderNick)
463
}
464
465
displayNick := b.nick
466
if senderNick != "" {
467
displayNick = senderNick
468
}
469
b.dispatch(Message{
470
At: time.Now(),
471
Channel: channel,
472
Nick: displayNick,
473
Text: text,
474
Meta: meta,
475
})
476
return nil
477
}
478
479
// TouchUser marks a bridge/web nick as active in the given channel without
480
// sending a visible IRC message. This is used by broker-style local runtimes
481
// to maintain presence in the user list while idle.
482
func (b *Bot) TouchUser(channel, nick string) {
483
if nick == "" {
484
return
485
}
486
b.mu.Lock()
487
if b.webUsers[channel] == nil {
488
b.webUsers[channel] = make(map[string]time.Time)
489
}
490
b.webUsers[channel][nick] = time.Now()
491
b.mu.Unlock()
492
}
493
494
// RefreshNames sends a NAMES command for a channel, forcing girc to
495
// update its user list from the server's authoritative response.
496
func (b *Bot) RefreshNames(channel string) {
497
if b.client != nil {
498
b.client.Cmd.SendRawf("NAMES %s", channel)
499
}
500
}
501
502
// namesRefreshLoop periodically sends NAMES for all joined channels so
503
// girc's user tracking stays in sync with the server.
504
func (b *Bot) namesRefreshLoop(ctx context.Context) {
505
// Wait for initial connection and bot joins to settle.
506
select {
507
case <-ctx.Done():
508
return
509
case <-time.After(30 * time.Second):
510
}
511
ticker := time.NewTicker(30 * time.Second)
512
defer ticker.Stop()
513
for {
514
select {
515
case <-ctx.Done():
516
return
517
case <-ticker.C:
518
b.mu.RLock()
519
channels := make([]string, 0, len(b.joined))
520
for ch := range b.joined {
521
channels = append(channels, ch)
522
}
523
b.mu.RUnlock()
524
// Clear stale data before refresh.
525
b.mu.Lock()
526
for _, ch := range channels {
527
b.namesUsers[ch] = make(map[string]string)
528
}
529
b.mu.Unlock()
530
for _, ch := range channels {
531
b.RefreshNames(ch)
532
}
533
}
534
}
535
}
536
537
// Users returns the current nick list for a channel — from our NAMES cache
538
// plus web UI users who have posted recently within the configured TTL.
539
func (b *Bot) Users(channel string) []string {
540
seen := make(map[string]bool)
541
var nicks []string
542
543
// IRC-connected nicks from our NAMES cache.
544
b.mu.RLock()
545
for nick := range b.namesUsers[channel] {
546
if !seen[nick] {
547
seen[nick] = true
548
nicks = append(nicks, nick)
549
}
550
}
551
b.mu.RUnlock()
552
553
// Web UI senders active within the configured TTL. Also prune expired nicks
554
// so the bridge doesn't retain dead web-user entries forever.
555
now := time.Now()
556
b.mu.Lock()
557
cutoff := now.Add(-b.webUserTTL)
558
for nick, last := range b.webUsers[channel] {
559
if !last.After(cutoff) {
560
delete(b.webUsers[channel], nick)
561
continue
562
}
563
if !seen[nick] {
564
seen[nick] = true
565
nicks = append(nicks, nick)
566
}
567
}
568
b.mu.Unlock()
569
570
return nicks
571
}
572
573
// UserInfo describes a user with their IRC modes.
574
type UserInfo struct {
575
Nick string `json:"nick"`
576
Modes []string `json:"modes,omitempty"` // e.g. ["o", "v", "B"]
577
}
578
579
// UsersWithModes returns the current user list with mode info for a channel.
580
func (b *Bot) UsersWithModes(channel string) []UserInfo {
581
seen := make(map[string]bool)
582
var users []UserInfo
583
584
// Use our NAMES cache for reliable user+mode data.
585
b.mu.RLock()
586
for nick, prefix := range b.namesUsers[channel] {
587
if seen[nick] {
588
continue
589
}
590
seen[nick] = true
591
var modes []string
592
if prefix == "@" {
593
modes = append(modes, "o")
594
} else if prefix == "+" {
595
modes = append(modes, "v")
596
}
597
users = append(users, UserInfo{Nick: nick, Modes: modes})
598
}
599
b.mu.RUnlock()
600
601
now := time.Now()
602
b.mu.Lock()
603
cutoff := now.Add(-b.webUserTTL)
604
for nick, last := range b.webUsers[channel] {
605
if !last.After(cutoff) {
606
delete(b.webUsers[channel], nick)
607
continue
608
}
609
if !seen[nick] {
610
seen[nick] = true
611
users = append(users, UserInfo{Nick: nick})
612
}
613
}
614
b.mu.Unlock()
615
616
return users
617
}
618
619
// ChannelModes returns the channel mode string (e.g. "+mnt") for a channel.
620
func (b *Bot) ChannelModes(channel string) string {
621
if b.client == nil {
622
return ""
623
}
624
ch := b.client.LookupChannel(channel)
625
if ch == nil {
626
return ""
627
}
628
return ch.Modes.String()
629
}
630
631
// Stats returns a snapshot of bridge activity.
632
func (b *Bot) Stats() Stats {
633
b.mu.RLock()
634
channels := len(b.joined)
635
subs := 0
636
for _, m := range b.subs {
637
subs += len(m)
638
}
639
b.mu.RUnlock()
640
return Stats{
641
Channels: channels,
642
MessagesTotal: b.msgTotal.Load(),
643
ActiveSubs: subs,
644
}
645
}
646
647
// dispatch pushes a message to the ring buffer and fans out to subscribers.
648
func (b *Bot) dispatch(msg Message) {
649
b.msgTotal.Add(1)
650
b.mu.Lock()
651
defer b.mu.Unlock()
652
rb := b.buffers[msg.Channel]
653
if rb == nil {
654
return
655
}
656
rb.push(msg)
657
for _, ch := range b.subs[msg.Channel] {
658
select {
659
case ch <- msg:
660
default: // slow consumer, drop
661
}
662
}
663
}
664
665
// joinLoop reads from joinCh and joins channels on demand.
666
func (b *Bot) joinLoop(ctx context.Context, c *girc.Client) {
667
for {
668
select {
669
case <-ctx.Done():
670
return
671
case ch := <-b.joinCh:
672
b.mu.RLock()
673
already := b.joined[ch]
674
b.mu.RUnlock()
675
if !already {
676
c.Cmd.Join(ch)
677
}
678
}
679
}
680
}
681
682
func splitHostPort(addr string) (string, int, error) {
683
host, portStr, err := net.SplitHostPort(addr)
684
if err != nil {
685
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
686
}
687
port, err := strconv.Atoi(portStr)
688
if err != nil {
689
return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
690
}
691
return host, port, nil
692
}
693

Keyboard Shortcuts

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