chore: drop CF→AWS migration tooling and stale JS-port references

The CF→AWS data migration (closed 2026-05-16) is long done and the
tooling isn't wired into any production path. Remove the one-shot binary,
its support package, and the migration runbook.

In live code, replace 'JS-parity' / 'same shape as JS' / 'cross-runtime
KV migration' comments with the real, stable reason for each behavior
(wire-format invariant, null-vs-zero distinction, CloudWatch alarm field
name, etc.). 24 files touched across lolschedule, loldle, wordle, twentyq,
trading, misc, util, server, metrics, ai, keylock.

- delete cmd/migrate_cf_data/
- delete internal/migration/
- delete docs/cf-to-aws-migration-runbook.md
This commit is contained in:
2026-05-25 09:39:17 +07:00
parent c901f3ad40
commit ec77a24db7
47 changed files with 142 additions and 1491 deletions
-115
View File
@@ -1,115 +0,0 @@
// One-shot rewrite of the table's `value` attribute from Binary (legacy
// shape) to String (current shape). Operator-elective; needed once after the
// runtime swap from MemberB to MemberS in internal/storage/dynamodb_kv.go.
package main
import (
"context"
"flag"
"fmt"
"os"
"strconv"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)
// signalContext is defined in main.go and gives every subcommand a SIGINT /
// SIGTERM-cancellable context — Ctrl-C mid-scan now propagates as a clean
// context error instead of leaving a half-converted table.
//
// (signature mirrors signal.NotifyContext for documentation purposes; no
// re-declaration here, just a pointer for future readers.)
func runConvertValueToString(args []string) error {
fs := flag.NewFlagSet("convert-value-to-string", flag.ExitOnError)
table := fs.String("table", "", "target DynamoDB table (required)")
dryRun := fs.Bool("dry-run", false, "log actions but do not write")
if err := fs.Parse(args); err != nil {
return err
}
if *table == "" {
return fmt.Errorf("--table is required")
}
ctx, cancel := signalContext()
defer cancel()
cfg, err := awsconfig.LoadDefaultConfig(ctx)
if err != nil {
return fmt.Errorf("aws config: %w", err)
}
client := dynamodb.NewFromConfig(cfg)
converted, alreadyString, skipped, failed := 0, 0, 0, 0
pager := dynamodb.NewScanPaginator(client, &dynamodb.ScanInput{TableName: aws.String(*table)})
for pager.HasMorePages() {
page, err := pager.NextPage(ctx)
if err != nil {
return fmt.Errorf("scan: %w", err)
}
for _, item := range page.Items {
pk, sk, ok := itemPKSK(item)
if !ok {
skipped++
continue
}
valAttr, ok := item["value"]
if !ok {
skipped++
continue
}
binAttr, isBinary := valAttr.(*types.AttributeValueMemberB)
if !isBinary {
alreadyString++
continue
}
if *dryRun {
fmt.Printf(" DRY-RUN would convert pk=%s sk=%s len=%d\n", pk, sk, len(binAttr.Value))
converted++
continue
}
if err := putAsString(ctx, client, *table, pk, sk, binAttr.Value); err != nil {
fmt.Fprintf(os.Stderr, " put %s/%s: %v\n", pk, sk, err)
failed++
continue
}
converted++
}
}
fmt.Printf("\nconvert-value-to-string report\n")
fmt.Printf(" converted (B → S): %d\n", converted)
fmt.Printf(" already String: %d\n", alreadyString)
fmt.Printf(" skipped (no pk/sk/value): %d\n", skipped)
fmt.Printf(" failed: %d\n", failed)
return nil
}
func itemPKSK(item map[string]types.AttributeValue) (string, string, bool) {
pkAttr, ok := item["pk"].(*types.AttributeValueMemberS)
if !ok {
return "", "", false
}
skAttr, ok := item["sk"].(*types.AttributeValueMemberS)
if !ok {
return "", "", false
}
return pkAttr.Value, skAttr.Value, true
}
func putAsString(ctx context.Context, client *dynamodb.Client, table, pk, sk string, val []byte) error {
_, err := client.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(table),
Item: map[string]types.AttributeValue{
"pk": &types.AttributeValueMemberS{Value: pk},
"sk": &types.AttributeValueMemberS{Value: sk},
"value": &types.AttributeValueMemberS{Value: string(val)},
"updatedAt": &types.AttributeValueMemberN{Value: strconv.FormatInt(time.Now().UTC().UnixNano(), 10)},
},
})
return err
}
-243
View File
@@ -1,243 +0,0 @@
// Command migrate_cf_data moves durable data from the legacy Cloudflare
// KV/D1 stack into the live AWS DynamoDB table. Operator-invoked only.
//
// Subcommands:
//
// inventory Read CF KV keys, apply the Phase 01 policy, print
// a classification report. No writes anywhere.
//
// kv-import Copy migrate-action KV keys into DynamoDB.
// Idempotent by default (attribute_not_exists guard).
// Flags: --table, --dry-run, --overwrite.
//
// trading-audit-dump Stream D1 `trading_trades` rows to a JSONL file.
// Audit-only; not an import input.
// Flags: --out (required).
//
// convert-value-to-string
// One-shot rewrite of the table's `value` attribute
// from Binary (legacy shape) to String (current
// shape). Idempotent — items already stored as
// String are skipped.
// Flags: --table, --dry-run.
//
// Required env:
//
// CLOUDFLARE_API_TOKEN — read-scoped token for KV + D1
// CLOUDFLARE_ACCOUNT_ID — production CF account
// CF_KV_NAMESPACE_ID — production KV namespace
// CF_D1_DATABASE_ID — production D1 database (only needed for
// trading-audit-dump)
// AWS_REGION (or standard AWS SDK env) — only needed for kv-import
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/tiennm99/miti99bot/internal/migration"
)
// signalContext returns a context cancelled on Ctrl-C / SIGTERM. Used by every
// subcommand so a mid-scan abort leaves a clean error trail instead of a
// half-converted table the operator has to reason about.
func signalContext() (context.Context, context.CancelFunc) {
return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
}
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
sub := os.Args[1]
args := os.Args[2:]
var err error
switch sub {
case "inventory":
err = runInventory(args)
case "kv-import":
err = runKVImport(args)
case "trading-audit-dump":
err = runTradingAuditDump(args)
case "convert-value-to-string":
err = runConvertValueToString(args)
case "-h", "--help", "help":
usage()
return
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: migrate_cf_data <inventory|kv-import|trading-audit-dump|convert-value-to-string> [flags]")
}
func runInventory(args []string) error {
fs := flag.NewFlagSet("inventory", flag.ExitOnError)
if err := fs.Parse(args); err != nil {
return err
}
kv, err := newKVClient()
if err != nil {
return err
}
ctx, cancel := signalContext()
defer cancel()
keys, err := kv.ListKeys(ctx)
if err != nil {
return fmt.Errorf("list keys: %w", err)
}
migrate := map[string]int{}
skip := map[string]int{}
for _, k := range keys {
d := migration.Classify(k)
if d.Action == migration.ActionMigrate {
migrate[migration.PrefixOf(k)]++
} else {
skip[d.Reason]++
}
}
fmt.Printf("Cloudflare KV namespace contains %d keys.\n\n", len(keys))
fmt.Println("Migrate-action keys by prefix:")
for p, n := range migrate {
fmt.Printf(" %-30s %d\n", p, n)
}
fmt.Println("\nSkip-action keys by reason:")
for r, n := range skip {
fmt.Printf(" %-30s %d\n", r, n)
}
return nil
}
func runKVImport(args []string) error {
fs := flag.NewFlagSet("kv-import", flag.ExitOnError)
table := fs.String("table", "", "target DynamoDB table (required)")
dryRun := fs.Bool("dry-run", false, "log actions but do not write")
overwrite := fs.Bool("overwrite", false, "drop attribute_not_exists guard")
if err := fs.Parse(args); err != nil {
return err
}
if *table == "" {
return fmt.Errorf("--table is required")
}
kv, err := newKVClient()
if err != nil {
return err
}
ctx, cancel := signalContext()
defer cancel()
var writer *migration.DynamoDBWriter
if !*dryRun {
cfg, err := awsconfig.LoadDefaultConfig(ctx)
if err != nil {
return fmt.Errorf("aws config: %w", err)
}
writer = migration.NewDynamoDBWriter(dynamodb.NewFromConfig(cfg), *table, *overwrite)
}
keys, err := kv.ListKeys(ctx)
if err != nil {
return fmt.Errorf("list keys: %w", err)
}
report := migration.NewReport()
for _, k := range keys {
d := migration.Classify(k)
if d.Action != migration.ActionMigrate {
report.AddSkippedPolicy(d.Reason)
continue
}
val, err := kv.GetValue(ctx, k)
if err != nil {
fmt.Fprintf(os.Stderr, " get %s: %v\n", k, err)
report.AddFailed(migration.PrefixOf(k))
continue
}
if *dryRun {
fmt.Printf(" DRY-RUN would write pk=%s sk=%s len=%d\n", d.PK, d.SK, len(val))
report.AddImported(migration.PrefixOf(k))
continue
}
switch err := writer.Put(ctx, d.PK, d.SK, val); err {
case nil:
report.AddImported(migration.PrefixOf(k))
case migration.ErrItemExists:
report.AddSkippedExisting(migration.PrefixOf(k))
default:
fmt.Fprintf(os.Stderr, " put %s/%s: %v\n", d.PK, d.SK, err)
report.AddFailed(migration.PrefixOf(k))
}
}
report.Format(os.Stdout)
return nil
}
func runTradingAuditDump(args []string) error {
fs := flag.NewFlagSet("trading-audit-dump", flag.ExitOnError)
out := fs.String("out", "", "output JSONL file path (required)")
if err := fs.Parse(args); err != nil {
return err
}
if *out == "" {
return fmt.Errorf("--out is required")
}
d1, err := newD1Client()
if err != nil {
return err
}
ctx, cancel := signalContext()
defer cancel()
rows, err := d1.Query(ctx,
"SELECT id, user_id, symbol, side, qty, price_vnd, ts FROM trading_trades ORDER BY id", nil)
if err != nil {
return fmt.Errorf("d1 query: %w", err)
}
f, err := os.Create(*out)
if err != nil {
return err
}
// Surface Close's error: an audit JSONL that fails to flush is silently
// truncated otherwise. Encode succeeding doesn't guarantee fsync — if
// the final Close hits ENOSPC the operator must see it (this file is
// evidence; a partial dump is worse than no dump).
enc := json.NewEncoder(f)
for _, r := range rows {
if err := enc.Encode(r); err != nil {
_ = f.Close()
return err
}
}
if err := f.Close(); err != nil {
return fmt.Errorf("close audit dump: %w", err)
}
fmt.Printf("Wrote %d rows to %s\n", len(rows), *out)
return nil
}
func newKVClient() (*migration.CloudflareKVClient, error) {
token, account, ns := os.Getenv("CLOUDFLARE_API_TOKEN"), os.Getenv("CLOUDFLARE_ACCOUNT_ID"), os.Getenv("CF_KV_NAMESPACE_ID")
if token == "" || account == "" || ns == "" {
return nil, fmt.Errorf("set CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CF_KV_NAMESPACE_ID")
}
return migration.NewCloudflareKVClient(account, ns, token), nil
}
func newD1Client() (*migration.CloudflareD1Client, error) {
token, account, db := os.Getenv("CLOUDFLARE_API_TOKEN"), os.Getenv("CLOUDFLARE_ACCOUNT_ID"), os.Getenv("CF_D1_DATABASE_ID")
if token == "" || account == "" || db == "" {
return nil, fmt.Errorf("set CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CF_D1_DATABASE_ID")
}
return migration.NewCloudflareD1Client(account, db, token), nil
}
-198
View File
@@ -1,198 +0,0 @@
# Runbook: Cloudflare data → AWS migration
This doc is the operator runbook for moving durable Cloudflare KV / D1 data into the live AWS DynamoDB shape used by `miti99bot`.
> Scope here is only durable state that the current Go runtime still reads. Do not bulk-copy legacy Cloudflare data.
## Live AWS target shape
DynamoDB runtime contract:
- partition key: `pk = moduleName`
- sort key: `sk = caller key`
- payload attr: `value`
Examples:
- `wordle` + `stats:<subject>`
- `loldle` + `config:<subject>`
- `lolschedule` + `subscribers`
- `misc` + `last_ping`
- `trading` + `user:<telegram_id>`
## Migration matrix (locked from live code AND live CF inventory)
Live CF inventory taken from production via wrangler on 2026-05-16:
- D1 `miti99bot-db`: only `trading_trades` exists (plus internal `_cf_KV`, `_migrations`, `sqlite_sequence`). No `users` or `holdings` tables. 11 rows, 1 distinct user.
- KV namespace `f7f190fcb2fa42eb84a05542911334b0`: 21 keys total (see breakdown below).
| Source dataset / prefix | Live keys | Current consumer | Action | AWS target | Notes |
|---|---|---|---|---|---|
| `wordle:stats:*` | 1 | `internal/modules/wordle/state.go` | migrate | `pk=wordle`, `sk=stats:<subject>` | durable player stats |
| `wordle:game:*` | 0 | `internal/modules/wordle/state.go` | skip | none | ephemeral; not present |
| `loldle:stats:*` | 4 | `internal/modules/loldle/state.go` | migrate | `pk=loldle`, `sk=stats:<subject>` | durable player stats |
| `loldle:config:*` | 1 | `internal/modules/loldle/state.go` | migrate | `pk=loldle`, `sk=config:<subject>` | durable per-subject config |
| `loldle:game:*` | 0 | `internal/modules/loldle/state.go` | skip | none | ephemeral; not present |
| `twentyq:stats:*` | 1 | `internal/modules/twentyq/state.go` | migrate | `pk=twentyq`, `sk=stats:<subject>` | durable player stats |
| `twentyq:game:*` | 0 | `internal/modules/twentyq/state.go` | skip | none | ephemeral; not present |
| `lolschedule:subscribers` | 1 | `internal/modules/lolschedule/subscribers.go` | migrate | `pk=lolschedule`, `sk=subscribers` | durable subscriber list |
| `lolschedule:matches:*` | 0 | `internal/modules/lolschedule/api_client.go` | skip | none | cache only; not present |
| `misc:last_ping` | 0 | `internal/modules/misc/misc.go` | skip | none | JS Worker never wrote it (KV 404). `/mstats` will start fresh on AWS. |
| `trading:user:*` | 1 | `internal/modules/trading/portfolio.go` | **migrate (flat KV copy)** | `pk=trading`, `sk=user:<telegram_id>` | CF KV already holds the exact `Portfolio` JSON shape (`currency`/`assets`/`meta`). No D1 transform required. |
| `trading:sym:*` | 7 | `internal/modules/trading/symbols.go` | skip | none | symbol price cache |
| `trading_trades` (D1) | 11 rows | n/a (no AWS consumer) | archive-only | none | optional cold JSONL export for audit; not import input |
| `doantu:stats:*` | 2 | retired Go module | skip | none | retired; operator chose not to archive |
| `loldle-ability:stats:*` | 1 | retired Go module | skip | none | retired; operator chose not to archive |
| `loldle-emoji:stats:*` | 1 | retired Go module | skip | none | retired; operator chose not to archive |
| `semantle:stats:*` | 1 | retired Go module | skip | none | retired; operator chose not to archive |
## Trading source — LOCKED 2026-05-16
The legacy CF JS Worker already snapshots the user portfolio into KV at `trading:user:<telegram_id>`. The stored value is byte-for-byte the JSON shape the Go AWS runtime expects:
```json
{
"currency": {"VND": 170850000},
"assets": {"TCB": 1000, "TCX": 1000, "FPT": 10000, "VCB": 1000},
"meta": {"invested": 1000000000, "createdAt": 1776743792792}
}
```
Decision record:
- **Source** for trading migration = CF KV key `trading:user:<telegram_id>`. Not D1.
- **Mapping rule** = identity copy. Read KV value, write to DynamoDB at `(pk=trading, sk=user:<telegram_id>)` with the same `value` attribute.
- **`meta.invested`** = read directly from KV; no derivation.
- **`meta.createdAt`** = read directly from KV; no derivation.
- **D1 `trading_trades`** = archive-only audit export (JSONL). Not used by the import path. Only 11 rows / 1 user as of 2026-05-16 — operator may export or skip without runtime impact.
This invalidates the earlier "Phase 03 D1 transform" framing. Phase 03 is now a plain KV copy plus optional D1 audit dump.
## Phase 01 operator procedure
### 1) Inventory D1 tables
List all tables:
```sh
wrangler d1 execute <database> --remote \
--command "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" \
--json
```
Dump table definitions for all discovered tables, then inspect every name that looks trading-, portfolio-, user-, or holding-related:
```sh
wrangler d1 execute <database> --remote \
--command "SELECT name, sql FROM sqlite_master WHERE type='table' ORDER BY name" \
--json
```
Inspect columns for every candidate table that could hold portfolio snapshots or metadata:
```sh
wrangler d1 execute <database> --remote \
--command "PRAGMA table_info(<table_name>)" \
--json
```
Do not stop at historical names like `trading_trades`, `users`, or `holdings`; the goal is to inspect whatever production D1 actually contains today.
### 2) Lock the trading source
Resolved 2026-05-16. CF KV `trading:user:<telegram_id>` already holds the exact `Portfolio` JSON shape; no transform path is needed. See "Trading source — LOCKED 2026-05-16" above for the decision record. The historical D1 `trading_trades` table is archive-only audit data.
### 3) Inventory Cloudflare KV prefixes
For each durable and skip candidate prefix above, list keys and capture representative values.
Use Wrangler KV listing for at least:
- `wordle:stats:`
- `wordle:game:`
- `loldle:stats:`
- `loldle:config:`
- `loldle:game:`
- `twentyq:stats:`
- `twentyq:game:`
- `lolschedule:subscribers`
- `lolschedule:matches:`
- `misc:last_ping`
- `trading:user:` (candidate only; inspect if a legacy portfolio snapshot prefix exists)
- `trading:sym:`
### 4) Freeze the matrix
Do not proceed to import tooling until each discovered dataset is tagged as one of:
- `migrate`
- `skip`
- `archive`
Anything not in the matrix is out of scope by default.
## Phase 01 done checklist — CLOSED 2026-05-16
- [x] Every live Cloudflare KV prefix is classified as `migrate`, `skip`, or `archive`
- [x] Every migrated KV dataset has an exact DynamoDB `(pk, sk)` target
- [x] Trading source table(s) and column(s) are locked (resolved as KV-only — see "Trading source — LOCKED")
- [x] `meta.invested` source is explicit (KV `trading:user:*`)
- [x] `meta.createdAt` source is explicit (KV `trading:user:*`)
- [x] Retired module namespaces are explicitly excluded from runtime import
- [x] AWS cutover remains gated on a green parity report from the migration plan (`plans/260510-0114-aws-port/phase-07-cutover.md` lines 13, 20, 42, 72)
## Phase 03 impact
The D1-transform branch of Phase 03 is dropped. Phase 03 is now:
- flat KV → DynamoDB copy across the 9 durable keys above
- optional one-shot `trading_trades` JSONL audit dump (operator-elective)
Effort revised from 4-6h to ~2h.
## Phase 02 toolchain (built 2026-05-16)
Single Go binary `cmd/migrate_cf_data` with 3 subcommands. No admin HTTP routes. No dependency on the running AWS bot process. Shared helpers live under `internal/migration/`.
### Required environment
| Var | Purpose | Used by |
|---|---|---|
| `CLOUDFLARE_API_TOKEN` | read-scope token | inventory, kv-import, trading-audit-dump |
| `CLOUDFLARE_ACCOUNT_ID` | production CF account | all |
| `CF_KV_NAMESPACE_ID` | production KV namespace | inventory, kv-import |
| `CF_D1_DATABASE_ID` | production D1 database | trading-audit-dump |
| `AWS_REGION` (+ standard AWS SDK creds) | DynamoDB writes | kv-import (non-dry-run) |
### Commands
Inventory (read-only classification, prints counts; no writes anywhere):
```sh
go run ./cmd/migrate_cf_data inventory
```
KV import dry-run against staging — proves wiring and key-shape mapping without any DynamoDB writes:
```sh
go run ./cmd/migrate_cf_data kv-import --table=miti99bot-staging --dry-run
```
KV import (default idempotent — rejects writes where `(pk, sk)` already exists; rerun is safe):
```sh
go run ./cmd/migrate_cf_data kv-import --table=miti99bot-staging
```
KV import with explicit overwrite (drops the `attribute_not_exists` guard; only use when intentionally re-importing a known-good source after rollback):
```sh
go run ./cmd/migrate_cf_data kv-import --table=miti99bot-staging --overwrite
```
Optional `trading_trades` audit dump (D1 → JSONL, audit-only — not an import input):
```sh
go run ./cmd/migrate_cf_data trading-audit-dump --out=plans/reports/migration-260515-2250-trading-audit.jsonl
```
### Report layout
Every `kv-import` run prints a Migration report block with four buckets: `Imported`, `Skipped (already present)`, `Skipped (policy)`, `Failed`. Each bucket is keyed by source prefix (or by skip-reason for the policy bucket). Phase 04 parity verification compares Phase 01 inventory counts against this report.
### Verified end-to-end against production CF on 2026-05-16
`inventory` and `kv-import --dry-run` both run cleanly against prod CF. 9 durable keys map to the runtime `(pk, sk)` shape exactly as the policy table predicts. No DynamoDB writes were issued during verification (`--dry-run`).
+3 -2
View File
@@ -54,8 +54,9 @@ func NewClient(ctx context.Context, apiKey string) (*Client, error) {
// Generate runs a single-turn chat with `system` as the system instruction
// and `user` as the user message. Returns the model's text reply.
//
// The output cap matches what the JS twentyq prompt expects (≤200 tokens,
// single-line JSON). Temperature 0.7 mirrors the JS code path.
// The output is capped to keep the twentyq prompt's response budget tight
// (≤200 tokens, single-line JSON). Temperature 0.7 trades a little
// variability for hint freshness.
func (c *Client) Generate(ctx context.Context, system, user string) (string, error) {
if c == nil || c.g == nil {
return "", ErrNotConfigured
+3 -2
View File
@@ -3,8 +3,9 @@
//
// Why a separate package: every game module needs a per-subject mutex to
// turn KVStore's single-op atomicity into safe Get→mutate→Put. The bot
// dispatcher runs each Telegram update in its own goroutine, and the JS
// source's Cloudflare Workers serialisation is not a property Go inherits.
// dispatcher runs each Telegram update in its own goroutine, so without
// explicit per-subject serialisation two updates to the same game could
// race and drop one write.
//
// Trade-off: the underlying sync.Map grows unboundedly with distinct keys
// (~32 B each). At 1M keys that's ~32 MB — acceptable for the lifetime of
+2 -2
View File
@@ -23,8 +23,8 @@ import (
)
// DefaultFlushInterval is how often Run flushes counters to the log. 60s
// matches the JS source and keeps log volume modest (1 metrics line per
// minute per active instance).
// keeps log volume modest (1 metrics line per minute per active instance)
// while still surfacing minute-scale traffic shifts in CloudWatch.
const DefaultFlushInterval = 60 * time.Second
// Registry holds named counters across three categories: command
@@ -1,92 +0,0 @@
package migration
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// CloudflareD1Client is a thin read-only REST client for the legacy
// Cloudflare D1 database. The migration only uses it for the optional
// trading_trades audit dump in Phase 03; runtime data flow never reads D1.
type CloudflareD1Client struct {
httpClient *http.Client
apiBase string
accountID string
databaseID string
apiToken string
}
func NewCloudflareD1Client(accountID, databaseID, apiToken string) *CloudflareD1Client {
if accountID == "" || databaseID == "" || apiToken == "" {
panic("migration: CloudflareD1Client requires accountID, databaseID, apiToken")
}
return &CloudflareD1Client{
httpClient: &http.Client{Timeout: 60 * time.Second},
apiBase: "https://api.cloudflare.com/client/v4",
accountID: accountID,
databaseID: databaseID,
apiToken: apiToken,
}
}
func (c *CloudflareD1Client) SetBaseURL(base string) { c.apiBase = base }
// d1QueryEnvelope matches the D1 query response. Result is an array because
// D1 returns one entry per statement; we only ever send one.
type d1QueryEnvelope struct {
Result []struct {
Results []map[string]any `json:"results"`
Success bool `json:"success"`
} `json:"result"`
Success bool `json:"success"`
Errors []map[string]any `json:"errors"`
}
// Query runs a single SQL statement and returns the row maps in result-set
// order. params is optional; pass nil for parameterless statements.
func (c *CloudflareD1Client) Query(ctx context.Context, sql string, params []any) ([]map[string]any, error) {
payload := map[string]any{"sql": sql}
if len(params) > 0 {
payload["params"] = params
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
endpoint := fmt.Sprintf("%s/accounts/%s/d1/database/%s/query",
c.apiBase, c.accountID, c.databaseID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("d1 query: status %d: %s", resp.StatusCode, string(raw))
}
var env d1QueryEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("d1 query: decode: %w", err)
}
if !env.Success {
return nil, fmt.Errorf("d1 query: api error: %v", env.Errors)
}
if len(env.Result) == 0 {
return nil, nil
}
return env.Result[0].Results, nil
}
@@ -1,67 +0,0 @@
package migration
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestD1QueryHappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method=%s want POST", r.Method)
}
if !strings.HasSuffix(r.URL.Path, "/d1/database/db123/query") {
t.Errorf("path=%s", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
var got map[string]any
_ = json.Unmarshal(body, &got)
if got["sql"] != "SELECT * FROM trading_trades" {
t.Errorf("sql=%v", got["sql"])
}
_, _ = w.Write([]byte(`{
"success": true,
"result": [{
"results": [
{"id": 1, "user_id": 100, "symbol": "FPT"},
{"id": 2, "user_id": 100, "symbol": "TCB"}
],
"success": true
}]
}`))
}))
defer srv.Close()
c := NewCloudflareD1Client("acct", "db123", "tok")
c.SetBaseURL(srv.URL)
rows, err := c.Query(context.Background(), "SELECT * FROM trading_trades", nil)
if err != nil {
t.Fatalf("query: %v", err)
}
if len(rows) != 2 {
t.Fatalf("got %d rows", len(rows))
}
if rows[0]["symbol"] != "FPT" || rows[1]["symbol"] != "TCB" {
t.Errorf("rows=%v", rows)
}
}
func TestD1QueryApiError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"success": false, "errors": [{"code": 8000, "message": "bad sql"}]}`))
}))
defer srv.Close()
c := NewCloudflareD1Client("acct", "db", "tok")
c.SetBaseURL(srv.URL)
_, err := c.Query(context.Background(), "SELECT", nil)
if err == nil || !strings.Contains(err.Error(), "api error") {
t.Fatalf("got %v, want api error", err)
}
}
-135
View File
@@ -1,135 +0,0 @@
package migration
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// CloudflareKVClient is a thin read-only REST client for the legacy
// Cloudflare Workers KV namespace. List+Get are the only operations the
// migration needs; mutations stay out of scope.
type CloudflareKVClient struct {
httpClient *http.Client
apiBase string
accountID string
namespaceID string
apiToken string
}
// NewCloudflareKVClient panics on empty required fields so misconfiguration
// surfaces at startup rather than after partial work.
func NewCloudflareKVClient(accountID, namespaceID, apiToken string) *CloudflareKVClient {
if accountID == "" || namespaceID == "" || apiToken == "" {
panic("migration: CloudflareKVClient requires accountID, namespaceID, apiToken")
}
return &CloudflareKVClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
apiBase: "https://api.cloudflare.com/client/v4",
accountID: accountID,
namespaceID: namespaceID,
apiToken: apiToken,
}
}
// SetBaseURL overrides the API base; used by tests with httptest.Server.
func (c *CloudflareKVClient) SetBaseURL(base string) { c.apiBase = base }
// kvListEnvelope matches the Cloudflare REST list-keys response shape.
// See: https://developers.cloudflare.com/api/operations/workers-kv-namespace-list-a-namespace-s-keys
type kvListEnvelope struct {
Result []struct {
Name string `json:"name"`
} `json:"result"`
Success bool `json:"success"`
Errors []map[string]any `json:"errors"`
ResultInfo struct {
Cursor string `json:"cursor"`
Count int `json:"count"`
} `json:"result_info"`
}
// ListKeys returns every key in the namespace. The Phase 01 inventory
// proved the namespace fits in well under one REST page (21 keys vs 1000
// page limit), but pagination is still honored for safety.
func (c *CloudflareKVClient) ListKeys(ctx context.Context) ([]string, error) {
var out []string
cursor := ""
for {
page, next, err := c.listPage(ctx, cursor)
if err != nil {
return nil, err
}
out = append(out, page...)
if next == "" {
return out, nil
}
cursor = next
}
}
func (c *CloudflareKVClient) listPage(ctx context.Context, cursor string) ([]string, string, error) {
endpoint := fmt.Sprintf("%s/accounts/%s/storage/kv/namespaces/%s/keys",
c.apiBase, c.accountID, c.namespaceID)
if cursor != "" {
endpoint += "?" + url.Values{"cursor": []string{cursor}}.Encode()
}
body, err := c.do(ctx, http.MethodGet, endpoint)
if err != nil {
return nil, "", err
}
var env kvListEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, "", fmt.Errorf("kv list: decode: %w", err)
}
if !env.Success {
return nil, "", fmt.Errorf("kv list: api error: %v", env.Errors)
}
names := make([]string, 0, len(env.Result))
for _, r := range env.Result {
names = append(names, r.Name)
}
return names, env.ResultInfo.Cursor, nil
}
// GetValue returns the raw value bytes for one KV key. CF returns 404 as
// errKeyNotFound so callers can decide whether a missing key is fatal.
func (c *CloudflareKVClient) GetValue(ctx context.Context, key string) ([]byte, error) {
endpoint := fmt.Sprintf("%s/accounts/%s/storage/kv/namespaces/%s/values/%s",
c.apiBase, c.accountID, c.namespaceID, url.PathEscape(key))
return c.do(ctx, http.MethodGet, endpoint)
}
// ErrKeyNotFound signals a 404 on a value GET. Returned wrapped so callers
// can use errors.Is.
var ErrKeyNotFound = errors.New("cloudflare kv: key not found")
func (c *CloudflareKVClient) do(ctx context.Context, method, endpoint string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, method, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("%w: %s", ErrKeyNotFound, endpoint)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("cloudflare api %s %s: status %d: %s",
method, endpoint, resp.StatusCode, string(body))
}
return body, nil
}
@@ -1,107 +0,0 @@
package migration
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestListKeysSinglePage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Errorf("auth header = %q", got)
}
if !strings.HasSuffix(r.URL.Path, "/storage/kv/namespaces/ns123/keys") {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{
"result": [{"name":"wordle:stats:1"},{"name":"trading:sym:FPT"}],
"success": true,
"result_info": {"count": 2, "cursor": ""}
}`))
}))
defer srv.Close()
c := NewCloudflareKVClient("acct", "ns123", "test-token")
c.SetBaseURL(srv.URL)
keys, err := c.ListKeys(context.Background())
if err != nil {
t.Fatalf("list: %v", err)
}
if len(keys) != 2 || keys[0] != "wordle:stats:1" || keys[1] != "trading:sym:FPT" {
t.Fatalf("got %v", keys)
}
}
func TestListKeysPaginates(t *testing.T) {
page := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page++
switch page {
case 1:
_, _ = w.Write([]byte(`{
"result": [{"name":"a"}],
"success": true,
"result_info": {"cursor": "p2"}
}`))
case 2:
if got := r.URL.Query().Get("cursor"); got != "p2" {
t.Errorf("cursor=%q want p2", got)
}
_, _ = w.Write([]byte(`{
"result": [{"name":"b"}],
"success": true,
"result_info": {"cursor": ""}
}`))
default:
t.Fatalf("too many pages")
}
}))
defer srv.Close()
c := NewCloudflareKVClient("acct", "ns", "tok")
c.SetBaseURL(srv.URL)
keys, err := c.ListKeys(context.Background())
if err != nil {
t.Fatalf("list: %v", err)
}
if len(keys) != 2 || keys[0] != "a" || keys[1] != "b" {
t.Fatalf("got %v", keys)
}
}
func TestGetValueReturns404AsErrKeyNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
c := NewCloudflareKVClient("acct", "ns", "tok")
c.SetBaseURL(srv.URL)
_, err := c.GetValue(context.Background(), "misc:last_ping")
if !errors.Is(err, ErrKeyNotFound) {
t.Fatalf("got %v, want ErrKeyNotFound", err)
}
}
func TestGetValueRawBytes(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"currency":{"VND":100},"meta":{"invested":0}}`))
}))
defer srv.Close()
c := NewCloudflareKVClient("acct", "ns", "tok")
c.SetBaseURL(srv.URL)
val, err := c.GetValue(context.Background(), "trading:user:42")
if err != nil {
t.Fatalf("get: %v", err)
}
want := `{"currency":{"VND":100},"meta":{"invested":0}}`
if string(val) != want {
t.Errorf("got %q want %q", string(val), want)
}
}
-61
View File
@@ -1,61 +0,0 @@
package migration
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)
// DynamoDBWriter writes migrated KV records into the runtime DynamoDB table
// using the exact attribute shape internal/storage/dynamodb_kv.go expects:
// pk (S), sk (S), value (S), updatedAt (N).
//
// Idempotency: by default the writer attaches a ConditionExpression that
// rejects writes where the (pk, sk) pair already exists. The CLI exposes an
// --overwrite flag that drops the condition for explicit re-imports.
type DynamoDBWriter struct {
client *dynamodb.Client
table string
overwrite bool
}
func NewDynamoDBWriter(client *dynamodb.Client, table string, overwrite bool) *DynamoDBWriter {
return &DynamoDBWriter{client: client, table: table, overwrite: overwrite}
}
// ErrItemExists signals a guarded write skipped because (pk, sk) was already
// present. The caller increments the "skipped (already imported)" counter.
var ErrItemExists = errors.New("dynamodb: item exists")
// Put writes one record. value bytes are stored as a DynamoDB String so the
// payload is human-readable in the AWS console; all current sources are JSON
// and therefore UTF-8 safe.
func (w *DynamoDBWriter) Put(ctx context.Context, pk, sk string, value []byte) error {
in := &dynamodb.PutItemInput{
TableName: aws.String(w.table),
Item: map[string]types.AttributeValue{
"pk": &types.AttributeValueMemberS{Value: pk},
"sk": &types.AttributeValueMemberS{Value: sk},
"value": &types.AttributeValueMemberS{Value: string(value)},
"updatedAt": &types.AttributeValueMemberN{Value: strconv.FormatInt(time.Now().UTC().UnixNano(), 10)},
},
}
if !w.overwrite {
in.ConditionExpression = aws.String("attribute_not_exists(pk)")
}
_, err := w.client.PutItem(ctx, in)
if err != nil {
var cf *types.ConditionalCheckFailedException
if errors.As(err, &cf) {
return ErrItemExists
}
return fmt.Errorf("dynamodb put %s/%s: %w", pk, sk, err)
}
return nil
}
-99
View File
@@ -1,99 +0,0 @@
// Package migration provides operator-run tooling that moves data from the
// legacy Cloudflare KV/D1 stack into the live AWS DynamoDB store.
//
// The package is intentionally small. It exposes:
// - Policy: classify a CF KV key as migrate/skip/archive and resolve its
// target DynamoDB (pk, sk).
// - CloudflareKVClient / CloudflareD1Client: thin REST readers.
// - DynamoDBWriter: idempotent writes against the runtime KV table shape.
// - Report: per-prefix counts for an import run.
//
// The runtime production code (cmd/server) never imports this package.
package migration
import "strings"
// Action is the per-source-key migration decision locked in Phase 01.
type Action string
const (
ActionMigrate Action = "migrate"
ActionSkip Action = "skip"
)
// Decision is the resolved migration decision for one CF KV key.
type Decision struct {
Action Action
// PK and SK are populated only when Action == ActionMigrate.
PK string
SK string
// Reason explains a Skip (cache, retired, missing, etc.).
Reason string
}
// kvRule is one entry in the static allowlist. Order matters: rules are
// matched top-down with HasPrefix so the most specific prefix wins when
// shorter prefixes would otherwise swallow a longer one.
type kvRule struct {
prefix string
module string // DynamoDB pk; empty when action != migrate
skip string // non-empty marks the rule as skip; value is the reason
}
// kvRules is the locked Phase 01 inventory. Adding a new live CF prefix
// requires re-running the Phase 01 inventory and updating this list.
var kvRules = []kvRule{
// Durable user data — migrate.
{prefix: "wordle:stats:", module: "wordle"},
{prefix: "loldle:stats:", module: "loldle"},
{prefix: "loldle:config:", module: "loldle"},
{prefix: "twentyq:stats:", module: "twentyq"},
{prefix: "lolschedule:subscribers", module: "lolschedule"},
{prefix: "trading:user:", module: "trading"},
// Caches — skip.
{prefix: "trading:sym:", skip: "cache"},
{prefix: "wordle:game:", skip: "ephemeral"},
{prefix: "loldle:game:", skip: "ephemeral"},
{prefix: "twentyq:game:", skip: "ephemeral"},
{prefix: "lolschedule:matches:", skip: "cache"},
// Retired modules — operator chose not to archive (Phase 01, 2026-05-16).
{prefix: "doantu:", skip: "retired"},
{prefix: "loldle-ability:", skip: "retired"},
{prefix: "loldle-emoji:", skip: "retired"},
{prefix: "loldle-quote:", skip: "retired"},
{prefix: "loldle-splash:", skip: "retired"},
{prefix: "semantle:", skip: "retired"},
}
// Classify returns the migration decision for a CF KV key.
// Unknown keys default to skip with reason "unknown" so new upstream prefixes
// are visible in the inventory report instead of being silently imported.
func Classify(cfKey string) Decision {
for _, r := range kvRules {
if !strings.HasPrefix(cfKey, r.prefix) {
continue
}
if r.skip != "" {
return Decision{Action: ActionSkip, Reason: r.skip}
}
// Migrate. Target sk is the CF key with the leading "<module>:" stripped.
// Live runtime stores e.g. wordle/stats:<subject>, not wordle/wordle:stats:<subject>.
sk := strings.TrimPrefix(cfKey, r.module+":")
return Decision{Action: ActionMigrate, PK: r.module, SK: sk}
}
return Decision{Action: ActionSkip, Reason: "unknown"}
}
// DurablePrefixes returns the migrate-action prefixes for use by inventory
// and reporting. Order matches kvRules.
func DurablePrefixes() []string {
out := make([]string, 0, len(kvRules))
for _, r := range kvRules {
if r.skip == "" {
out = append(out, r.prefix)
}
}
return out
}
-86
View File
@@ -1,86 +0,0 @@
package migration
import "testing"
func TestClassify(t *testing.T) {
cases := []struct {
key string
wantAct Action
wantPK string
wantSK string
wantSkip string
}{
// Durable migrate paths — these are the 6 prefixes Phase 01 locked.
{"wordle:stats:-1001760292100", ActionMigrate, "wordle", "stats:-1001760292100", ""},
{"loldle:stats:1064111334", ActionMigrate, "loldle", "stats:1064111334", ""},
{"loldle:config:-1001760292100", ActionMigrate, "loldle", "config:-1001760292100", ""},
{"twentyq:stats:-1001760292100", ActionMigrate, "twentyq", "stats:-1001760292100", ""},
{"lolschedule:subscribers", ActionMigrate, "lolschedule", "subscribers", ""},
{"trading:user:1064111334", ActionMigrate, "trading", "user:1064111334", ""},
// Cache + ephemeral — skip.
{"trading:sym:FPT", ActionSkip, "", "", "cache"},
{"wordle:game:abc", ActionSkip, "", "", "ephemeral"},
{"lolschedule:matches:2026", ActionSkip, "", "", "cache"},
// Retired modules — skip.
{"doantu:stats:1064111334", ActionSkip, "", "", "retired"},
{"semantle:stats:-1001760292100", ActionSkip, "", "", "retired"},
{"loldle-emoji:stats:-1001760292100", ActionSkip, "", "", "retired"},
// Unknown prefix — skip with reason "unknown" so it surfaces in reports.
{"newmodule:foo", ActionSkip, "", "", "unknown"},
}
for _, c := range cases {
t.Run(c.key, func(t *testing.T) {
got := Classify(c.key)
if got.Action != c.wantAct {
t.Fatalf("action=%v want %v", got.Action, c.wantAct)
}
if got.PK != c.wantPK {
t.Errorf("pk=%q want %q", got.PK, c.wantPK)
}
if got.SK != c.wantSK {
t.Errorf("sk=%q want %q", got.SK, c.wantSK)
}
if got.Reason != c.wantSkip {
t.Errorf("reason=%q want %q", got.Reason, c.wantSkip)
}
})
}
}
func TestDurablePrefixes(t *testing.T) {
got := DurablePrefixes()
want := []string{
"wordle:stats:",
"loldle:stats:",
"loldle:config:",
"twentyq:stats:",
"lolschedule:subscribers",
"trading:user:",
}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("idx %d: got %q want %q", i, got[i], want[i])
}
}
}
func TestPrefixOfLongestMatch(t *testing.T) {
// loldle:stats: and loldle:config: both share the loldle: super-prefix.
// Longest match wins so report buckets stay precise.
if got := PrefixOf("loldle:stats:1234"); got != "loldle:stats:" {
t.Errorf("loldle:stats:1234 → %q, want loldle:stats:", got)
}
if got := PrefixOf("loldle:config:5"); got != "loldle:config:" {
t.Errorf("loldle:config:5 → %q, want loldle:config:", got)
}
if got := PrefixOf("zzz:unknown"); got != "unknown" {
t.Errorf("unknown bucket: got %q", got)
}
}
-96
View File
@@ -1,96 +0,0 @@
package migration
import (
"fmt"
"io"
"sort"
)
// Report aggregates counts for one migration run. It is intentionally
// per-prefix rather than per-key so the runbook can compare totals to the
// Phase 01 inventory at a glance.
type Report struct {
// Imported is keys actually written to DynamoDB.
Imported map[string]int
// SkippedExisting is keys already present (idempotent rerun).
SkippedExisting map[string]int
// SkippedPolicy is keys rejected by the Phase 01 allowlist; keyed by
// reason (cache, retired, unknown, ephemeral).
SkippedPolicy map[string]int
// Failed is keys that errored mid-import.
Failed map[string]int
}
func NewReport() *Report {
return &Report{
Imported: map[string]int{},
SkippedExisting: map[string]int{},
SkippedPolicy: map[string]int{},
Failed: map[string]int{},
}
}
func (r *Report) AddImported(prefix string) { r.Imported[prefix]++ }
func (r *Report) AddSkippedExisting(prefix string) { r.SkippedExisting[prefix]++ }
func (r *Report) AddSkippedPolicy(reason string) { r.SkippedPolicy[reason]++ }
func (r *Report) AddFailed(prefix string) { r.Failed[prefix]++ }
// Format writes a human-readable summary. Stable ordering (alphabetical) so
// rerun diffs stay clean. Write errors are ignored — callers pass os.Stdout
// or *bytes.Buffer, where short writes are not actionable.
func (r *Report) Format(w io.Writer) {
_, _ = fmt.Fprintln(w, "Migration report")
_, _ = fmt.Fprintln(w, "================")
writeSection(w, "Imported", r.Imported)
writeSection(w, "Skipped (already present)", r.SkippedExisting)
writeSection(w, "Skipped (policy)", r.SkippedPolicy)
writeSection(w, "Failed", r.Failed)
_, _ = fmt.Fprintf(w, "TOTAL imported=%d skipped_existing=%d skipped_policy=%d failed=%d\n",
sum(r.Imported), sum(r.SkippedExisting), sum(r.SkippedPolicy), sum(r.Failed))
}
func writeSection(w io.Writer, label string, m map[string]int) {
_, _ = fmt.Fprintf(w, "\n%s:\n", label)
if len(m) == 0 {
_, _ = fmt.Fprintln(w, " (none)")
return
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
_, _ = fmt.Fprintf(w, " %-30s %d\n", k, m[k])
}
}
func sum(m map[string]int) int {
t := 0
for _, v := range m {
t += v
}
return t
}
// PrefixOf returns the longest known prefix from kvRules that matches key,
// or "unknown". Used by Report consumers to bucket counts.
func PrefixOf(key string) string {
best := ""
for _, r := range kvRules {
if len(r.prefix) > len(best) && hasPrefix(key, r.prefix) {
best = r.prefix
}
}
if best == "" {
return "unknown"
}
return best
}
func hasPrefix(s, p string) bool {
if len(s) < len(p) {
return false
}
return s[:len(p)] == p
}
-52
View File
@@ -1,52 +0,0 @@
package migration
import (
"bytes"
"strings"
"testing"
)
func TestReportFormat(t *testing.T) {
r := NewReport()
r.AddImported("wordle:stats:")
r.AddImported("wordle:stats:")
r.AddImported("trading:user:")
r.AddSkippedExisting("loldle:stats:")
r.AddSkippedPolicy("cache")
r.AddSkippedPolicy("cache")
r.AddSkippedPolicy("retired")
r.AddFailed("twentyq:stats:")
var buf bytes.Buffer
r.Format(&buf)
got := buf.String()
// Spot-check structure and totals.
for _, want := range []string{
"Imported:",
"wordle:stats: 2",
"trading:user: 1",
"Skipped (already present):",
"loldle:stats: 1",
"Skipped (policy):",
"cache 2",
"retired 1",
"Failed:",
"twentyq:stats: 1",
"TOTAL imported=3 skipped_existing=1 skipped_policy=3 failed=1",
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in output:\n%s", want, got)
}
}
}
func TestReportEmptySection(t *testing.T) {
r := NewReport()
r.AddImported("wordle:stats:")
var buf bytes.Buffer
r.Format(&buf)
if !strings.Contains(buf.String(), "Failed:\n (none)") {
t.Errorf("empty section missing (none) marker:\n%s", buf.String())
}
}
+5 -6
View File
@@ -1,4 +1,4 @@
// Package loldle ports the JS loldle classic mode — guess the League
// Package loldle implements the loldle classic mode — guess the League
// champion from attribute hints (gender, species, regions, etc.).
package loldle
@@ -9,8 +9,8 @@ import (
)
// Champion is one row of champions.json. Field tags match loldle.net's
// scraped schema verbatim so the embedded JSON file lifts unmodified from
// the JS source.
// scraped schema verbatim so the embedded JSON file can be regenerated from
// the upstream scrape without a transform step.
type Champion struct {
ChampionName string `json:"championName"`
Gender string `json:"gender"`
@@ -22,9 +22,8 @@ type Champion struct {
ReleaseDate string `json:"release_date"` // YYYY-MM-DD
}
// rawChampions holds the embedded JSON byte stream. The data file is copied
// byte-for-byte from src/modules/loldle/champions.json in the JS repo so
// dictionaries are identical across runtimes (no normalization at port time).
// rawChampions holds the embedded JSON byte stream — the loldle.net champion
// dictionary scraped offline and checked into data/champions.json.
//
//go:embed data/champions.json
var rawChampions []byte
+18 -16
View File
@@ -4,8 +4,8 @@ import (
"strings"
)
// AttrType classifies how a single attribute is compared. Matches the JS
// source's "exact" | "multi" | "year" discriminator.
// AttrType classifies how a single attribute is compared:
// "exact" | "multi" | "year".
type AttrType string
const (
@@ -14,16 +14,18 @@ const (
attrYear AttrType = "year"
)
// Result categories. Match JS strings byte-for-byte (handlers/render rely on
// these literals via the marker maps).
// Result categories. handlers/render rely on these literals via the marker
// maps, so renaming a constant requires updating those maps in lockstep.
const (
ResultCorrect = "correct"
ResultPartial = "partial"
ResultWrong = "wrong"
)
// AttributeRow describes one attribute's comparison output. JS shape:
// {key, label, type, guessValue, targetValue, result, direction?}.
// AttributeRow describes one attribute's comparison output as one row of
// the render board: key/label identify the row, type drives the comparison
// algorithm, result is the rendered marker, direction is set only for
// wrong year-type rows ("up"/"down").
type AttributeRow struct {
Key string
Label string
@@ -35,8 +37,8 @@ type AttributeRow struct {
}
// classicAttributes is the ordered comparison spec. Order matters for both
// render output and the JS-port test that asserts `compareChampions(...)`
// returns rows in this exact sequence.
// render output and the test that asserts CompareChampions returns rows in
// this exact sequence — reordering changes the on-screen board.
var classicAttributes = []AttributeRow{
{Key: "gender", Label: "Gender", Type: attrExact},
{Key: "species", Label: "Species", Type: attrMulti},
@@ -51,7 +53,7 @@ var classicAttributes = []AttributeRow{
// Values are compared per attr.Type:
// - exact: case-insensitive string equality.
// - multi: set comparison; full match → correct, partial overlap → partial,
// no overlap → wrong. Two empty sets are correct (matches JS).
// no overlap → wrong. Two empty sets are correct.
// - year: parses leading 4 digits; equal → correct, else wrong + a
// direction hint ("up" if guess<target, "down" if guess>target).
func CompareChampions(guess, target *Champion) []AttributeRow {
@@ -126,8 +128,9 @@ func asStringSlice(v any) []string {
case []string:
return x
case string:
// JS toSet falls back to splitting on "," when the value isn't an array.
// Champions.json never produces this branch but parity matters.
// Defensive: champions.json never sends a comma-joined string for a
// multi-valued attribute, but if a future data refresh ever does,
// split on "," instead of treating it as a single token.
if x == "" {
return nil
}
@@ -138,7 +141,7 @@ func asStringSlice(v any) []string {
// compareMultiValue: full match (case-insensitive, order-independent) →
// correct; any intersection → partial; otherwise wrong. Two empty sets
// are correct (matches JS, e.g. for hypothetical "no positions" champs).
// are correct (e.g. for a hypothetical "no positions" champion).
func compareMultiValue(guess, target []string) string {
g := toLowerSet(guess)
t := toLowerSet(target)
@@ -182,8 +185,7 @@ func setsEqual(a, b map[string]struct{}) bool {
return true
}
// parseYear extracts the first 4 digits of s. Returns 0 if absent/non-numeric
// — JS regex parity (`^(\d{4})`).
// parseYear extracts the first 4 digits of s. Returns 0 if absent/non-numeric.
func parseYear(s string) int {
if len(s) < 4 {
return 0
@@ -232,8 +234,8 @@ func compareYear(g, t int) (result, direction string) {
return ResultWrong, "down"
}
// formatValue mirrors JS's `formatValue`: empty → "—", array → comma-joined,
// otherwise toString.
// formatValue renders a value cell: empty → "—", array → comma-joined,
// otherwise stringified.
func formatValue(v any) string {
switch x := v.(type) {
case nil:
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"testing"
)
// Fixtures lifted from tests/modules/loldle/compare.test.js so any future
// drift between Go and JS scoring shows up as a test failure here.
// Fixtures cover the compare-scoring rules. Drift in the scoring algorithm
// or the canonical attribute order surfaces here as a test failure.
var aatrox = &Champion{
ChampionName: "Aatrox",
+5 -5
View File
@@ -2,9 +2,9 @@ package loldle
import "fmt"
// attemptFlavor returns a one-word reaction to a winning attempt. Branches
// match the JS source exactly (1 → "First try!", 2 → "Sharp!", final →
// "Phew — last one!", final-2 → "Close call!", else "Nice.").
// attemptFlavor returns a one-word reaction to a winning attempt:
// 1 → "First try!", 2 → "Sharp!", final → "Phew — last one!",
// final-2 → "Close call!", else "Nice.".
func attemptFlavor(attempt, max int) string {
if attempt <= 1 {
return "First try!"
@@ -27,11 +27,11 @@ func attemptFlavor(attempt, max int) string {
// < 60min → "3m 14s" (or "3m" when seconds == 0)
// otherwise → "1h 12m" (or "1h" when remaining minutes == 0)
//
// JS parity. Negative inputs clamp to 0 (matches JS's Math.max(0, …)).
// Negative inputs clamp to 0.
func formatDuration(ms int64) string {
total := ms / 1000
if ms%1000 >= 500 {
total++ // round-half-up to match JS Math.round
total++ // round-half-up
}
if total < 0 {
total = 0
+2 -2
View File
@@ -13,7 +13,7 @@ func TestAttemptFlavor(t *testing.T) {
6: "Close call!",
7: "Close call!",
8: "Phew — last one!",
9: "Phew — last one!", // attempt > max — defensive; matches JS >=
9: "Phew — last one!", // attempt > max — defensive (`>=` branch)
}
for attempt, want := range cases {
if got := attemptFlavor(attempt, maxAttempts); got != want {
@@ -28,7 +28,7 @@ func TestFormatDuration(t *testing.T) {
want string
}{
{0, "0s"},
{500, "1s"}, // round-half-up to JS Math.round
{500, "1s"}, // round-half-up
{499, "0s"},
{42_000, "42s"},
{60_000, "1m"},
+2 -1
View File
@@ -37,7 +37,8 @@ func (s *state) findByName(name string) *Champion {
// rehydrateGuesses recomputes board rows from the stored championNames.
// Champions removed from champions.json since the round started are skipped
// (returns the surviving prefix), matching JS.
// (returns the surviving prefix) so a data refresh never breaks an active
// round.
func (s *state) rehydrateGuesses(g *gameState) []boardEntry {
target := s.findByName(g.Target)
if target == nil {
+2 -3
View File
@@ -3,9 +3,8 @@ package loldle
import "strings"
// normalizeName folds a name to a comparable form: lowercase, alphanumeric
// only. JS-parity with util/normalize-name.js — `String(s).toLowerCase().
// replace(/[^a-z0-9]/g, "")`. Used for case/space/punctuation-insensitive
// lookup so "Kai'Sa", "kaisa", and "KAI SA" all collapse to the same key.
// only. Used for case/space/punctuation-insensitive lookup so "Kai'Sa",
// "kaisa", and "KAI SA" all collapse to the same key.
func normalizeName(s string) string {
lower := strings.ToLower(s)
out := make([]byte, 0, len(lower))
+2 -2
View File
@@ -63,8 +63,8 @@ func buildRows(championName string, results []AttributeRow) []guessRow {
}
// formatRowGroups joins one or more guess-row groups into a single <pre>
// block. Label column width is the max label across ALL groups, so stacked
// guesses on a board align with each other (JS parity).
// block. Label column width is the max label across ALL groups so stacked
// guesses on a board align with each other.
func formatRowGroups(groups [][]guessRow) string {
width := 0
for _, g := range groups {
+1 -2
View File
@@ -7,8 +7,7 @@ import (
// TestRenderGuess_AlignsLabelColumn locks the monospace column-alignment
// invariant: the label column must be padded to the longest label so stacked
// guesses on the board line up. Captures the JS render contract that the
// rest of the test suite never exercises.
// guesses on the board line up.
func TestRenderGuess_AlignsLabelColumn(t *testing.T) {
rows := []AttributeRow{
{Key: "gender", Label: "Gender", Type: attrExact, GuessValue: "Male", Result: ResultCorrect},
+10 -9
View File
@@ -15,11 +15,11 @@ const (
MaxGuessesCap = 10
)
// gameState is the per-subject KV record. Field tags match JS exactly so a
// JS-written round decodes cleanly. StartedAt is *int64 because the JS
// source initialises it to `null` (timer doesn't start until first guess) —
// gameState is the per-subject KV record.
//
// StartedAt is *int64 because the timer doesn't start until the first guess;
// using time.Time would marshal as "0001-01-01T00:00:00Z" instead of null
// and break wire-format parity.
// and lose that distinction.
//
// Guesses is just championNames; comparison rows are recomputed at render
// time against current champions.json so a weekly data refresh updates
@@ -30,8 +30,9 @@ type gameState struct {
StartedAt *int64 `json:"startedAt"` // ms-since-epoch | null
}
// stats lifetime score. JS shape — note no LastResultAt field (differs from
// wordle's stats; the JS loldle source omits it, parity dictates we do too).
// stats lifetime score. No LastResultAt field by design (differs from
// wordle's Stats — loldle only ever needed running streaks, not "last
// played at").
type stats struct {
Played int `json:"played"`
Wins int `json:"wins"`
@@ -80,8 +81,8 @@ func clearGame(ctx context.Context, kv storage.KVStore, subject string) error {
return nil
}
// loadStats returns lifetime score; missing → fresh-zero record (matches
// the JS `?? {…}` fallback).
// loadStats returns lifetime score; missing → fresh-zero record so callers
// never need a nil check.
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*stats, error) {
var s stats
err := kv.GetJSON(ctx, statsKey(subject), &s)
@@ -118,7 +119,7 @@ func recordResult(ctx context.Context, kv storage.KVStore, subject string, won b
// getMaxGuesses returns the effective round length: the per-subject override
// if set and in range, otherwise MaxGuesses. Out-of-range values are
// silently ignored (matches JS).
// silently ignored — better to serve the default than 500 the user.
func getMaxGuesses(ctx context.Context, kv storage.KVStore, subject string) (int, error) {
var cfg roundConfig
err := kv.GetJSON(ctx, configKey(subject), &cfg)
+3 -1
View File
@@ -31,7 +31,9 @@ func TestGameState_StartedAtAsNumber(t *testing.T) {
}
func TestStats_NoLastResultAtField(t *testing.T) {
// JS loldle stats schema differs from wordle: no lastResultAt. Lock that.
// loldle's stats schema deliberately differs from wordle's — no
// lastResultAt field. Lock that, since adding the field would silently
// change the on-disk shape for every existing player.
b, _ := json.Marshal(stats{})
want := `{"played":0,"wins":0,"streak":0,"bestStreak":0}`
if string(b) != want {
+8 -10
View File
@@ -1,14 +1,13 @@
// Package lolschedule ports the JS lolschedule module — LoL esports match
// schedule via lolesports.com's persisted API.
// Package lolschedule serves LoL esports match schedules via lolesports.com's
// persisted API plus a daily push to subscribers.
//
// Endpoint: https://esports-api.lolesports.com/persisted/gw/getSchedule
// Auth: x-api-key header (the public key embedded in lolesports.com's web
// client no registration). If Riot ever rotates it, lift the new value
// Auth: x-api-key header the public key embedded in lolesports.com's web
// client (no registration). If Riot ever rotates it, lift the new value
// from their public JS bundle.
//
// Cache strategy: KV-backed, 120s fresh window with 60-minute stale
// fallback. Same shape as the JS source so cross-runtime KV migration
// round-trips byte-for-byte.
// Cache strategy: KV-backed cacheRecord with a 120s fresh window and a
// 60-minute stale fallback (stale-while-error).
package lolschedule
import (
@@ -30,7 +29,7 @@ const (
apiURL = "https://esports-api.lolesports.com/persisted/gw/getSchedule"
// apiKey is the public lolesports.com web client key (not a secret).
// gosec flags it as a hardcoded credential; the value is shipped in
// Riot's own public JS bundle and serves the live site too.
// Riot's own public web bundle and serves the live site too.
// #nosec G101
apiKey = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z"
userAgent = "miti99bot/0.1 (https://t.me/miti99bot)"
@@ -104,8 +103,7 @@ type schedulePage struct {
} `json:"data"`
}
// cacheRecord is the KV value: timestamp + events. Same shape as JS so KV
// export/import migration round-trips.
// cacheRecord is the KV value: fetch timestamp (ms-epoch) + events.
type cacheRecord struct {
Ts int64 `json:"ts"` // ms-since-epoch when fetched
Events []ScheduleEvent `json:"events"`
+2 -1
View File
@@ -53,7 +53,8 @@ func addDays(date time.Time, days int) time.Time {
}
// splitParts breaks the trimmed input into [dd, mm?, yyyy?] string parts.
// Mirrors JS splitParts: dash- or slash-separated, or 1/2/4/8-digit unbroken.
// Accepts dash- or slash-separated values, or a 1/2/4/8-digit unbroken
// run (today, this-month, this-year, full ddmmyyyy).
func splitParts(trimmed string) ([]string, string) {
if strings.ContainsAny(trimmed, "-/") {
// Replace both delimiters with a single one, then split.
+3 -3
View File
@@ -31,9 +31,9 @@ const defaultTarget = "VNG"
// %s slots: target (escaped), sender mention, sender mention.
const trongTruongHopTemplate = "Trong trường hợp nhóm này bị điều tra bởi %s, %s khẳng định không liên quan tới nhóm hoặc những cá nhân khác trong nhóm này. %s không rõ tại sao lại có mặt ở đây vào thời điểm này, có lẽ tài khoản đã được thêm bởi một bên thứ ba."
// lastPing mirrors the JS bot's wire format: { at: <ms-since-epoch number> }.
// Stored as int64 ms-epoch (not time.Time → RFC3339) so a future cross-runtime
// KV export/import migration round-trips byte-for-byte.
// lastPing is the value stored at the `last_ping` key: { at: <ms-since-epoch> }.
// int64 ms-epoch (not time.Time → RFC3339) keeps the on-disk shape compact
// and consistent with every other timestamp field in the bot's KV.
type lastPing struct {
At int64 `json:"at"`
}
+5 -6
View File
@@ -45,9 +45,8 @@ func TestPing_WritesLastPingKV(t *testing.T) {
ctx := context.Background()
kv := storage.NewMemoryKVStore()
// Drive the KV side directly: lock the wire format (ms-epoch number, not
// RFC3339 string). A JS-written {at: 1700000000000} must round-trip into
// the Go struct without a custom decoder.
// Drive the KV side directly: lock the wire format as a ms-epoch number
// (not an RFC3339 string), matching every other timestamp in the bot's KV.
if err := kv.PutJSON(ctx, lastPingKey, lastPing{At: time.Now().UTC().UnixMilli()}); err != nil {
t.Fatalf("PutJSON: %v", err)
}
@@ -60,16 +59,16 @@ func TestPing_WritesLastPingKV(t *testing.T) {
t.Errorf("read-back lastPing.At = %d, want positive ms-epoch", got.At)
}
// Also verify a value with the JS-shape decodes correctly.
// Verify a hand-written {"at": <ms-epoch>} document decodes correctly.
if err := kv.Put(ctx, lastPingKey, []byte(`{"at":1700000000000}`)); err != nil {
t.Fatal(err)
}
got = lastPing{}
if err := kv.GetJSON(ctx, lastPingKey, &got); err != nil {
t.Fatalf("GetJSON js-shape: %v", err)
t.Fatalf("GetJSON ms-epoch shape: %v", err)
}
if got.At != 1700000000000 {
t.Errorf("js-shape round-trip: At = %d, want 1700000000000", got.At)
t.Errorf("ms-epoch round-trip: At = %d, want 1700000000000", got.At)
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
// Package trading is a paper-trading module for VN stocks. Per-user
// portfolio + buy/sell at market price + stats with P&L. Ported from the
// JS trading module in miti99bot; SQL-based history + retention cron are
// out of scope for this v1 port.
// portfolio + buy/sell at market price + stats with P&L. SQL-based trade
// history and a retention cron are out of scope today; the current
// implementation keeps only the live portfolio in KV.
package trading
import (
@@ -37,7 +37,7 @@ func FormatStock(n float64) string {
// FormatPnL renders a signed VND delta + percentage line, e.g.
// "+1.234 VND (+12.34%)" or "-500.000 VND (-5.00%)". When invested is zero
// the percentage is reported as 0.00 to avoid division-by-zero — matches JS.
// the percentage is reported as 0.00 to avoid division-by-zero.
func FormatPnL(currentValue, invested float64) string {
diff := currentValue - invested
pct := 0.0
+1 -1
View File
@@ -19,7 +19,7 @@ type Portfolio struct {
}
// PortfolioMeta tracks invested cost basis for P&L. CreatedAt is purely
// informational; carried for parity with JS schema.
// informational (ms-epoch when the portfolio first existed).
type PortfolioMeta struct {
Invested float64 `json:"invested"`
CreatedAt int64 `json:"createdAt"`
+4 -2
View File
@@ -42,7 +42,8 @@ func (s *state) randomSeed() string {
return seeds[s.rng.IntN(len(seeds))]
}
// fallbackRoundStart matches JS roundstart fallback when the model fails.
// fallbackRoundStart returns a generic category + hint pair the round can
// start with when the LLM call fails or returns unparseable output.
func fallbackRoundStart() (string, string) {
return "object", "it is something you might encounter in everyday life"
}
@@ -91,7 +92,8 @@ func (s *state) handleTwentyq(ctx context.Context, b *bot.Bot, update *models.Up
if err != nil {
return err
}
// Solved-but-lingering rounds → start fresh transparently (JS-parity).
// Solved-but-lingering rounds → start fresh transparently rather than
// reject the user with "round already solved".
if game != nil && game.Solved {
// Best-effort delete: a hard failure here means the solved-state
// branch re-enters every call and the user is stuck. Log but keep
+7 -6
View File
@@ -6,8 +6,8 @@ import (
"strings"
)
// Judgement is the canonical shape after parse + normalize. JS-parity field
// names — IsGuess for "is_guess", Answer ∈ {"yes","no"}.
// Judgement is the canonical shape after parse + normalize.
// Answer ∈ {"yes","no"}.
type Judgement struct {
IsGuess bool `json:"is_guess"`
Answer string `json:"answer"`
@@ -26,7 +26,8 @@ const defaultHint = "I couldn't fully parse that — try a clear yes/no question
var fenceRe = regexp.MustCompile("(?i)```(?:json)?")
// parseJSON returns the first balanced {...} JSON object found in `text`.
// Returns nil on parse failure — matches JS parseJudgementJson tolerance.
// Returns nil on parse failure — tolerant by design since LLM output is
// untrusted and we'd rather fall back to defaults than 500.
func parseJSON(text string) map[string]any {
if text == "" {
return nil
@@ -73,7 +74,7 @@ func parseJSON(text string) map[string]any {
}
// normalizeJudgement coerces a parsed payload to the canonical Judgement
// shape with safe defaults. JS-parity behaviour.
// shape with safe defaults (answer="no", hint=defaultHint, is_guess=false).
func normalizeJudgement(payload map[string]any) Judgement {
out := Judgement{Answer: "no", Hint: defaultHint}
if payload == nil {
@@ -110,8 +111,8 @@ func redactSecret(hint, target string) string {
return re.ReplaceAllString(hint, "(redacted)")
}
// parseRoundStart returns (category, initialHint) or zero values + nil on
// any failure. Caller substitutes JS-parity fallbacks.
// parseRoundStart returns (category, initialHint, ok). ok=false on any
// failure so the caller can substitute fallbacks rather than serve garbage.
func parseRoundStart(payload map[string]any) (string, string, bool) {
if payload == nil {
return "", "", false
+2 -2
View File
@@ -7,8 +7,8 @@ import (
const historyWindow = 5
// buildSystemPrompt: per-turn judge prompt. JS-parity verbatim. The LLM is
// instructed to emit one-line JSON; parser.go does the parse.
// buildSystemPrompt: per-turn judge prompt. The LLM is instructed to emit
// one-line JSON; parser.go does the parse.
func buildSystemPrompt(g GameState) string {
recent := g.Turns
if len(recent) > historyWindow {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/tiennm99/miti99bot/internal/storage"
)
// Turn is one Q&A entry. JS-parity field names.
// Turn is one Q&A entry stored in the game's history.
type Turn struct {
Text string `json:"text"`
IsGuess bool `json:"isGuess"`
+2 -1
View File
@@ -11,7 +11,8 @@ const (
)
// openEndedRe rejects open-ended questions before spending a Gemini call.
// JS-parity prefix list.
// The prefix list is intentionally narrow (canonical interrogatives only)
// to avoid blocking borderline yes/no phrasings.
var openEndedRe = regexp.MustCompile(`(?i)^\s*(what|how|why|which|who|where|when|tell me|describe|explain)\b`)
// ValidateResult is the public outcome of validateQuestion. Either OK + the
+9 -12
View File
@@ -15,14 +15,11 @@ import (
"github.com/go-telegram/bot/models"
)
// SubjectFor mirrors JS getSubject: group/supergroup → chat ID (shared game
// state), otherwise → user ID. Returns "" when no usable id is present
// (caller should reply with a "cannot identify chat" error). Channels and
// unknown chat types fall through to From.ID.
//
// Canonical shape: the wordle module previously had an explicit
// ChatTypePrivate branch returning From.ID, which is identical to the
// default branch — folded together here.
// SubjectFor returns the identity key per-module state should be scoped by:
// group/supergroup → chat ID (shared game state), otherwise → user ID.
// Returns "" when no usable id is present (caller should reply with a
// "cannot identify chat" error). Channels and unknown chat types fall
// through to From.ID.
func SubjectFor(msg *models.Message) string {
if msg == nil {
return ""
@@ -39,7 +36,7 @@ func SubjectFor(msg *models.Message) string {
}
// ArgAfterCommand returns everything after the first space in text, trimmed.
// Works for `/cmd arg`, `/cmd@bot arg`, etc. JS-parity.
// Works for `/cmd arg`, `/cmd@bot arg`, etc.
func ArgAfterCommand(text string) string {
if text == "" {
return ""
@@ -88,9 +85,9 @@ func ReplyHTML(ctx context.Context, b *bot.Bot, msg *models.Message, text string
}
// WinRate computes wins/played as a percentage rounded to nearest int.
// math.Round matches JS Math.round (round half away from zero for positive
// inputs); plain int(...) truncation would render 2/3 as 66% where JS shows
// 67%. Returns 0 when played == 0 (avoids NaN).
// Uses math.Round (round half away from zero for positive inputs) so 2/3
// renders as 67%, not 66% as plain int(...) truncation would give.
// Returns 0 when played == 0 (avoids NaN).
func WinRate(wins, played int) int {
if played <= 0 {
return 0
+1 -1
View File
@@ -32,7 +32,7 @@ func infoCommand() modules.Command {
}
chatID := fmt.Sprintf("%d", msg.Chat.ID)
// Telegram omits message_thread_id outside forum topics, so a 0
// here is "no thread", same as JS's `?? "n/a"`.
// here means "no thread" — render as "n/a" instead of "0".
threadID := "n/a"
if msg.MessageThreadID != 0 {
threadID = fmt.Sprintf("%d", msg.MessageThreadID)
+1 -2
View File
@@ -57,8 +57,7 @@ func stickerIDCommand() modules.Command {
}
}
// stickerFrom pulls the sticker out of the *replied-to* message, mirroring the
// JS handler: ctx.message.reply_to_message.sticker.
// stickerFrom pulls the sticker out of the *replied-to* message.
func stickerFrom(msg *models.Message) *models.Sticker {
if msg.ReplyToMessage == nil {
return nil
+6 -7
View File
@@ -1,21 +1,20 @@
// Package wordle ports the JS wordle module — classic 5-letter word-guess
// game, scored letter-by-letter green/yellow/grey.
// Package wordle implements the classic 5-letter word-guess game, scored
// letter-by-letter green/yellow/grey.
package wordle
// WordLength is wordle's fixed 5. Exposed so render.go and tests can reuse it
// without magic numbers.
const WordLength = 5
// LetterResult labels a single guessed letter's state. Values match the JS
// wire format byte-for-byte: "correct" | "partial" | "wrong".
// LetterResult labels a single guessed letter's state. Values are part of
// the stored game's JSON shape: "correct" | "partial" | "wrong".
const (
ResultCorrect = "correct"
ResultPartial = "partial"
ResultWrong = "wrong"
)
// LetterScore is the JSON shape stored in KV per guess. Field tags match JS
// exactly so a saved JS game round-trips through Go without a custom decoder.
// LetterScore is the JSON shape stored in KV per guess.
type LetterScore struct {
Letter string `json:"letter"`
Result string `json:"result"`
@@ -65,7 +64,7 @@ func CompareWords(guess, target string) []LetterScore {
// indexOfByte returns the first index of c in s, or -1.
// (bytes.IndexByte gives the same answer; inlined to keep this file
// dependency-free and emphasize the JS-parity origin.)
// dependency-free since the scoring algorithm is the whole point.)
func indexOfByte(s []byte, c byte) int {
for i, b := range s {
if b == c {
+1 -2
View File
@@ -22,8 +22,7 @@ type state struct {
locks keylock.Map // per-subject mutex; serialises Get→mutate→Put
}
// rejectMessage maps a validation failure into the user-facing reply. JS
// parity word-for-word.
// rejectMessage maps a validation failure into the user-facing reply.
func rejectMessage(reason rejectReason) string {
switch reason {
case reasonEmpty:
+7 -8
View File
@@ -2,8 +2,7 @@ package wordle
import "strings"
// normalizeWord lowercases input and strips anything outside a-z. JS parity:
// `String(input).toLowerCase().replace(/[^a-z]/g, "")`.
// normalizeWord lowercases input and strips anything outside a-z.
func normalizeWord(input string) string {
lower := strings.ToLower(input)
out := make([]byte, 0, len(lower))
@@ -16,9 +15,9 @@ func normalizeWord(input string) string {
return string(out)
}
// rejectReason classifies why validateGuess returned not-ok. Values match the
// JS source's discriminated-union strings so the user-facing reply mapping
// (handlers.rejectReason) stays parallel.
// rejectReason classifies why validateGuess returned not-ok. The user-facing
// reply mapping in handlers branches on these values, so renaming a constant
// here requires updating that mapping.
type rejectReason string
const (
@@ -27,9 +26,9 @@ const (
reasonUnknown rejectReason = "unknown"
)
// guessResult mirrors JS's `{ok: true, word} | {ok: false, reason, word}`.
// Word is always populated (the normalized form), even on failure, so callers
// can include it in error messages if desired.
// guessResult is the validateGuess outcome. Word is always populated with the
// normalized form, even on failure, so callers can include it in error
// messages without re-normalizing.
type guessResult struct {
OK bool
Word string
+11 -9
View File
@@ -11,11 +11,11 @@ import (
// MaxGuesses is the standard wordle round length.
const MaxGuesses = 6
// Cloud Firestore has no native per-document TTL equivalent to Cloudflare KV
// — saved games linger until manually cleaned. Out of scope today; tracked
// in port plan as a future cron.
// Note: the KV store has no native per-document TTL — saved games linger
// until manually cleaned. Out of scope today; could be added via a sweep
// cron if storage cost ever matters.
// GuessRecord is one entry in a game's history. JSON shape locks JS parity:
// GuessRecord is one entry in a game's history:
//
// { "word": "crane", "results": [{"letter":"c","result":"correct"}, ...] }
type GuessRecord struct {
@@ -24,11 +24,12 @@ type GuessRecord struct {
}
// GameState is the per-subject KV record for an in-progress (or finished)
// round. Field tags match JS exactly so a JS-written round decodes cleanly.
// round.
//
// `giveup` is always emitted (initialized to false on /wordle_new). Do NOT
// add omitempty — the JS source serializes the field unconditionally and
// cross-runtime migration depends on shape parity.
// add omitempty — the field is part of the stored document's shape, so
// emitting it unconditionally keeps already-saved games self-describing
// when inspected via raw KV dumps.
type GameState struct {
Target string `json:"target"`
Guesses []GuessRecord `json:"guesses"`
@@ -38,7 +39,8 @@ type GameState struct {
}
// Stats is the lifetime score record. lastResultAt is *int64 so an unplayed
// account marshals as `"lastResultAt": null` matching JS's initial shape.
// account marshals as `"lastResultAt": null` — distinguishes "never played"
// from "played at epoch zero".
type Stats struct {
Played int `json:"played"`
Wins int `json:"wins"`
@@ -73,7 +75,7 @@ func saveGame(ctx context.Context, kv storage.KVStore, subject string, g *GameSt
}
// loadStats returns lifetime stats; missing → fresh-zero record (with
// LastResultAt=nil), matching the JS `?? {…}` fallback.
// LastResultAt=nil) so callers never need a nil check.
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*Stats, error) {
var s Stats
err := kv.GetJSON(ctx, statsKey(subject), &s)
+4 -3
View File
@@ -9,8 +9,9 @@ import (
)
func TestStats_DefaultLastResultAtIsNull(t *testing.T) {
// JS shape: `{ ..., lastResultAt: null }` — Go's *int64 must marshal
// as null when nil to keep cross-runtime KV documents compatible.
// Go's *int64 must marshal as null when nil, so unplayed accounts emit
// `"lastResultAt": null` and the field stays distinguishable from
// "played at ms-epoch 0".
s := Stats{}
b, err := json.Marshal(s)
if err != nil {
@@ -33,7 +34,7 @@ func TestStats_WithResultMarshalsAsNumber(t *testing.T) {
}
}
func TestGameState_JSONShapeMatchesJS(t *testing.T) {
func TestGameState_JSONShapeIsStable(t *testing.T) {
g := GameState{
Target: "crane",
Guesses: []GuessRecord{
+2 -3
View File
@@ -5,9 +5,8 @@ import (
"strings"
)
// rawWords holds the raw words.txt bytes embedded at compile time. The file
// was extracted byte-for-byte from the JS source's words-data.js so the Go
// and JS bots have identical dictionaries.
// rawWords holds the raw words.txt bytes embedded at compile time. One word
// per line, lowercase, exactly WordLength a-z; see loadWords for validation.
//
//go:embed data/words.txt
var rawWords string
+1 -1
View File
@@ -36,7 +36,7 @@ func (r *statusRecorder) effectiveStatus() int {
// {"msg":"req","method":"POST","path":"/webhook","status":200,"ms":12}
//
// CloudWatch Logs filters on `jsonPayload.msg=req AND jsonPayload.status>=500`
// for 5xx-rate alerting. Mirrors the JS source's index.js shape.
// for 5xx-rate alerting — keep the field names stable or the alarm goes dark.
//
// The req line is emitted from a deferred closure so a panic in a downstream
// handler still produces an observable log entry — without this, a cron