mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-16 00:20:28 +00:00
fix: project-wide review — trading safety, loldle drift guard, doc refresh
Code fixes: - trading/handlers + stats-handler: guard ctx.from?.id to prevent cross-user state corruption when channel posts or inline queries lack a sender - trading/prices + trading/symbols: encodeURIComponent on ticker before interpolating into TCBS API URLs - trading/stats-handler: parallelize per-stock price fetches with Promise.allSettled so N-stock portfolios don't stack serial latency - loldle/handlers: guard target champion lookup against champions.json refresh drift — start a fresh round or fall back to the stored id - wordle + loldle: explicitly initialize giveup:false in startFreshGame for stable state shape - wordle/lookup: fix stale JSDoc that claimed null return - biome: ignore auto-generated champions.json / champions-data.js / words-data.js - Apply formatter to src/index.js, loldle/handlers.js imports, and loldle/compare.test.js (previously red) Docs refresh: - README: 105+ tests -> 200+; wordle/loldle described as real modules - architecture: module tree updated, test count 105 -> 200, runtime ~500ms -> ~2s, stub list narrowed to misc only - codebase-summary: module table rewritten (wordle/loldle now Complete with real command lists and KV schema); test coverage table updated - loldle/README: full rewrite matching the current implementation (was describing the original stub) - New docs/development-roadmap.md tracking upcoming features (daily-mode for wordle + loldle, crypto/gold/forex trading, shared picker util, handler-level tests, coverage reporting, staging env) Tests: 200/200 passing. Lint: clean.
This commit is contained in:
@@ -11,7 +11,7 @@ Modules are added or removed via a single `MODULES` env var. Each module registe
|
||||
- **Dual storage backends.** Modules talk to a small `KVStore` interface (Cloudflare KV for simple state) or `SqlStore` interface (D1 for relational data, scans, leaderboards). Swappable with one-file changes.
|
||||
- **Scheduled jobs.** Modules declare cron-based cleanup, stats refresh, or maintenance tasks — registered via `wrangler.toml` and dispatched automatically.
|
||||
- **Zero admin surface.** No in-Worker `/admin/*` routes, no admin secret. `setWebhook` + `setMyCommands` run at deploy time from a local node script.
|
||||
- **Tested.** 105+ vitest unit tests cover registry, storage, dispatcher, cron validation, help renderer, validators, HTML escaping, and the trading module.
|
||||
- **Tested.** 200+ vitest unit tests cover registry, storage, dispatcher, cron validation, help renderer, validators, HTML escaping, and the trading / loldle / wordle modules.
|
||||
|
||||
## How a request flows
|
||||
|
||||
@@ -64,8 +64,8 @@ src/
|
||||
│ ├── trading/ # paper trading — VN stocks (D1 storage, daily cron)
|
||||
│ │ └── migrations/
|
||||
│ │ └── 0001_trades.sql
|
||||
│ ├── wordle/ # stub — proves plugin system
|
||||
│ ├── loldle/ # stub
|
||||
│ ├── wordle/ # 5-letter guessing game (KV storage, 14k-word dict)
|
||||
│ ├── loldle/ # classic-mode LoL champion guessing (KV storage)
|
||||
│ └── misc/ # stub (KV storage)
|
||||
└── util/
|
||||
└── escape-html.js
|
||||
|
||||
+9
-1
@@ -28,6 +28,14 @@
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["node_modules", ".wrangler", "dist", "coverage"]
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".wrangler",
|
||||
"dist",
|
||||
"coverage",
|
||||
"src/modules/loldle/champions.json",
|
||||
"src/modules/loldle/champions-data.js",
|
||||
"src/modules/wordle/words-data.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ src/
|
||||
│ ├── validate-command.js ── shared validators
|
||||
│ ├── util/ ── fully implemented: /info + /help
|
||||
│ ├── trading/ ── paper trading: VN stocks (dynamic symbol resolution)
|
||||
│ ├── wordle/ loldle/ ── stub modules proving the plugin system
|
||||
│ ├── wordle/ ── 5-letter guessing game (KV storage)
|
||||
│ ├── loldle/ ── classic-mode LoL champion guesser (KV storage)
|
||||
│ └── misc/ ── stub that exercises the DB (ping/mstats)
|
||||
└── util/
|
||||
└── escape-html.js
|
||||
@@ -359,7 +360,7 @@ A previous design sketched a `POST /admin/setup` route inside the Worker, gated
|
||||
|
||||
## 12. Testing philosophy
|
||||
|
||||
Pure-logic unit tests only. No `workerd` pool, no Telegram fixtures, no integration-level tooling. 105 tests run in ~500ms.
|
||||
Pure-logic unit tests only. No `workerd` pool, no Telegram fixtures, no integration-level tooling. 200 tests run in ~2s.
|
||||
|
||||
Test seams:
|
||||
|
||||
@@ -381,7 +382,7 @@ Each module maintains its own `README.md` with commands, data model, and impleme
|
||||
|
||||
## 14. Non-goals (for now)
|
||||
|
||||
- Real game logic in `wordle` / `loldle` / `misc` — they're stubs that exercise the framework. Real implementations can land later.
|
||||
- Real game logic in `misc` — it's a stub that exercises the DB. Real game modules (`wordle`, `loldle`, `trading`) are live; `misc` stays a framework sanity check.
|
||||
- A sandbox between modules. Same-origin trust model: all modules are first-party code.
|
||||
- Per-user rate limiting. Cloudflare's own rate limiting is available as a higher layer if needed.
|
||||
- `nodejs_compat` flag. Not needed — grammY + this codebase use only Web APIs.
|
||||
|
||||
@@ -20,10 +20,10 @@ Telegram bot on Cloudflare Workers with a plug-n-play module system. grammY hand
|
||||
| Module | Status | Commands | Storage | Crons | Description |
|
||||
|--------|--------|----------|---------|-------|-------------|
|
||||
| `util` | Complete | `/info`, `/help` | — | — | Bot info and command help renderer |
|
||||
| `trading` | Complete | `/trade_topup`, `/trade_buy`, `/trade_sell`, `/trade_convert`, `/trade_stats`, `/history` | D1 (trades) | Daily 5PM trim | Paper trading — VN stocks with dynamic symbol resolution. Crypto/gold/forex coming soon. |
|
||||
| `trading` | Complete | `/trade_topup`, `/trade_buy`, `/trade_sell`, `/trade_convert`, `/trade_stats`, `/history` | D1 (trades) + KV (portfolio, symbol cache) | Daily 5PM trim | Paper trading — VN stocks with dynamic symbol resolution. Crypto/gold/forex coming soon. |
|
||||
| `wordle` | Complete | `/wordle`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` | KV (game, stats) | — | Classic 5-letter word game. 14,855-word dict sourced from [dracos's gist](https://gist.github.com/dracos/dd0668f281e685bad51479e5acaadb93). |
|
||||
| `loldle` | Complete | `/loldle`, `/loldle_new`, `/loldle_giveup`, `/loldle_stats` | KV (game, stats) | — | Classic-mode LoL champion guesser. Champion data synced from `tiennm99/loldle-data`. |
|
||||
| `misc` | Stub | `/ping`, `/mstats`, `/fortytwo` | KV | — | Health check + DB demo |
|
||||
| `wordle` | Stub | `/wordle`, `/wstats`, `/konami` | — | — | Placeholder for word game |
|
||||
| `loldle` | Stub | `/loldle`, `/ggwp` | — | — | Placeholder for LoL game |
|
||||
|
||||
## Key Data Flows
|
||||
|
||||
@@ -70,12 +70,14 @@ Each module maintains its own `README.md` with commands, data model, and impleme
|
||||
|
||||
## Test Coverage
|
||||
|
||||
105+ tests across 11+ test files:
|
||||
200 tests across 21 test files (run via `npm test` — ~2s):
|
||||
|
||||
| Area | Tests | What's Covered |
|
||||
|------|-------|---------------|
|
||||
| DB layer (KV) | 19 | KV store, prefixing, JSON helpers, pagination |
|
||||
| DB layer (D1) | — | Fake D1 in-memory implementation (fake-d1.js) |
|
||||
| DB layer (D1) | — | Fake D1 in-memory implementation (fake-d1.js) backs trading tests |
|
||||
| Module framework | 33 | Registry, dispatcher, validators, help renderer, cron validation |
|
||||
| Utilities | 4 | HTML escaping |
|
||||
| Trading module | 49 | Dynamic symbol resolution, formatters, flat portfolio CRUD, command handlers, history/retention |
|
||||
| Trading module | 79 | Symbol resolution, formatters, flat portfolio CRUD, command handlers, history/retention |
|
||||
| Loldle module | 18 | Classic-mode champion comparison, champion lookup, daily picker |
|
||||
| Wordle module | 13 | Duplicate-letter two-pass comparison, guess validation |
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Development Roadmap
|
||||
|
||||
Forward-looking plan for upcoming features and milestones. This document
|
||||
tracks what's **next**, not what's done — for completed work, see git log and
|
||||
`plans/*/` directories.
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
- Each item lists the *future* value, not the past state.
|
||||
- Cross-module infra lands before per-module features that need it.
|
||||
- Keep items small enough to ship in one PR when possible.
|
||||
|
||||
## Modules
|
||||
|
||||
### Wordle
|
||||
|
||||
- **Daily mode.** Wire up `pickDaily(words, todayUtc())` so `/wordle` defaults
|
||||
to the shared puzzle of the day (one target per UTC date for every player).
|
||||
Give per-day stats a dedicated key so historical streaks aren't lost when
|
||||
random mode and daily mode are mixed. `pickDaily` already exists in
|
||||
`src/modules/wordle/daily.js` and has tests; the wiring in `handlers.js` is
|
||||
the missing piece.
|
||||
- Decide: should random-mode `/wordle_new` still work alongside daily, or
|
||||
should the flow be "one puzzle per day, no reroll"?
|
||||
|
||||
### Loldle
|
||||
|
||||
- **Daily mode.** Same shape as wordle's daily-mode plan — use `pickDaily`
|
||||
from `src/modules/loldle/daily.js` instead of `pickRandom`.
|
||||
|
||||
### Trading
|
||||
|
||||
- **Crypto support.** Add BTC / ETH price feed + asset category. Mirror the
|
||||
stock flow: dynamic symbol resolution, cache in KV, reuse `portfolio.assets`.
|
||||
- **Gold support.** SJC / PNJ spot price feed, treated as a single asset.
|
||||
- **Currency exchange.** `/trade_convert VND USD 1000000` — already scaffolded
|
||||
as "coming soon". Needs a forex buy/sell step that debits one currency and
|
||||
credits another.
|
||||
- **Leaderboard.** Cross-user ranking by realized P&L over a time window.
|
||||
D1 query on `trading_trades` plus portfolio snapshots.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- **Shared `picker` util.** Extract the duplicated `todayUtc` / `pickDaily` /
|
||||
`pickRandom` / djb2 hash from `loldle/daily.js` and `wordle/daily.js` into a
|
||||
single `src/util/picker.js`. Consolidate tests.
|
||||
- **Handler-level tests** for wordle + loldle mirroring
|
||||
`tests/modules/trading/handlers.test.js` (subject resolution, giveup flow,
|
||||
stats rendering, finished-round branch).
|
||||
- **Coverage reporting.** Add vitest `--coverage` config and wire a threshold
|
||||
gate into `npm test`.
|
||||
- **Staging environment.** Separate D1 database + KV namespace for a staging
|
||||
Worker, so migrations can be validated before prod.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
- Daily-mode scope: global puzzle (everyone gets the same word / champion) vs
|
||||
per-chat daily (each group has its own seed)?
|
||||
- Does daily mode count toward the existing `stats:<subject>` record, or live
|
||||
in a separate `daily-stats:<subject>:<date>` namespace?
|
||||
- Do we keep `/wordle_new` and `/loldle_new` in daily mode, or hide them until
|
||||
the next UTC rollover?
|
||||
+9
-7
@@ -63,13 +63,15 @@ export default {
|
||||
const response = await route(request, env, pathname);
|
||||
|
||||
// Structured request log for Workers Observability dashboard.
|
||||
console.log(JSON.stringify({
|
||||
msg: "req",
|
||||
method,
|
||||
path: pathname,
|
||||
status: response.status,
|
||||
ms: Date.now() - start,
|
||||
}));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
msg: "req",
|
||||
method,
|
||||
path: pathname,
|
||||
status: response.status,
|
||||
ms: Date.now() - start,
|
||||
}),
|
||||
);
|
||||
return response;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,20 +1,56 @@
|
||||
# Loldle Module
|
||||
|
||||
League of Legends guessing game — currently a stub proving the plugin system.
|
||||
Classic-mode League of Legends champion guessing game — ported from
|
||||
[`tiennm99/loldle`](https://github.com/tiennm99/loldle) (`lib/classic-mode.js`).
|
||||
Champion data is synced from
|
||||
[`tiennm99/loldle-data`](https://github.com/tiennm99/loldle-data) via a
|
||||
GitHub Actions workflow that regenerates `champions-data.js`.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Visibility | Description |
|
||||
|---------|-----------|-------------|
|
||||
| `/loldle` | public | Start a loldle game (stub) |
|
||||
| `/lstats` | protected | Show loldle stats (stub) |
|
||||
| `/ggwp` | private | Easter egg — "gg well played" |
|
||||
| `/loldle` | public | Show current board, start a game, or submit a champion guess when an argument is provided |
|
||||
| `/loldle_new` | public | Start a new round (auto-gives-up any in-progress one) |
|
||||
| `/loldle_giveup` | public | Reveal the current loldle answer |
|
||||
| `/loldle_stats` | public | Show your loldle stats (wins, streak) |
|
||||
|
||||
Submit a guess with `/loldle <champion>` — e.g. `/loldle Ahri`. Champion names
|
||||
are matched case/space/punctuation-insensitive with a unique-prefix fallback
|
||||
(see `lookup.js`).
|
||||
|
||||
## Architecture
|
||||
|
||||
- All commands return stub responses. Real game logic is not yet implemented.
|
||||
- No `init()` hook — this module does not use KV storage yet.
|
||||
- `compare.js` — pure attribute comparison across 7 classic-mode attributes
|
||||
(gender, genre, range, resource, region, lane, year). Returns `correct`,
|
||||
`partial`, or `wrong` per attribute, plus a `direction` hint for year.
|
||||
- `lookup.js` — normalizes user input and resolves it to a champion record.
|
||||
- `daily.js` — `pickRandom` / `pickDaily` (djb2-hashed date seed for future
|
||||
daily-mode use).
|
||||
- `render.js` — Telegram-friendly plain-text rendering (✅/🟨/❌ markers and
|
||||
⬆/⬇ year direction hints).
|
||||
- `state.js` — KV persistence with `MAX_GUESSES = 8`, per-subject stats with
|
||||
streak tracking.
|
||||
- `handlers.js` — wires subject resolution (user id in DMs, chat id in groups)
|
||||
to the pure functions above.
|
||||
- `champions-data.js` — auto-generated ES-module wrapper over `champions.json`
|
||||
(do not edit by hand; regenerate with `node scripts/build-loldle-data.js`).
|
||||
|
||||
Subject resolution: private chats track per-user games; groups track per-chat
|
||||
shared games (everyone plays the same round).
|
||||
|
||||
## Database
|
||||
|
||||
**No KV usage currently.** When game logic is implemented, it will use KV namespace prefix `loldle:`.
|
||||
KV namespace prefix: `loldle:`
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `game:<subject>` | JSON | Active round: target champion id, guesses, solved/giveup flags, startedAt |
|
||||
| `stats:<subject>` | JSON | Aggregate stats: played, wins, streak, bestStreak, lastResultAt |
|
||||
|
||||
Active rounds expire after 7 days if untouched.
|
||||
|
||||
## Credits
|
||||
|
||||
Champion data © Riot Games. The comparison attribute definitions and scoring
|
||||
rules are ported from the original `tiennm99/loldle` project.
|
||||
|
||||
@@ -18,7 +18,7 @@ import { compareChampions } from "./compare.js";
|
||||
import { pickRandom } from "./daily.js";
|
||||
import { findChampion } from "./lookup.js";
|
||||
import { renderBoard, renderGuess } from "./render.js";
|
||||
import { loadGame, loadStats, MAX_GUESSES, recordResult, saveGame } from "./state.js";
|
||||
import { MAX_GUESSES, loadGame, loadStats, recordResult, saveGame } from "./state.js";
|
||||
|
||||
/** @type {Array<Record<string, any>>} */
|
||||
const champions = championsData;
|
||||
@@ -59,7 +59,13 @@ async function getOrInitGame(db, subject) {
|
||||
|
||||
async function startFreshGame(db, subject) {
|
||||
const target = pickRandom(champions);
|
||||
const fresh = { target: target.id, guesses: [], solved: false, startedAt: Date.now() };
|
||||
const fresh = {
|
||||
target: target.id,
|
||||
guesses: [],
|
||||
solved: false,
|
||||
giveup: false,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
await saveGame(db, subject, fresh);
|
||||
return fresh;
|
||||
}
|
||||
@@ -94,6 +100,13 @@ export async function handleLoldle(ctx, db) {
|
||||
if (!guess) return ctx.reply(`Champion not found: "${arg}".`);
|
||||
|
||||
const target = champions.find((c) => c.id === game.target);
|
||||
// champions.json can be refreshed between rounds — an active target may disappear.
|
||||
if (!target) {
|
||||
await startFreshGame(db, subject);
|
||||
return ctx.reply(
|
||||
"Champion data was updated since this round started. Starting a fresh round — try again.",
|
||||
);
|
||||
}
|
||||
const results = compareChampions(guess, target);
|
||||
game.guesses.push({ champion: guess.name, results });
|
||||
const won = guess.id === target.id;
|
||||
@@ -129,6 +142,7 @@ export async function handleNew(ctx, db) {
|
||||
if (prior && !isFinished(prior)) {
|
||||
await recordResult(db, subject, false);
|
||||
const prev = champions.find((c) => c.id === prior.target);
|
||||
// prev may be undefined if champion data was refreshed — fall back to the id we stored.
|
||||
prelude = `🏳️ Previous round abandoned (auto-giveup). Answer was ${prev?.name ?? prior.target}.\n\n`;
|
||||
}
|
||||
|
||||
@@ -150,6 +164,9 @@ export async function handleGiveup(ctx, db) {
|
||||
await saveGame(db, subject, game);
|
||||
await recordResult(db, subject, false);
|
||||
const target = champions.find((c) => c.id === game.target);
|
||||
if (!target) {
|
||||
return ctx.reply(`🏳️ Answer was ${game.target}. /loldle_new for another.`);
|
||||
}
|
||||
return ctx.reply(`🏳️ Answer was ${target.name} — ${target.title}. /loldle_new for another.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,20 @@ function usageReply(ctx, usage) {
|
||||
return ctx.reply(`Usage: ${usage}`);
|
||||
}
|
||||
|
||||
// channel posts / inline queries may lack ctx.from — per-user state would collide under key "user:undefined".
|
||||
function requireUid(ctx) {
|
||||
const id = uid(ctx);
|
||||
if (id == null) {
|
||||
ctx.reply("Cannot identify user — trading only works in private or group chats with a sender.");
|
||||
return null;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** /trade_topup <amount> — add VND to account */
|
||||
export async function handleTopup(ctx, db) {
|
||||
const id = requireUid(ctx);
|
||||
if (id == null) return;
|
||||
const args = parseArgs(ctx);
|
||||
if (args.length < 1)
|
||||
return usageReply(ctx, "/trade_topup <amount>\nExample: /trade_topup 5000000");
|
||||
@@ -37,10 +49,10 @@ export async function handleTopup(ctx, db) {
|
||||
if (!Number.isFinite(amount) || amount <= 0)
|
||||
return ctx.reply("Amount must be a positive number.");
|
||||
|
||||
const p = await getPortfolio(db, uid(ctx));
|
||||
const p = await getPortfolio(db, id);
|
||||
addCurrency(p, "VND", amount);
|
||||
p.meta.invested += amount;
|
||||
await savePortfolio(db, uid(ctx), p);
|
||||
await savePortfolio(db, id, p);
|
||||
await ctx.reply(`Topped up ${formatVND(amount)}.\nBalance: ${formatVND(p.currency.VND)}`);
|
||||
}
|
||||
|
||||
@@ -52,6 +64,8 @@ export async function handleTopup(ctx, db) {
|
||||
* @param {((trade: {symbol:string, side:"buy"|"sell", qty:number, priceVnd:number}) => Promise<void>) | null} [onTrade]
|
||||
*/
|
||||
export async function handleBuy(ctx, db, onTrade = null) {
|
||||
const id = requireUid(ctx);
|
||||
if (id == null) return;
|
||||
const args = parseArgs(ctx);
|
||||
if (args.length < 2)
|
||||
return usageReply(ctx, "/trade_buy <qty> <TICKER>\nExample: /trade_buy 100 TCB");
|
||||
@@ -73,7 +87,7 @@ export async function handleBuy(ctx, db, onTrade = null) {
|
||||
if (price == null) return ctx.reply(`No price available for ${info.symbol}.`);
|
||||
|
||||
const cost = amount * price;
|
||||
const p = await getPortfolio(db, uid(ctx));
|
||||
const p = await getPortfolio(db, id);
|
||||
const result = deductCurrency(p, "VND", cost);
|
||||
if (!result.ok) {
|
||||
return ctx.reply(
|
||||
@@ -81,7 +95,7 @@ export async function handleBuy(ctx, db, onTrade = null) {
|
||||
);
|
||||
}
|
||||
addAsset(p, info.symbol, amount);
|
||||
await savePortfolio(db, uid(ctx), p);
|
||||
await savePortfolio(db, id, p);
|
||||
if (onTrade) await onTrade({ symbol: info.symbol, side: "buy", qty: amount, priceVnd: price });
|
||||
await ctx.reply(
|
||||
`Bought ${formatStock(amount)} ${info.symbol} @ ${formatVND(price)}\nCost: ${formatVND(cost)}`,
|
||||
@@ -96,6 +110,8 @@ export async function handleBuy(ctx, db, onTrade = null) {
|
||||
* @param {((trade: {symbol:string, side:"buy"|"sell", qty:number, priceVnd:number}) => Promise<void>) | null} [onTrade]
|
||||
*/
|
||||
export async function handleSell(ctx, db, onTrade = null) {
|
||||
const id = requireUid(ctx);
|
||||
if (id == null) return;
|
||||
const args = parseArgs(ctx);
|
||||
if (args.length < 2)
|
||||
return usageReply(ctx, "/trade_sell <qty> <TICKER>\nExample: /trade_sell 100 TCB");
|
||||
@@ -105,7 +121,7 @@ export async function handleSell(ctx, db, onTrade = null) {
|
||||
if (!Number.isInteger(amount)) return ctx.reply("Stock quantities must be whole numbers.");
|
||||
|
||||
const symbol = args[1].toUpperCase();
|
||||
const p = await getPortfolio(db, uid(ctx));
|
||||
const p = await getPortfolio(db, id);
|
||||
const result = deductAsset(p, symbol, amount);
|
||||
if (!result.ok) return ctx.reply(`Insufficient ${symbol}. You have: ${formatStock(result.held)}`);
|
||||
|
||||
@@ -119,7 +135,7 @@ export async function handleSell(ctx, db, onTrade = null) {
|
||||
|
||||
const revenue = amount * price;
|
||||
addCurrency(p, "VND", revenue);
|
||||
await savePortfolio(db, uid(ctx), p);
|
||||
await savePortfolio(db, id, p);
|
||||
if (onTrade) await onTrade({ symbol, side: "sell", qty: amount, priceVnd: price });
|
||||
await ctx.reply(
|
||||
`Sold ${formatStock(amount)} ${symbol} @ ${formatVND(price)}\nRevenue: ${formatVND(revenue)}`,
|
||||
|
||||
@@ -15,7 +15,7 @@ const STALE_LIMIT_MS = 300_000;
|
||||
*/
|
||||
export async function fetchStockPrice(ticker) {
|
||||
const to = Math.floor(Date.now() / 1000);
|
||||
const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${ticker}&type=stock&resolution=D&countBack=1&to=${to}`;
|
||||
const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${encodeURIComponent(ticker)}&type=stock&resolution=D&countBack=1&to=${to}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* @file /trade_stats handler — portfolio summary with P&L breakdown.
|
||||
* Fetches live stock prices for each held asset.
|
||||
*
|
||||
* Price fetches are issued in parallel with Promise.allSettled so a portfolio
|
||||
* holding N stocks only waits for the slowest fetch, not the sum. Without this
|
||||
* 10+ symbols would serially stack TCBS latency and can blow Cloudflare's
|
||||
* subrequest budget.
|
||||
*/
|
||||
|
||||
import { formatPnL, formatStock, formatVND } from "./format.js";
|
||||
@@ -9,30 +13,29 @@ import { getStockPrice } from "./prices.js";
|
||||
|
||||
/** /trade_stats — show full portfolio valued in VND with P&L */
|
||||
export async function handleStats(ctx, db) {
|
||||
const p = await getPortfolio(db, ctx.from?.id);
|
||||
const id = ctx.from?.id;
|
||||
if (id == null) {
|
||||
return ctx.reply("Cannot identify user — /trade_stats needs a sender.");
|
||||
}
|
||||
const p = await getPortfolio(db, id);
|
||||
|
||||
const lines = ["📊 Portfolio Summary\n"];
|
||||
let totalValue = 0;
|
||||
|
||||
// VND balance
|
||||
const vnd = p.currency.VND || 0;
|
||||
if (vnd > 0) {
|
||||
totalValue += vnd;
|
||||
lines.push(`VND: ${formatVND(vnd)}`);
|
||||
}
|
||||
|
||||
// stock assets
|
||||
const assetEntries = Object.entries(p.assets);
|
||||
if (assetEntries.length > 0) {
|
||||
const held = Object.entries(p.assets).filter(([, qty]) => qty !== 0);
|
||||
if (held.length > 0) {
|
||||
lines.push("\nStocks:");
|
||||
for (const [sym, qty] of assetEntries) {
|
||||
if (qty === 0) continue;
|
||||
let price;
|
||||
try {
|
||||
price = await getStockPrice(sym);
|
||||
} catch {
|
||||
price = null;
|
||||
}
|
||||
const prices = await Promise.allSettled(held.map(([sym]) => getStockPrice(sym)));
|
||||
for (let i = 0; i < held.length; i++) {
|
||||
const [sym, qty] = held[i];
|
||||
const settled = prices[i];
|
||||
const price = settled.status === "fulfilled" ? settled.value : null;
|
||||
if (price == null) {
|
||||
lines.push(` ${sym} x${formatStock(qty)} (no price)`);
|
||||
continue;
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function resolveSymbol(db, ticker) {
|
||||
|
||||
// query TCBS to verify this is a real VN stock
|
||||
const to = Math.floor(Date.now() / 1000);
|
||||
const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${symbol}&type=stock&resolution=D&countBack=1&to=${to}`;
|
||||
const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${encodeURIComponent(symbol)}&type=stock&resolution=D&countBack=1&to=${to}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
|
||||
@@ -53,7 +53,13 @@ async function getOrInitGame(db, subject) {
|
||||
|
||||
async function startFreshGame(db, subject) {
|
||||
const target = pickRandom(words);
|
||||
const fresh = { target, guesses: [], solved: false, startedAt: Date.now() };
|
||||
const fresh = {
|
||||
target,
|
||||
guesses: [],
|
||||
solved: false,
|
||||
giveup: false,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
await saveGame(db, subject, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,11 @@ export function makeWordSet(words) {
|
||||
|
||||
/**
|
||||
* Validate a guess against the dictionary.
|
||||
* null — input empty, wrong length, or not in dictionary (for "not a word")
|
||||
* Returns a discriminated result so the caller can tell *why* validation failed.
|
||||
*
|
||||
* Returns a discriminated result so the caller can tell *why* validation failed:
|
||||
* - "empty" — input was blank or stripped to nothing.
|
||||
* - "length" — normalized word is not WORD_LENGTH letters.
|
||||
* - "unknown" — right length, but not in the dictionary.
|
||||
*
|
||||
* @param {Set<string>} wordSet
|
||||
* @param {string} input
|
||||
|
||||
@@ -59,10 +59,7 @@ describe("compareChampions", () => {
|
||||
});
|
||||
|
||||
it("multi-value identical sets are correct even if order/case differ", () => {
|
||||
const r = compareChampions(
|
||||
{ ...akali, genre: "assassin" },
|
||||
{ ...akali, genre: "Assassin" },
|
||||
);
|
||||
const r = compareChampions({ ...akali, genre: "assassin" }, { ...akali, genre: "Assassin" });
|
||||
expect(byKey(r, "genre").result).toBe("correct");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user