refactor(semantle): swap word2sim backend for ConceptNet

ConceptNet provides a free public /relatedness endpoint (returns cosine-like
[-1, 1]) and /c/en/{term} for vocabulary check. No random-word endpoint, so
we ship a curated local target pool in wordlist.js (~250 words) and verify
each pick via the concept endpoint with a fallback to an unverified pick.

Each guess now makes two parallel ConceptNet calls (concept + relatedness)
instead of a single word2sim call. Slightly higher latency but zero hosting
cost and no dependency on the self-hosted word2sim instance.

- api-client.js rewritten; UpstreamError replaces Word2SimError (aliased
  for backwards compat with older imports).
- wordlist.js added (curated target pool + pickFromPool).
- handlers.js: drops RANDOM_FILTERS (no filtering needed; pool is curated).
- index.js: drops WORD2SIM_API_URL env var; ConceptNet base hardcoded.
- wrangler.toml + .dev.vars.example: drop WORD2SIM_API_URL.
- api-client tests rewritten for ConceptNet shape; total tests 336 → 341.
This commit is contained in:
2026-04-22 23:07:54 +07:00
parent 52c50528a7
commit b459cc9ee7
8 changed files with 337 additions and 164 deletions
-4
View File
@@ -2,7 +2,3 @@
# Copy to .dev.vars (gitignored) and fill in real values.
TELEGRAM_BOT_TOKEN=
TELEGRAM_WEBHOOK_SECRET=
# Optional: override the word2sim base URL for local/self-hosted instances
# (semantle module). Default is https://word2sim.sg.miti99.com.
# WORD2SIM_API_URL=http://localhost:8000
+29 -23
View File
@@ -1,9 +1,9 @@
# Semantle Module
Word2vec similarity guessing game. A secret word is picked from our hosted
[word2sim](https://github.com/tiennm99/word2sim) instance; each guess is
scored by cosine similarity against the target. Unlimited guesses per round
— you play until you get the exact word (case-insensitive).
Semantic-similarity guessing game. A secret word is picked from a local
curated pool and validated against ConceptNet; each guess is scored by
ConceptNet's relatedness API against the target. Unlimited guesses per
round — you play until you get the exact word (case-insensitive).
## Commands
@@ -20,28 +20,38 @@ ignored (no cost, no stat inflation).
## Data source
Target words and similarity scores come from **[word2sim](https://word2sim.sg.miti99.com)**,
our hosted FastAPI service over the GoogleNews pretrained word2vec model
(3M tokens × 300 dims). Two endpoints are used:
**[ConceptNet 5](https://api.conceptnet.io/)** — free public API, no auth,
~300k English concepts including multi-word phrases. Two endpoints:
- `GET /random` — round-start target pick, filtered to game-friendly words.
- `GET /similarity?a&b` — per-guess cosine similarity.
- `GET /relatedness?node1=/c/en/X&node2=/c/en/Y` — per-guess similarity,
returns `{ value: number ∈ [-1, 1] }`.
- `GET /c/en/{term}` — vocabulary check: term is in vocab iff the response
carries at least one edge.
No local model — every guess is a network round-trip. Typical latency
~200400ms; `api-client.js` enforces a 5s timeout and surfaces a
"Upstream hiccup" message on failure.
Because ConceptNet has no random-word endpoint, the target pool ships in
`wordlist.js` (~250 curated English words, 410 letters, all alphabetic).
Each new round picks locally, verifies via the concept endpoint, and falls
back to an unverified pick after a few misses.
Every guess costs **two** ConceptNet calls (concept edges + relatedness)
issued in parallel. Typical latency ~300600ms round-trip from Cloudflare
Workers; `api-client.js` enforces a 5s timeout and surfaces a "Upstream
hiccup" message on failure.
## Architecture
- `api-client.js`word2sim HTTP wrapper (`randomWord`, `similarity`) plus
`Word2SimError` with `{status, body, cause}` metadata.
- `api-client.js`ConceptNet HTTP wrapper (`randomWord`, `similarity`,
plus lower-level `concept` / `relatedness`) with `UpstreamError` metadata.
Preserves the earlier word2sim response shape so the rest of the module
didn't need rewriting.
- `wordlist.js` — curated local target pool and `pickFromPool()`.
- `state.js` — KV persistence for game + stats. Target stored lowercased.
- `lookup.js` — guess normalization and shape validation.
- `format.js` — warmth-percent and emoji-bucket formatters.
- `render.js` — Telegram HTML `<pre>` monospace board, sorted by similarity
desc, capped at top 15 rows to stay under Telegram's message-length limit.
- `handlers.js` — subject resolution (user in DMs, chat in groups) + the
four command entry points.
three command entry points.
Subject resolution: private chats track per-user games; groups track
per-chat shared games. Mirrors `loldle`/`wordle`.
@@ -60,12 +70,9 @@ form is lowercased on write so the solve check is a single string compare.
## Config
| Env var | Default | Meaning |
|---------|---------|---------|
| `WORD2SIM_API_URL` | `https://word2sim.sg.miti99.com` | word2sim base URL; override for local word2sim or self-hosted |
Set in `wrangler.toml` `[vars]`. For local `wrangler dev`, optionally add
to `.dev.vars` (gitignored).
No env vars. ConceptNet's public API base (`https://api.conceptnet.io`) is
hardcoded in `api-client.js`; pass an override to `createClient(url)` if you
need to point at a mirror or test double.
## Why unlimited guesses?
@@ -76,6 +83,5 @@ across all rounds.
## Credits
- Embedding model: Google's pretrained word2vec (3M tokens, 300 dims, trained on Google News).
- Hosting layer: [tiennm99/word2sim](https://github.com/tiennm99/word2sim).
- Similarity + vocabulary: [ConceptNet 5](https://conceptnet.io) by Robyn Speer et al.
- Game concept: [Semantle](https://semantle.com/) by David Turner.
+85 -24
View File
@@ -1,29 +1,38 @@
/**
* @file word2sim HTTP API client.
* @file ConceptNet API client for the semantle module.
*
* Wraps two endpoints:
* GET /random → pick a secret word at round start
* GET /similarity → cosine similarity between target and guess per turn
* ConceptNet endpoints:
* GET /relatedness?node1=/c/en/X&node2=/c/en/Y → { value: number ∈ [-1, 1] }
* GET /c/en/{term} → { edges: [...] } (empty ⇒ OOV)
*
* Stateless. No caching layer — word2sim itself is cheap enough, and caching
* per-pair scores in KV would pollute the namespace without measurable gain.
* There is no official random-word endpoint, so the client picks a candidate
* from our local `TARGET_POOL` and verifies it has a ConceptNet entry with
* edges. After a few failed attempts it falls back to an unverified pick —
* the curated pool is trusted enough that this should be rare.
*
* The returned `similarity(a, b)` shape mirrors the earlier word2sim contract
* so handlers/render/state don't have to change.
*/
import { pickFromPool } from "./wordlist.js";
const DEFAULT_API_BASE = "https://api.conceptnet.io";
const DEFAULT_TIMEOUT_MS = 5000;
const USER_AGENT = "miti99bot/semantle";
const MAX_RANDOM_ATTEMPTS = 5;
export class Word2SimError extends Error {
export class UpstreamError extends Error {
/** @param {string} message @param {{status?: number, body?: string, cause?: unknown}} [meta] */
constructor(message, meta = {}) {
super(message);
this.name = "Word2SimError";
this.name = "UpstreamError";
this.status = meta.status;
this.body = meta.body;
if (meta.cause !== undefined) this.cause = meta.cause;
}
}
function buildUrl(base, path, params) {
function buildUrl(base, path, params = {}) {
const normalized = String(base).replace(/\/+$/, "");
const url = new URL(`${normalized}${path}`);
for (const [k, v] of Object.entries(params)) {
@@ -44,12 +53,12 @@ async function fetchJson(url, timeoutMs) {
});
} catch (err) {
clearTimeout(timer);
throw new Word2SimError("word2sim fetch failed", { cause: err });
throw new UpstreamError("conceptnet fetch failed", { cause: err });
}
clearTimeout(timer);
const text = await res.text();
if (!res.ok) {
throw new Word2SimError(`word2sim HTTP ${res.status}`, {
throw new UpstreamError(`conceptnet HTTP ${res.status}`, {
status: res.status,
body: text.slice(0, 500),
});
@@ -57,37 +66,89 @@ async function fetchJson(url, timeoutMs) {
try {
return JSON.parse(text);
} catch (err) {
throw new Word2SimError("word2sim non-JSON response", { cause: err });
throw new UpstreamError("conceptnet non-JSON response", { cause: err });
}
}
function hasEdges(concept) {
return Array.isArray(concept?.edges) && concept.edges.length > 0;
}
/**
* @param {string} apiBase — e.g. "https://word2sim.sg.miti99.com"
* @param {string} [apiBase]override for mirrors/tests (default api.conceptnet.io)
* @param {{ timeoutMs?: number }} [opts]
*/
export function createClient(apiBase, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
export function createClient(apiBase = DEFAULT_API_BASE, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
/** @param {string} term */
function concept(term) {
return fetchJson(buildUrl(apiBase, `/c/en/${encodeURIComponent(term)}`), timeoutMs);
}
/** @param {string} a @param {string} b */
function relatedness(a, b) {
return fetchJson(
buildUrl(apiBase, "/relatedness", {
node1: `/c/en/${a}`,
node2: `/c/en/${b}`,
}),
timeoutMs,
);
}
return {
concept,
relatedness,
/**
* Pick a random vocab word matching filters.
* @param {Record<string, string|number|boolean>} [filters]
* @returns {Promise<{ word: string, rank: number }>}
* Pick a target word from the local pool. Verifies each candidate has a
* ConceptNet entry; falls back to an unverified pick after a few tries.
* Shape matches the old word2sim `/random` response for handler reuse.
* @returns {Promise<{ word: string, verified: boolean }>}
*/
randomWord(filters = {}) {
return fetchJson(buildUrl(apiBase, "/random", filters), timeoutMs);
async randomWord() {
for (let i = 0; i < MAX_RANDOM_ATTEMPTS; i++) {
const candidate = pickFromPool();
try {
const c = await concept(candidate);
if (hasEdges(c)) return { word: candidate, verified: true };
} catch {
// swallow — try the next candidate
}
}
return { word: pickFromPool(), verified: false };
},
/**
* Cosine similarity between two words.
* Cosine-like similarity between `a` (target) and `b` (guess). Runs the
* edge-check for `b` in parallel with the relatedness call so OOV guesses
* are identified on the same round-trip.
*
* Shape deliberately mirrors the old word2sim response.
* @param {string} a
* @param {string} b
* @returns {Promise<{
* a: string, b: string,
* canonical_a: string|null, canonical_b: string|null,
* canonical_a: string, canonical_b: string,
* in_vocab_a: boolean, in_vocab_b: boolean,
* similarity: number|null
* similarity: number | null,
* }>}
*/
similarity(a, b) {
return fetchJson(buildUrl(apiBase, "/similarity", { a, b }), timeoutMs);
async similarity(a, b) {
const [conceptB, rel] = await Promise.all([concept(b), relatedness(a, b)]);
const inVocabB = hasEdges(conceptB);
const value = typeof rel?.value === "number" ? rel.value : null;
return {
a,
b,
canonical_a: a,
canonical_b: b,
in_vocab_a: true, // target was verified at round start
in_vocab_b: inVocabB,
similarity: inVocabB ? value : null,
};
},
};
}
// Backwards-compat alias — older imports referenced `Word2SimError`.
export { UpstreamError as Word2SimError };
+4 -11
View File
@@ -13,19 +13,12 @@
*/
import { escapeHtml } from "../../util/escape-html.js";
import { Word2SimError } from "./api-client.js";
import { UpstreamError } from "./api-client.js";
import { isValidShape, normalize } from "./lookup.js";
import { renderBoard, renderGuess } from "./render.js";
import { clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js";
const UPSTREAM_FAIL = "⚠️ Upstream hiccup — try again in a few seconds.";
const RANDOM_FILTERS = {
min_rank: 500,
max_rank: 20000,
alpha_only: true,
min_len: 4,
max_len: 10,
};
function getSubject(ctx) {
const type = ctx.chat?.type;
@@ -44,15 +37,15 @@ function logFail(stage, err) {
JSON.stringify({
msg: "semantle_upstream_fail",
stage,
err: err instanceof Word2SimError ? { status: err.status, body: err.body } : String(err),
err: err instanceof UpstreamError ? { status: err.status, body: err.body } : String(err),
}),
);
}
async function startFreshGame(db, client, subject) {
const picked = await client.randomWord(RANDOM_FILTERS);
const picked = await client.randomWord();
const target = String(picked?.word ?? "").toLowerCase();
if (!target) throw new Word2SimError("empty target from /random");
if (!target) throw new UpstreamError("empty target from randomWord");
const fresh = { target, startedAt: null, solved: false, guesses: [] };
await saveGame(db, subject, fresh);
return fresh;
+7 -9
View File
@@ -1,16 +1,15 @@
/**
* @file Semantle module — word2vec similarity guessing game.
* @file Semantle module — similarity guessing game backed by ConceptNet.
*
* Target words come from our own hosted word2sim instance
* (default: https://word2sim.sg.miti99.com). Override via env var
* `WORD2SIM_API_URL` for local dev or self-hosting.
* Targets come from a curated local wordlist (ConceptNet has no /random).
* Similarity scores come from `api.conceptnet.io/relatedness`. The ConceptNet
* base URL is hardcoded in the client; tests can still override via
* `createClient(url)` if needed.
*/
import { createClient } from "./api-client.js";
import { handleGiveup, handleSemantle, handleStats } from "./handlers.js";
const DEFAULT_API_URL = "https://word2sim.sg.miti99.com";
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
let db = null;
/** @type {ReturnType<typeof createClient> | null} */
@@ -19,10 +18,9 @@ let client = null;
/** @type {import("../registry.js").BotModule} */
const semantleModule = {
name: "semantle",
init: async ({ db: store, env }) => {
init: async ({ db: store }) => {
db = store;
const base = env?.WORD2SIM_API_URL || DEFAULT_API_URL;
client = createClient(base);
client = createClient();
},
commands: [
{
+75
View File
@@ -0,0 +1,75 @@
/**
* @file Curated target-word pool for the semantle module.
*
* ConceptNet has no random-word endpoint, so we ship a hand-picked list of
* common, game-friendly English nouns/verbs/adjectives (410 ASCII letters).
* The list is small on purpose — every entry is a reasonable Semantle target,
* which matters more than raw size. Expand freely as the game matures.
*
* Entries are lowercase and alphabetic only (the similarity endpoint accepts
* these directly as `/c/en/<word>` concept IDs).
*/
// biome-ignore format: keep the list compact and grep-friendly
export const TARGET_POOL = [
// nature / geography
"ocean", "mountain", "forest", "desert", "river", "valley", "garden", "island",
"beach", "cave", "meadow", "glacier", "volcano", "canyon", "jungle", "lake",
"hill", "plateau", "cliff", "harbor", "coast", "swamp", "prairie", "tundra",
"delta", "creek", "stream", "pebble", "boulder", "horizon", "iceberg", "dune",
// weather / time
"winter", "summer", "autumn", "spring", "morning", "evening", "midnight",
"sunrise", "sunset", "thunder", "rainbow", "blizzard", "breeze", "drought",
"storm", "shadow", "twilight", "decade",
// people / relations
"friend", "family", "mother", "father", "brother", "sister", "child",
"stranger", "neighbor", "partner", "sibling", "elder", "infant",
// arts
"music", "dance", "poem", "story", "painting", "theater", "cinema", "novel",
"symphony", "sculpture", "sketch", "ballet", "opera", "concert",
// objects
"computer", "phone", "camera", "robot", "engine", "wheel", "pencil", "hammer",
"mirror", "bicycle", "umbrella", "lantern", "compass", "anchor", "blanket",
"candle", "cushion", "kettle", "ladder", "needle", "paper", "pillow",
"scissors", "telescope", "throne", "vase", "window", "zipper", "bottle",
"basket", "bridge", "tower",
// animals
"eagle", "tiger", "dolphin", "rabbit", "snake", "salmon", "wolf", "horse",
"butterfly", "elephant", "panda", "falcon", "sparrow", "penguin", "octopus",
"beetle", "crow", "dragon", "hawk", "jaguar", "kangaroo", "lion", "monkey",
"otter", "parrot", "raccoon", "squirrel", "turtle", "whale", "bear", "fox",
"shark",
// food
"apple", "bread", "cheese", "coffee", "sugar", "pepper", "potato", "orange",
"honey", "chocolate", "cinnamon", "almond", "berry", "butter", "grape",
"lemon", "olive", "tomato", "walnut", "yogurt", "ginger",
// emotions / abstract
"love", "anger", "fear", "courage", "sorrow", "wonder", "dream", "memory",
"silence", "laughter", "hope", "delight", "regret", "trust", "justice",
"freedom", "honor", "peace", "victory", "promise", "secret", "truth",
"wisdom", "mystery", "destiny", "patience", "loyalty",
// professions
"teacher", "doctor", "artist", "farmer", "pilot", "soldier", "writer",
"sailor", "hunter", "engineer", "chef", "dentist", "nurse", "judge",
"scientist",
// body
"shoulder", "finger", "elbow", "throat", "pulse", "tongue", "ankle", "spine",
"muscle", "nerve", "eyelid", "beard", "tooth", "thumb", "knee",
// verbs / actions
"gather", "wonder", "linger", "stumble", "whisper", "shimmer", "wander",
"rescue", "gallop", "imagine", "pursue", "retreat", "thrive", "squeeze",
"shatter", "tremble", "travel", "borrow", "descend", "inherit", "vanish",
"forget", "invite", "resist", "settle",
// adjectives
"ancient", "gentle", "fragile", "massive", "glowing", "rugged", "silent",
"frozen", "steady", "brittle", "clever", "gloomy", "cheerful", "noisy",
"silver", "golden", "radiant", "distant", "graceful", "humble", "vivid",
"quiet", "sacred", "sudden", "tender", "wealthy", "generous", "majestic",
];
/**
* @returns {string} — a random lowercase word from the pool.
*/
export function pickFromPool() {
return TARGET_POOL[Math.floor(Math.random() * TARGET_POOL.length)];
}
+137 -91
View File
@@ -1,76 +1,142 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Word2SimError, createClient } from "../../../src/modules/semantle/api-client.js";
import {
UpstreamError,
Word2SimError,
createClient,
} from "../../../src/modules/semantle/api-client.js";
/**
* ConceptNet stubs — minimal shape the client cares about.
*/
function conceptResp(edgeCount = 5) {
return {
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
edges: Array.from({ length: edgeCount }, (_, i) => ({ id: `e${i}` })),
}),
),
};
}
function relatednessResp(value) {
return {
ok: true,
text: () => Promise.resolve(JSON.stringify({ value })),
};
}
describe("semantle/api-client", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("Word2SimError", () => {
describe("UpstreamError", () => {
it("stores status and body metadata", () => {
const err = new Word2SimError("test", { status: 404, body: "not found" });
const err = new UpstreamError("test", { status: 404, body: "not found" });
expect(err.message).toBe("test");
expect(err.status).toBe(404);
expect(err.body).toBe("not found");
expect(err.name).toBe("Word2SimError");
expect(err.name).toBe("UpstreamError");
});
it("stores cause when provided", () => {
const cause = new Error("underlying");
const err = new Word2SimError("wrapper", { cause });
const err = new UpstreamError("wrapper", { cause });
expect(err.cause).toBe(cause);
});
it("is re-exported as Word2SimError alias for legacy callers", () => {
expect(Word2SimError).toBe(UpstreamError);
});
});
describe("createClient", () => {
it("randomWord builds correct URL with filters", async () => {
it("similarity runs concept + relatedness in parallel", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
const calls = [];
global.fetch = vi.fn((url) => {
expect(url).toContain("/random");
expect(url).toContain("min_rank=5");
expect(url).toContain("alpha=true");
return Promise.resolve({
ok: true,
text: () => Promise.resolve('{"word":"apple","rank":1234}'),
});
});
const res = await client.randomWord({ min_rank: 5, alpha: true });
expect(res.word).toBe("apple");
expect(res.rank).toBe(1234);
});
it("similarity builds URL with both words", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn((url) => {
expect(url).toContain("/similarity");
expect(url).toContain("a=apple");
expect(url).toContain("b=orange");
return Promise.resolve({
ok: true,
text: () =>
Promise.resolve(
'{"a":"apple","b":"orange","in_vocab_a":true,"in_vocab_b":true,"similarity":0.45}',
),
});
calls.push(String(url));
if (url.includes("/relatedness")) return Promise.resolve(relatednessResp(0.45));
return Promise.resolve(conceptResp(3));
});
const res = await client.similarity("apple", "orange");
expect(res.similarity).toBe(0.45);
expect(res.in_vocab_b).toBe(true);
expect(res.canonical_b).toBe("orange");
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(calls.some((u) => u.includes("/c/en/orange"))).toBe(true);
expect(calls.some((u) => u.includes("node1=%2Fc%2Fen%2Fapple"))).toBe(true);
expect(calls.some((u) => u.includes("node2=%2Fc%2Fen%2Forange"))).toBe(true);
});
it("URL-encodes special characters in params", async () => {
it("similarity flags OOV when the concept endpoint returns no edges", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn((url) => {
expect(url).toMatch(/search=hello/);
return Promise.resolve({
ok: true,
text: () => Promise.resolve('{"word":"test"}'),
});
if (url.includes("/relatedness")) return Promise.resolve(relatednessResp(0.02));
return Promise.resolve(conceptResp(0));
});
await client.randomWord({ search: "hello world" });
expect(global.fetch).toHaveBeenCalled();
const res = await client.similarity("apple", "zzzfoo");
expect(res.in_vocab_b).toBe(false);
expect(res.similarity).toBe(null);
});
it("throws Word2SimError on non-2xx response", async () => {
it("similarity returns null when relatedness payload lacks a numeric value", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn((url) => {
if (url.includes("/relatedness")) {
return Promise.resolve({ ok: true, text: () => Promise.resolve("{}") });
}
return Promise.resolve(conceptResp(5));
});
const res = await client.similarity("apple", "orange");
expect(res.similarity).toBe(null);
});
it("similarity distinguishes 0 from null", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn((url) => {
if (url.includes("/relatedness")) return Promise.resolve(relatednessResp(0));
return Promise.resolve(conceptResp(5));
});
const res = await client.similarity("apple", "orange");
expect(res.similarity).toBe(0);
expect(res.in_vocab_b).toBe(true);
});
it("randomWord returns a verified pick when edges present", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn(() => Promise.resolve(conceptResp(5)));
const res = await client.randomWord();
expect(typeof res.word).toBe("string");
expect(res.word.length).toBeGreaterThan(0);
expect(res.verified).toBe(true);
});
it("randomWord falls back to unverified pick after max attempts", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
// Every concept lookup returns zero edges → exhausts retries.
global.fetch = vi.fn(() => Promise.resolve(conceptResp(0)));
const res = await client.randomWord();
expect(res.verified).toBe(false);
expect(typeof res.word).toBe("string");
});
it("randomWord swallows transient fetch errors during verification", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
let n = 0;
global.fetch = vi.fn(() => {
n += 1;
// Error for the first few attempts, then succeed.
if (n <= 2) return Promise.reject(new Error("transient"));
return Promise.resolve(conceptResp(3));
});
const res = await client.randomWord();
expect(res.verified).toBe(true);
});
it("concept throws UpstreamError on non-2xx response", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn(() =>
Promise.resolve({
@@ -79,95 +145,75 @@ describe("semantle/api-client", () => {
text: () => Promise.resolve("Internal Server Error"),
}),
);
await expect(client.randomWord()).rejects.toMatchObject({
name: "Word2SimError",
await expect(client.concept("apple")).rejects.toMatchObject({
name: "UpstreamError",
status: 500,
body: "Internal Server Error",
});
});
it("throws Word2SimError when response is not valid JSON", async () => {
it("concept throws UpstreamError when response is not valid JSON", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
text: () => Promise.resolve("not json at all"),
}),
Promise.resolve({ ok: true, text: () => Promise.resolve("not json") }),
);
await expect(client.randomWord()).rejects.toMatchObject({
name: "Word2SimError",
});
await expect(client.concept("apple")).rejects.toMatchObject({ name: "UpstreamError" });
});
it("throws Word2SimError on fetch failure", async () => {
it("concept throws UpstreamError on fetch failure", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn(() => Promise.reject(new Error("network error")));
await expect(client.randomWord()).rejects.toThrow("word2sim fetch failed");
await expect(client.concept("apple")).rejects.toThrow("conceptnet fetch failed");
});
it("uses custom timeout and truncates response body to 500 chars", async () => {
it("truncates response body to 500 chars in UpstreamError", async () => {
const client = createClient("https://api.test", { timeoutMs: 50 });
const longBody = "x".repeat(600);
global.fetch = vi.fn(() =>
Promise.resolve({
ok: false,
status: 400,
text: () => Promise.resolve(longBody),
}),
Promise.resolve({ ok: false, status: 400, text: () => Promise.resolve(longBody) }),
);
try {
await client.randomWord();
await client.concept("apple");
} catch (err) {
expect(err.body.length).toBe(500);
}
});
it("includes User-Agent header", async () => {
it("sends User-Agent and Accept headers", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn((_, opts) => {
expect(opts.headers["User-Agent"]).toContain("miti99bot");
return Promise.resolve({
ok: true,
text: () => Promise.resolve('{"word":"test"}'),
});
});
await client.randomWord();
});
it("includes Accept header", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn((_, opts) => {
expect(opts.headers.Accept).toBe("application/json");
return Promise.resolve({
ok: true,
text: () => Promise.resolve('{"word":"test"}'),
});
return Promise.resolve(conceptResp(1));
});
await client.randomWord();
await client.concept("apple");
});
it("handles trailing slashes in API base URL", async () => {
it("strips trailing slashes from the API base URL", async () => {
const client = createClient("https://api.test///", { timeoutMs: 100 });
global.fetch = vi.fn((url) => {
expect(url.startsWith("https://api.test/")).toBe(true);
return Promise.resolve({
ok: true,
text: () => Promise.resolve('{"word":"test"}'),
});
expect(url.startsWith("https://api.test/c/en/")).toBe(true);
return Promise.resolve(conceptResp(1));
});
await client.randomWord();
await client.concept("apple");
});
it("filters out undefined/null params", async () => {
it("URL-encodes the term path segment", async () => {
const client = createClient("https://api.test", { timeoutMs: 100 });
global.fetch = vi.fn((url) => {
expect(url).not.toContain("min_rank=");
return Promise.resolve({
ok: true,
text: () => Promise.resolve('{"word":"test"}'),
});
expect(url).toContain("/c/en/hello%20world");
return Promise.resolve(conceptResp(1));
});
await client.randomWord({ min_rank: undefined, max_rank: null });
await client.concept("hello world");
});
it("defaults to the public ConceptNet base URL when none provided", async () => {
const client = createClient();
global.fetch = vi.fn((url) => {
expect(url.startsWith("https://api.conceptnet.io/")).toBe(true);
return Promise.resolve(conceptResp(1));
});
await client.concept("apple");
});
});
});
-2
View File
@@ -6,8 +6,6 @@ compatibility_date = "2025-10-01"
# Also duplicate this value into .env.deploy so scripts/register.js derives the same public command list.
[vars]
MODULES = "util,wordle,loldle,misc,trading,lolschedule,semantle"
# Base URL for the hosted word2sim similarity API (semantle module).
WORD2SIM_API_URL = "https://word2sim.sg.miti99.com"
# KV namespace holding all module state. Each module auto-prefixes its keys via createStore().
# Production-only — no preview namespace. Create with: