From d2697221d14bbf87ccba0913b12a58ef2f44f6f8 Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 15 Sep 2026 07:54:13 +0800 Subject: [PATCH] feat: add realtime rate command --- README.md | 1 + internal/module/rate/module.go | 41 ++++++++++++++++ internal/module/rate/query.go | 28 +++++++++++ scripts/rate_query.py | 86 ++++++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 internal/module/rate/module.go create mode 100644 internal/module/rate/query.go create mode 100644 scripts/rate_query.py diff --git a/README.md b/README.md index 237c287..9e9a08c 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ qqbot: | `/sub del ` | 删除订阅 | | `/sub cancel` | 取消进行中的新增流程 | | `/ops modules` | 查看模块启用状态 | +| `/rate 138sgd to cny` | 实时货币转换(Google / Visa / Mastercard) | ## 📜 Runbook(自动化工单) diff --git a/internal/module/rate/module.go b/internal/module/rate/module.go new file mode 100644 index 0000000..a4676ca --- /dev/null +++ b/internal/module/rate/module.go @@ -0,0 +1,41 @@ +package rate + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "ops-assistant/internal/core/command" +) + +type Module struct{} + +func New() *Module { return &Module{} } + +var re = regexp.MustCompile(`(?i)^/rate\s+([0-9]+(?:\.[0-9]+)?)\s*([a-z]{3})\s*(?:(?:to)\s*|->\s*|/\s*)([a-z]{3})\s*$`) + +func (m *Module) Handle(_ int64, cmd *command.ParsedCommand) (string, error) { + text := strings.TrimSpace(cmd.Raw) + if strings.EqualFold(text, "/rate") || strings.HasPrefix(strings.ToLower(text), "/rate help") { + return help(), nil + } + hit := re.FindStringSubmatch(text) + if hit == nil { + return help(), nil + } + amount, err := strconv.ParseFloat(hit[1], 64) + if err != nil || amount <= 0 { + return "❌ 金额无效", nil + } + base := strings.ToUpper(hit[2]) + quote := strings.ToUpper(hit[3]) + if base == quote { + return fmt.Sprintf("💱 %s → %s\n\n%s %s = %s %s", base, quote, hit[1], base, hit[1], quote), nil + } + return query(amount, base, quote) +} + +func help() string { + return "💱 汇率转换\n\n用法:/rate 138sgd to cny\n\n也支持:\n/rate 100 usd to cny\n/rate 50 eur -> sgd\n/rate 1000 php/cny\n\n数据源:Google Finance、Visa、Mastercard。Visa/万事达为官方结算汇率;Google 为市场参考价。" +} diff --git a/internal/module/rate/query.go b/internal/module/rate/query.go new file mode 100644 index 0000000..3bcb297 --- /dev/null +++ b/internal/module/rate/query.go @@ -0,0 +1,28 @@ +package rate + +import ( + "context" + "fmt" + "os/exec" + "strconv" + "strings" + "time" +) + +func query(amount float64, base, quote string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 190*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "python3", "scripts/rate_query.py", strconv.FormatFloat(amount, 'f', -1, 64), base, quote) + out, err := cmd.Output() + if ctx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("汇率服务超时,请稍后重试") + } + if err != nil { + return "", fmt.Errorf("汇率查询失败") + } + text := strings.TrimSpace(string(out)) + if text == "" { + return "", fmt.Errorf("未获取到汇率") + } + return text, nil +} diff --git a/scripts/rate_query.py b/scripts/rate_query.py new file mode 100644 index 0000000..256d941 --- /dev/null +++ b/scripts/rate_query.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +import json +import os +import re +import subprocess +import sys +from datetime import datetime, timezone +import requests + +CACHE = "/root/.openclaw/workspace/fx_realtime.json" +FETCH = "/root/.openclaw/workspace/web_tasks/fx_fetch_real.js" +PAIRS = {"USD/CNY", "EUR/CNY", "SGD/CNY", "GBP/CNY", "USD/EUR", "GBP/EUR"} +PAT = re.compile(r'\[\[(20\d{2}),(\d{1,2}),(\d{1,2}),(\d{1,2}),(\d{1,2}),null,null,\[\]\],\[([0-9]+\.[0-9]+),') + +def google_rate(base, quote): + s = requests.Session() + s.headers.update({"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0 Safari/537.36", "Accept-Language":"en-US,en;q=0.9"}) + s.cookies.set("SOCS", "CAESHAgBEhJnd3NfMjAyMzA4MTAtMF9SQzIaAmVuIAEaBgiAo_CmBg", domain=".google.com") + u = f"https://www.google.com/finance/quote/{base}-{quote}" + r = s.get(u, timeout=15, allow_redirects=True) + hits = PAT.findall(r.text) + if not hits: + return None, None + y, mo, d, h, mi, price = max(hits, key=lambda x: tuple(int(v) for v in x[:5])) + return float(price), f"{y}-{int(mo):02d}-{int(d):02d} {int(h):02d}:{int(mi):02d} UTC" + +def fresh_cache(): + try: + with open(CACHE, encoding="utf-8") as f: raw = json.load(f) + gen = datetime.fromisoformat(raw.get("generatedAt", "").replace("Z", "+00:00")) + return raw if (datetime.now(timezone.utc) - gen).total_seconds() <= 600 else None + except Exception: + return None + +def ensure_real(): + raw = fresh_cache() + if raw: + return raw + try: + subprocess.run(["node", FETCH], cwd=os.path.dirname(FETCH), timeout=150, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + except Exception: + return None + return fresh_cache() + +def network_rates(raw, base, quote): + key = f"{base}/{quote}" + rev = f"{quote}/{base}" + for name, data in (("Visa", (raw.get("visa") or {}).get(key)), ("Mastercard", (raw.get("mastercard") or {}).get(key))): + if data and data.get("ok"): + try: + orig = data["data"]["originalValues"] + fx = float(orig["fxRateVisa"]) + f, t = orig.get("fromCurrency"), orig.get("toCurrency") + if f == base and t == quote: yield name, fx, data.get("queryDate") or data.get("data", {}).get("disclaimerDate", "") + elif f == quote and t == base: yield name, 1/fx, data.get("queryDate", "") + except Exception: pass + data = (raw.get("mastercard") or {}).get(key) + if data and data.get("data") and data["data"].get("data"): + try: yield "Mastercard", float(data["data"]["data"]["conversionRate"]), data["data"]["data"].get("fxDate", "") + except Exception: pass + data = (raw.get("mastercard") or {}).get(rev) + if data and data.get("data") and data["data"].get("data"): + try: yield "Mastercard", 1/float(data["data"]["data"]["conversionRate"]), data["data"]["data"].get("fxDate", "") + except Exception: pass + +def main(): + if len(sys.argv) != 4: raise SystemExit("usage: rate_query.py AMOUNT BASE QUOTE") + amount = float(sys.argv[1]); base = sys.argv[2].upper(); quote = sys.argv[3].upper() + g, gt = google_rate(base, quote) + raw = ensure_real() if f"{base}/{quote}" in PAIRS or f"{quote}/{base}" in PAIRS else None + lines = [f"💱 {amount:g} {base} → {quote}", ""] + if g is not None: + lines += [f"Google 1 {base} = {g:.6f} {quote}", f" {amount:g} {base} = {amount*g:.2f} {quote}"] + else: lines.append("Google 暂时无法获取") + seen = set() + if raw: + for name, rate, date in network_rates(raw, base, quote): + if name in seen: continue + seen.add(name) + lines += [f"{name:<12}1 {base} = {rate:.6f} {quote}", f" {amount:g} {base} = {amount*rate:.2f} {quote} ({date})"] + if not seen: lines.append("Visa/MC 暂无最新官方结算数据") + lines += ["", "Google 为参考市场价;Visa/万事达为官方计算器结算汇率。"] + print("\n".join(lines)) + +if __name__ == "__main__": + main()