ScuttleBot

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

Keyboard Shortcuts

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