feat: añadir tools wikipedia_search y exchange_rate
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user