From f2718aa17c6efd19c7933891f75de397c3948c86 Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Sat, 11 Apr 2026 00:25:10 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20tools=20wikipedia=5Fsearc?= =?UTF-8?q?h=20y=20exchange=5Frate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools/wikipedia/wikipedia.go: tool wikipedia_search que consulta la API pública de Wikipedia (sin auth). Devuelve resumen del artículo. - tools/exchange/exchange.go: 4 tools de tipo de cambio usando exchangerate-api.com: exchange_rate_get, exchange_rate_convert, exchange_rate_list, exchange_rate_historical. - internal/config/schema.go: añadir ExchangeRateToolCfg con Enabled, APIKey, APIKeyEnv y Timeout. - devagents/registry_build.go: registrar ambas tool families. wikipedia_search siempre disponible; exchange rate tools requieren APIKey configurado (deny-by-default con WARN si falta). - devagents/registry_build_test.go: actualizar test de registry build. Co-Authored-By: Claude Sonnet 4.6 --- devagents/registry_build.go | 31 +++ devagents/registry_build_test.go | 4 +- internal/config/schema.go | 8 + tools/exchange/exchange.go | 415 +++++++++++++++++++++++++++++++ tools/wikipedia/wikipedia.go | 233 +++++++++++++++++ 5 files changed, 689 insertions(+), 2 deletions(-) create mode 100644 tools/exchange/exchange.go create mode 100644 tools/wikipedia/wikipedia.go diff --git a/devagents/registry_build.go b/devagents/registry_build.go index 6dc2c9c..6798279 100644 --- a/devagents/registry_build.go +++ b/devagents/registry_build.go @@ -25,6 +25,8 @@ import ( toolskills "github.com/enmanuel/agents/tools/skilltools" toolssh "github.com/enmanuel/agents/tools/ssh" toolweather "github.com/enmanuel/agents/tools/weather" + toolwikipedia "github.com/enmanuel/agents/tools/wikipedia" + toolexchange "github.com/enmanuel/agents/tools/exchange" "github.com/enmanuel/agents/shell/matrix" ) @@ -187,12 +189,41 @@ func buildToolRegistry( reg.Register(toolweather.NewWeather()) logger.Debug("registered weather tool") + // wikipedia_search tool is always available + reg.Register(toolwikipedia.NewWikipediaSearch()) + logger.Debug("registered wikipedia_search tool") + // imdb tool (enabled via config) if cfg.Tools.IMDb.Enabled { reg.Register(toolimdb.NewIMDbSearch(cfg.Tools.IMDb)) logger.Debug("registered imdb tool") } + // exchange rate tools (enabled via config) + if cfg.Tools.ExchangeRate.Enabled { + if t, err := toolexchange.NewExchangeRateGet(cfg.Tools.ExchangeRate); err != nil { + logger.Warn("exchange_rate_get disabled: API key not configured", "err", err) + } else { + reg.Register(t) + } + if t, err := toolexchange.NewExchangeRateConvert(cfg.Tools.ExchangeRate); err != nil { + logger.Warn("exchange_rate_convert disabled: API key not configured", "err", err) + } else { + reg.Register(t) + } + if t, err := toolexchange.NewExchangeRateList(cfg.Tools.ExchangeRate); err != nil { + logger.Warn("exchange_rate_list disabled: API key not configured", "err", err) + } else { + reg.Register(t) + } + if t, err := toolexchange.NewExchangeRateHistorical(cfg.Tools.ExchangeRate); err != nil { + logger.Warn("exchange_rate_historical disabled: API key not configured", "err", err) + } else { + reg.Register(t) + } + logger.Debug("registered exchange rate tools") + } + // matrix_send is always available reg.Register(toolmatrix.NewMatrixSend(matrixClient, cfg.Tools.Matrix)) logger.Debug("registered matrix tool") diff --git a/devagents/registry_build_test.go b/devagents/registry_build_test.go index f186734..af3f967 100644 --- a/devagents/registry_build_test.go +++ b/devagents/registry_build_test.go @@ -143,8 +143,8 @@ func TestBuildToolRegistry_ToolCount(t *testing.T) { reg := buildToolRegistry(cfg, nil, nil, nil, nil, nil, nil, nil, nil, roomCtx, logger) - // 3 always-on + 2 HTTP + 1 SSH + 5 file + 1 IMDb = 12 - expected := 12 + // 4 always-on (current_time, get_weather, wikipedia_search, matrix_send) + 2 HTTP + 1 SSH + 5 file + 1 IMDb = 13 + expected := 13 if got := reg.Len(); got != expected { t.Errorf("expected %d tools, got %d: %v", expected, got, reg.Names()) } diff --git a/internal/config/schema.go b/internal/config/schema.go index 089d8f5..73b750a 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -164,6 +164,7 @@ type ToolsCfg struct { SharedKnowledge SharedKnowledgeToolCfg `yaml:"shared_knowledge"` Skills SkillsToolCfg `yaml:"skills"` IMDb IMDbToolCfg `yaml:"imdb"` + ExchangeRate ExchangeRateToolCfg `yaml:"exchange_rate"` } type MatrixToolCfg struct { @@ -438,6 +439,13 @@ type IMDbToolCfg struct { Timeout time.Duration `yaml:"timeout"` // timeout for API requests (default: 10s) } +type ExchangeRateToolCfg struct { + Enabled bool `yaml:"enabled"` + APIKey string `yaml:"api_key"` // ExchangeRate-API key (get from https://www.exchangerate-api.com) + APIKeyEnv string `yaml:"api_key_env"` // env var name for API key (e.g., "EXCHANGE_RATE_API_KEY") + Timeout time.Duration `yaml:"timeout"` // timeout for API requests (default: 10s) +} + // ── Special Agents ──────────────────────────────────────────────────────── // SpecialConfig is the root configuration for a special agent (no Matrix identity). diff --git a/tools/exchange/exchange.go b/tools/exchange/exchange.go new file mode 100644 index 0000000..34a8b02 --- /dev/null +++ b/tools/exchange/exchange.go @@ -0,0 +1,415 @@ +// Package exchange provides tools for querying currency exchange rates +// via the ExchangeRate-API v6 (https://www.exchangerate-api.com). +// +// Tools exposed: +// - exchange_rate_get — current rate between two currencies +// - exchange_rate_convert — convert an amount from one currency to another +// - exchange_rate_list — list all supported currency codes +// - exchange_rate_historical — exchange rate on a specific past date +package exchange + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/enmanuel/agents/internal/config" + "github.com/enmanuel/agents/tools" +) + +const ( + baseURL = "https://v6.exchangerate-api.com/v6" + defaultTimeout = 10 * time.Second + maxBodySize = 64 * 1024 // 64 KB +) + +// client holds shared HTTP client and resolved API key. +type client struct { + http *http.Client + apiKey string +} + +// newClient builds a shared client from config. +// Returns an error if no API key can be resolved. +func newClient(cfg config.ExchangeRateToolCfg) (*client, error) { + apiKey := cfg.APIKey + if apiKey == "" && cfg.APIKeyEnv != "" { + apiKey = os.Getenv(cfg.APIKeyEnv) + } + if apiKey == "" { + return nil, fmt.Errorf("exchange rate: no API key configured (set api_key or api_key_env in config)") + } + + timeout := cfg.Timeout + if timeout == 0 { + timeout = defaultTimeout + } + + return &client{ + http: &http.Client{Timeout: timeout}, + apiKey: apiKey, + }, nil +} + +// get performs a GET request and returns the parsed JSON body. +func (c *client) get(ctx context.Context, path string) (map[string]any, error) { + url := fmt.Sprintf("%s/%s/%s", baseURL, c.apiKey, path) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("User-Agent", "exchange-bot/1.0 (Matrix agent)") + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("http request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize)) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + var result map[string]any + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse JSON: %w", err) + } + + // ExchangeRate-API sets "result" field to "success" or "error" + if res, ok := result["result"].(string); ok && res != "success" { + errType, _ := result["error-type"].(string) + return nil, fmt.Errorf("API error: %s", errType) + } + + return result, nil +} + +// normalizeCurrency uppercases and trims a currency code. +func normalizeCurrency(code string) string { + return strings.ToUpper(strings.TrimSpace(code)) +} + +// getFloat safely extracts a float64 from a JSON-decoded map. +func getFloat(m map[string]any, key string) (float64, bool) { + v, ok := m[key] + if !ok { + return 0, false + } + f, ok := v.(float64) + return f, ok +} + +// getString safely extracts a string from a JSON-decoded map. +func getString(m map[string]any, key string) string { + v, ok := m[key] + if !ok { + return "" + } + s, _ := v.(string) + return s +} + +// ── Tool constructors ───────────────────────────────────────────────────── + +// NewExchangeRateGet creates the exchange_rate_get tool. +// Returns the current exchange rate between two currencies. +func NewExchangeRateGet(cfg config.ExchangeRateToolCfg) (tools.Tool, error) { + c, err := newClient(cfg) + if err != nil { + return tools.Tool{}, err + } + + return tools.Tool{ + Def: tools.Def{ + Name: "exchange_rate_get", + Description: "Get the current exchange rate between two currencies. " + + "Returns the rate, last update time, and next update time. " + + "Use ISO 4217 currency codes (e.g. USD, EUR, GBP, JPY, MXN, COP, ARS).", + Parameters: []tools.Param{ + { + Name: "from", + Type: "string", + Description: "Source currency code (ISO 4217), e.g. USD, EUR, GBP", + Required: true, + }, + { + Name: "to", + Type: "string", + Description: "Target currency code (ISO 4217), e.g. EUR, MXN, JPY", + Required: true, + }, + }, + }, + Exec: func(ctx context.Context, args map[string]any) tools.Result { + from := normalizeCurrency(tools.GetString(args, "from")) + to := normalizeCurrency(tools.GetString(args, "to")) + if from == "" || to == "" { + return tools.Result{Err: fmt.Errorf("exchange_rate_get: 'from' and 'to' are required")} + } + + data, err := c.get(ctx, fmt.Sprintf("pair/%s/%s", from, to)) + if err != nil { + return tools.Result{Err: fmt.Errorf("exchange_rate_get: %w", err)} + } + + rate, ok := getFloat(data, "conversion_rate") + if !ok { + return tools.Result{Err: fmt.Errorf("exchange_rate_get: unexpected API response")} + } + + nextUpdate := getString(data, "time_next_update_utc") + lastUpdate := getString(data, "time_last_update_utc") + + output := fmt.Sprintf( + "💱 **%s → %s**\n\nTasa actual: **%.6f**\n\n"+ + "_Actualizado: %s_\n_Próxima actualización: %s_", + from, to, rate, lastUpdate, nextUpdate, + ) + return tools.Result{Output: output} + }, + }, nil +} + +// NewExchangeRateConvert creates the exchange_rate_convert tool. +// Converts an amount from one currency to another using the current rate. +func NewExchangeRateConvert(cfg config.ExchangeRateToolCfg) (tools.Tool, error) { + c, err := newClient(cfg) + if err != nil { + return tools.Tool{}, err + } + + return tools.Tool{ + Def: tools.Def{ + Name: "exchange_rate_convert", + Description: "Convert a monetary amount from one currency to another using the current exchange rate. " + + "Returns the converted amount and the rate used. " + + "Use ISO 4217 currency codes (e.g. USD, EUR, GBP, JPY, MXN, COP, ARS).", + Parameters: []tools.Param{ + { + Name: "from", + Type: "string", + Description: "Source currency code (ISO 4217), e.g. USD", + Required: true, + }, + { + Name: "to", + Type: "string", + Description: "Target currency code (ISO 4217), e.g. EUR", + Required: true, + }, + { + Name: "amount", + Type: "number", + Description: "Amount to convert (e.g. 100, 1500.50)", + Required: true, + }, + }, + }, + Exec: func(ctx context.Context, args map[string]any) tools.Result { + from := normalizeCurrency(tools.GetString(args, "from")) + to := normalizeCurrency(tools.GetString(args, "to")) + amount, ok := args["amount"].(float64) + if !ok { + return tools.Result{Err: fmt.Errorf("exchange_rate_convert: 'amount' must be a number")} + } + if from == "" || to == "" { + return tools.Result{Err: fmt.Errorf("exchange_rate_convert: 'from' and 'to' are required")} + } + if amount <= 0 { + return tools.Result{Err: fmt.Errorf("exchange_rate_convert: 'amount' must be greater than 0")} + } + + data, err := c.get(ctx, fmt.Sprintf("pair/%s/%s/%.2f", from, to, amount)) + if err != nil { + return tools.Result{Err: fmt.Errorf("exchange_rate_convert: %w", err)} + } + + rate, _ := getFloat(data, "conversion_rate") + converted, ok := getFloat(data, "conversion_result") + if !ok { + return tools.Result{Err: fmt.Errorf("exchange_rate_convert: unexpected API response")} + } + + output := fmt.Sprintf( + "💰 **Conversión de divisas**\n\n"+ + "%.2f **%s** = **%.2f %s**\n\n"+ + "Tasa: 1 %s = %.6f %s", + amount, from, converted, to, + from, rate, to, + ) + return tools.Result{Output: output} + }, + }, nil +} + +// NewExchangeRateList creates the exchange_rate_list tool. +// Returns all currency codes supported by the API. +func NewExchangeRateList(cfg config.ExchangeRateToolCfg) (tools.Tool, error) { + c, err := newClient(cfg) + if err != nil { + return tools.Tool{}, err + } + + return tools.Tool{ + Def: tools.Def{ + Name: "exchange_rate_list", + Description: "List all currency codes supported by the exchange rate service. " + + "Returns ISO 4217 codes with their full currency names. " + + "Use this to discover valid currency codes before using other exchange rate tools.", + Parameters: []tools.Param{ + { + Name: "filter", + Type: "string", + Description: "Optional text filter to search currencies by code or name (case-insensitive, e.g. 'peso', 'EUR', 'dollar')", + Required: false, + }, + }, + }, + Exec: func(ctx context.Context, args map[string]any) tools.Result { + filter := strings.ToLower(strings.TrimSpace(tools.GetString(args, "filter"))) + + data, err := c.get(ctx, "codes") + if err != nil { + return tools.Result{Err: fmt.Errorf("exchange_rate_list: %w", err)} + } + + rawCodes, ok := data["supported_codes"].([]any) + if !ok { + return tools.Result{Err: fmt.Errorf("exchange_rate_list: unexpected API response")} + } + + var sb strings.Builder + sb.WriteString("🌐 **Monedas disponibles**\n\n") + + count := 0 + for _, entry := range rawCodes { + pair, ok := entry.([]any) + if !ok || len(pair) < 2 { + continue + } + code, _ := pair[0].(string) + name, _ := pair[1].(string) + + if filter != "" { + codeLow := strings.ToLower(code) + nameLow := strings.ToLower(name) + if !strings.Contains(codeLow, filter) && !strings.Contains(nameLow, filter) { + continue + } + } + + fmt.Fprintf(&sb, "• **%s** — %s\n", code, name) + count++ + + // Cap output at 100 entries to avoid flooding + if count >= 100 { + sb.WriteString("\n_... y más. Usa 'filter' para buscar una moneda específica._") + break + } + } + + if count == 0 { + sb.WriteString(fmt.Sprintf("No se encontraron monedas que coincidan con %q.", filter)) + } else if filter == "" { + fmt.Fprintf(&sb, "\n_Total: %d monedas disponibles_", count) + } + + return tools.Result{Output: sb.String()} + }, + }, nil +} + +// NewExchangeRateHistorical creates the exchange_rate_historical tool. +// Returns the exchange rate between two currencies on a specific past date. +func NewExchangeRateHistorical(cfg config.ExchangeRateToolCfg) (tools.Tool, error) { + c, err := newClient(cfg) + if err != nil { + return tools.Tool{}, err + } + + return tools.Tool{ + Def: tools.Def{ + Name: "exchange_rate_historical", + Description: "Get the historical exchange rate between two currencies on a specific past date. " + + "Returns the rate that was in effect on that date. " + + "Use ISO 4217 currency codes and YYYY-MM-DD date format.", + Parameters: []tools.Param{ + { + Name: "from", + Type: "string", + Description: "Source currency code (ISO 4217), e.g. USD", + Required: true, + }, + { + Name: "to", + Type: "string", + Description: "Target currency code (ISO 4217), e.g. EUR", + Required: true, + }, + { + Name: "date", + Type: "string", + Description: "Date in YYYY-MM-DD format (e.g. 2024-01-15). Must be in the past.", + Required: true, + }, + }, + }, + Exec: func(ctx context.Context, args map[string]any) tools.Result { + from := normalizeCurrency(tools.GetString(args, "from")) + to := normalizeCurrency(tools.GetString(args, "to")) + dateStr := strings.TrimSpace(tools.GetString(args, "date")) + + if from == "" || to == "" { + return tools.Result{Err: fmt.Errorf("exchange_rate_historical: 'from' and 'to' are required")} + } + if dateStr == "" { + return tools.Result{Err: fmt.Errorf("exchange_rate_historical: 'date' is required (YYYY-MM-DD)")} + } + + // Parse and validate date + date, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return tools.Result{Err: fmt.Errorf("exchange_rate_historical: invalid date format %q — use YYYY-MM-DD", dateStr)} + } + if !date.Before(time.Now()) { + return tools.Result{Err: fmt.Errorf("exchange_rate_historical: date must be in the past")} + } + + path := fmt.Sprintf("history/%s/%d/%d/%d", + from, date.Year(), int(date.Month()), date.Day()) + + data, err := c.get(ctx, path) + if err != nil { + return tools.Result{Err: fmt.Errorf("exchange_rate_historical: %w", err)} + } + + // Navigate: data["conversion_rates"][to] + ratesRaw, ok := data["conversion_rates"].(map[string]any) + if !ok { + return tools.Result{Err: fmt.Errorf("exchange_rate_historical: unexpected API response")} + } + + rateVal, ok := ratesRaw[to] + if !ok { + return tools.Result{Err: fmt.Errorf("exchange_rate_historical: currency %s not found in historical data", to)} + } + rate, _ := rateVal.(float64) + + output := fmt.Sprintf( + "📅 **Tasa histórica — %s**\n\n"+ + "**%s → %s**\n"+ + "Tasa: **%.6f**\n\n"+ + "_Datos del %s (cierre de mercado)_", + dateStr, from, to, rate, dateStr, + ) + return tools.Result{Output: output} + }, + }, nil +} diff --git a/tools/wikipedia/wikipedia.go b/tools/wikipedia/wikipedia.go new file mode 100644 index 0000000..b89f287 --- /dev/null +++ b/tools/wikipedia/wikipedia.go @@ -0,0 +1,233 @@ +package wikipedia + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/enmanuel/agents/tools" +) + +// NewWikipediaSearch creates a wikipedia_search tool that searches Wikipedia +// and returns a structured summary of the top result. +// Uses the public Wikipedia REST API — no API key required. +func NewWikipediaSearch() tools.Tool { + client := &http.Client{Timeout: 15 * time.Second} + + return tools.Tool{ + Def: tools.Def{ + Name: "wikipedia_search", + Description: "Search Wikipedia and retrieve a structured article summary. " + + "Returns the title, description, extract (plain text summary), and article URL. " + + "Supports multiple languages via the 'lang' parameter (e.g. 'es', 'en', 'fr'). " + + "Use this tool whenever the user asks about a topic, person, place, concept, or event " + + "that would have a Wikipedia article.", + Parameters: []tools.Param{ + { + Name: "query", + Type: "string", + Description: "Search term or topic to look up on Wikipedia (e.g. 'Albert Einstein', 'fotosíntesis', 'Segunda Guerra Mundial')", + Required: true, + }, + { + Name: "lang", + Type: "string", + Description: "Wikipedia language code (default: 'es' for Spanish). Common values: 'es' (Spanish), 'en' (English), 'fr' (French), 'de' (German), 'pt' (Portuguese)", + Required: false, + }, + }, + }, + Exec: func(ctx context.Context, args map[string]any) tools.Result { + query := tools.GetString(args, "query") + if query == "" { + return tools.Result{Err: fmt.Errorf("wikipedia_search: query is required")} + } + + lang := tools.GetString(args, "lang") + if lang == "" { + lang = "es" + } + // Sanitize lang: only allow simple language codes (2-3 chars, letters only) + lang = sanitizeLang(lang) + + // Step 1: Search for the best matching article title + title, err := searchArticle(ctx, client, lang, query) + if err != nil { + return tools.Result{Err: fmt.Errorf("wikipedia_search: search failed: %w", err)} + } + if title == "" { + return tools.Result{Output: fmt.Sprintf("No se encontraron artículos en Wikipedia (%s) para: %q", lang, query)} + } + + // Step 2: Fetch the article summary + summary, err := fetchSummary(ctx, client, lang, title) + if err != nil { + return tools.Result{Err: fmt.Errorf("wikipedia_search: summary fetch failed: %w", err)} + } + + return tools.Result{Output: formatSummary(lang, summary)} + }, + } +} + +// sanitizeLang normalizes and validates a language code. +// Only allows alphanumeric characters (e.g. "es", "en", "pt-br"). +func sanitizeLang(lang string) string { + var b strings.Builder + for _, r := range strings.ToLower(lang) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } + } + result := b.String() + if result == "" { + return "es" + } + return result +} + +// opensearchResponse models the Wikipedia OpenSearch API response. +// Format: [query, [titles...], [descriptions...], [urls...]] +type opensearchResponse [4]json.RawMessage + +// searchArticle uses the Wikipedia OpenSearch API to find the best matching title. +func searchArticle(ctx context.Context, client *http.Client, lang, query string) (string, error) { + apiURL := fmt.Sprintf( + "https://%s.wikipedia.org/w/api.php?action=opensearch&search=%s&limit=1&namespace=0&format=json&redirects=resolve", + url.PathEscape(lang), + url.QueryEscape(query), + ) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", fmt.Errorf("build request: %w", err) + } + req.Header.Set("User-Agent", "wikipedia-bot/1.0 (Matrix agent; educational use)") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("http request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("API returned HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024)) + if err != nil { + return "", fmt.Errorf("read response: %w", err) + } + + var result opensearchResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("parse response: %w", err) + } + + // result[1] is the array of titles + var titles []string + if err := json.Unmarshal(result[1], &titles); err != nil || len(titles) == 0 { + return "", nil // no results + } + + return titles[0], nil +} + +// articleSummary models the Wikipedia REST API summary response. +type articleSummary struct { + Title string `json:"title"` + DisplayTitle string `json:"displaytitle"` + Description string `json:"description"` + Extract string `json:"extract"` + ContentURLs struct { + Desktop struct { + Page string `json:"page"` + } `json:"desktop"` + } `json:"content_urls"` + Thumbnail struct { + Source string `json:"source"` + } `json:"thumbnail"` +} + +// fetchSummary retrieves a structured article summary from the Wikipedia REST API. +func fetchSummary(ctx context.Context, client *http.Client, lang, title string) (*articleSummary, error) { + apiURL := fmt.Sprintf( + "https://%s.wikipedia.org/api/rest_v1/page/summary/%s", + url.PathEscape(lang), + url.PathEscape(title), + ) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("User-Agent", "wikipedia-bot/1.0 (Matrix agent; educational use)") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("http request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("article %q not found", title) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + var summary articleSummary + if err := json.Unmarshal(body, &summary); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + return &summary, nil +} + +// formatSummary converts an articleSummary into a human-readable string. +func formatSummary(lang string, s *articleSummary) string { + var b strings.Builder + + fmt.Fprintf(&b, "📖 **%s**\n", s.Title) + + if s.Description != "" { + fmt.Fprintf(&b, "_%s_\n", s.Description) + } + + fmt.Fprintln(&b) + + if s.Extract != "" { + // Truncate extract to ~1500 chars to stay within context limits + extract := s.Extract + if len(extract) > 1500 { + // Find last sentence boundary before 1500 chars + cutoff := 1500 + for cutoff > 1000 && extract[cutoff] != '.' { + cutoff-- + } + extract = extract[:cutoff+1] + " [...]" + } + fmt.Fprintln(&b, extract) + } + + if s.ContentURLs.Desktop.Page != "" { + fmt.Fprintln(&b) + fmt.Fprintf(&b, "🔗 %s\n", s.ContentURLs.Desktop.Page) + } + + fmt.Fprintf(&b, "_(Wikipedia %s)_", strings.ToUpper(lang)) + + return b.String() +}