// 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 }