package plugin import ( "fmt" "log/slog" "math" "strconv" "strings" ) // fxParseAmount parses a numeric token, allowing thousands separators // ("1,500", "1500", "1500.50", "1.5k"). Returns an error on a non-numeric // token. Negative amounts are rejected. func fxParseAmount(tok string) (float64, error) { t := strings.TrimSpace(tok) t = strings.ReplaceAll(t, ",", "") t = strings.ReplaceAll(t, "_", "") multiplier := 1.0 if len(t) > 0 { switch strings.ToLower(t[len(t)-1:]) { case "k": multiplier = 1_000 t = t[:len(t)-1] case "m": multiplier = 1_000_000 t = t[:len(t)-1] } } v, err := strconv.ParseFloat(t, 64) if err != nil { return 0, err } if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) { return 0, fmt.Errorf("non-positive amount") } return v * multiplier, nil } // fxParseConvert parses a conversion request from free-form args. // Accepts: `1500 USD EUR`, `1500 USD to EUR`, `1500 USD/EUR`, `USD/EUR 1500`, // `1500USD EUR` is NOT supported (keep parsing simple β€” require whitespace). // "to" / "into" / "in" are filtered as noise tokens. func fxParseConvert(args []string) (amount float64, base, quote string, err error) { if len(args) == 0 { return 0, "", "", fmt.Errorf("no arguments") } tokens := make([]string, 0, len(args)) for _, a := range args { switch strings.ToLower(a) { case "to", "into", "in", "=": continue } tokens = append(tokens, a) } var amountSet bool var currencies []string for _, tok := range tokens { // Pair form: USD/EUR if strings.ContainsAny(tok, "/|-") { pair := fxParsePair([]string{tok}, true) if pair != nil { if base != "" { return 0, "", "", fmt.Errorf("multiple currency pairs") } base, quote = pair.Base, pair.Quote continue } } // Number? if v, perr := fxParseAmount(tok); perr == nil { if amountSet { return 0, "", "", fmt.Errorf("multiple amounts") } amount = v amountSet = true continue } // Currency or crypto code? up := strings.ToUpper(tok) if fxIsConvertible(up) { currencies = append(currencies, up) continue } return 0, "", "", fmt.Errorf("unrecognized token: %s", tok) } if !amountSet { return 0, "", "", fmt.Errorf("missing amount") } if base == "" { if len(currencies) != 2 { return 0, "", "", fmt.Errorf("expected two currencies, got %d", len(currencies)) } base, quote = currencies[0], currencies[1] } else if len(currencies) != 0 { return 0, "", "", fmt.Errorf("currency pair plus extra currency") } if base == quote { return 0, "", "", fmt.Errorf("base and quote are the same") } return amount, base, quote, nil } func (p *ForexPlugin) cmdConvert(ctx MessageContext, args []string) error { amount, base, quote, err := fxParseConvert(args) if err != nil { return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!fx 1500 USD to EUR` (or `!fx convert 1500 USD EUR`, `!fx convert USD/EUR 1500`)") } pair := &fxPair{Base: base, Quote: quote} rate, err := p.fxLivePairRate(pair) if err != nil { slog.Error("forex: fetch rates failed", "err", err) return p.SendReply(ctx.RoomID, ctx.EventID, "Could not fetch rates. Try again shortly.") } converted := amount * rate baseEmoji := fxEmoji(base) quoteEmoji := fxEmoji(quote) msg := fmt.Sprintf("%s **%s %s** = %s **%s %s**\n_1 %s = %s %s_", baseEmoji, fxFormatAmount(base, amount), base, quoteEmoji, fxFormatAmount(quote, converted), quote, base, fxFormatRate(quote, rate), quote) return p.SendReply(ctx.RoomID, ctx.EventID, msg) } // fxEmoji returns the flag/symbol emoji for a supported currency or crypto // asset, falling back to USD's flag. func fxEmoji(cur string) string { if m, ok := fxMeta[cur]; ok && m.Emoji != "" { return m.Emoji } if c, ok := fxCryptoMeta[strings.ToUpper(cur)]; ok && c.Emoji != "" { return c.Emoji } if cur == "USD" { return "πŸ‡ΊπŸ‡Έ" } return "" } // fxFormatAmount formats a converted amount with thousands separators. // Crypto uses up to 8 decimals (trailing zeros trimmed) since amounts are // often fractional; JPY uses 0 (yen are not subdivided in practice for // display); other currencies use 2. func fxFormatAmount(currency string, amount float64) string { if fxIsCrypto(currency) { return fxTrimZeros(fxAddCommas(amount, 8)) } decimals := 2 if currency == "JPY" { decimals = 0 } return fxAddCommas(amount, decimals) } // fxTrimZeros drops trailing fractional zeros (and a bare trailing dot) from a // formatted decimal string, leaving integers untouched. func fxTrimZeros(s string) string { if !strings.Contains(s, ".") { return s } s = strings.TrimRight(s, "0") return strings.TrimRight(s, ".") } // fxAddCommas formats a float with N decimal places and thousands separators. func fxAddCommas(v float64, decimals int) string { s := strconv.FormatFloat(v, 'f', decimals, 64) intPart := s fracPart := "" if i := strings.IndexByte(s, '.'); i >= 0 { intPart = s[:i] fracPart = s[i:] } neg := false if strings.HasPrefix(intPart, "-") { neg = true intPart = intPart[1:] } n := len(intPart) if n <= 3 { if neg { return "-" + intPart + fracPart } return intPart + fracPart } var b strings.Builder if neg { b.WriteByte('-') } pre := n % 3 if pre > 0 { b.WriteString(intPart[:pre]) if n > pre { b.WriteByte(',') } } for i := pre; i < n; i += 3 { b.WriteString(intPart[i : i+3]) if i+3 < n { b.WriteByte(',') } } b.WriteString(fracPart) return b.String() }