ScuttleBot

scuttlebot / pkg / client / discovery.go
Source Blame History 389 lines
b2b8269… lmata 1 package client
b2b8269… lmata 2
b2b8269… lmata 3 import (
b2b8269… lmata 4 "context"
b2b8269… lmata 5 "fmt"
b2b8269… lmata 6 "strings"
b2b8269… lmata 7 "sync"
b2b8269… lmata 8 "time"
b2b8269… lmata 9
b2b8269… lmata 10 "github.com/lrstanley/girc"
b2b8269… lmata 11 )
b2b8269… lmata 12
b2b8269… lmata 13 // DiscoveryOptions configures the caching behaviour for discovery calls.
b2b8269… lmata 14 type DiscoveryOptions struct {
b2b8269… lmata 15 // CacheTTL is how long results are cached before re-querying Ergo.
b2b8269… lmata 16 // Default: 30s. Set to 0 to disable caching.
b2b8269… lmata 17 CacheTTL time.Duration
b2b8269… lmata 18 }
b2b8269… lmata 19
b2b8269… lmata 20 // ChannelSummary is a channel returned by ListChannels.
b2b8269… lmata 21 type ChannelSummary struct {
1066004… lmata 22 Name string
b2b8269… lmata 23 MemberCount int
1066004… lmata 24 Topic string
b2b8269… lmata 25 }
b2b8269… lmata 26
b2b8269… lmata 27 // Member is an entry in the channel member list.
b2b8269… lmata 28 type Member struct {
b2b8269… lmata 29 Nick string
b2b8269… lmata 30 IsOp bool
b2b8269… lmata 31 IsVoice bool
b2b8269… lmata 32 }
b2b8269… lmata 33
b2b8269… lmata 34 // TopicInfo is the result of GetTopic.
b2b8269… lmata 35 type TopicInfo struct {
b2b8269… lmata 36 Channel string
b2b8269… lmata 37 Topic string
b2b8269… lmata 38 SetBy string
b2b8269… lmata 39 SetAt time.Time
b2b8269… lmata 40 }
b2b8269… lmata 41
b2b8269… lmata 42 // WhoIsInfo is the result of WhoIs.
b2b8269… lmata 43 type WhoIsInfo struct {
b2b8269… lmata 44 Nick string
b2b8269… lmata 45 User string
b2b8269… lmata 46 Host string
b2b8269… lmata 47 RealName string
b2b8269… lmata 48 Channels []string
b2b8269… lmata 49 Account string // NickServ account name, if identified
b2b8269… lmata 50 }
b2b8269… lmata 51
b2b8269… lmata 52 // discoveryCacheEntry wraps a result with an expiry.
b2b8269… lmata 53 type discoveryCacheEntry struct {
b2b8269… lmata 54 value any
b2b8269… lmata 55 expiry time.Time
b2b8269… lmata 56 }
b2b8269… lmata 57
b2b8269… lmata 58 // discoveryCache is a simple TTL cache keyed by string.
b2b8269… lmata 59 type discoveryCache struct {
b2b8269… lmata 60 mu sync.Mutex
b2b8269… lmata 61 entries map[string]discoveryCacheEntry
b2b8269… lmata 62 ttl time.Duration
b2b8269… lmata 63 }
b2b8269… lmata 64
b2b8269… lmata 65 func newDiscoveryCache(ttl time.Duration) *discoveryCache {
b2b8269… lmata 66 return &discoveryCache{entries: make(map[string]discoveryCacheEntry), ttl: ttl}
b2b8269… lmata 67 }
b2b8269… lmata 68
b2b8269… lmata 69 func (c *discoveryCache) get(key string) (any, bool) {
b2b8269… lmata 70 if c.ttl == 0 {
b2b8269… lmata 71 return nil, false
b2b8269… lmata 72 }
b2b8269… lmata 73 c.mu.Lock()
b2b8269… lmata 74 defer c.mu.Unlock()
b2b8269… lmata 75 e, ok := c.entries[key]
b2b8269… lmata 76 if !ok || time.Now().After(e.expiry) {
b2b8269… lmata 77 return nil, false
b2b8269… lmata 78 }
b2b8269… lmata 79 return e.value, true
b2b8269… lmata 80 }
b2b8269… lmata 81
b2b8269… lmata 82 func (c *discoveryCache) set(key string, value any) {
b2b8269… lmata 83 if c.ttl == 0 {
b2b8269… lmata 84 return
b2b8269… lmata 85 }
b2b8269… lmata 86 c.mu.Lock()
b2b8269… lmata 87 defer c.mu.Unlock()
b2b8269… lmata 88 c.entries[key] = discoveryCacheEntry{value: value, expiry: time.Now().Add(c.ttl)}
b2b8269… lmata 89 }
b2b8269… lmata 90
b2b8269… lmata 91 // Discovery wraps a connected Client with typed IRC discovery methods.
b2b8269… lmata 92 // Results are cached to avoid flooding Ergo.
b2b8269… lmata 93 //
b2b8269… lmata 94 // Typical usage:
b2b8269… lmata 95 //
b2b8269… lmata 96 // d := client.NewDiscovery(c, client.DiscoveryOptions{CacheTTL: 30 * time.Second})
b2b8269… lmata 97 // channels, err := d.ListChannels(ctx)
b2b8269… lmata 98 type Discovery struct {
b2b8269… lmata 99 client *Client
b2b8269… lmata 100 cache *discoveryCache
b2b8269… lmata 101 }
b2b8269… lmata 102
b2b8269… lmata 103 // NewDiscovery creates a Discovery using the given (connected) Client.
b2b8269… lmata 104 func NewDiscovery(c *Client, opts DiscoveryOptions) *Discovery {
b2b8269… lmata 105 ttl := opts.CacheTTL
b2b8269… lmata 106 if ttl == 0 {
b2b8269… lmata 107 ttl = 30 * time.Second
b2b8269… lmata 108 }
b2b8269… lmata 109 return &Discovery{client: c, cache: newDiscoveryCache(ttl)}
b2b8269… lmata 110 }
b2b8269… lmata 111
b2b8269… lmata 112 // ListChannels returns all public channels on the server.
b2b8269… lmata 113 func (d *Discovery) ListChannels(ctx context.Context) ([]ChannelSummary, error) {
b2b8269… lmata 114 const cacheKey = "list_channels"
b2b8269… lmata 115 if v, ok := d.cache.get(cacheKey); ok {
b2b8269… lmata 116 return v.([]ChannelSummary), nil
b2b8269… lmata 117 }
b2b8269… lmata 118
b2b8269… lmata 119 irc := d.ircClient()
b2b8269… lmata 120 if irc == nil {
b2b8269… lmata 121 return nil, fmt.Errorf("discovery: not connected")
b2b8269… lmata 122 }
b2b8269… lmata 123
b2b8269… lmata 124 type item struct {
b2b8269… lmata 125 name string
b2b8269… lmata 126 count int
b2b8269… lmata 127 topic string
b2b8269… lmata 128 }
b2b8269… lmata 129 var (
b2b8269… lmata 130 mu sync.Mutex
b2b8269… lmata 131 results []item
b2b8269… lmata 132 done = make(chan struct{})
b2b8269… lmata 133 )
b2b8269… lmata 134
b2b8269… lmata 135 listID := irc.Handlers.AddBg(girc.LIST, func(_ *girc.Client, e girc.Event) {
b2b8269… lmata 136 // RPL_LIST: params are [me, channel, count, topic]
b2b8269… lmata 137 if len(e.Params) < 3 {
b2b8269… lmata 138 return
b2b8269… lmata 139 }
b2b8269… lmata 140 var count int
f7eb47b… lmata 141 _, _ = fmt.Sscanf(e.Params[2], "%d", &count)
b2b8269… lmata 142 mu.Lock()
b2b8269… lmata 143 results = append(results, item{e.Params[1], count, e.Last()})
b2b8269… lmata 144 mu.Unlock()
b2b8269… lmata 145 })
b2b8269… lmata 146
b2b8269… lmata 147 endID := irc.Handlers.AddBg(girc.RPL_LISTEND, func(_ *girc.Client, _ girc.Event) {
b2b8269… lmata 148 select {
b2b8269… lmata 149 case done <- struct{}{}:
b2b8269… lmata 150 default:
b2b8269… lmata 151 }
b2b8269… lmata 152 })
b2b8269… lmata 153
b2b8269… lmata 154 defer func() {
b2b8269… lmata 155 irc.Handlers.Remove(listID)
b2b8269… lmata 156 irc.Handlers.Remove(endID)
b2b8269… lmata 157 }()
b2b8269… lmata 158
b2b8269… lmata 159 irc.Cmd.List()
b2b8269… lmata 160
b2b8269… lmata 161 select {
b2b8269… lmata 162 case <-ctx.Done():
b2b8269… lmata 163 return nil, ctx.Err()
b2b8269… lmata 164 case <-done:
b2b8269… lmata 165 }
b2b8269… lmata 166
b2b8269… lmata 167 mu.Lock()
b2b8269… lmata 168 defer mu.Unlock()
b2b8269… lmata 169 out := make([]ChannelSummary, len(results))
b2b8269… lmata 170 for i, r := range results {
b2b8269… lmata 171 out[i] = ChannelSummary{Name: r.name, MemberCount: r.count, Topic: r.topic}
b2b8269… lmata 172 }
b2b8269… lmata 173 d.cache.set(cacheKey, out)
b2b8269… lmata 174 return out, nil
b2b8269… lmata 175 }
b2b8269… lmata 176
b2b8269… lmata 177 // ChannelMembers returns the current member list for a channel.
b2b8269… lmata 178 func (d *Discovery) ChannelMembers(ctx context.Context, channel string) ([]Member, error) {
b2b8269… lmata 179 cacheKey := "members:" + channel
b2b8269… lmata 180 if v, ok := d.cache.get(cacheKey); ok {
b2b8269… lmata 181 return v.([]Member), nil
b2b8269… lmata 182 }
b2b8269… lmata 183
b2b8269… lmata 184 irc := d.ircClient()
b2b8269… lmata 185 if irc == nil {
b2b8269… lmata 186 return nil, fmt.Errorf("discovery: not connected")
b2b8269… lmata 187 }
b2b8269… lmata 188
b2b8269… lmata 189 var (
b2b8269… lmata 190 mu sync.Mutex
b2b8269… lmata 191 members []Member
b2b8269… lmata 192 done = make(chan struct{})
b2b8269… lmata 193 )
b2b8269… lmata 194
b2b8269… lmata 195 namesID := irc.Handlers.AddBg(girc.RPL_NAMREPLY, func(_ *girc.Client, e girc.Event) {
b2b8269… lmata 196 // params: [me, mode, channel, names...]
b2b8269… lmata 197 if len(e.Params) < 3 {
b2b8269… lmata 198 return
b2b8269… lmata 199 }
b2b8269… lmata 200 // last param is space-separated nicks with optional @/+ prefix
b2b8269… lmata 201 for _, n := range strings.Fields(e.Last()) {
b2b8269… lmata 202 m := Member{Nick: n}
b2b8269… lmata 203 if strings.HasPrefix(n, "@") {
b2b8269… lmata 204 m.Nick = n[1:]
b2b8269… lmata 205 m.IsOp = true
b2b8269… lmata 206 } else if strings.HasPrefix(n, "+") {
b2b8269… lmata 207 m.Nick = n[1:]
b2b8269… lmata 208 m.IsVoice = true
b2b8269… lmata 209 }
b2b8269… lmata 210 mu.Lock()
b2b8269… lmata 211 members = append(members, m)
b2b8269… lmata 212 mu.Unlock()
b2b8269… lmata 213 }
b2b8269… lmata 214 })
b2b8269… lmata 215
b2b8269… lmata 216 endID := irc.Handlers.AddBg(girc.RPL_ENDOFNAMES, func(_ *girc.Client, e girc.Event) {
b2b8269… lmata 217 if len(e.Params) >= 2 && strings.EqualFold(e.Params[1], channel) {
b2b8269… lmata 218 select {
b2b8269… lmata 219 case done <- struct{}{}:
b2b8269… lmata 220 default:
b2b8269… lmata 221 }
b2b8269… lmata 222 }
b2b8269… lmata 223 })
b2b8269… lmata 224
b2b8269… lmata 225 defer func() {
b2b8269… lmata 226 irc.Handlers.Remove(namesID)
b2b8269… lmata 227 irc.Handlers.Remove(endID)
b2b8269… lmata 228 }()
b2b8269… lmata 229
b2b8269… lmata 230 _ = irc.Cmd.SendRaw("NAMES " + channel)
b2b8269… lmata 231
b2b8269… lmata 232 select {
b2b8269… lmata 233 case <-ctx.Done():
b2b8269… lmata 234 return nil, ctx.Err()
b2b8269… lmata 235 case <-done:
b2b8269… lmata 236 }
b2b8269… lmata 237
b2b8269… lmata 238 mu.Lock()
b2b8269… lmata 239 defer mu.Unlock()
b2b8269… lmata 240 d.cache.set(cacheKey, members)
b2b8269… lmata 241 return members, nil
b2b8269… lmata 242 }
b2b8269… lmata 243
b2b8269… lmata 244 // GetTopic returns the topic for a channel.
b2b8269… lmata 245 func (d *Discovery) GetTopic(ctx context.Context, channel string) (TopicInfo, error) {
b2b8269… lmata 246 cacheKey := "topic:" + channel
b2b8269… lmata 247 if v, ok := d.cache.get(cacheKey); ok {
b2b8269… lmata 248 return v.(TopicInfo), nil
b2b8269… lmata 249 }
b2b8269… lmata 250
b2b8269… lmata 251 irc := d.ircClient()
b2b8269… lmata 252 if irc == nil {
b2b8269… lmata 253 return TopicInfo{}, fmt.Errorf("discovery: not connected")
b2b8269… lmata 254 }
b2b8269… lmata 255
b2b8269… lmata 256 result := make(chan TopicInfo, 1)
b2b8269… lmata 257
b2b8269… lmata 258 topicID := irc.Handlers.AddBg(girc.RPL_TOPIC, func(_ *girc.Client, e girc.Event) {
b2b8269… lmata 259 if len(e.Params) >= 2 && strings.EqualFold(e.Params[1], channel) {
b2b8269… lmata 260 select {
b2b8269… lmata 261 case result <- TopicInfo{Channel: channel, Topic: e.Last()}:
b2b8269… lmata 262 default:
b2b8269… lmata 263 }
b2b8269… lmata 264 }
b2b8269… lmata 265 })
b2b8269… lmata 266
b2b8269… lmata 267 noTopicID := irc.Handlers.AddBg(girc.RPL_NOTOPIC, func(_ *girc.Client, e girc.Event) {
b2b8269… lmata 268 if len(e.Params) >= 2 && strings.EqualFold(e.Params[1], channel) {
b2b8269… lmata 269 select {
b2b8269… lmata 270 case result <- TopicInfo{Channel: channel}:
b2b8269… lmata 271 default:
b2b8269… lmata 272 }
b2b8269… lmata 273 }
b2b8269… lmata 274 })
b2b8269… lmata 275
b2b8269… lmata 276 defer func() {
b2b8269… lmata 277 irc.Handlers.Remove(topicID)
b2b8269… lmata 278 irc.Handlers.Remove(noTopicID)
b2b8269… lmata 279 }()
b2b8269… lmata 280
b2b8269… lmata 281 _ = irc.Cmd.SendRaw("TOPIC " + channel)
b2b8269… lmata 282
b2b8269… lmata 283 select {
b2b8269… lmata 284 case <-ctx.Done():
b2b8269… lmata 285 return TopicInfo{}, ctx.Err()
b2b8269… lmata 286 case info := <-result:
b2b8269… lmata 287 d.cache.set(cacheKey, info)
b2b8269… lmata 288 return info, nil
b2b8269… lmata 289 }
b2b8269… lmata 290 }
b2b8269… lmata 291
b2b8269… lmata 292 // WhoIs returns identity information for a nick.
b2b8269… lmata 293 func (d *Discovery) WhoIs(ctx context.Context, nick string) (WhoIsInfo, error) {
b2b8269… lmata 294 cacheKey := "whois:" + nick
b2b8269… lmata 295 if v, ok := d.cache.get(cacheKey); ok {
b2b8269… lmata 296 return v.(WhoIsInfo), nil
b2b8269… lmata 297 }
b2b8269… lmata 298
b2b8269… lmata 299 irc := d.ircClient()
b2b8269… lmata 300 if irc == nil {
b2b8269… lmata 301 return WhoIsInfo{}, fmt.Errorf("discovery: not connected")
b2b8269… lmata 302 }
b2b8269… lmata 303
b2b8269… lmata 304 var (
b2b8269… lmata 305 mu sync.Mutex
b2b8269… lmata 306 info WhoIsInfo
b2b8269… lmata 307 done = make(chan struct{})
b2b8269… lmata 308 )
b2b8269… lmata 309 info.Nick = nick
b2b8269… lmata 310
b2b8269… lmata 311 // RPL_WHOISUSER (311): nick, user, host, *, realname
b2b8269… lmata 312 userID := irc.Handlers.AddBg(girc.RPL_WHOISUSER, func(_ *girc.Client, e girc.Event) {
b2b8269… lmata 313 if len(e.Params) < 4 || !strings.EqualFold(e.Params[1], nick) {
b2b8269… lmata 314 return
b2b8269… lmata 315 }
b2b8269… lmata 316 mu.Lock()
b2b8269… lmata 317 info.User = e.Params[2]
b2b8269… lmata 318 info.Host = e.Params[3]
b2b8269… lmata 319 info.RealName = e.Last()
b2b8269… lmata 320 mu.Unlock()
b2b8269… lmata 321 })
b2b8269… lmata 322
b2b8269… lmata 323 // RPL_WHOISCHANNELS (319): nick, channels
b2b8269… lmata 324 chansID := irc.Handlers.AddBg(girc.RPL_WHOISCHANNELS, func(_ *girc.Client, e girc.Event) {
b2b8269… lmata 325 if len(e.Params) < 2 || !strings.EqualFold(e.Params[1], nick) {
b2b8269… lmata 326 return
b2b8269… lmata 327 }
b2b8269… lmata 328 mu.Lock()
b2b8269… lmata 329 for _, ch := range strings.Fields(e.Last()) {
b2b8269… lmata 330 // Strip mode prefixes (@, +) from channel names.
b2b8269… lmata 331 info.Channels = append(info.Channels, strings.TrimLeft(ch, "@+~&%"))
b2b8269… lmata 332 }
b2b8269… lmata 333 mu.Unlock()
b2b8269… lmata 334 })
b2b8269… lmata 335
b2b8269… lmata 336 // RPL_WHOISACCOUNT (330): nick, account, "is logged in as"
b2b8269… lmata 337 acctID := irc.Handlers.AddBg(girc.RPL_WHOISACCOUNT, func(_ *girc.Client, e girc.Event) {
b2b8269… lmata 338 if len(e.Params) < 3 || !strings.EqualFold(e.Params[1], nick) {
b2b8269… lmata 339 return
b2b8269… lmata 340 }
b2b8269… lmata 341 mu.Lock()
b2b8269… lmata 342 info.Account = e.Params[2]
b2b8269… lmata 343 mu.Unlock()
b2b8269… lmata 344 })
b2b8269… lmata 345
b2b8269… lmata 346 // RPL_ENDOFWHOIS (318)
b2b8269… lmata 347 endID := irc.Handlers.AddBg(girc.RPL_ENDOFWHOIS, func(_ *girc.Client, e girc.Event) {
b2b8269… lmata 348 if len(e.Params) >= 2 && strings.EqualFold(e.Params[1], nick) {
b2b8269… lmata 349 select {
b2b8269… lmata 350 case done <- struct{}{}:
b2b8269… lmata 351 default:
b2b8269… lmata 352 }
b2b8269… lmata 353 }
b2b8269… lmata 354 })
b2b8269… lmata 355
b2b8269… lmata 356 defer func() {
b2b8269… lmata 357 irc.Handlers.Remove(userID)
b2b8269… lmata 358 irc.Handlers.Remove(chansID)
b2b8269… lmata 359 irc.Handlers.Remove(acctID)
b2b8269… lmata 360 irc.Handlers.Remove(endID)
b2b8269… lmata 361 }()
b2b8269… lmata 362
b2b8269… lmata 363 irc.Cmd.Whois(nick)
b2b8269… lmata 364
b2b8269… lmata 365 select {
b2b8269… lmata 366 case <-ctx.Done():
b2b8269… lmata 367 return WhoIsInfo{}, ctx.Err()
b2b8269… lmata 368 case <-done:
b2b8269… lmata 369 }
b2b8269… lmata 370
b2b8269… lmata 371 mu.Lock()
b2b8269… lmata 372 defer mu.Unlock()
b2b8269… lmata 373 d.cache.set(cacheKey, info)
b2b8269… lmata 374 return info, nil
b2b8269… lmata 375 }
b2b8269… lmata 376
b2b8269… lmata 377 // Invalidate removes a specific entry from the cache (e.g. on channel join/part events).
b2b8269… lmata 378 // key forms: "list_channels", "members:#fleet", "topic:#fleet", "whois:nick"
b2b8269… lmata 379 func (d *Discovery) Invalidate(key string) {
b2b8269… lmata 380 d.cache.mu.Lock()
b2b8269… lmata 381 delete(d.cache.entries, key)
b2b8269… lmata 382 d.cache.mu.Unlock()
b2b8269… lmata 383 }
b2b8269… lmata 384
b2b8269… lmata 385 func (d *Discovery) ircClient() *girc.Client {
b2b8269… lmata 386 d.client.mu.RLock()
b2b8269… lmata 387 defer d.client.mu.RUnlock()
b2b8269… lmata 388 return d.client.irc
b2b8269… lmata 389 }

Keyboard Shortcuts

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