ScuttleBot

scuttlebot / pkg / ircagent / ircagent.go
Source Blame History 521 lines
016a29f… lmata 1 package ircagent
016a29f… lmata 2
016a29f… lmata 3 import (
016a29f… lmata 4 "bytes"
016a29f… lmata 5 "context"
016a29f… lmata 6 "encoding/json"
016a29f… lmata 7 "fmt"
016a29f… lmata 8 "io"
016a29f… lmata 9 "log/slog"
016a29f… lmata 10 "net"
016a29f… lmata 11 "net/http"
016a29f… lmata 12 "strconv"
016a29f… lmata 13 "strings"
016a29f… lmata 14 "sync"
016a29f… lmata 15 "time"
016a29f… lmata 16
016a29f… lmata 17 "github.com/conflicthq/scuttlebot/internal/llm"
016a29f… lmata 18 "github.com/lrstanley/girc"
016a29f… lmata 19 )
016a29f… lmata 20
016a29f… lmata 21 const (
016a29f… lmata 22 defaultHistoryLen = 20
016a29f… lmata 23 defaultTypingDelay = 400 * time.Millisecond
016a29f… lmata 24 defaultErrorJoiner = " - "
016a29f… lmata 25 defaultGatewayTimout = 60 * time.Second
016a29f… lmata 26 )
016a29f… lmata 27
016a29f… lmata 28 var defaultActivityPrefixes = []string{"claude-", "codex-", "gemini-"}
016a29f… lmata 29
016a29f… lmata 30 // DefaultActivityPrefixes returns the default set of nick prefixes treated as
016a29f… lmata 31 // status/activity senders rather than chat participants.
016a29f… lmata 32 func DefaultActivityPrefixes() []string {
016a29f… lmata 33 return append([]string(nil), defaultActivityPrefixes...)
016a29f… lmata 34 }
016a29f… lmata 35
016a29f… lmata 36 // Config configures the shared IRC agent runtime.
016a29f… lmata 37 type Config struct {
016a29f… lmata 38 IRCAddr string
016a29f… lmata 39 Nick string
016a29f… lmata 40 Pass string
016a29f… lmata 41 Channels []string
016a29f… lmata 42 SystemPrompt string
016a29f… lmata 43 Logger *slog.Logger
016a29f… lmata 44 HistoryLen int
016a29f… lmata 45 TypingDelay time.Duration
016a29f… lmata 46 ErrorJoiner string
016a29f… lmata 47 ActivityPrefixes []string
016a29f… lmata 48 Direct *DirectConfig
016a29f… lmata 49 Gateway *GatewayConfig
016a29f… lmata 50 }
016a29f… lmata 51
016a29f… lmata 52 // DirectConfig configures direct provider mode.
016a29f… lmata 53 type DirectConfig struct {
016a29f… lmata 54 Backend string
016a29f… lmata 55 APIKey string
016a29f… lmata 56 Model string
016a29f… lmata 57 }
016a29f… lmata 58
016a29f… lmata 59 // GatewayConfig configures scuttlebot gateway mode.
016a29f… lmata 60 type GatewayConfig struct {
016a29f… lmata 61 APIURL string
016a29f… lmata 62 Token string
016a29f… lmata 63 Backend string
016a29f… lmata 64 HTTPClient *http.Client
016a29f… lmata 65 }
016a29f… lmata 66
016a29f… lmata 67 type historyEntry struct {
016a29f… lmata 68 role string
016a29f… lmata 69 nick string
016a29f… lmata 70 content string
016a29f… lmata 71 }
016a29f… lmata 72
016a29f… lmata 73 type completer interface {
016a29f… lmata 74 complete(ctx context.Context, prompt string) (string, error)
016a29f… lmata 75 }
016a29f… lmata 76
016a29f… lmata 77 type directCompleter struct {
016a29f… lmata 78 provider llm.Provider
016a29f… lmata 79 }
016a29f… lmata 80
016a29f… lmata 81 func (d *directCompleter) complete(ctx context.Context, prompt string) (string, error) {
016a29f… lmata 82 return d.provider.Summarize(ctx, prompt)
016a29f… lmata 83 }
016a29f… lmata 84
016a29f… lmata 85 type gatewayCompleter struct {
016a29f… lmata 86 apiURL string
016a29f… lmata 87 token string
016a29f… lmata 88 backend string
016a29f… lmata 89 http *http.Client
016a29f… lmata 90 }
016a29f… lmata 91
016a29f… lmata 92 func (g *gatewayCompleter) complete(ctx context.Context, prompt string) (string, error) {
016a29f… lmata 93 body, _ := json.Marshal(map[string]string{"backend": g.backend, "prompt": prompt})
016a29f… lmata 94 req, err := http.NewRequestWithContext(ctx, "POST", g.apiURL+"/v1/llm/complete", bytes.NewReader(body))
016a29f… lmata 95 if err != nil {
016a29f… lmata 96 return "", err
016a29f… lmata 97 }
016a29f… lmata 98 req.Header.Set("Content-Type", "application/json")
016a29f… lmata 99 req.Header.Set("Authorization", "Bearer "+g.token)
016a29f… lmata 100
016a29f… lmata 101 resp, err := g.http.Do(req)
016a29f… lmata 102 if err != nil {
016a29f… lmata 103 return "", fmt.Errorf("gateway request: %w", err)
016a29f… lmata 104 }
016a29f… lmata 105 defer resp.Body.Close()
016a29f… lmata 106
016a29f… lmata 107 data, _ := io.ReadAll(resp.Body)
016a29f… lmata 108 if resp.StatusCode != http.StatusOK {
016a29f… lmata 109 return "", fmt.Errorf("gateway error %d: %s", resp.StatusCode, string(data))
016a29f… lmata 110 }
016a29f… lmata 111
016a29f… lmata 112 var result struct {
016a29f… lmata 113 Text string `json:"text"`
016a29f… lmata 114 }
016a29f… lmata 115 if err := json.Unmarshal(data, &result); err != nil {
016a29f… lmata 116 return "", fmt.Errorf("gateway parse: %w", err)
016a29f… lmata 117 }
016a29f… lmata 118 return result.Text, nil
016a29f… lmata 119 }
016a29f… lmata 120
016a29f… lmata 121 type agent struct {
016a29f… lmata 122 cfg Config
016a29f… lmata 123 llm completer
016a29f… lmata 124 log *slog.Logger
016a29f… lmata 125 irc *girc.Client
016a29f… lmata 126 mu sync.Mutex
016a29f… lmata 127 history map[string][]historyEntry
016a29f… lmata 128 }
016a29f… lmata 129
016a29f… lmata 130 // Run starts the IRC agent and blocks until the context is canceled or the IRC
016a29f… lmata 131 // connection fails.
016a29f… lmata 132 func Run(ctx context.Context, cfg Config) error {
016a29f… lmata 133 cfg = withDefaults(cfg)
016a29f… lmata 134 if err := validateConfig(cfg); err != nil {
016a29f… lmata 135 return err
016a29f… lmata 136 }
016a29f… lmata 137
016a29f… lmata 138 llmClient, err := buildCompleter(cfg)
016a29f… lmata 139 if err != nil {
016a29f… lmata 140 return err
016a29f… lmata 141 }
016a29f… lmata 142
016a29f… lmata 143 a := &agent{
016a29f… lmata 144 cfg: cfg,
016a29f… lmata 145 llm: llmClient,
016a29f… lmata 146 log: cfg.Logger,
016a29f… lmata 147 history: make(map[string][]historyEntry),
016a29f… lmata 148 }
016a29f… lmata 149 return a.run(ctx)
016a29f… lmata 150 }
016a29f… lmata 151
016a29f… lmata 152 // SplitCSV trims and splits comma-separated channel strings.
016a29f… lmata 153 func SplitCSV(s string) []string {
016a29f… lmata 154 var out []string
016a29f… lmata 155 for _, part := range strings.Split(s, ",") {
016a29f… lmata 156 if part = strings.TrimSpace(part); part != "" {
016a29f… lmata 157 out = append(out, part)
016a29f… lmata 158 }
016a29f… lmata 159 }
016a29f… lmata 160 return out
016a29f… lmata 161 }
016a29f… lmata 162
016a29f… lmata 163 func withDefaults(cfg Config) Config {
016a29f… lmata 164 if cfg.Logger == nil {
016a29f… lmata 165 cfg.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
016a29f… lmata 166 }
016a29f… lmata 167 if cfg.HistoryLen <= 0 {
016a29f… lmata 168 cfg.HistoryLen = defaultHistoryLen
016a29f… lmata 169 }
016a29f… lmata 170 if cfg.TypingDelay <= 0 {
016a29f… lmata 171 cfg.TypingDelay = defaultTypingDelay
016a29f… lmata 172 }
016a29f… lmata 173 if cfg.ErrorJoiner == "" {
016a29f… lmata 174 cfg.ErrorJoiner = defaultErrorJoiner
016a29f… lmata 175 }
016a29f… lmata 176 if len(cfg.ActivityPrefixes) == 0 {
016a29f… lmata 177 cfg.ActivityPrefixes = append([]string(nil), defaultActivityPrefixes...)
016a29f… lmata 178 }
016a29f… lmata 179 if len(cfg.Channels) == 0 {
016a29f… lmata 180 cfg.Channels = []string{"#general"}
016a29f… lmata 181 }
016a29f… lmata 182 return cfg
016a29f… lmata 183 }
016a29f… lmata 184
016a29f… lmata 185 func validateConfig(cfg Config) error {
016a29f… lmata 186 switch {
016a29f… lmata 187 case cfg.IRCAddr == "":
016a29f… lmata 188 return fmt.Errorf("irc address is required")
016a29f… lmata 189 case cfg.Nick == "":
016a29f… lmata 190 return fmt.Errorf("nick is required")
016a29f… lmata 191 case cfg.Pass == "":
016a29f… lmata 192 return fmt.Errorf("pass is required")
016a29f… lmata 193 case cfg.SystemPrompt == "":
016a29f… lmata 194 return fmt.Errorf("system prompt is required")
016a29f… lmata 195 }
016a29f… lmata 196 return nil
016a29f… lmata 197 }
016a29f… lmata 198
016a29f… lmata 199 func buildCompleter(cfg Config) (completer, error) {
016a29f… lmata 200 gatewayConfigured := cfg.Gateway != nil && cfg.Gateway.Token != ""
016a29f… lmata 201 directConfigured := cfg.Direct != nil && cfg.Direct.APIKey != ""
016a29f… lmata 202
016a29f… lmata 203 if gatewayConfigured && !directConfigured {
016a29f… lmata 204 if cfg.Gateway.APIURL == "" {
016a29f… lmata 205 return nil, fmt.Errorf("gateway api url is required")
016a29f… lmata 206 }
016a29f… lmata 207 if cfg.Gateway.Backend == "" {
016a29f… lmata 208 return nil, fmt.Errorf("gateway backend is required")
016a29f… lmata 209 }
016a29f… lmata 210 httpClient := cfg.Gateway.HTTPClient
016a29f… lmata 211 if httpClient == nil {
016a29f… lmata 212 httpClient = &http.Client{Timeout: defaultGatewayTimout}
016a29f… lmata 213 }
016a29f… lmata 214 cfg.Logger.Info("mode: gateway", "api-url", cfg.Gateway.APIURL, "backend", cfg.Gateway.Backend)
016a29f… lmata 215 return &gatewayCompleter{
016a29f… lmata 216 apiURL: cfg.Gateway.APIURL,
016a29f… lmata 217 token: cfg.Gateway.Token,
016a29f… lmata 218 backend: cfg.Gateway.Backend,
016a29f… lmata 219 http: httpClient,
016a29f… lmata 220 }, nil
016a29f… lmata 221 }
016a29f… lmata 222
016a29f… lmata 223 if directConfigured {
016a29f… lmata 224 if cfg.Direct.Backend == "" {
016a29f… lmata 225 return nil, fmt.Errorf("direct backend is required")
016a29f… lmata 226 }
016a29f… lmata 227 cfg.Logger.Info("mode: direct", "backend", cfg.Direct.Backend, "model", cfg.Direct.Model)
016a29f… lmata 228 provider, err := llm.New(llm.BackendConfig{
016a29f… lmata 229 Backend: cfg.Direct.Backend,
016a29f… lmata 230 APIKey: cfg.Direct.APIKey,
016a29f… lmata 231 Model: cfg.Direct.Model,
016a29f… lmata 232 })
016a29f… lmata 233 if err != nil {
016a29f… lmata 234 return nil, fmt.Errorf("build provider: %w", err)
016a29f… lmata 235 }
016a29f… lmata 236 return &directCompleter{provider: provider}, nil
016a29f… lmata 237 }
016a29f… lmata 238
016a29f… lmata 239 return nil, fmt.Errorf("set gateway token or direct api key")
016a29f… lmata 240 }
016a29f… lmata 241
016a29f… lmata 242 func (a *agent) run(ctx context.Context) error {
016a29f… lmata 243 host, port, err := splitHostPort(a.cfg.IRCAddr)
016a29f… lmata 244 if err != nil {
016a29f… lmata 245 return err
016a29f… lmata 246 }
016a29f… lmata 247
016a29f… lmata 248 client := girc.New(girc.Config{
016a29f… lmata 249 Server: host,
016a29f… lmata 250 Port: port,
016a29f… lmata 251 Nick: a.cfg.Nick,
016a29f… lmata 252 User: a.cfg.Nick,
016a29f… lmata 253 Name: a.cfg.Nick + " (AI agent)",
016a29f… lmata 254 SASL: &girc.SASLPlain{User: a.cfg.Nick, Pass: a.cfg.Pass},
016a29f… lmata 255 })
016a29f… lmata 256
016a29f… lmata 257 client.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
016a29f… lmata 258 a.log.Info("connected", "server", a.cfg.IRCAddr)
016a29f… lmata 259 for _, ch := range a.cfg.Channels {
016a29f… lmata 260 cl.Cmd.Join(ch)
016a29f… lmata 261 }
016a29f… lmata 262 })
016a29f… lmata 263
016a29f… lmata 264 client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
016a29f… lmata 265 if len(e.Params) < 1 || e.Source == nil {
016a29f… lmata 266 return
016a29f… lmata 267 }
016a29f… lmata 268
016a29f… lmata 269 target := e.Params[0]
016a29f… lmata 270 senderNick := e.Source.Name
016a29f… lmata 271 text := strings.TrimSpace(e.Last())
016a29f… lmata 272 if senderNick == a.cfg.Nick {
016a29f… lmata 273 return
016a29f… lmata 274 }
016a29f… lmata 275
c3c693d… noreply 276 // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
c3c693d… noreply 277 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
c3c693d… noreply 278 if idx := strings.Index(senderNick, sep); idx != -1 {
c3c693d… noreply 279 senderNick = senderNick[:idx]
c3c693d… noreply 280 }
c3c693d… noreply 281 }
c3c693d… noreply 282 // Fallback: parse legacy [nick] prefix from bridge bot.
016a29f… lmata 283 if strings.HasPrefix(text, "[") {
016a29f… lmata 284 if end := strings.Index(text, "] "); end != -1 {
016a29f… lmata 285 senderNick = text[1:end]
016a29f… lmata 286 text = text[end+2:]
016a29f… lmata 287 }
016a29f… lmata 288 }
016a29f… lmata 289
016a29f… lmata 290 isDM := !strings.HasPrefix(target, "#")
016a29f… lmata 291 isMentioned := MentionsNick(text, a.cfg.Nick)
016a29f… lmata 292 isActivityPost := HasAnyPrefix(senderNick, a.cfg.ActivityPrefixes)
016a29f… lmata 293
016a29f… lmata 294 convKey := target
016a29f… lmata 295 if isDM {
016a29f… lmata 296 convKey = senderNick
016a29f… lmata 297 }
016a29f… lmata 298 a.appendHistory(convKey, "user", senderNick, text)
016a29f… lmata 299
016a29f… lmata 300 if isActivityPost {
016a29f… lmata 301 return
016a29f… lmata 302 }
016a29f… lmata 303 if !isDM && !isMentioned {
016a29f… lmata 304 return
016a29f… lmata 305 }
016a29f… lmata 306
016a29f… lmata 307 cleaned := TrimAddressedText(text, a.cfg.Nick)
016a29f… lmata 308
016a29f… lmata 309 a.mu.Lock()
016a29f… lmata 310 history := a.history[convKey]
016a29f… lmata 311 if len(history) > 0 {
016a29f… lmata 312 history[len(history)-1].content = cleaned
016a29f… lmata 313 a.history[convKey] = history
016a29f… lmata 314 }
016a29f… lmata 315 a.mu.Unlock()
016a29f… lmata 316
016a29f… lmata 317 replyTo := target
016a29f… lmata 318 if isDM {
016a29f… lmata 319 replyTo = senderNick
016a29f… lmata 320 }
016a29f… lmata 321 go a.respond(ctx, cl, convKey, replyTo, senderNick, isDM)
016a29f… lmata 322 })
016a29f… lmata 323
016a29f… lmata 324 a.irc = client
016a29f… lmata 325
016a29f… lmata 326 errCh := make(chan error, 1)
016a29f… lmata 327 go func() {
016a29f… lmata 328 if err := client.Connect(); err != nil && ctx.Err() == nil {
016a29f… lmata 329 errCh <- err
016a29f… lmata 330 }
016a29f… lmata 331 }()
016a29f… lmata 332
016a29f… lmata 333 select {
016a29f… lmata 334 case <-ctx.Done():
016a29f… lmata 335 client.Close()
016a29f… lmata 336 return nil
016a29f… lmata 337 case err := <-errCh:
016a29f… lmata 338 return fmt.Errorf("irc: %w", err)
016a29f… lmata 339 }
016a29f… lmata 340 }
016a29f… lmata 341
016a29f… lmata 342 func (a *agent) respond(ctx context.Context, cl *girc.Client, convKey, replyTo, senderNick string, isDM bool) {
016a29f… lmata 343 prompt := a.buildPrompt(convKey)
016a29f… lmata 344 time.Sleep(a.cfg.TypingDelay)
016a29f… lmata 345
016a29f… lmata 346 reply, err := a.llm.complete(ctx, prompt)
016a29f… lmata 347 if err != nil {
016a29f… lmata 348 a.log.Error("llm error", "err", err)
016a29f… lmata 349 cl.Cmd.Message(replyTo, senderNick+": sorry, something went wrong"+a.cfg.ErrorJoiner+err.Error())
016a29f… lmata 350 return
016a29f… lmata 351 }
016a29f… lmata 352
016a29f… lmata 353 reply = strings.TrimSpace(reply)
016a29f… lmata 354 a.appendHistory(convKey, "assistant", a.cfg.Nick, reply)
016a29f… lmata 355
016a29f… lmata 356 prefix := ""
016a29f… lmata 357 if !isDM && senderNick != "" {
016a29f… lmata 358 prefix = senderNick + ": "
016a29f… lmata 359 }
016a29f… lmata 360 for i, line := range strings.Split(reply, "\n") {
016a29f… lmata 361 line = strings.TrimSpace(line)
016a29f… lmata 362 if line == "" {
016a29f… lmata 363 continue
016a29f… lmata 364 }
016a29f… lmata 365 if i == 0 {
016a29f… lmata 366 line = prefix + line
016a29f… lmata 367 }
016a29f… lmata 368 cl.Cmd.Message(replyTo, line)
016a29f… lmata 369 }
016a29f… lmata 370 }
016a29f… lmata 371
016a29f… lmata 372 func (a *agent) buildPrompt(convKey string) string {
016a29f… lmata 373 a.mu.Lock()
016a29f… lmata 374 history := append([]historyEntry(nil), a.history[convKey]...)
016a29f… lmata 375 a.mu.Unlock()
016a29f… lmata 376
016a29f… lmata 377 var sb strings.Builder
016a29f… lmata 378 sb.WriteString(a.cfg.SystemPrompt)
016a29f… lmata 379 sb.WriteString("\n\nConversation history:\n")
016a29f… lmata 380 for _, entry := range history {
016a29f… lmata 381 role := "User"
016a29f… lmata 382 if entry.role == "assistant" {
016a29f… lmata 383 role = "Assistant"
016a29f… lmata 384 }
016a29f… lmata 385 fmt.Fprintf(&sb, "[%s] %s: %s\n", role, entry.nick, entry.content)
016a29f… lmata 386 }
016a29f… lmata 387 sb.WriteString("\nRespond to the last user message. Be concise.")
016a29f… lmata 388 return sb.String()
016a29f… lmata 389 }
016a29f… lmata 390
016a29f… lmata 391 func (a *agent) appendHistory(convKey, role, nick, content string) {
016a29f… lmata 392 a.mu.Lock()
016a29f… lmata 393 defer a.mu.Unlock()
016a29f… lmata 394
016a29f… lmata 395 history := a.history[convKey]
016a29f… lmata 396 history = append(history, historyEntry{role: role, nick: nick, content: content})
016a29f… lmata 397 if len(history) > a.cfg.HistoryLen {
016a29f… lmata 398 history = history[len(history)-a.cfg.HistoryLen:]
016a29f… lmata 399 }
016a29f… lmata 400 a.history[convKey] = history
016a29f… lmata 401 }
016a29f… lmata 402
016a29f… lmata 403 // MentionsNick reports whether text contains a standalone mention of nick.
016a29f… lmata 404 func MentionsNick(text, nick string) bool {
016a29f… lmata 405 lower := strings.ToLower(text)
016a29f… lmata 406 needle := strings.ToLower(nick)
016a29f… lmata 407 start := 0
016a29f… lmata 408
016a29f… lmata 409 for {
016a29f… lmata 410 idx := strings.Index(lower[start:], needle)
016a29f… lmata 411 if idx < 0 {
016a29f… lmata 412 return false
016a29f… lmata 413 }
016a29f… lmata 414 idx += start
016a29f… lmata 415
016a29f… lmata 416 before := idx == 0 || !isMentionAdjacent(lower[idx-1])
016a29f… lmata 417 after := idx+len(needle) >= len(lower) || !isMentionAdjacent(lower[idx+len(needle)])
016a29f… lmata 418 if before && after {
016a29f… lmata 419 return true
016a29f… lmata 420 }
016a29f… lmata 421
016a29f… lmata 422 start = idx + 1
016a29f… lmata 423 }
cefe27d… lmata 424 }
cefe27d… lmata 425
cefe27d… lmata 426 // MatchesGroupMention checks if text contains a group mention that applies
cefe27d… lmata 427 // to an agent with the given nick and type. Supported patterns:
cefe27d… lmata 428 //
cefe27d… lmata 429 // - @all — matches every agent
cefe27d… lmata 430 // - @worker, @observer, @orchestrator, @operator — matches by agent type
cefe27d… lmata 431 // - @prefix-* — matches agents whose nick starts with prefix- (e.g. @claude-* matches claude-kohakku-abc)
cefe27d… lmata 432 func MatchesGroupMention(text, nick, agentType string) bool {
cefe27d… lmata 433 lower := strings.ToLower(text)
cefe27d… lmata 434
cefe27d… lmata 435 // @all
cefe27d… lmata 436 if containsWord(lower, "@all") {
cefe27d… lmata 437 return true
cefe27d… lmata 438 }
cefe27d… lmata 439
cefe27d… lmata 440 // @role — e.g. @worker, @observer
cefe27d… lmata 441 if agentType != "" && containsWord(lower, "@"+strings.ToLower(agentType)) {
cefe27d… lmata 442 return true
cefe27d… lmata 443 }
cefe27d… lmata 444
cefe27d… lmata 445 // @prefix-* patterns — find all @word-* tokens in the text.
cefe27d… lmata 446 for i := 0; i < len(lower); i++ {
cefe27d… lmata 447 if lower[i] != '@' {
cefe27d… lmata 448 continue
cefe27d… lmata 449 }
cefe27d… lmata 450 // Extract the token after @.
cefe27d… lmata 451 j := i + 1
cefe27d… lmata 452 for j < len(lower) && (isAlNum(lower[j]) || lower[j] == '*') {
cefe27d… lmata 453 j++
cefe27d… lmata 454 }
cefe27d… lmata 455 token := lower[i+1 : j]
cefe27d… lmata 456 if !strings.HasSuffix(token, "*") || len(token) < 2 {
cefe27d… lmata 457 continue
cefe27d… lmata 458 }
cefe27d… lmata 459 prefix := token[:len(token)-1] // remove the *
cefe27d… lmata 460 if strings.HasPrefix(strings.ToLower(nick), prefix) {
cefe27d… lmata 461 return true
cefe27d… lmata 462 }
cefe27d… lmata 463 }
cefe27d… lmata 464
cefe27d… lmata 465 return false
cefe27d… lmata 466 }
cefe27d… lmata 467
cefe27d… lmata 468 func containsWord(text, word string) bool {
cefe27d… lmata 469 idx := strings.Index(text, word)
cefe27d… lmata 470 if idx < 0 {
cefe27d… lmata 471 return false
cefe27d… lmata 472 }
cefe27d… lmata 473 end := idx + len(word)
cefe27d… lmata 474 before := idx == 0 || !isAlNum(text[idx-1])
cefe27d… lmata 475 after := end >= len(text) || !isAlNum(text[end])
cefe27d… lmata 476 return before && after
016a29f… lmata 477 }
016a29f… lmata 478
016a29f… lmata 479 // TrimAddressedText removes an initial nick address from text when present.
016a29f… lmata 480 func TrimAddressedText(text, nick string) string {
016a29f… lmata 481 cleaned := text
016a29f… lmata 482 lower := strings.ToLower(text)
016a29f… lmata 483 if idx := strings.Index(lower, strings.ToLower(nick)); idx != -1 {
016a29f… lmata 484 after := strings.TrimSpace(text[idx+len(nick):])
016a29f… lmata 485 after = strings.TrimLeft(after, ":, ")
016a29f… lmata 486 if after != "" {
016a29f… lmata 487 cleaned = after
016a29f… lmata 488 }
016a29f… lmata 489 }
016a29f… lmata 490 return cleaned
016a29f… lmata 491 }
016a29f… lmata 492
016a29f… lmata 493 func isAlNum(c byte) bool {
016a29f… lmata 494 return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_'
016a29f… lmata 495 }
016a29f… lmata 496
016a29f… lmata 497 func isMentionAdjacent(c byte) bool {
016a29f… lmata 498 return isAlNum(c) || c == '.' || c == '/' || c == '\\'
016a29f… lmata 499 }
016a29f… lmata 500
016a29f… lmata 501 // HasAnyPrefix reports whether s starts with any prefix in prefixes.
016a29f… lmata 502 func HasAnyPrefix(s string, prefixes []string) bool {
016a29f… lmata 503 for _, prefix := range prefixes {
016a29f… lmata 504 if strings.HasPrefix(s, prefix) {
016a29f… lmata 505 return true
016a29f… lmata 506 }
016a29f… lmata 507 }
016a29f… lmata 508 return false
016a29f… lmata 509 }
016a29f… lmata 510
016a29f… lmata 511 func splitHostPort(addr string) (string, int, error) {
016a29f… lmata 512 host, portStr, err := net.SplitHostPort(addr)
016a29f… lmata 513 if err != nil {
016a29f… lmata 514 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
016a29f… lmata 515 }
016a29f… lmata 516 port, err := strconv.Atoi(portStr)
016a29f… lmata 517 if err != nil {
016a29f… lmata 518 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
016a29f… lmata 519 }
016a29f… lmata 520 return host, port, nil
016a29f… lmata 521 }

Keyboard Shortcuts

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