diff --git a/bot/bot.go b/bot/bot.go index 8aefaa7..b146370 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -204,9 +204,7 @@ func (b *Bot) connect(ctx context.Context) error { } errc := make(chan error, 1) client := irc.NewClient(conn, irc.ClientConfig{Nick: b.Config.Identity.Nick, User: b.Config.Identity.User, Name: b.Config.Identity.Realname, Handler: irc.HandlerFunc(func(c *irc.Client, m *irc.Message) { - if b.Config.Identity.SASLUser != "" && b.Config.Identity.SASLPass != "" { - handleSASL(c, m, b.Config.Identity.SASLUser, b.Config.Identity.SASLPass, b.Log) - } + handleSASL(c, m, b.Config.Identity.SASLUser, b.Config.Identity.SASLPass, b.Log) b.logIRCEvent(m) if m.Command == "INVITE" { b.handleInvite(m) @@ -250,8 +248,10 @@ func (b *Bot) connect(ctx context.Context) error { zap.String("server", b.Config.Server.Host), zap.String("account", b.Config.Identity.SASLUser), ) - client.Write("CAP LS 302") + } else { + b.Log.Info("starting IRC capability negotiation", zap.String("network", b.Config.NetworkName), zap.String("server", b.Config.Server.Host)) } + client.Write("CAP LS 302") go func() { errc <- client.Run() }() select { case <-ctx.Done(): @@ -462,17 +462,25 @@ func handleSASL(client *irc.Client, message *irc.Message, username, password str subcommand := strings.ToUpper(message.Params[1]) switch subcommand { case "LS": - if strings.Contains(strings.ToLower(message.Trailing()), "sasl") { - log.Info("server supports SASL") - client.Write("CAP REQ :sasl") + request := capabilityRequest(message.Trailing(), username != "" && password != "") + if request != "" { + if hasCapability(message.Trailing(), "sasl") { + log.Info("server supports SASL") + } + if hasCapability(message.Trailing(), "account-tag") { + log.Info("server supports account-tag") + } + client.Write("CAP REQ :" + request) } else { - log.Warn("server did not advertise SASL") + log.Warn("server did not advertise requested IRC capabilities") client.Write("CAP END") } case "ACK": - if strings.Contains(strings.ToLower(message.Trailing()), "sasl") { + if hasCapability(message.Trailing(), "sasl") && username != "" && password != "" { log.Info("server acknowledged SASL capability") client.Write("AUTHENTICATE PLAIN") + } else { + client.Write("CAP END") } } case "AUTHENTICATE": @@ -493,6 +501,26 @@ func handleSASL(client *irc.Client, message *irc.Message, username, password str } } +func capabilityRequest(advertised string, saslEnabled bool) string { + requested := make([]string, 0, 2) + if saslEnabled && hasCapability(advertised, "sasl") { + requested = append(requested, "sasl") + } + if hasCapability(advertised, "account-tag") { + requested = append(requested, "account-tag") + } + return strings.Join(requested, " ") +} + +func hasCapability(advertised, wanted string) bool { + for _, capability := range strings.Fields(strings.ToLower(advertised)) { + if strings.SplitN(capability, "=", 2)[0] == strings.ToLower(wanted) { + return true + } + } + return false +} + func (b *Bot) shouldUseNickServFallback() bool { return b.Config.Identity.NickServFallback && b.Config.Identity.SASLUser != "" && b.Config.Identity.SASLPass != "" } diff --git a/bot/message_test.go b/bot/message_test.go index bfc017d..4086364 100644 --- a/bot/message_test.go +++ b/bot/message_test.go @@ -72,3 +72,15 @@ func TestPrivateReloadIsOwnerOnly(t *testing.T) { t.Fatalf("unexpected reload handler calls: %d", called) } } + +func TestCapabilityRequestIncludesAccountTag(t *testing.T) { + if got := capabilityRequest("multi-prefix sasl account-tag away", true); got != "sasl account-tag" { + t.Fatalf("capabilityRequest with SASL = %q, want %q", got, "sasl account-tag") + } + if got := capabilityRequest("account-tag", false); got != "account-tag" { + t.Fatalf("capabilityRequest without SASL = %q, want %q", got, "account-tag") + } + if got := capabilityRequest("sasl", false); got != "" { + t.Fatalf("capabilityRequest without account tag = %q, want empty", got) + } +} diff --git a/plugins/ask.go b/plugins/ask.go index ae9e09d..8c88497 100644 --- a/plugins/ask.go +++ b/plugins/ask.go @@ -156,11 +156,19 @@ func (p *Ask) Handle(b *bot.Bot, m bot.Message) bool { } started := time.Now() if rewritten, ok := p.rewriteWithConfig(ctx, question, source, cfg); ok { - if rewritten = cleanExternalText(rewritten); usableAskRewrite(rewritten) { + rewritten = cleanExternalText(rewritten) + if usableAskRewrite(rewritten) { answer = rewritten if b.Log != nil { b.Log.Info("ask AI rewrite used", zap.String("provider", provider), zap.Duration("duration", time.Since(started))) } + } else if b.Log != nil { + b.Log.Warn("ask AI rewrite rejected; using source summary", + zap.String("provider", provider), + zap.String("model", model), + zap.String("reason", askRewriteRejectionReason(rewritten)), + zap.Duration("duration", time.Since(started)), + ) } } else if b.Log != nil { b.Log.Warn("ask AI rewrite unavailable; using source summary", zap.String("provider", provider), zap.String("model", model), zap.Duration("duration", time.Since(started))) @@ -251,6 +259,26 @@ func usableAskRewrite(answer string) bool { return true } +func askRewriteRejectionReason(answer string) string { + answer = strings.ToLower(strings.TrimSpace(answer)) + if answer == "" { + return "empty_response" + } + for _, phrase := range []string{ + "the user asks", + "the source does not", + "the source is", + "the answer should be", + "according to the source", + "not enough information in this source", + } { + if strings.Contains(answer, phrase) { + return "provider_meta_text" + } + } + return "unusable_response" +} + type askSource struct { Title string Summary string diff --git a/plugins/ask_test.go b/plugins/ask_test.go index 5fd4c25..b214231 100644 --- a/plugins/ask_test.go +++ b/plugins/ask_test.go @@ -131,3 +131,19 @@ func TestUsableAskRewriteRejectsProviderMetaText(t *testing.T) { t.Fatal("usableAskRewrite rejected a direct factual answer") } } + +func TestAskRewriteRejectionReasonDoesNotExposeResponse(t *testing.T) { + tests := []struct { + answer string + want string + }{ + {answer: "", want: "empty_response"}, + {answer: "The user asks: what is Linux?", want: "provider_meta_text"}, + {answer: "A response with no accepted structure", want: "unusable_response"}, + } + for _, test := range tests { + if got := askRewriteRejectionReason(test.answer); got != test.want { + t.Errorf("askRewriteRejectionReason(%q) = %q, want %q", test.answer, got, test.want) + } + } +}