|
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
|
|