5 Commits

Author SHA1 Message Date
dependabot[bot]
be369d92cb Bump modernc.org/sqlite from 1.50.1 to 1.53.0
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.50.1 to 1.53.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.50.1...v1.53.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-24 12:14:00 +00:00
prosolis
6e4928ca17 Merge pull request #11 from prosolis/forex-crypto-coingecko
Forex: convert to/from crypto via CoinGecko (keyless)
2026-05-21 22:21:52 -07:00
prosolis
72f4ef3b27 Forex: convert to/from crypto via CoinGecko (keyless)
Wire a curated set of crypto assets (BTC, ETH, SOL, XRP, DOGE, ADA, LTC)
into the !fx conversion path only. fxLivePairRate now resolves each side
to units-per-USD and crosses them, so USD/fiat/crypto mix uniformly; fiat
sides still batch into one Frankfurter call, crypto hits CoinGecko's
keyless simple/price with a 60s cache.

Analysis (rate/report/setalert) stays fiat-only on purpose -- a daily
snapshot buy-signal/52w range is meaningless for 24/7 crypto.
2026-05-21 18:05:37 -07:00
prosolis
6cda1cac38 Merge branch 'phase-H-harvest-charges': repo cleanup 2026-05-18 00:11:57 -07:00
prosolis
1f6c05a565 Merge pull request #10 from prosolis/phase-H-harvest-charges
Phase h harvest charges
2026-05-17 18:52:00 -07:00
6 changed files with 212 additions and 49 deletions

4
go.mod
View File

@@ -14,7 +14,7 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/image v0.40.0
maunium.net/go/mautrix v0.28.0
modernc.org/sqlite v1.50.1
modernc.org/sqlite v1.53.0
)
require (
@@ -41,7 +41,7 @@ require (
golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

20
go.sum
View File

@@ -163,20 +163,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -185,8 +185,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -113,6 +113,7 @@ const fxHelpText = "**Forex Commands**\n\n" +
"`!fx rate EUR/USD` — cross-pair rate from first currency's perspective\n" +
"`!fx report [EUR|JPY|CAD]` — full analysis (averages, 52w range, buy score)\n" +
"`!fx 1500 USD to EUR` — convert an amount (also: `!fx convert 1500 USD EUR`)\n" +
"`!fx 100 USD to BTC` — convert to/from crypto (BTC, ETH, SOL, XRP, DOGE, ADA, LTC)\n" +
"`!fx setalert <currency> <rate>` — alert when USD/currency reaches threshold\n" +
"`!fx alerts` — list active alerts in this room\n" +
"`!fx delalert <currency> <rate>` — remove an alert\n" +
@@ -122,7 +123,7 @@ const fxHelpText = "**Forex Commands**\n\n" +
func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error {
// Check for cross-pair syntax like EUR/USD or USD/JPY
if pair := fxParsePair(args); pair != nil {
if pair := fxParsePair(args, false); pair != nil {
return p.cmdPairRateOrReport(ctx, pair, false)
}
@@ -156,8 +157,10 @@ type fxPair struct {
}
// fxParsePair detects "XXX/YYY" syntax in args. Both sides must be USD or a
// tracked currency. Returns nil if no pair is found.
func fxParsePair(args []string) *fxPair {
// tracked currency; when allowCrypto is set, supported crypto assets are
// accepted too (used by the conversion path, not by analysis). Returns nil if
// no pair is found.
func fxParsePair(args []string, allowCrypto bool) *fxPair {
if len(args) == 0 {
return nil
}
@@ -174,7 +177,11 @@ func fxParsePair(args []string) *fxPair {
return nil
}
base, quote := parts[0], parts[1]
if !fxIsSupported(base) || !fxIsSupported(quote) {
ok := fxIsSupported
if allowCrypto {
ok = fxIsConvertible
}
if !ok(base) || !ok(quote) {
return nil
}
if base == quote {
@@ -215,46 +222,62 @@ func (p *ForexPlugin) cmdPairRateOrReport(ctx MessageContext, pair *fxPair, full
return p.SendReply(ctx.RoomID, ctx.EventID, sig.FormatQuick())
}
// fxLivePairRate computes the live cross-pair rate from USD-base rates.
// fxLivePairRate computes the live cross-pair rate (how many Quote per 1 Base).
// Each side is resolved to a "units per USD" figure, then crossed; this handles
// USD, fiat (Frankfurter), and crypto (CoinGecko) sides uniformly.
func (p *ForexPlugin) fxLivePairRate(pair *fxPair) (float64, error) {
var currencies []string
if pair.Base != "USD" {
currencies = append(currencies, pair.Base)
// Batch the fiat sides into a single Frankfurter call.
var fiat []string
for _, c := range []string{pair.Base, pair.Quote} {
if c != "USD" && !fxIsCrypto(c) {
fiat = append(fiat, c)
}
}
var fiatRates map[string]float64
if len(fiat) > 0 {
var err error
if fiatRates, err = p.fxLiveRates(fiat); err != nil {
return 0, err
}
if pair.Quote != "USD" {
currencies = append(currencies, pair.Quote)
}
rates, err := p.fxLiveRates(currencies)
basePer, err := p.fxPerUSD(pair.Base, fiatRates)
if err != nil {
return 0, err
}
switch {
case pair.Base == "USD":
r, ok := rates[pair.Quote]
if !ok {
return 0, fmt.Errorf("no rate data for %s", pair.Quote)
quotePer, err := p.fxPerUSD(pair.Quote, fiatRates)
if err != nil {
return 0, err
}
return r, nil
case pair.Quote == "USD":
r, ok := rates[pair.Base]
if !ok || r == 0 {
return 0, fmt.Errorf("no rate data for %s", pair.Base)
}
return 1.0 / r, nil
default:
br, ok1 := rates[pair.Base]
qr, ok2 := rates[pair.Quote]
if !ok1 || !ok2 || br == 0 {
if basePer == 0 {
return 0, fmt.Errorf("missing rate data for cross-pair")
}
return qr / br, nil
return quotePer / basePer, nil
}
// fxPerUSD returns how many units of cur equal 1 USD. Fiat rates are taken from
// the pre-fetched fiatRates map; crypto is fetched live from CoinGecko.
func (p *ForexPlugin) fxPerUSD(cur string, fiatRates map[string]float64) (float64, error) {
switch {
case cur == "USD":
return 1.0, nil
case fxIsCrypto(cur):
price, err := p.cgSpotUSD(cur)
if err != nil {
return 0, err
}
return 1.0 / price, nil
default:
r, ok := fiatRates[cur]
if !ok || r == 0 {
return 0, fmt.Errorf("no rate data for %s", cur)
}
return r, nil
}
}
func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error {
if pair := fxParsePair(args); pair != nil {
if pair := fxParsePair(args, false); pair != nil {
return p.cmdPairRateOrReport(ctx, pair, true)
}
currencies := fxParseCurrencies(args)

View File

@@ -34,8 +34,12 @@ func fxIsTracked(cur string) bool {
return false
}
// fxFormatRate formats a rate: JPY uses 2 decimal places, others use 4.
// fxFormatRate formats a rate: crypto uses up to 8 decimals (trailing zeros
// trimmed) for tiny per-USD figures, JPY uses 2, others use 4.
func fxFormatRate(currency string, rate float64) string {
if fxIsCrypto(currency) {
return fxTrimZeros(fmt.Sprintf("%.8f", rate))
}
if currency == "JPY" {
return fmt.Sprintf("%.2f", rate)
}

View File

@@ -58,7 +58,7 @@ func fxParseConvert(args []string) (amount float64, base, quote string, err erro
for _, tok := range tokens {
// Pair form: USD/EUR
if strings.ContainsAny(tok, "/|-") {
pair := fxParsePair([]string{tok})
pair := fxParsePair([]string{tok}, true)
if pair != nil {
if base != "" {
return 0, "", "", fmt.Errorf("multiple currency pairs")
@@ -76,9 +76,9 @@ func fxParseConvert(args []string) (amount float64, base, quote string, err erro
amountSet = true
continue
}
// Currency code?
// Currency or crypto code?
up := strings.ToUpper(tok)
if fxIsSupported(up) {
if fxIsConvertible(up) {
currencies = append(currencies, up)
continue
}
@@ -127,12 +127,15 @@ func (p *ForexPlugin) cmdConvert(ctx MessageContext, args []string) error {
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
// fxEmoji returns the flag emoji for a supported currency, falling back
// to USD's flag.
// 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 "🇺🇸"
}
@@ -140,9 +143,13 @@ func fxEmoji(cur string) string {
}
// fxFormatAmount formats a converted amount with thousands separators.
// JPY uses 0 decimal places (yen are not subdivided in practice for display),
// other currencies use 2.
// 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
@@ -150,6 +157,16 @@ func fxFormatAmount(currency string, amount float64) string {
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)

View File

@@ -0,0 +1,119 @@
package plugin
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
// ── Crypto support (CoinGecko, keyless) ──────────────────────────────────────
//
// CoinGecko's public simple/price endpoint needs no API key and no usage tier,
// matching Frankfurter's keyless model. Crypto is wired into the *conversion*
// path only (`!fx 100 USD to BTC`). The rate/report/alert analysis stays
// fiat-only on purpose: a daily-snapshot "buy signal" or 52-week range is
// meaningless for 24/7 crypto markets.
type fxCryptoInfo struct {
CoinGeckoID string
Emoji string
Label string
}
// fxCryptoMeta maps a ticker to its CoinGecko coin ID and display metadata.
// Add a row here to support another asset.
var fxCryptoMeta = map[string]fxCryptoInfo{
"BTC": {"bitcoin", "₿", "Bitcoin"},
"ETH": {"ethereum", "Ξ", "Ethereum"},
"SOL": {"solana", "◎", "Solana"},
"XRP": {"ripple", "✕", "XRP"},
"DOGE": {"dogecoin", "🐕", "Dogecoin"},
"ADA": {"cardano", "₳", "Cardano"},
"LTC": {"litecoin", "Ł", "Litecoin"},
}
// fxIsCrypto reports whether sym is a supported crypto asset.
func fxIsCrypto(sym string) bool {
_, ok := fxCryptoMeta[strings.ToUpper(sym)]
return ok
}
// fxIsConvertible reports whether a symbol can appear in a conversion —
// USD, a tracked fiat currency, or a supported crypto asset.
func fxIsConvertible(cur string) bool {
return fxIsSupported(cur) || fxIsCrypto(cur)
}
const coinGeckoBaseURL = "https://api.coingecko.com/api/v3"
// Short-TTL price cache to stay well under CoinGecko's public rate limit and
// keep repeated conversions snappy.
const cgCacheTTL = 60 * time.Second
var (
cgMu sync.Mutex
cgCache = map[string]cgCacheEntry{}
)
type cgCacheEntry struct {
priceUSD float64
at time.Time
}
// cgSpotUSD returns the USD spot price for one unit of the given crypto symbol.
func (p *ForexPlugin) cgSpotUSD(sym string) (float64, error) {
sym = strings.ToUpper(sym)
info, ok := fxCryptoMeta[sym]
if !ok {
return 0, fmt.Errorf("unsupported crypto %s", sym)
}
cgMu.Lock()
if e, ok := cgCache[sym]; ok && time.Since(e.at) < cgCacheTTL {
cgMu.Unlock()
return e.priceUSD, nil
}
cgMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
url := fmt.Sprintf("%s/simple/price?ids=%s&vs_currencies=usd", coinGeckoBaseURL, info.CoinGeckoID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return 0, err
}
resp, err := p.httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("coingecko API error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("coingecko API returned %d", resp.StatusCode)
}
var data map[string]map[string]float64
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return 0, fmt.Errorf("coingecko decode error: %w", err)
}
entry, ok := data[info.CoinGeckoID]
if !ok {
return 0, fmt.Errorf("no price for %s", sym)
}
price, ok := entry["usd"]
if !ok || price <= 0 {
return 0, fmt.Errorf("invalid price for %s", sym)
}
cgMu.Lock()
cgCache[sym] = cgCacheEntry{priceUSD: price, at: time.Now()}
cgMu.Unlock()
return price, nil
}