mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Compare commits
3 Commits
6cda1cac38
...
forex-cryp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ea3e42612 | ||
|
|
3ed2e1d8e0 | ||
|
|
72f4ef3b27 |
@@ -206,6 +206,25 @@ func petShouldArrive(pet PetState, house HouseState) bool {
|
|||||||
return rand.Float64() < 0.30
|
return rand.Float64() < 0.30
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maybeRollPetArrivalOnEmerge rolls the pet-arrival check when a player
|
||||||
|
// surfaces from an expedition (voluntary extract, abandon, or a survived
|
||||||
|
// forced extraction) or revives after death. The arrival roll lives on the
|
||||||
|
// emergence seam — not the legacy 08:00 overworld morning DM — because
|
||||||
|
// expedition players are almost never in the overworld at the scheduled hour,
|
||||||
|
// so the morning roll never reached them. Story beat: while the player was
|
||||||
|
// underground, an animal wandered into the empty house looking for food.
|
||||||
|
//
|
||||||
|
// Safe to call unconditionally on any emergence: petShouldArrive gates on
|
||||||
|
// house tier / not-yet-arrived, and petArrivalDM won't clobber an existing
|
||||||
|
// pending interaction.
|
||||||
|
func (p *AdventurePlugin) maybeRollPetArrivalOnEmerge(userID id.UserID) {
|
||||||
|
pet, _ := loadPetState(userID)
|
||||||
|
house, _ := loadHouseState(userID)
|
||||||
|
if petShouldArrive(pet, house) {
|
||||||
|
p.petArrivalDM(userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// petArrivalDM sends the initial "there's an animal in your house" DM.
|
// petArrivalDM sends the initial "there's an animal in your house" DM.
|
||||||
func (p *AdventurePlugin) petArrivalDM(userID id.UserID) {
|
func (p *AdventurePlugin) petArrivalDM(userID id.UserID) {
|
||||||
// Don't overwrite an existing pending interaction
|
// Don't overwrite an existing pending interaction
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
|||||||
if err := p.SendDM(char.UserID, text); err != nil {
|
if err := p.SendDM(char.UserID, text); err != nil {
|
||||||
slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err)
|
slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emergence seam (death case): a player who died underground
|
||||||
|
// "comes home" on respawn. This is the deferred half of the
|
||||||
|
// emergence roll — survived extractions roll at their exit
|
||||||
|
// site; deaths roll here. See maybeRollPetArrivalOnEmerge.
|
||||||
|
p.maybeRollPetArrivalOnEmerge(char.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Babysitting: pet-care trickle (no harvest actions; safe-rest perk
|
// Babysitting: pet-care trickle (no harvest actions; safe-rest perk
|
||||||
@@ -133,13 +139,12 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pet arrival check (fires before normal morning DM)
|
// Pet arrival no longer rolls here. The 08:00 overworld morning DM
|
||||||
house, _ := loadHouseState(char.UserID)
|
// is skipped for anyone underground (expedition gate above), so it
|
||||||
|
// never reached expedition players. Arrival now fires on the
|
||||||
|
// emergence seam — see maybeRollPetArrivalOnEmerge, called from the
|
||||||
|
// extract/abandon/forced-extract and respawn paths.
|
||||||
pet, _ := loadPetState(char.UserID)
|
pet, _ := loadPetState(char.UserID)
|
||||||
if petShouldArrive(pet, house) {
|
|
||||||
p.petArrivalDM(char.UserID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Morning pet event
|
// Morning pet event
|
||||||
petEvent := petMorningEvent(pet)
|
petEvent := petMorningEvent(pet)
|
||||||
|
|||||||
@@ -486,9 +486,14 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
_ = retireAllRegionRuns(exp)
|
_ = retireAllRegionRuns(exp)
|
||||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
if err := p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
|
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
|
||||||
zone.Display, exp.CurrentDay))
|
zone.Display, exp.CurrentDay)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Emergence seam: see maybeRollPetArrivalOnEmerge.
|
||||||
|
p.maybeRollPetArrivalOnEmerge(ctx.Sender)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// helper: ensure we don't shadow id.UserID import in test harness.
|
// helper: ensure we don't shadow id.UserID import in test harness.
|
||||||
|
|||||||
@@ -303,6 +303,14 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
|||||||
if err := p.SendDM(uid, body); err != nil {
|
if err := p.SendDM(uid, body); err != nil {
|
||||||
slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
|
slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
|
||||||
}
|
}
|
||||||
|
// Emergence seam: a briefing-time forced extraction (starvation /
|
||||||
|
// abyss collapse) surfaces the player alive — roll pet arrival.
|
||||||
|
// Combat/patrol deaths never reach deliverBriefing (the row is
|
||||||
|
// already abandoned), so an abandoned status here means a survived
|
||||||
|
// emergence; those death paths roll on respawn instead.
|
||||||
|
if e.Status == ExpeditionStatusAbandoned {
|
||||||
|
p.maybeRollPetArrivalOnEmerge(uid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing",
|
if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing",
|
||||||
fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), line); err != nil {
|
fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), line); err != nil {
|
||||||
|
|||||||
@@ -179,7 +179,13 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
|
|||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.",
|
b.WriteString(fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.",
|
||||||
(time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST")))
|
(time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST")))
|
||||||
return p.SendDM(ctx.Sender, b.String())
|
if err := p.SendDM(ctx.Sender, b.String()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Emergence seam: surfacing from a run is when an animal may have moved
|
||||||
|
// into the empty house.
|
||||||
|
p.maybeRollPetArrivalOnEmerge(ctx.Sender)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── !resume command ─────────────────────────────────────────────────────────
|
// ── !resume command ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ const fxHelpText = "**Forex Commands**\n\n" +
|
|||||||
"`!fx rate EUR/USD` — cross-pair rate from first currency's perspective\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 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 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 setalert <currency> <rate>` — alert when USD/currency reaches threshold\n" +
|
||||||
"`!fx alerts` — list active alerts in this room\n" +
|
"`!fx alerts` — list active alerts in this room\n" +
|
||||||
"`!fx delalert <currency> <rate>` — remove an alert\n" +
|
"`!fx delalert <currency> <rate>` — remove an alert\n" +
|
||||||
@@ -121,8 +122,12 @@ const fxHelpText = "**Forex Commands**\n\n" +
|
|||||||
// ── Command Handlers ────────────────────────────────────────────────────────
|
// ── Command Handlers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error {
|
func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error {
|
||||||
|
if msg := fxValidateAnalysisArgs(args); msg != "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
// Check for cross-pair syntax like EUR/USD or USD/JPY
|
// 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)
|
return p.cmdPairRateOrReport(ctx, pair, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,8 +161,10 @@ type fxPair struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fxParsePair detects "XXX/YYY" syntax in args. Both sides must be USD or a
|
// fxParsePair detects "XXX/YYY" syntax in args. Both sides must be USD or a
|
||||||
// tracked currency. Returns nil if no pair is found.
|
// tracked currency; when allowCrypto is set, supported crypto assets are
|
||||||
func fxParsePair(args []string) *fxPair {
|
// 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 {
|
if len(args) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -174,7 +181,11 @@ func fxParsePair(args []string) *fxPair {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
base, quote := parts[0], parts[1]
|
base, quote := parts[0], parts[1]
|
||||||
if !fxIsSupported(base) || !fxIsSupported(quote) {
|
ok := fxIsSupported
|
||||||
|
if allowCrypto {
|
||||||
|
ok = fxIsConvertible
|
||||||
|
}
|
||||||
|
if !ok(base) || !ok(quote) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if base == quote {
|
if base == quote {
|
||||||
@@ -215,46 +226,65 @@ func (p *ForexPlugin) cmdPairRateOrReport(ctx MessageContext, pair *fxPair, full
|
|||||||
return p.SendReply(ctx.RoomID, ctx.EventID, sig.FormatQuick())
|
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) {
|
func (p *ForexPlugin) fxLivePairRate(pair *fxPair) (float64, error) {
|
||||||
var currencies []string
|
// Batch the fiat sides into a single Frankfurter call.
|
||||||
if pair.Base != "USD" {
|
var fiat []string
|
||||||
currencies = append(currencies, pair.Base)
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
quotePer, err := p.fxPerUSD(pair.Quote, fiatRates)
|
||||||
switch {
|
if err != nil {
|
||||||
case pair.Base == "USD":
|
return 0, err
|
||||||
r, ok := rates[pair.Quote]
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("no rate data for %s", pair.Quote)
|
|
||||||
}
|
}
|
||||||
return r, nil
|
if basePer == 0 {
|
||||||
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 {
|
|
||||||
return 0, fmt.Errorf("missing rate data for cross-pair")
|
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 {
|
func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error {
|
||||||
if pair := fxParsePair(args); pair != nil {
|
if msg := fxValidateAnalysisArgs(args); msg != "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
if pair := fxParsePair(args, false); pair != nil {
|
||||||
return p.cmdPairRateOrReport(ctx, pair, true)
|
return p.cmdPairRateOrReport(ctx, pair, true)
|
||||||
}
|
}
|
||||||
currencies := fxParseCurrencies(args)
|
currencies := fxParseCurrencies(args)
|
||||||
@@ -381,6 +411,34 @@ func (p *ForexPlugin) checkAlerts(rates map[string]float64) {
|
|||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// fxValidateAnalysisArgs vets the currency tokens passed to the fiat-only
|
||||||
|
// rate/report path. It returns a player-facing error message if any token is
|
||||||
|
// unusable here, or "" when the args are fine (including empty args, which fall
|
||||||
|
// back to the default tracked set). Crypto tokens get a dedicated nudge since
|
||||||
|
// crypto only works on the conversion path; anything else is just invalid.
|
||||||
|
func fxValidateAnalysisArgs(args []string) string {
|
||||||
|
for _, a := range args {
|
||||||
|
// Split pair syntax (BTC/USD) into its sides so each is checked.
|
||||||
|
raw := strings.ToUpper(a)
|
||||||
|
toks := []string{raw}
|
||||||
|
for _, sep := range []string{"/", "|", "-"} {
|
||||||
|
if strings.Contains(raw, sep) {
|
||||||
|
toks = strings.SplitN(raw, sep, 2)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range toks {
|
||||||
|
if fxIsCrypto(t) {
|
||||||
|
return "Silly rabbit. Crypto is for kids! (In other words.. that's an invalid command — crypto only works for conversions like `!fx 100 USD to BTC`.)"
|
||||||
|
}
|
||||||
|
if !fxIsSupported(t) {
|
||||||
|
return fmt.Sprintf("Don't know the currency `%s`. Try `!fx help`.", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func fxParseCurrencies(args []string) []string {
|
func fxParseCurrencies(args []string) []string {
|
||||||
var out []string
|
var out []string
|
||||||
for _, a := range args {
|
for _, a := range args {
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ func fxIsTracked(cur string) bool {
|
|||||||
return false
|
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 {
|
func fxFormatRate(currency string, rate float64) string {
|
||||||
|
if fxIsCrypto(currency) {
|
||||||
|
return fxTrimZeros(fmt.Sprintf("%.8f", rate))
|
||||||
|
}
|
||||||
if currency == "JPY" {
|
if currency == "JPY" {
|
||||||
return fmt.Sprintf("%.2f", rate)
|
return fmt.Sprintf("%.2f", rate)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func fxParseConvert(args []string) (amount float64, base, quote string, err erro
|
|||||||
for _, tok := range tokens {
|
for _, tok := range tokens {
|
||||||
// Pair form: USD/EUR
|
// Pair form: USD/EUR
|
||||||
if strings.ContainsAny(tok, "/|-") {
|
if strings.ContainsAny(tok, "/|-") {
|
||||||
pair := fxParsePair([]string{tok})
|
pair := fxParsePair([]string{tok}, true)
|
||||||
if pair != nil {
|
if pair != nil {
|
||||||
if base != "" {
|
if base != "" {
|
||||||
return 0, "", "", fmt.Errorf("multiple currency pairs")
|
return 0, "", "", fmt.Errorf("multiple currency pairs")
|
||||||
@@ -76,9 +76,9 @@ func fxParseConvert(args []string) (amount float64, base, quote string, err erro
|
|||||||
amountSet = true
|
amountSet = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Currency code?
|
// Currency or crypto code?
|
||||||
up := strings.ToUpper(tok)
|
up := strings.ToUpper(tok)
|
||||||
if fxIsSupported(up) {
|
if fxIsConvertible(up) {
|
||||||
currencies = append(currencies, up)
|
currencies = append(currencies, up)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -127,12 +127,15 @@ func (p *ForexPlugin) cmdConvert(ctx MessageContext, args []string) error {
|
|||||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fxEmoji returns the flag emoji for a supported currency, falling back
|
// fxEmoji returns the flag/symbol emoji for a supported currency or crypto
|
||||||
// to USD's flag.
|
// asset, falling back to USD's flag.
|
||||||
func fxEmoji(cur string) string {
|
func fxEmoji(cur string) string {
|
||||||
if m, ok := fxMeta[cur]; ok && m.Emoji != "" {
|
if m, ok := fxMeta[cur]; ok && m.Emoji != "" {
|
||||||
return m.Emoji
|
return m.Emoji
|
||||||
}
|
}
|
||||||
|
if c, ok := fxCryptoMeta[strings.ToUpper(cur)]; ok && c.Emoji != "" {
|
||||||
|
return c.Emoji
|
||||||
|
}
|
||||||
if cur == "USD" {
|
if cur == "USD" {
|
||||||
return "🇺🇸"
|
return "🇺🇸"
|
||||||
}
|
}
|
||||||
@@ -140,9 +143,13 @@ func fxEmoji(cur string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fxFormatAmount formats a converted amount with thousands separators.
|
// fxFormatAmount formats a converted amount with thousands separators.
|
||||||
// JPY uses 0 decimal places (yen are not subdivided in practice for display),
|
// Crypto uses up to 8 decimals (trailing zeros trimmed) since amounts are
|
||||||
// other currencies use 2.
|
// often fractional; JPY uses 0 (yen are not subdivided in practice for
|
||||||
|
// display); other currencies use 2.
|
||||||
func fxFormatAmount(currency string, amount float64) string {
|
func fxFormatAmount(currency string, amount float64) string {
|
||||||
|
if fxIsCrypto(currency) {
|
||||||
|
return fxTrimZeros(fxAddCommas(amount, 8))
|
||||||
|
}
|
||||||
decimals := 2
|
decimals := 2
|
||||||
if currency == "JPY" {
|
if currency == "JPY" {
|
||||||
decimals = 0
|
decimals = 0
|
||||||
@@ -150,6 +157,16 @@ func fxFormatAmount(currency string, amount float64) string {
|
|||||||
return fxAddCommas(amount, decimals)
|
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.
|
// fxAddCommas formats a float with N decimal places and thousands separators.
|
||||||
func fxAddCommas(v float64, decimals int) string {
|
func fxAddCommas(v float64, decimals int) string {
|
||||||
s := strconv.FormatFloat(v, 'f', decimals, 64)
|
s := strconv.FormatFloat(v, 'f', decimals, 64)
|
||||||
|
|||||||
119
internal/plugin/forex_crypto.go
Normal file
119
internal/plugin/forex_crypto.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user