ScuttleBot

scuttlebot / internal / bots / steward / steward.go
Source Blame History 431 lines
5ac549c… lmata 1 // Package steward implements the steward bot — a moderation action bot that
5ac549c… lmata 2 // watches for sentinel incident reports and takes proportional IRC action.
5ac549c… lmata 3 //
5ac549c… lmata 4 // Steward reads structured reports from the mod channel posted by sentinel
5ac549c… lmata 5 // (or any other source using the same format) and responds based on configured
5ac549c… lmata 6 // severity thresholds:
5ac549c… lmata 7 //
5ac549c… lmata 8 // - low: warn the user via NOTICE
5ac549c… lmata 9 // - medium: warn + temporary mute (channel mode +q)
5ac549c… lmata 10 // - high: warn + kick (with reason)
5ac549c… lmata 11 //
5ac549c… lmata 12 // Every action steward takes is announced in the mod channel so the audit
5ac549c… lmata 13 // trail remains fully human-observable in IRC.
5ac549c… lmata 14 //
5ac549c… lmata 15 // Steward can also be commanded directly via DM by operators:
5ac549c… lmata 16 //
5ac549c… lmata 17 // warn <nick> <#channel> <reason>
5ac549c… lmata 18 // mute <nick> <#channel> [duration]
5ac549c… lmata 19 // kick <nick> <#channel> <reason>
5ac549c… lmata 20 // unmute <nick> <#channel>
5ac549c… lmata 21 package steward
5ac549c… lmata 22
5ac549c… lmata 23 import (
5ac549c… lmata 24 "context"
5ac549c… lmata 25 "fmt"
5ac549c… lmata 26 "log/slog"
5ac549c… lmata 27 "net"
5ac549c… lmata 28 "strconv"
5ac549c… lmata 29 "strings"
5ac549c… lmata 30 "sync"
5ac549c… lmata 31 "time"
5ac549c… lmata 32
5ac549c… lmata 33 "github.com/lrstanley/girc"
e8d318d… noreply 34
e8d318d… noreply 35 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
5ac549c… lmata 36 )
5ac549c… lmata 37
5ac549c… lmata 38 const defaultNick = "steward"
5ac549c… lmata 39
5ac549c… lmata 40 // Config controls steward's behaviour.
5ac549c… lmata 41 type Config struct {
5ac549c… lmata 42 // IRCAddr is host:port of the Ergo IRC server.
5ac549c… lmata 43 IRCAddr string
5ac549c… lmata 44 // Nick is the IRC nick. Default: "steward".
5ac549c… lmata 45 Nick string
5ac549c… lmata 46 // Password is the SASL PLAIN passphrase.
5ac549c… lmata 47 Password string
5ac549c… lmata 48
5ac549c… lmata 49 // ModChannel is the channel steward watches for sentinel reports and
5ac549c… lmata 50 // where it announces its own actions. Default: "#moderation".
5ac549c… lmata 51 ModChannel string
5ac549c… lmata 52
5ac549c… lmata 53 // OperatorNicks is the list of nicks allowed to issue direct commands.
5ac549c… lmata 54 OperatorNicks []string
5ac549c… lmata 55
5ac549c… lmata 56 // AutoAct enables automatic action on sentinel reports.
5ac549c… lmata 57 // When false, steward only acts on direct operator commands.
5ac549c… lmata 58 AutoAct bool
5ac549c… lmata 59
5ac549c… lmata 60 // MuteDuration is how long a medium-severity mute lasts. Default: 10m.
5ac549c… lmata 61 MuteDuration time.Duration
5ac549c… lmata 62
5ac549c… lmata 63 // WarnOnLow — send a warning notice for low-severity incidents.
5ac549c… lmata 64 // Default: true.
5ac549c… lmata 65 WarnOnLow bool
5ac549c… lmata 66 // DMOnAction, when true, sends a DM to all OperatorNicks when steward takes action.
5ac549c… lmata 67 DMOnAction bool
5ac549c… lmata 68
5ac549c… lmata 69 // CooldownPerNick is the minimum time between automated actions on the
5ac549c… lmata 70 // same nick. Default: 5 minutes.
5ac549c… lmata 71 CooldownPerNick time.Duration
3420a83… lmata 72
3420a83… lmata 73 // Channels is the list of channels to join on connect.
3420a83… lmata 74 Channels []string
5ac549c… lmata 75 }
5ac549c… lmata 76
5ac549c… lmata 77 func (c *Config) setDefaults() {
5ac549c… lmata 78 if c.Nick == "" {
5ac549c… lmata 79 c.Nick = defaultNick
5ac549c… lmata 80 }
5ac549c… lmata 81 if c.ModChannel == "" {
5ac549c… lmata 82 c.ModChannel = "#moderation"
5ac549c… lmata 83 }
5ac549c… lmata 84 if c.MuteDuration == 0 {
5ac549c… lmata 85 c.MuteDuration = 10 * time.Minute
5ac549c… lmata 86 }
5ac549c… lmata 87 if c.CooldownPerNick == 0 {
5ac549c… lmata 88 c.CooldownPerNick = 5 * time.Minute
5ac549c… lmata 89 }
5ac549c… lmata 90 if !c.WarnOnLow {
5ac549c… lmata 91 c.WarnOnLow = true
5ac549c… lmata 92 }
5ac549c… lmata 93 }
5ac549c… lmata 94
5ac549c… lmata 95 // Bot is the steward bot.
5ac549c… lmata 96 type Bot struct {
5ac549c… lmata 97 cfg Config
5ac549c… lmata 98 log *slog.Logger
5ac549c… lmata 99 client *girc.Client
5ac549c… lmata 100
5ac549c… lmata 101 mu sync.Mutex
5ac549c… lmata 102 cooldown map[string]time.Time // nick → last action time
5ac549c… lmata 103 mutes map[string]time.Time // "channel:nick" → unmute at
5ac549c… lmata 104 }
5ac549c… lmata 105
5ac549c… lmata 106 // New creates a steward Bot.
5ac549c… lmata 107 func New(cfg Config, log *slog.Logger) *Bot {
5ac549c… lmata 108 cfg.setDefaults()
5ac549c… lmata 109 return &Bot{
5ac549c… lmata 110 cfg: cfg,
5ac549c… lmata 111 log: log,
5ac549c… lmata 112 cooldown: make(map[string]time.Time),
5ac549c… lmata 113 mutes: make(map[string]time.Time),
5ac549c… lmata 114 }
5ac549c… lmata 115 }
5ac549c… lmata 116
5ac549c… lmata 117 // Start connects to IRC and begins watching for sentinel reports.
5ac549c… lmata 118 // Blocks until ctx is done.
5ac549c… lmata 119 func (b *Bot) Start(ctx context.Context) error {
5ac549c… lmata 120 host, port, err := splitHostPort(b.cfg.IRCAddr)
5ac549c… lmata 121 if err != nil {
5ac549c… lmata 122 return fmt.Errorf("steward: %w", err)
5ac549c… lmata 123 }
5ac549c… lmata 124
5ac549c… lmata 125 c := girc.New(girc.Config{
d924aea… lmata 126 Server: host,
d924aea… lmata 127 Port: port,
d924aea… lmata 128 Nick: b.cfg.Nick,
d924aea… lmata 129 User: b.cfg.Nick,
d924aea… lmata 130 Name: "scuttlebot steward",
81587e6… lmata 131 SASL: &girc.SASLPlain{User: b.cfg.Nick, Pass: b.cfg.Password},
81587e6… lmata 132 PingDelay: 30 * time.Second,
81587e6… lmata 133 PingTimeout: 30 * time.Second,
5ac549c… lmata 134 })
5ac549c… lmata 135
5ac549c… lmata 136 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
f64fe5f… noreply 137 cl.Cmd.Mode(cl.GetNick(), "+B")
3420a83… lmata 138 for _, ch := range b.cfg.Channels {
3420a83… lmata 139 cl.Cmd.Join(ch)
5ac549c… lmata 140 }
5ac549c… lmata 141 cl.Cmd.Join(b.cfg.ModChannel)
3420a83… lmata 142 if b.log != nil {
3420a83… lmata 143 b.log.Info("steward connected", "channels", b.cfg.Channels)
3420a83… lmata 144 }
bd16e1f… lmata 145 })
bd16e1f… lmata 146
bd16e1f… lmata 147 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
bd16e1f… lmata 148 if ch := e.Last(); strings.HasPrefix(ch, "#") {
bd16e1f… lmata 149 cl.Cmd.Join(ch)
bd16e1f… lmata 150 }
bd16e1f… lmata 151 })
bd16e1f… lmata 152
e8d318d… noreply 153 router := cmdparse.NewRouter(b.cfg.Nick)
e8d318d… noreply 154 router.Register(cmdparse.Command{
e8d318d… noreply 155 Name: "act",
e8d318d… noreply 156 Usage: "ACT <incident-id>",
e8d318d… noreply 157 Description: "manually trigger action on incident",
e8d318d… noreply 158 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
e8d318d… noreply 159 })
e8d318d… noreply 160 router.Register(cmdparse.Command{
e8d318d… noreply 161 Name: "override",
e8d318d… noreply 162 Usage: "OVERRIDE <incident-id>",
e8d318d… noreply 163 Description: "override pending action",
e8d318d… noreply 164 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
e8d318d… noreply 165 })
e8d318d… noreply 166 router.Register(cmdparse.Command{
e8d318d… noreply 167 Name: "status",
e8d318d… noreply 168 Usage: "STATUS",
e8d318d… noreply 169 Description: "show current pending actions",
e8d318d… noreply 170 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
e8d318d… noreply 171 })
e8d318d… noreply 172
5ac549c… lmata 173 c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
5ac549c… lmata 174 if len(e.Params) < 1 || e.Source == nil {
e8d318d… noreply 175 return
e8d318d… noreply 176 }
e8d318d… noreply 177 // Dispatch commands (DMs and channel messages).
e8d318d… noreply 178 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
e8d318d… noreply 179 c.Cmd.Message(reply.Target, reply.Text)
5ac549c… lmata 180 return
5ac549c… lmata 181 }
5ac549c… lmata 182 target := e.Params[0]
5ac549c… lmata 183 nick := e.Source.Name
5ac549c… lmata 184 text := strings.TrimSpace(e.Last())
5ac549c… lmata 185
5ac549c… lmata 186 if nick == b.cfg.Nick {
5ac549c… lmata 187 return
5ac549c… lmata 188 }
5ac549c… lmata 189
5ac549c… lmata 190 // Sentinel reports arrive as channel messages in the mod channel.
5ac549c… lmata 191 if target == b.cfg.ModChannel && b.cfg.AutoAct {
5ac549c… lmata 192 b.handleReport(c, text)
5ac549c… lmata 193 return
5ac549c… lmata 194 }
5ac549c… lmata 195
5ac549c… lmata 196 // Direct operator commands arrive as DMs.
5ac549c… lmata 197 if !strings.HasPrefix(target, "#") && b.isOperator(nick) {
5ac549c… lmata 198 b.handleCommand(c, nick, text)
5ac549c… lmata 199 }
5ac549c… lmata 200 })
5ac549c… lmata 201
5ac549c… lmata 202 b.client = c
5ac549c… lmata 203
5ac549c… lmata 204 // Background loop: unmute nicks whose mute duration has elapsed.
5ac549c… lmata 205 go b.unmuteLoop(ctx)
5ac549c… lmata 206
5ac549c… lmata 207 errCh := make(chan error, 1)
5ac549c… lmata 208 go func() {
5ac549c… lmata 209 if err := c.Connect(); err != nil && ctx.Err() == nil {
5ac549c… lmata 210 errCh <- err
5ac549c… lmata 211 }
5ac549c… lmata 212 }()
5ac549c… lmata 213
5ac549c… lmata 214 select {
5ac549c… lmata 215 case <-ctx.Done():
5ac549c… lmata 216 c.Close()
5ac549c… lmata 217 return nil
5ac549c… lmata 218 case err := <-errCh:
5ac549c… lmata 219 return fmt.Errorf("steward: irc: %w", err)
5ac549c… lmata 220 }
5ac549c… lmata 221 }
5ac549c… lmata 222
5ac549c… lmata 223 // JoinChannel joins an additional channel (needed to set channel modes).
5ac549c… lmata 224 func (b *Bot) JoinChannel(channel string) {
5ac549c… lmata 225 if b.client != nil {
5ac549c… lmata 226 b.client.Cmd.Join(channel)
5ac549c… lmata 227 }
5ac549c… lmata 228 }
5ac549c… lmata 229
5ac549c… lmata 230 // handleReport parses a sentinel incident report and takes action.
5ac549c… lmata 231 func (b *Bot) handleReport(c *girc.Client, text string) {
5ac549c… lmata 232 if !strings.HasPrefix(text, "[sentinel]") {
5ac549c… lmata 233 return
5ac549c… lmata 234 }
5ac549c… lmata 235 // [sentinel] incident in #channel | nick: X | severity: Y | reason: Z
5ac549c… lmata 236 channel, nick, severity, reason := parseSentinelReport(text)
5ac549c… lmata 237 if nick == "" || channel == "" {
5ac549c… lmata 238 return
5ac549c… lmata 239 }
5ac549c… lmata 240
5ac549c… lmata 241 // Cooldown check.
5ac549c… lmata 242 b.mu.Lock()
5ac549c… lmata 243 if last, ok := b.cooldown[nick]; ok && time.Since(last) < b.cfg.CooldownPerNick {
5ac549c… lmata 244 b.mu.Unlock()
5ac549c… lmata 245 return
5ac549c… lmata 246 }
5ac549c… lmata 247 b.cooldown[nick] = time.Now()
5ac549c… lmata 248 b.mu.Unlock()
5ac549c… lmata 249
5ac549c… lmata 250 switch severity {
5ac549c… lmata 251 case "high":
5ac549c… lmata 252 b.kick(c, nick, channel, reason)
5ac549c… lmata 253 case "medium":
5ac549c… lmata 254 b.warn(c, nick, channel, reason)
5ac549c… lmata 255 b.mute(c, nick, channel, b.cfg.MuteDuration)
5ac549c… lmata 256 case "low":
5ac549c… lmata 257 if b.cfg.WarnOnLow {
5ac549c… lmata 258 b.warn(c, nick, channel, reason)
5ac549c… lmata 259 }
5ac549c… lmata 260 }
5ac549c… lmata 261 }
5ac549c… lmata 262
5ac549c… lmata 263 // handleCommand processes direct operator commands.
5ac549c… lmata 264 func (b *Bot) handleCommand(c *girc.Client, op, text string) {
5ac549c… lmata 265 parts := strings.Fields(text)
5ac549c… lmata 266 if len(parts) < 3 {
5ac549c… lmata 267 c.Cmd.Notice(op, "steward: usage: warn|mute|kick|unmute <nick> <#channel> [reason/duration]")
5ac549c… lmata 268 return
5ac549c… lmata 269 }
5ac549c… lmata 270 cmd, nick, channel := parts[0], parts[1], parts[2]
5ac549c… lmata 271 rest := strings.Join(parts[3:], " ")
5ac549c… lmata 272
5ac549c… lmata 273 switch strings.ToLower(cmd) {
5ac549c… lmata 274 case "warn":
5ac549c… lmata 275 reason := rest
5ac549c… lmata 276 if reason == "" {
5ac549c… lmata 277 reason = "operator warning"
5ac549c… lmata 278 }
5ac549c… lmata 279 b.warn(c, nick, channel, reason)
5ac549c… lmata 280 case "mute":
5ac549c… lmata 281 d := b.cfg.MuteDuration
5ac549c… lmata 282 if rest != "" {
5ac549c… lmata 283 if parsed, err := time.ParseDuration(rest); err == nil {
5ac549c… lmata 284 d = parsed
5ac549c… lmata 285 }
5ac549c… lmata 286 }
5ac549c… lmata 287 b.mute(c, nick, channel, d)
5ac549c… lmata 288 case "kick":
5ac549c… lmata 289 reason := rest
5ac549c… lmata 290 if reason == "" {
5ac549c… lmata 291 reason = "removed by steward"
5ac549c… lmata 292 }
5ac549c… lmata 293 b.kick(c, nick, channel, reason)
5ac549c… lmata 294 case "unmute":
5ac549c… lmata 295 b.unmute(c, nick, channel)
5ac549c… lmata 296 default:
5ac549c… lmata 297 c.Cmd.Notice(op, fmt.Sprintf("steward: unknown command %q", cmd))
5ac549c… lmata 298 }
5ac549c… lmata 299 }
5ac549c… lmata 300
5ac549c… lmata 301 func (b *Bot) warn(c *girc.Client, nick, channel, reason string) {
5ac549c… lmata 302 c.Cmd.Notice(nick, fmt.Sprintf("[steward] warning in %s: %s", channel, reason))
5ac549c… lmata 303 b.announce(c, fmt.Sprintf("warned %s in %s — %s", nick, channel, reason))
5ac549c… lmata 304 if b.log != nil {
5ac549c… lmata 305 b.log.Info("steward warn", "nick", nick, "channel", channel, "reason", reason)
5ac549c… lmata 306 }
5ac549c… lmata 307 }
5ac549c… lmata 308
5ac549c… lmata 309 func (b *Bot) mute(c *girc.Client, nick, channel string, d time.Duration) {
6ebcaed… noreply 310 // Extended ban m: to mute — agent stays in channel but cannot speak.
6ebcaed… noreply 311 c.Cmd.Mode(channel, "+b", "m:"+nick+"!*@*")
5ac549c… lmata 312 key := channel + ":" + nick
5ac549c… lmata 313 b.mu.Lock()
5ac549c… lmata 314 b.mutes[key] = time.Now().Add(d)
5ac549c… lmata 315 b.mu.Unlock()
5ac549c… lmata 316 b.announce(c, fmt.Sprintf("muted %s in %s for %s", nick, channel, d.Round(time.Second)))
5ac549c… lmata 317 if b.log != nil {
5ac549c… lmata 318 b.log.Info("steward mute", "nick", nick, "channel", channel, "duration", d)
5ac549c… lmata 319 }
5ac549c… lmata 320 }
5ac549c… lmata 321
5ac549c… lmata 322 func (b *Bot) unmute(c *girc.Client, nick, channel string) {
6ebcaed… noreply 323 c.Cmd.Mode(channel, "-b", "m:"+nick+"!*@*")
5ac549c… lmata 324 key := channel + ":" + nick
5ac549c… lmata 325 b.mu.Lock()
5ac549c… lmata 326 delete(b.mutes, key)
5ac549c… lmata 327 b.mu.Unlock()
5ac549c… lmata 328 b.announce(c, fmt.Sprintf("unmuted %s in %s", nick, channel))
5ac549c… lmata 329 if b.log != nil {
5ac549c… lmata 330 b.log.Info("steward unmute", "nick", nick, "channel", channel)
5ac549c… lmata 331 }
5ac549c… lmata 332 }
5ac549c… lmata 333
5ac549c… lmata 334 func (b *Bot) kick(c *girc.Client, nick, channel, reason string) {
5ac549c… lmata 335 c.Cmd.Kick(channel, nick, reason)
5ac549c… lmata 336 b.announce(c, fmt.Sprintf("kicked %s from %s — %s", nick, channel, reason))
5ac549c… lmata 337 if b.log != nil {
5ac549c… lmata 338 b.log.Info("steward kick", "nick", nick, "channel", channel, "reason", reason)
5ac549c… lmata 339 }
5ac549c… lmata 340 }
5ac549c… lmata 341
5ac549c… lmata 342 func (b *Bot) announce(c *girc.Client, msg string) {
5ac549c… lmata 343 full := "[steward] " + msg
5ac549c… lmata 344 c.Cmd.Message(b.cfg.ModChannel, full)
5ac549c… lmata 345 if b.cfg.DMOnAction {
5ac549c… lmata 346 for _, op := range b.cfg.OperatorNicks {
5ac549c… lmata 347 c.Cmd.Message(op, full)
5ac549c… lmata 348 }
5ac549c… lmata 349 }
5ac549c… lmata 350 }
5ac549c… lmata 351
5ac549c… lmata 352 // unmuteLoop lifts expired mutes.
5ac549c… lmata 353 func (b *Bot) unmuteLoop(ctx context.Context) {
5ac549c… lmata 354 ticker := time.NewTicker(30 * time.Second)
5ac549c… lmata 355 defer ticker.Stop()
5ac549c… lmata 356 for {
5ac549c… lmata 357 select {
5ac549c… lmata 358 case <-ctx.Done():
5ac549c… lmata 359 return
5ac549c… lmata 360 case <-ticker.C:
5ac549c… lmata 361 now := time.Now()
5ac549c… lmata 362 b.mu.Lock()
5ac549c… lmata 363 expired := make(map[string]time.Time)
5ac549c… lmata 364 for key, at := range b.mutes {
5ac549c… lmata 365 if now.After(at) {
5ac549c… lmata 366 expired[key] = at
5ac549c… lmata 367 delete(b.mutes, key)
5ac549c… lmata 368 }
5ac549c… lmata 369 }
5ac549c… lmata 370 b.mu.Unlock()
5ac549c… lmata 371 for key := range expired {
5ac549c… lmata 372 parts := strings.SplitN(key, ":", 2)
5ac549c… lmata 373 if len(parts) != 2 {
5ac549c… lmata 374 continue
5ac549c… lmata 375 }
5ac549c… lmata 376 channel, nick := parts[0], parts[1]
5ac549c… lmata 377 if b.client != nil {
5ac549c… lmata 378 b.unmute(b.client, nick, channel)
5ac549c… lmata 379 }
5ac549c… lmata 380 }
5ac549c… lmata 381 }
5ac549c… lmata 382 }
5ac549c… lmata 383 }
5ac549c… lmata 384
5ac549c… lmata 385 func (b *Bot) isOperator(nick string) bool {
5ac549c… lmata 386 for _, op := range b.cfg.OperatorNicks {
5ac549c… lmata 387 if strings.EqualFold(op, nick) {
5ac549c… lmata 388 return true
5ac549c… lmata 389 }
5ac549c… lmata 390 }
5ac549c… lmata 391 return false
5ac549c… lmata 392 }
5ac549c… lmata 393
5ac549c… lmata 394 // parseSentinelReport parses:
5ac549c… lmata 395 // [sentinel] incident in #channel | nick: X | severity: Y | reason: Z
5ac549c… lmata 396 func parseSentinelReport(text string) (channel, nick, severity, reason string) {
5ac549c… lmata 397 // Strip prefix up to "incident in"
5ac549c… lmata 398 idx := strings.Index(strings.ToLower(text), "incident in")
5ac549c… lmata 399 if idx == -1 {
5ac549c… lmata 400 return
5ac549c… lmata 401 }
5ac549c… lmata 402 rest := text[idx+len("incident in"):]
5ac549c… lmata 403 parts := strings.Split(rest, "|")
5ac549c… lmata 404 if len(parts) < 1 {
5ac549c… lmata 405 return
5ac549c… lmata 406 }
5ac549c… lmata 407 channel = strings.TrimSpace(parts[0])
5ac549c… lmata 408 for _, p := range parts[1:] {
5ac549c… lmata 409 p = strings.TrimSpace(p)
5ac549c… lmata 410 if kv, ok := strings.CutPrefix(p, "nick:"); ok {
5ac549c… lmata 411 nick = strings.TrimSpace(kv)
5ac549c… lmata 412 } else if kv, ok := strings.CutPrefix(p, "severity:"); ok {
5ac549c… lmata 413 severity = strings.ToLower(strings.TrimSpace(kv))
5ac549c… lmata 414 } else if kv, ok := strings.CutPrefix(p, "reason:"); ok {
5ac549c… lmata 415 reason = strings.TrimSpace(kv)
5ac549c… lmata 416 }
5ac549c… lmata 417 }
5ac549c… lmata 418 return
5ac549c… lmata 419 }
5ac549c… lmata 420
5ac549c… lmata 421 func splitHostPort(addr string) (string, int, error) {
5ac549c… lmata 422 host, portStr, err := net.SplitHostPort(addr)
5ac549c… lmata 423 if err != nil {
5ac549c… lmata 424 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
5ac549c… lmata 425 }
5ac549c… lmata 426 port, err := strconv.Atoi(portStr)
5ac549c… lmata 427 if err != nil {
5ac549c… lmata 428 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
5ac549c… lmata 429 }
5ac549c… lmata 430 return host, port, nil
5ac549c… lmata 431 }

Keyboard Shortcuts

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