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
2 changes: 1 addition & 1 deletion config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ plugins:
wikidata_fallback: true
max_length: 360
max_response_chars: 240
timeout_seconds: 8
timeout_seconds: 15
cooldown_seconds: 15
cache_seconds: 300
wikipedia: {enabled: true, max_summary_length: 300}
Expand Down
18 changes: 16 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ plugins:
wikidata_fallback: true
max_length: 360
max_response_chars: 240
timeout_seconds: 8
timeout_seconds: 15
cooldown_seconds: 15
cache_seconds: 300
grab: {enabled: true, max_length: 320, max_quotes_per_user: 20}
Expand Down Expand Up @@ -194,7 +194,21 @@ source-grounded, sanitized, one-line, and bounded by `max_length` and
in memory for `cache_seconds` (default five minutes), keyed by normalized
question text, so repeated questions do not depend on a second provider race.
The cache is in memory and is cleared when GoBot restarts. No credentials are required.
`define` uses the public English dictionary service, `reddit` uses Reddit's public post and
The default overall `ask` timeout is 15 seconds (maximum 30). Each provider
reserves time for later fallbacks. Existing configurations explicitly using 8
seconds keep that limit; increasing it to 15–20 seconds gives the browser and
fallbacks more time. Chromium must be installed separately for browser lookup
(Debian: `sudo apt-get install chromium`). A browser does not bypass provider
challenges or guarantee a Search Assist answer. Entity questions can also fall
back to a matching Wikipedia summary; procedural and opinion questions cannot
use an unrelated encyclopedia summary as an answer.

`define` uses the public English dictionary service with an independent English
Wiktionary fallback. The primary request is capped at two seconds or one third
of the remaining deadline, leaving time for fallback. Wiktionary responses link
to the source entry and strip HTML. An unavailable lookup may mean missing
coverage or a provider failure, rather than proof that a word does not exist.
`reddit` uses Reddit's public post and
subreddit JSON endpoints with an RSS fallback, and `horoscope` uses a public
daily horoscope API. `foods` and `sports` use local text files only. `fun` uses
local operator-editable text catalogs under `data/fun`, and `weapons` uses the
Expand Down
10 changes: 8 additions & 2 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,8 @@ message/command counters. It is useful for a quick IRC-side health check.

## Definitions

Look up one English word using the public Free Dictionary API:
Look up one English word using the public Free Dictionary API, with an English
Wiktionary fallback when the primary service is unreachable or lacks the entry:

~~~text
!define resilient
Expand All @@ -848,6 +849,11 @@ GoBot returns the first concise definition and part of speech in one bounded
message. No API key is required. The lookup is English-only and third-party
text is cleaned before it is sent to IRC.

Proper nouns such as `!define Linux` can be found through Wiktionary. Fallback
answers include the entry link. Coverage is not universal; failure messages
distinguish an unavailable lookup from a definitive statement that no word
exists. Use `!wiki Linux` for an encyclopedia summary.

## Calculator and unit conversion

Calculator expressions are evaluated locally with a small parser; GoBot never
Expand Down Expand Up @@ -1354,7 +1360,7 @@ plugins:
wikidata_fallback: true
max_length: 360
max_response_chars: 240
timeout_seconds: 8
timeout_seconds: 15
cooldown_seconds: 15
cache_seconds: 300
~~~
Expand Down
23 changes: 19 additions & 4 deletions plugins/ask.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func (p *Ask) Handle(b *bot.Bot, m bot.Message) bool {
return true
}

timeout := cfg.Int("timeout_seconds", 8)
timeout := cfg.Int("timeout_seconds", 15)
if timeout < 1 {
timeout = 1
}
Expand Down Expand Up @@ -257,7 +257,7 @@ func (p *Ask) findSource(ctx context.Context, question string, cfg bot.PluginCon
if cfg.Bool("search_assist_browser_enabled", true) {
fetchResults := cfg.Bool("search_results_enabled", true)
webResultTried = fetchResults
stepCtx, cancel = askStepContext(ctx, 3200*time.Millisecond)
stepCtx, cancel = askStepContext(ctx, 5*time.Second)
if source, ok := askDuckDuckGoRenderedSearchAssist(stepCtx, question, cfg.String("browser_path", ""), fetchResults); ok {
cancel()
if source.Provider == "search_result" {
Expand Down Expand Up @@ -343,10 +343,21 @@ func (p *Ask) findSource(ctx context.Context, question string, cfg bot.PluginCon
}
cancel()
}
// An encyclopedia summary is useful only for entity questions, not as a
// substitute for an opinion, procedure, relationship, or date answer.
if cfg.Bool("wikidata_fallback", true) && focused != "" && !askNeedsWebResultAnswer(question) && !askNeedsRelationshipAnswer(question) && !askNeedsTemporalAnswer(question) {
if article, ok := wikipediaSummary(ctx, focused); ok {
return askSource{Title: article.Title, Summary: cleanExternalText(article.Extract), URL: article.ContentURLs.Desktop.Page, Provider: "wikipedia"}, true
}
}
return askSource{}, false
}

func askStepContext(parent context.Context, max time.Duration) (context.Context, context.CancelFunc) {
// Leave part of the remaining budget for later independent providers.
if deadline, ok := parent.Deadline(); ok && time.Until(deadline)/2 < max {
max = time.Until(deadline) / 2
}
if max <= 0 {
return context.WithCancel(parent)
}
Expand Down Expand Up @@ -787,7 +798,8 @@ func askDuckDuckGoSearchAssistOnce(ctx context.Context, question, fallbackURL st
return askSource{}, false
}
assistURL := html.UnescapeString(string(matches[1]))
if !validHTTPURL(assistURL) {
assistParsed, parseErr := url.Parse(assistURL)
if parseErr != nil || assistParsed.Scheme != "https" || !isDuckDuckGoHost(assistParsed.Hostname()) || assistParsed.User != nil || assistParsed.Port() != "" {
return askSource{}, false
}
assistReq, err := http.NewRequestWithContext(ctx, http.MethodGet, assistURL, nil)
Expand Down Expand Up @@ -1809,7 +1821,10 @@ func newAskWebHTTPClient() *http.Client {
return &http.Client{
Timeout: 6 * time.Second,
Transport: transport,
CheckRedirect: func(req *http.Request, _ []*http.Request) error {
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
if !validPublicHTTPURL(req.URL.String()) {
return fmt.Errorf("refusing non-public redirect")
}
Expand Down
70 changes: 65 additions & 5 deletions plugins/define.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"strings"
"time"

xhtml "golang.org/x/net/html"

"github.com/variablenix/GoBot/bot"
"github.com/variablenix/GoBot/storage"
)
Expand All @@ -36,28 +38,86 @@ func (p *Define) Handle(b *bot.Bot, m bot.Message) bool {

ctx, cancel := context.WithTimeout(context.Background(), definitionTimeout(p.cfg))
defer cancel()
entry, ok := dictionaryEntry(ctx, term)
entry, ok := lookupDefinition(ctx, term)
if !ok {
b.Send(m.ReplyTarget(), ircColor(ircRed, "no English definition found"))
b.Send(m.ReplyTarget(), ircColor(ircRed, "No definition available: the word may be missing or the dictionary services unavailable. Try !wiki "+cleanExternalText(term)))
return true
}
maxLength := p.cfg.Int("max_length", 240)
if maxLength < 80 {
maxLength = 80
if maxLength < 80 || maxLength > 400 {
maxLength = 240
}
definition := truncateRunes(cleanExternalText(entry.Definition), maxLength)
part := cleanExternalText(entry.PartOfSpeech)
if part != "" {
part = " (" + part + ")"
}
b.Send(m.ReplyTarget(), fmt.Sprintf("📖 %s%s: %s", cleanExternalText(entry.Word), part, definition))
message := fmt.Sprintf("📖 %s%s: %s", cleanExternalText(entry.Word), part, definition)
if entry.URL != "" {
message += " — " + entry.URL
}
b.Send(m.ReplyTarget(), message)
return true
}

type dictionaryEntryResult struct {
Word string
PartOfSpeech string
Definition string
URL string
}

// Reserve time for an independent provider when the primary is unreachable.
func lookupDefinition(ctx context.Context, term string) (dictionaryEntryResult, bool) {
budget := 2 * time.Second
if deadline, ok := ctx.Deadline(); ok && time.Until(deadline)/3 < budget {
budget = time.Until(deadline) / 3
}
primary, cancel := context.WithTimeout(ctx, budget)
entry, ok := dictionaryEntry(primary, term)
cancel()
if ok {
return entry, true
}
return wiktionaryEntry(ctx, term)
}

func wiktionaryEntry(ctx context.Context, term string) (dictionaryEntryResult, bool) {
endpoint := "https://en.wiktionary.org/api/rest_v1/page/definition/" + url.PathEscape(term)
req, err := wikipediaRequest(ctx, endpoint)
if err != nil {
return dictionaryEntryResult{}, false
}
res, err := apiHTTPClient.Do(req)
if err != nil {
return dictionaryEntryResult{}, false
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return dictionaryEntryResult{}, false
}
var entries map[string][]struct {
PartOfSpeech string `json:"partOfSpeech"`
Definitions []struct {
Definition string `json:"definition"`
} `json:"definitions"`
}
if json.NewDecoder(io.LimitReader(res.Body, 512*1024)).Decode(&entries) != nil {
return dictionaryEntryResult{}, false
}
for _, entry := range entries["en"] {
for _, definition := range entry.Definitions {
node, err := xhtml.Parse(strings.NewReader(definition.Definition))
if err != nil {
continue
}
text := cleanExternalText(askHTMLText(node))
if text != "" {
return dictionaryEntryResult{Word: term, PartOfSpeech: entry.PartOfSpeech, Definition: text, URL: "https://en.wiktionary.org/wiki/" + url.PathEscape(term) + "#English"}, true
}
}
}
return dictionaryEntryResult{}, false
}

func dictionaryEntry(ctx context.Context, term string) (dictionaryEntryResult, bool) {
Expand Down
Loading
Loading