Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,13 @@ for `!assist`. The no-answer search link requests it too. This avoids relying on
the normal search page's automatic answer-display setting, but is not a public
Search Assist API, a challenge bypass, or a guarantee of an answer for every query.

Initial lightweight attempts are spaced by 300 ms. If all providers miss, GoBot
waits 500 ms and makes one final lightweight request for the original question
within the same command. Requests with at least 12 seconds remaining reserve
three seconds for this recovery step; shorter deadlines are not shortened.
Successful answers return immediately without the final retry. All waits and
requests respect the overall timeout, and failed lookups are not cached.

The plugin is configured under `plugins.ask`:

~~~yaml
Expand Down
46 changes: 46 additions & 0 deletions plugins/ask.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,35 @@ type askSource struct {
}

func (p *Ask) findSource(ctx context.Context, question string, cfg bot.PluginConfig) (askSource, bool) {
if !cfg.Bool("search_assist_enabled", true) {
return p.findSourceOnce(ctx, question, cfg)
}
lookupCtx, cancel := askInitialLookupContext(ctx)
source, ok := p.findSourceOnce(lookupCtx, question, cfg)
cancel()
if ok || ctx.Err() != nil {
return source, ok
}
// An answer may become available after the initial requests. Revisit the
// original question within this command, not only when the user asks again.
retryCtx, retryCancel := context.WithTimeout(ctx, 3*time.Second)
defer retryCancel()
if !askWaitForRetry(retryCtx, 500*time.Millisecond) {
return askSource{}, false
}
return askDuckDuckGoSearchAssistOnce(retryCtx, question, duckDuckGoSearchAssistURL(question))
}

func askInitialLookupContext(ctx context.Context) (context.Context, context.CancelFunc) {
// Reserve a recovery window on normal request budgets. Short user-defined
// deadlines retain the existing provider budget rather than being squeezed.
if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) >= 12*time.Second {
return context.WithDeadline(ctx, deadline.Add(-3*time.Second))
}
return context.WithCancel(ctx)
}

func (p *Ask) findSourceOnce(ctx context.Context, question string, cfg bot.PluginConfig) (askSource, bool) {
focused := askFocusedTerm(question)
webResultTried := false
var deferredWebResult askSource
Expand Down Expand Up @@ -776,6 +805,9 @@ func askDuckDuckGoSearchAssist(ctx context.Context, question string) (askSource,
// and request context limits.
queries := askSearchAssistQueryVariants(question)
for attempt := 0; attempt < 2; attempt++ {
if attempt > 0 && !askWaitForRetry(ctx, 300*time.Millisecond) {
break
}
query := queries[0]
if attempt < len(queries) {
query = queries[attempt]
Expand All @@ -790,6 +822,20 @@ func askDuckDuckGoSearchAssist(ctx context.Context, question string) (askSource,
return askSource{}, false
}

func askWaitForRetry(ctx context.Context, delay time.Duration) bool {
if ctx.Err() != nil {
return false
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return ctx.Err() == nil
}
}

func askSearchAssistQueryVariants(question string) []string {
original := strings.TrimSpace(question)
queries := []string{original}
Expand Down
115 changes: 115 additions & 0 deletions plugins/ask_resilience_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,121 @@ func TestAskExplicitAssistURL(t *testing.T) {
}
}

func TestAskRecoversDelayedAnswerInOriginalCommand(t *testing.T) {
old := askHTTPClient
t.Cleanup(func() { askHTTPClient = old })
for _, question := range []string{"what kind of GoKart does Mario use?", "what kind of GoKart does Toad use?"} {
t.Run(question, func(t *testing.T) {
started := time.Now()
pages := 0
askHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/assist.js" {
return newPluginResponse(200, `DDG.deep.deepPayload={"instantAnswers":[{"data":{"answer":"The available kart depends on the game.","sources":[{"article":{"link":"https://example.org/karts"}}]}}]};`), nil
}
pages++
if r.URL.Query().Get("q") != question {
t.Fatal("recovery changed the original question")
}
if pages <= 2 || time.Since(started) < 750*time.Millisecond {
return newPluginResponse(200, `<html>No generated answer yet</html>`), nil
}
return newPluginResponse(200, `<script id="deep_preload_script" src="/assist.js"></script>`), nil
})}
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
cfg := bot.PluginConfig{"search_assist_browser_enabled": false, "duckduckgo_enabled": false, "wikidata_fallback": false, "search_results_enabled": false}
source, ok := (&Ask{}).findSource(ctx, question, cfg)
if !ok || source.Provider != "search_assist" || pages != 3 {
t.Fatalf("original command did not recover: source=%+v ok=%v requests=%d", source, ok, pages)
}
})
}
}

func TestAskRecoveryIsBoundedAndCancelable(t *testing.T) {
old := askHTTPClient
t.Cleanup(func() { askHTTPClient = old })
for _, timeout := range []time.Duration{50 * time.Millisecond, 2 * time.Second} {
t.Run(timeout.String(), func(t *testing.T) {
pages := 0
askHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(r *http.Request) (*http.Response, error) {
if r.Context().Err() != nil {
t.Fatal("request made after cancellation")
}
pages++
return newPluginResponse(200, `<html>No answer</html>`), nil
})}
ctx, cancel := context.WithTimeout(t.Context(), timeout)
defer cancel()
cfg := bot.PluginConfig{"search_assist_browser_enabled": false, "duckduckgo_enabled": false, "wikidata_fallback": false, "search_results_enabled": false}
if _, ok := (&Ask{}).findSource(ctx, "what kind of kart?", cfg); ok {
t.Fatal("fabricated an answer from an empty response")
}
want := 3
if timeout < time.Second {
want = 1
}
if pages != want {
t.Fatalf("requests=%d want=%d", pages, want)
}
})
}
}

func TestAskSuccessDoesNotTriggerRecovery(t *testing.T) {
old := askHTTPClient
t.Cleanup(func() { askHTTPClient = old })
requests := 0
askHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(r *http.Request) (*http.Response, error) {
requests++
if r.URL.Path == "/assist.js" {
return newPluginResponse(200, `DDG.deep.deepPayload={"instantAnswers":[{"data":{"answer":"An existing successful answer."}}]};`), nil
}
return newPluginResponse(200, `<script id="deep_preload_script" src="/assist.js"></script>`), nil
})}
if _, ok := (&Ask{}).findSource(t.Context(), "a question", bot.PluginConfig{}); !ok || requests != 2 {
t.Fatalf("successful path changed: ok=%v requests=%d", ok, requests)
}
}

func TestAskReservesRecoveryBudgetWithoutExtendingDeadline(t *testing.T) {
for _, budget := range []time.Duration{8 * time.Second, 20 * time.Second} {
parent, cancel := context.WithTimeout(t.Context(), budget)
ctx, done := askInitialLookupContext(parent)
parentDeadline, _ := parent.Deadline()
deadline, _ := ctx.Deadline()
want := time.Duration(0)
if budget == 20*time.Second {
want = 3 * time.Second
}
if parentDeadline.Sub(deadline) != want {
t.Errorf("budget=%s reserved=%s want=%s", budget, parentDeadline.Sub(deadline), want)
}
done()
if parent.Err() != nil {
t.Fatal("canceling initial lookup canceled recovery parent")
}
cancel()
}
}

func TestLiveAskKartQuestions(t *testing.T) {
if os.Getenv("GOBOT_LIVE_LOOKUPS") != "1" {
t.Skip("opt-in live provider smoke test")
}
for _, question := range []string{"what kind of GoKart does Mario use?", "what kind of GoKart does Toad use?"} {
t.Run(question, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
defer cancel()
source, ok := (&Ask{}).findSource(ctx, question, bot.PluginConfig{})
if !ok {
t.Fatal("no answer within the original request")
}
t.Logf("provider=%s source=%s", source.Provider, source.URL)
})
}
}

func TestAskComparisonDoesNotReturnEntityDescription(t *testing.T) {
old := askHTTPClient
t.Cleanup(func() { askHTTPClient = old })
Expand Down
Loading