From bcfd512246c02f1c1d4e95da6ea7bf8f08149a9c Mon Sep 17 00:00:00 2001 From: Codebuff Date: Mon, 19 Oct 2026 11:24:29 +0000 Subject: [PATCH 1/7] feat(poller): compare per-cycle activity with a rolling baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a bounded rolling baseline of events per complete poll cycle so the per-cycle log lines and cumulative counters have context. Anomalous cycles are logged and exposed through /health and both metrics endpoints. Generated with Codebuff πŸ€– Co-Authored-By: Codebuff --- CHANGELOG.md | 16 ++ docs/alerting.md | 18 ++ docs/schemas/health-report.schema.json | 30 +++ src/activity-baseline.ts | 235 ++++++++++++++++++++++++ src/health.ts | 28 +++ src/metrics.ts | 26 ++- src/poller.ts | 47 +++++ tests/activity-baseline-poller.test.mjs | 153 +++++++++++++++ tests/activity-baseline.test.mjs | 162 ++++++++++++++++ tests/metrics.test.mjs | 33 ++++ 10 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 src/activity-baseline.ts create mode 100644 tests/activity-baseline-poller.test.mjs create mode 100644 tests/activity-baseline.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 39a9ad11..ce88889b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ All notable changes to this project are documented here. ### Added +**Per-cycle activity baseline** + +- The poller now keeps a bounded rolling window of events observed per complete poll + cycle (`src/activity-baseline.ts`). Each cycle is compared with the mean and sample + standard deviation of the cycles before it and classified as `normal`, `spike` + (z β‰₯ 3) or `drop` (z ≀ βˆ’3); until ten cycles exist it reports `warming-up`. Partial + cycles β€” one target in backoff or failed β€” are not recorded, so an RPC problem is not + mistaken for a quiet chain. +- Anomalies are logged once as a bounded `[poller] activity spike|drop: …` line and + counted in `mimir_bot_activity_anomalies_total`. +- `GET /health` exposes the full snapshot at `poller.activity` (window size, samples, + last value, baseline mean and standard deviation, z-score, classification and anomaly + count). Both metrics endpoints expose the last cycle's events, the baseline mean, the + z-score and the anomaly count (`mimir_bot_*` and `mimir_telegram_activity_*`). +- The health-report JSON schema is updated with the new closed `activity` object. + **Malformed-event robustness** - `clip()` is now applied to the `category` field in `claim_created` notifications (bounded diff --git a/docs/alerting.md b/docs/alerting.md index 948f8122..ebd73bcd 100644 --- a/docs/alerting.md +++ b/docs/alerting.md @@ -24,6 +24,24 @@ scrape_configs: - targets: ["127.0.0.1:8787"] ``` +### Activity baseline + +Both endpoints also export a comparison of the latest **complete** poll cycle with a rolling baseline of the twenty cycles before it (ten prior cycles are required before a classification is made; partial cycles with a failed or backed-off target are not recorded): + +| Metric | Meaning | +| :--- | :--- | +| `mimir_telegram_activity_last_cycle_events` / `mimir_bot_last_cycle_events` | Events observed in the most recent complete cycle. | +| `mimir_telegram_activity_baseline_events` / `mimir_bot_activity_baseline_events` | Mean events per cycle in the rolling baseline (0 while warming up). | +| `mimir_telegram_activity_deviation` | z-score of the latest cycle against the baseline (0 while warming up). | +| `mimir_telegram_activity_anomalies_total` / `mimir_bot_activity_anomalies_total` | Complete cycles classified as a spike (z β‰₯ 3) or drop (z ≀ βˆ’3). | + +The same snapshot is available from `curl -s http://127.0.0.1:8787/health | jq .poller.activity`. A spike is not a fault by itself β€” a genuinely busy chain should not page anyone β€” so read it as context next to `eventsSkipped`, the notification cap, and `poller.error`. A sustained *drop* on an otherwise active contract more often means a broken filter, a cursor problem, or an upstream outage: + +```promql +# Latest complete cycle is far below its rolling baseline (drop). +mimir_telegram_activity_deviation < -3 +``` + --- ## 2. Validating the Rules with `promtool` diff --git a/docs/schemas/health-report.schema.json b/docs/schemas/health-report.schema.json index 8935c68e..e37d54cc 100644 --- a/docs/schemas/health-report.schema.json +++ b/docs/schemas/health-report.schema.json @@ -39,6 +39,7 @@ "notificationsFailed", "eventsSkipped", "eventsDeduplicated", + "activity", "notificationsDropped", "cursorRewinds", "deadLetter", @@ -77,6 +78,35 @@ "notificationsFailed": { "type": "integer", "minimum": 0 }, "eventsSkipped": { "type": "integer", "minimum": 0 }, "eventsDeduplicated": { "type": "integer", "minimum": 0, "description": "Events suppressed as already seen across overlapping pages and resumes." }, + "activity": { + "type": "object", + "additionalProperties": false, + "description": "Comparison of the latest complete cycle's event count with a rolling baseline of recent complete cycles. Counts only.", + "required": [ + "windowSize", + "minSamples", + "zThreshold", + "samples", + "lastValue", + "baselineMean", + "baselineStdDev", + "zScore", + "deviation", + "anomalies" + ], + "properties": { + "windowSize": { "type": "integer", "minimum": 1, "description": "Maximum observations retained in the rolling window." }, + "minSamples": { "type": "integer", "minimum": 1, "description": "Prior observations required before a deviation is classified." }, + "zThreshold": { "type": "number", "minimum": 0, "description": "Absolute z-score at or above which a cycle is a spike or drop." }, + "samples": { "type": "integer", "minimum": 0, "description": "Observations currently retained (0 before the first complete cycle)." }, + "lastValue": { "type": ["integer", "null"], "minimum": 0, "description": "Events observed in the most recent complete cycle, or null before the first one." }, + "baselineMean": { "type": ["number", "null"], "minimum": 0, "description": "Mean events per cycle in the window used for the last comparison, or null while warming up." }, + "baselineStdDev": { "type": ["number", "null"], "minimum": 0, "description": "Sample standard deviation of that window, or null while warming up." }, + "zScore": { "type": ["number", "null"], "description": "Deviation of lastValue from the baseline in standard deviations, or null while warming up." }, + "deviation": { "enum": ["warming-up", "normal", "spike", "drop"], "description": "Classification of the most recent complete cycle." }, + "anomalies": { "type": "integer", "minimum": 0, "description": "Complete cycles classified as a spike or drop since process start." } + } + }, "notificationsDropped": { "type": "integer", "minimum": 0 }, "cursorRewinds": { "type": "integer", "minimum": 0, "description": "Cursors rewound to the RPC's retained floor this run." }, "deadLetter": { diff --git a/src/activity-baseline.ts b/src/activity-baseline.ts new file mode 100644 index 00000000..f1b0036f --- /dev/null +++ b/src/activity-baseline.ts @@ -0,0 +1,235 @@ +/** + * Rolling baseline for per-cycle poll activity. + * + * ── Why this exists ────────────────────────────────────────────────────────── + * + * The poller logs how many events each cycle saw and the metrics registry keeps + * cumulative totals. Neither answers the operational question an operator + * actually asks during an incident: **is this cycle unusually busy or quiet + * compared with what normal looks like for this deployment?** A total keeps + * growing and a single log line has nothing to compare against. + * + * This module keeps a small, bounded window of recent per-cycle event counts + * and compares every new observation with the mean of the window *before* it + * was added. The result is a small, JSON-safe snapshot that can be logged, + * served from `/health`, and exported as metrics. + * + * ── Statistical choices ────────────────────────────────────────────────────── + * + * - The window is a sample of recent cycles, so the comparator uses the sample + * standard deviation (n βˆ’ 1). A quiet chain with a few busy cycles widens + * the baseline instead of making every later event look anomalous. + * - `scaleFloor` puts a lower bound on the divisor. A perfectly flat baseline + * has a standard deviation of 0; dividing by it would make every non-zero + * count infinitely anomalous (and every zero count undefined). With a floor + * of one event, a single event is one deviation and a real burst still + * crosses the threshold. + * - The observation is compared with the window *before* being appended, so a + * spike cannot dilute its own baseline. It is appended afterwards, so the + * next cycle compares against it. + * - Until `minSamples` prior observations exist the snapshot says + * `warming-up`: a baseline inferred from one cycle is worse than no baseline + * at all. + * + * ── Safety ─────────────────────────────────────────────────────────────────── + * + * Only integer counts enter this module. There are no remote payloads, strings, + * identifiers, or timestamps, so a snapshot is safe to log or serve unchanged. + */ + +/** How an observed value relates to the retained baseline. */ +export type ActivityDeviation = "warming-up" | "normal" | "spike" | "drop"; + +export interface ActivityBaselineOptions { + /** Maximum observations retained. Default 20 (β‰ˆ10 minutes at 30s polls). */ + windowSize?: number; + /** Prior observations required before a comparison is made. Default 10. */ + minSamples?: number; + /** Absolute z-score at or above which a cycle is anomalous. Default 3. */ + zThreshold?: number; + /** Minimum divisor for the z-score, in observations. Default 1. */ + scaleFloor?: number; +} + +/** The comparison produced for one observation. */ +export interface ActivityComparison { + /** The count just observed. */ + value: number; + /** Mean of the prior window; null while warming up (no prior samples). */ + mean: number | null; + /** Sample standard deviation of the prior window; null while warming up. */ + stdDev: number | null; + /** `(value βˆ’ mean) / max(stdDev, scaleFloor)`; null while warming up. */ + zScore: number | null; + /** Prior observations used for this comparison. */ + samples: number; + deviation: ActivityDeviation; + /** True exactly when `deviation` is `"spike"` or `"drop"`. */ + anomalous: boolean; +} + +/** JSON-safe state of the baseline, suitable for `/health` and logs. */ +export interface ActivityBaselineSnapshot { + windowSize: number; + minSamples: number; + zThreshold: number; + /** Observations currently retained (0 … windowSize). */ + samples: number; + /** Most recently observed count, or null before the first observation. */ + lastValue: number | null; + /** Mean of the window used for the last comparison; null while warming up. */ + baselineMean: number | null; + /** Sample standard deviation of that window; null while warming up. */ + baselineStdDev: number | null; + /** z-score of `lastValue` against that window; null while warming up. */ + zScore: number | null; + /** Classification of the most recent observation. */ + deviation: ActivityDeviation; + /** Observations classified as spike or drop since construction / reset. */ + anomalies: number; +} + +const DEFAULT_WINDOW_SIZE = 20; +const DEFAULT_MIN_SAMPLES = 10; +const DEFAULT_Z_THRESHOLD = 3; +const DEFAULT_SCALE_FLOOR = 1; + +function positiveInteger(name: string, value: number | undefined, fallback: number): number { + if (value === undefined) return fallback; + if (!Number.isSafeInteger(value) || value < 1) { + throw new RangeError(`${name} must be a positive integer; got ${value}`); + } + return value; +} + +function positiveFinite(name: string, value: number | undefined, fallback: number): number { + if (value === undefined) return fallback; + if (!Number.isFinite(value) || value <= 0) { + throw new RangeError(`${name} must be a positive finite number; got ${value}`); + } + return value; +} + +function mean(values: readonly number[]): number { + let total = 0; + for (const value of values) total += value; + return total / values.length; +} + +/** Sample standard deviation (n βˆ’ 1). Caller guarantees at least two values. */ +function sampleStdDev(values: readonly number[], average: number): number { + let sumSquares = 0; + for (const value of values) { + const delta = value - average; + sumSquares += delta * delta; + } + return Math.sqrt(sumSquares / (values.length - 1)); +} + +/** + * A bounded rolling window of per-cycle activity. + * + * One instance belongs to one poller. `record()` is called once per *complete* + * cycle (every target scanned successfully), and returns the comparison for + * that cycle. Incomplete cycles should not be recorded: a target in backoff + * would otherwise look like a quiet chain. + */ +export class ActivityBaseline { + readonly windowSize: number; + readonly minSamples: number; + readonly zThreshold: number; + readonly scaleFloor: number; + + private readonly window: number[] = []; + private lastComparison: ActivityComparison | null = null; + private anomalyCount = 0; + + constructor(options: ActivityBaselineOptions = {}) { + this.windowSize = positiveInteger("windowSize", options.windowSize, DEFAULT_WINDOW_SIZE); + this.minSamples = positiveInteger("minSamples", options.minSamples, DEFAULT_MIN_SAMPLES); + if (this.minSamples > this.windowSize) { + throw new RangeError( + `minSamples (${this.minSamples}) cannot exceed windowSize (${this.windowSize})`, + ); + } + this.zThreshold = positiveFinite("zThreshold", options.zThreshold, DEFAULT_Z_THRESHOLD); + this.scaleFloor = positiveFinite("scaleFloor", options.scaleFloor, DEFAULT_SCALE_FLOOR); + } + + /** + * Compare `value` with the current window, then append it. + * + * Throws `RangeError` for anything that is not a non-negative safe integer: + * the only caller is the poller's own counter, so a malformed value is a bug, + * not remote input. + */ + record(value: number): ActivityComparison { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`activity counts must be non-negative safe integers; got ${value}`); + } + + const samples = this.window.length; + let baselineMean: number | null = null; + let baselineStdDev: number | null = null; + let zScore: number | null = null; + let deviation: ActivityDeviation = "warming-up"; + + if (samples >= this.minSamples) { + baselineMean = mean(this.window); + baselineStdDev = samples >= 2 ? sampleStdDev(this.window, baselineMean) : 0; + zScore = (value - baselineMean) / Math.max(baselineStdDev, this.scaleFloor); + if (zScore >= this.zThreshold) deviation = "spike"; + else if (zScore <= -this.zThreshold) deviation = "drop"; + else deviation = "normal"; + } + + const comparison: ActivityComparison = { + value, + mean: baselineMean, + stdDev: baselineStdDev, + zScore, + samples, + deviation, + anomalous: deviation === "spike" || deviation === "drop", + }; + + if (comparison.anomalous) this.anomalyCount += 1; + this.lastComparison = comparison; + + this.window.push(value); + if (this.window.length > this.windowSize) this.window.shift(); + + return comparison; + } + + /** Current JSON-safe state. Never throws. */ + snapshot(): ActivityBaselineSnapshot { + const last = this.lastComparison; + return { + windowSize: this.windowSize, + minSamples: this.minSamples, + zThreshold: this.zThreshold, + samples: this.window.length, + lastValue: last?.value ?? null, + baselineMean: last?.mean ?? null, + baselineStdDev: last?.stdDev ?? null, + zScore: last?.zScore ?? null, + deviation: last?.deviation ?? "warming-up", + anomalies: this.anomalyCount, + }; + } + + /** Drop all observations and anomaly history. Window size is retained. */ + reset(): void { + this.window.length = 0; + this.lastComparison = null; + this.anomalyCount = 0; + } +} + +/** Snapshot of a baseline that has not observed anything yet. */ +export function emptyActivityBaseline( + options: ActivityBaselineOptions = {}, +): ActivityBaselineSnapshot { + return new ActivityBaseline(options).snapshot(); +} diff --git a/src/health.ts b/src/health.ts index 089d2125..127fcc43 100644 --- a/src/health.ts +++ b/src/health.ts @@ -11,6 +11,10 @@ import http from "node:http"; import type { AddressInfo } from "node:net"; +import { + emptyActivityBaseline, + type ActivityBaselineSnapshot, +} from "./activity-baseline.js"; import { configProvenance, networkLabel, type BotConfig, type ConfigProvenance } from "./config.js"; import type { LogCapture } from "./logger.js"; import { safeErrorMessage } from "./notifications/format.js"; @@ -79,6 +83,12 @@ export interface HealthReport { eventsSkipped: number; /** Events suppressed as already-seen across overlapping pages / resumes. */ eventsDeduplicated: number; + /** + * Rolling comparison of the latest complete cycle's event count with the + * recent baseline. A spike is not a fault by itself; it is context for the + * counters and the per-cycle log lines. + */ + activity: ActivityBaselineSnapshot; notificationsDropped: number; /** Cursors automatically rewound to the RPC's retained floor this run. */ cursorRewinds: number; @@ -269,6 +279,7 @@ export function buildHealthReport( notificationsFailed: poller.notificationsFailed, eventsSkipped: poller.eventsSkipped, eventsDeduplicated: poller.eventsDeduplicated ?? 0, + activity: poller.activityBaseline ?? emptyActivityBaseline(), notificationsDropped: poller.notificationsDropped ?? 0, cursorRewinds: poller.cursorRewinds ?? 0, deadLetter: { @@ -331,6 +342,7 @@ export function buildMetricsReport( ): string { const uptimeMs = poller.startedAt > 0 ? Math.max(0, nowMs - poller.startedAt) : 0; const network = networkLabel(config); + const activity = poller.activityBaseline ?? emptyActivityBaseline(); const lines: string[] = [ `# HELP mimir_telegram_uptime_ms Uptime in milliseconds`, @@ -360,6 +372,22 @@ export function buildMetricsReport( `# HELP mimir_telegram_consecutive_failures Current number of consecutive failed poll cycles`, `# TYPE mimir_telegram_consecutive_failures gauge`, `mimir_telegram_consecutive_failures{network="${network}"} ${poller.consecutiveFailures}`, + ``, + `# HELP mimir_telegram_activity_last_cycle_events Events observed in the most recent complete poll cycle`, + `# TYPE mimir_telegram_activity_last_cycle_events gauge`, + `mimir_telegram_activity_last_cycle_events{network="${network}"} ${activity.lastValue ?? 0}`, + ``, + `# HELP mimir_telegram_activity_baseline_events Mean events per cycle in the rolling baseline (0 while warming up)`, + `# TYPE mimir_telegram_activity_baseline_events gauge`, + `mimir_telegram_activity_baseline_events{network="${network}"} ${activity.baselineMean ?? 0}`, + ``, + `# HELP mimir_telegram_activity_deviation Z-score of the latest complete cycle against the rolling baseline (0 while warming up)`, + `# TYPE mimir_telegram_activity_deviation gauge`, + `mimir_telegram_activity_deviation{network="${network}"} ${activity.zScore ?? 0}`, + ``, + `# HELP mimir_telegram_activity_anomalies_total Complete cycles classified as a spike or drop against the rolling baseline`, + `# TYPE mimir_telegram_activity_anomalies_total counter`, + `mimir_telegram_activity_anomalies_total{network="${network}"} ${activity.anomalies}`, ]; if (poller.latestLedger !== null) { diff --git a/src/metrics.ts b/src/metrics.ts index 42bea44a..1097e815 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -81,6 +81,14 @@ export interface MetricsRegistry { /** Current count of consecutive full-cycle failures (gauge: resets to 0 on * any success). */ consecutiveFailures: Gauge; + /** Events observed in the most recent complete poll cycle. */ + lastCycleEvents: Gauge; + /** Rolling mean events per cycle at the most recent baseline comparison, + * rounded to a whole event. */ + activityBaselineEvents: Gauge; + /** Complete cycles classified as a spike or drop against the rolling + * activity baseline. */ + activityAnomalies: Counter; /** * Render the registry as a Prometheus text exposition string. @@ -175,6 +183,18 @@ export function createMetrics(): MetricsRegistry { `${METRICS_PREFIX}consecutive_failures`, "Current number of consecutive full-cycle RPC failures (resets to 0 on any success).", ); + const lastCycleEvents = makeGauge( + `${METRICS_PREFIX}last_cycle_events`, + "Events observed in the most recent complete poll cycle.", + ); + const activityBaselineEvents = makeGauge( + `${METRICS_PREFIX}activity_baseline_events`, + "Rolling mean events per cycle at the most recent baseline comparison (rounded).", + ); + const activityAnomalies = makeCounter( + `${METRICS_PREFIX}activity_anomalies_total`, + "Complete poll cycles classified as a spike or drop against the rolling activity baseline.", + ); const counters = [ pollCycles, @@ -185,8 +205,9 @@ export function createMetrics(): MetricsRegistry { notificationsSent, notificationsFailed, staleCursorEvents, + activityAnomalies, ]; - const gauges = [consecutiveFailures]; + const gauges = [consecutiveFailures, lastCycleEvents, activityBaselineEvents]; function render(): string { const lines: string[] = []; @@ -282,6 +303,9 @@ export function createMetrics(): MetricsRegistry { notificationsFailed, staleCursorEvents, consecutiveFailures, + lastCycleEvents, + activityBaselineEvents, + activityAnomalies, render, startServer, }; diff --git a/src/poller.ts b/src/poller.ts index e250d9ea..2c2035d4 100644 --- a/src/poller.ts +++ b/src/poller.ts @@ -58,6 +58,11 @@ import path from "node:path"; import type { rpc } from "@stellar/stellar-sdk"; +import { + ActivityBaseline, + type ActivityBaselineOptions, + type ActivityBaselineSnapshot, +} from "./activity-baseline.js"; import type { Metrics } from "./metrics.js"; import type { SendExtra } from "./bot.js"; import { appendAuditFile, auditEntry, createAuditLog, type AuditLog } from "./audit.js"; @@ -243,6 +248,12 @@ export interface PollerStatus { emptyPages: number; /** Cumulative pages walked across all successful target scans. */ pagesScanned: number; + /** + * Rolling comparison of this cycle's event count against recent complete + * cycles. Updated only when every target was scanned, so a partial cycle is + * never mistaken for a quiet chain. + */ + activityBaseline?: ActivityBaselineSnapshot; /** Empty pages in the most recent completed cycle (all targets). */ lastCycleEmptyPages: number; /** Pages walked in the most recent completed cycle (all targets). */ @@ -460,6 +471,11 @@ export interface PollerDeps { targetBackoffOptions?: TargetBackoffOptions; /** Circuit breaker configuration */ circuitBreakerOptions?: CircuitBreakerOptions; + /** + * Tuning for the rolling activity baseline. Defaults are production values; + * tests can shrink the window to observe a comparison quickly. + */ + activityBaselineOptions?: ActivityBaselineOptions; /** Clock behind every timestamp this poller reports. Defaults to `Date.now`. */ now?: () => number; /** @@ -1271,6 +1287,7 @@ export function createPoller(deps: PollerDeps) { }; const now = deps.now ?? Date.now; const sleep = deps.sleep ?? defaultSleep; + const activityBaseline = new ActivityBaseline(deps.activityBaselineOptions); const targetInitialBackoff = deps.targetBackoffOptions?.initialBackoffMs ?? config.pollIntervalMs; const targetMaxBackoff = deps.targetBackoffOptions?.maxBackoffMs ?? DEFAULT_TARGET_MAX_BACKOFF_MS; const targetBackoffFactor = deps.targetBackoffOptions?.backoffFactor ?? DEFAULT_TARGET_BACKOFF_FACTOR; @@ -1427,6 +1444,7 @@ export function createPoller(deps: PollerDeps) { }, emptyPages: 0, pagesScanned: 0, + activityBaseline: activityBaseline.snapshot(), lastCycleEmptyPages: 0, lastCyclePages: 0, notificationsDropped: 0, @@ -2403,6 +2421,11 @@ export function createPoller(deps: PollerDeps) { let cyclePages = 0; let cycleEmptyPages = 0; let cycleFailures = 0; + let cycleScans = 0; + // Events handed back by every successful scan in this cycle. Compared with + // the rolling baseline only when every target was scanned, so a target in + // backoff cannot make a normal cycle look quiet. + let cycleEvents = 0; // Drain parked sends before this cycle's events, so a recovered channel // catches up in order. Budgeted like the burst below: one recovery must @@ -2473,6 +2496,8 @@ export function createPoller(deps: PollerDeps) { current.nextEligibleAt = null; anyOk = true; + cycleScans += 1; + cycleEvents += scan.events.length; cyclePages += scan.pages; cycleEmptyPages += scan.emptyPages; status.pagesScanned += scan.pages; @@ -2684,6 +2709,28 @@ export function createPoller(deps: PollerDeps) { status.lastSuccessAt = now(); status.consecutiveFailures = 0; metrics?.consecutiveFailures.set(0); + + // Compare this cycle's activity with what recent complete cycles + // looked like. Incomplete cycles are skipped rather than recorded at a + // lower count: an RPC backoff is not a quiet chain. + if (cycleScans === targets.length) { + const comparison = activityBaseline.record(cycleEvents); + status.activityBaseline = activityBaseline.snapshot(); + metrics?.lastCycleEvents.set(cycleEvents); + if (comparison.mean !== null) { + metrics?.activityBaselineEvents.set(Math.round(comparison.mean)); + } + if (comparison.anomalous) { + metrics?.activityAnomalies.inc(); + // Counts only: no event payloads, cursors, or RPC text. One line + // per anomalous cycle, which is exactly when an operator wants it. + console.warn( + `[poller] activity ${comparison.deviation}: ${cycleEvents} event(s) this cycle ` + + `vs baseline mean ${(comparison.mean ?? 0).toFixed(1)} ` + + `(z=${(comparison.zScore ?? 0).toFixed(2)}, ${comparison.samples} prior cycles)`, + ); + } + } } else { status.consecutiveFailures += 1; metrics?.consecutiveFailures.set(status.consecutiveFailures); diff --git a/tests/activity-baseline-poller.test.mjs b/tests/activity-baseline-poller.test.mjs new file mode 100644 index 00000000..98be2dab --- /dev/null +++ b/tests/activity-baseline-poller.test.mjs @@ -0,0 +1,153 @@ +/** + * Activity baseline poller integration. + * + * Drives the real poller against the local mock RPC to prove the per-cycle + * event count is recorded in the status snapshot, compared with the rolling + * baseline, surfaced in /health and exported through the metrics registry. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { rpc } from "@stellar/stellar-sdk"; + +import { buildHealthReport, buildMetricsReport } from "../dist/health.js"; +import { createMetrics } from "../dist/metrics.js"; +import { createPoller } from "../dist/poller.js"; +import { + MOCK_BOT_TOKEN, + MOCK_MARKET_CONTRACT_ID, + MOCK_NETWORK_PASSPHRASE, + MOCK_SQUAD_CONTRACT_ID, +} from "../dist/stellar/mock-constants.js"; +import { startMockRpc } from "../dist/stellar/mock-rpc.js"; +import { withTempDataDir } from "./helpers/temp-data.mjs"; + +const CAPTAIN = "GCLJONJVCLJGE6CSMCHCGYA563ADTCK5YGF3KERDMSNS3MNNDAIXHFA5"; + +function config(cursorFile, rpcUrl) { + return { + version: "0.1.0", + marketContractId: MOCK_MARKET_CONTRACT_ID, + squadContractId: MOCK_SQUAD_CONTRACT_ID, + rpcUrl, + horizonUrl: "https://example.invalid/horizon", + networkPassphrase: MOCK_NETWORK_PASSPHRASE, + explorerBaseUrl: "https://example.invalid/explorer", + botToken: MOCK_BOT_TOKEN, + chatId: "-1001234567890", + operatorTelegramUserId: null, + pollIntervalMs: 30_000, + startLookbackLedgers: 9, + cursorFile, + maxNotificationsPerCycle: 20, + healthHost: "127.0.0.1", + healthPort: 0, + healthStaleMs: 90_000, + }; +} + +test("poller records complete cycles, compares them and exposes the result", async () => { + await withTempDataDir(async (dataDir) => { + const mock = await startMockRpc({ + port: 0, + scenario: { + latestLedger: 10, + oldestLedger: 1, + ledgersPerPage: 50, + events: [ + { + source: "market", + ledger: 9, + eventName: "claim_created", + topics: [3, { address: CAPTAIN }], + fields: { category: "sports" }, + }, + { + source: "squad", + ledger: 10, + eventName: "market_created", + topics: [5, { address: CAPTAIN }], + fields: { + deadline: 1_893_456_000, + fee_bps: 100, + question: "Will baseline activity be observed?", + }, + }, + ], + }, + }); + + try { + const metrics = createMetrics(); + const pollerConfig = config(dataDir.file("cursor.json"), mock.url); + const poller = createPoller({ + config: pollerConfig, + server: new rpc.Server(mock.url, { allowHttp: true }), + metrics, + sleep: async () => {}, + sendOptions: { sendSpacingMs: 0 }, + persistAudit: false, + activityBaselineOptions: { windowSize: 4, minSamples: 1, zThreshold: 2 }, + send: async () => {}, + }); + + // Cycle 1: two events. One observation is not enough to compare. + await poller.cycle(); + let activity = poller.status().activityBaseline; + assert.equal(activity.samples, 1); + assert.equal(activity.lastValue, 2); + assert.equal(activity.deviation, "warming-up"); + assert.equal(activity.anomalies, 0); + + // Cycle 2: the cursor is at the tip, so no events. Against [2], zero is + // a two-deviation drop at the configured threshold. + await poller.cycle(); + activity = poller.status().activityBaseline; + assert.equal(activity.samples, 2); + assert.equal(activity.lastValue, 0); + assert.equal(activity.baselineMean, 2); + assert.equal(activity.baselineStdDev, 0); + assert.equal(activity.zScore, -2); + assert.equal(activity.deviation, "drop"); + assert.equal(activity.anomalies, 1); + assert.equal(metrics.lastCycleEvents.value(), 0); + assert.equal(metrics.activityBaselineEvents.value(), 2); + assert.equal(metrics.activityAnomalies.value(), 1); + + // Cycle 3: ten new events against [2, 0] is a spike. + for (let i = 0; i < 10; i += 1) { + mock.addEvent({ + source: "market", + ledger: 11 + i, + eventName: "claim_created", + topics: [100 + i, { address: CAPTAIN }], + fields: { category: "sports" }, + }); + } + await poller.cycle(); + activity = poller.status().activityBaseline; + assert.equal(activity.lastValue, 10); + assert.equal(activity.deviation, "spike"); + assert.equal(activity.anomalies, 2); + assert.equal(metrics.lastCycleEvents.value(), 10); + assert.equal(metrics.activityAnomalies.value(), 2); + + // /health carries the same snapshot, JSON-serialisable and unchanged. + const report = buildHealthReport(pollerConfig, poller.status(), Date.now()); + assert.deepEqual(JSON.parse(JSON.stringify(report.poller.activity)), activity); + // Driving cycles directly never calls start(), so the poller reports + // stopped; the activity snapshot is independent of that lifecycle flag. + assert.equal(report.poller.running, false); + + // The default (health) metrics endpoint exposes the same comparison. + const prometheus = buildMetricsReport(pollerConfig, poller.status(), Date.now()); + assert.match(prometheus, /mimir_telegram_activity_last_cycle_events\{network="[^"]+"\} 10/); + assert.match(prometheus, /mimir_telegram_activity_baseline_events\{network="[^"]+"\} 1/); + assert.match(prometheus, /mimir_telegram_activity_anomalies_total\{network="[^"]+"\} 2/); + assert.match(prometheus, /mimir_telegram_activity_deviation\{network="[^"]+"\} 6\.3/); + } finally { + await mock.close(); + } + }, "mimir-activity-baseline-"); +}); diff --git a/tests/activity-baseline.test.mjs b/tests/activity-baseline.test.mjs new file mode 100644 index 00000000..2889484a --- /dev/null +++ b/tests/activity-baseline.test.mjs @@ -0,0 +1,162 @@ +/** + * Activity baseline unit tests. + * + * The module is pure, so every case here is table-driven and needs no RPC, + * clock, or poller. The poller integration is covered separately in + * activity-baseline-poller.test.mjs. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + ActivityBaseline, + emptyActivityBaseline, +} from "../dist/activity-baseline.js"; + +test("empty baseline reports warming-up and no fabricated statistics", () => { + assert.deepEqual(emptyActivityBaseline(), { + windowSize: 20, + minSamples: 10, + zThreshold: 3, + samples: 0, + lastValue: null, + baselineMean: null, + baselineStdDev: null, + zScore: null, + deviation: "warming-up", + anomalies: 0, + }); +}); + +test("record stays warming-up until minSamples prior observations exist", () => { + const baseline = new ActivityBaseline({ windowSize: 5, minSamples: 3 }); + const deviations = [4, 4, 4, 4].map((value) => baseline.record(value).deviation); + assert.deepEqual(deviations, ["warming-up", "warming-up", "warming-up", "normal"]); + + // The fourth call is the first with three prior observations. + const fifth = baseline.record(4); + assert.equal(fifth.samples, 4); + assert.equal(fifth.mean, 4); + assert.equal(fifth.stdDev, 0); + assert.equal(fifth.zScore, 0); + assert.equal(fifth.deviation, "normal"); + assert.equal(fifth.anomalous, false); +}); + +test("a value far above a stable baseline is a spike", () => { + const baseline = new ActivityBaseline({ windowSize: 10, minSamples: 3 }); + for (const value of [5, 5, 5, 5]) baseline.record(value); + + const comparison = baseline.record(50); + assert.equal(comparison.deviation, "spike"); + assert.equal(comparison.anomalous, true); + assert.equal(comparison.mean, 5); + assert.equal(comparison.stdDev, 0); + assert.equal(comparison.zScore, 45); + assert.equal(baseline.snapshot().anomalies, 1); +}); + +test("a value far below a stable baseline is a drop", () => { + const baseline = new ActivityBaseline({ windowSize: 10, minSamples: 3 }); + for (const value of [12, 12, 12, 12]) baseline.record(value); + + const comparison = baseline.record(0); + assert.equal(comparison.deviation, "drop"); + assert.equal(comparison.anomalous, true); + assert.equal(comparison.mean, 12); + assert.equal(comparison.zScore, -12); + assert.equal(baseline.snapshot().anomalies, 1); +}); + +test("scaleFloor keeps a flat zero baseline from dividing by zero", () => { + const baseline = new ActivityBaseline({ + windowSize: 10, + minSamples: 3, + zThreshold: 3, + scaleFloor: 2, + }); + for (const value of [0, 0, 0, 0]) baseline.record(value); + + assert.equal(baseline.record(1).deviation, "normal"); + // The window is now [0, 0, 0, 0, 1]: mean 0.2, sd β‰ˆ 0.447, so + // (10 βˆ’ 0.2) / 2 = 4.9, still far above the threshold. + const burst = baseline.record(10); + assert.ok(Math.abs(burst.zScore - 4.9) < 1e-12, `zScore=${burst.zScore}`); + assert.equal(burst.deviation, "spike"); +}); + +test("the window is bounded and evicts the oldest observations", () => { + const baseline = new ActivityBaseline({ windowSize: 3, minSamples: 2 }); + baseline.record(100); + baseline.record(100); + baseline.record(100); + assert.equal(baseline.snapshot().samples, 3); + + // Replace the busy history with quiet cycles; the mean must follow the + // retained window rather than the whole process lifetime. Three zeroes are + // needed to evict all three 100s. + baseline.record(0); + baseline.record(0); + baseline.record(0); + const comparison = baseline.record(0); + assert.equal(baseline.snapshot().samples, 3); + assert.equal(comparison.mean, 0); + assert.equal(comparison.deviation, "normal"); +}); + +test("each value is compared before it is appended", () => { + const baseline = new ActivityBaseline({ windowSize: 5, minSamples: 3 }); + for (const value of [5, 5, 5]) baseline.record(value); + + // A spike must not dilute its own baseline: the mean stays 5 for this call. + const spike = baseline.record(55); + assert.equal(spike.mean, 5); + assert.equal(spike.deviation, "spike"); + + // The NEXT observation compares against the widened window [5, 5, 5, 55]. + const next = baseline.record(5); + assert.equal(next.mean, 17.5); + assert.equal(next.samples, 4); + assert.equal(next.deviation, "normal"); +}); + +test("reset clears the window, the last comparison and anomaly history", () => { + const baseline = new ActivityBaseline({ windowSize: 5, minSamples: 2 }); + baseline.record(1); + baseline.record(1); + const anomaly = baseline.record(100); + assert.equal(anomaly.deviation, "spike"); + assert.equal(baseline.snapshot().anomalies, 1); + + baseline.reset(); + assert.deepEqual(baseline.snapshot(), { + windowSize: 5, + minSamples: 2, + zThreshold: 3, + samples: 0, + lastValue: null, + baselineMean: null, + baselineStdDev: null, + zScore: null, + deviation: "warming-up", + anomalies: 0, + }); +}); + +test("malformed counts and options fail loudly", () => { + const baseline = new ActivityBaseline({ windowSize: 5, minSamples: 2 }); + for (const value of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws(() => baseline.record(value), RangeError, `value ${value}`); + } + + assert.throws(() => new ActivityBaseline({ windowSize: 0 }), RangeError); + assert.throws(() => new ActivityBaseline({ minSamples: 0 }), RangeError); + assert.throws( + () => new ActivityBaseline({ windowSize: 2, minSamples: 3 }), + /cannot exceed windowSize/, + ); + assert.throws(() => new ActivityBaseline({ zThreshold: 0 }), RangeError); + assert.throws(() => new ActivityBaseline({ scaleFloor: -1 }), RangeError); +}); + diff --git a/tests/metrics.test.mjs b/tests/metrics.test.mjs index eb5b0628..96ab5042 100644 --- a/tests/metrics.test.mjs +++ b/tests/metrics.test.mjs @@ -133,6 +133,36 @@ test("gauge: can decrease (unlike a counter)", () => { assert.equal(m.consecutiveFailures.value(), 3); }); +test("activity baseline: gauges start at zero and can be updated independently", () => { + const m = createMetrics(); + assert.equal(m.lastCycleEvents.value(), 0); + assert.equal(m.activityBaselineEvents.value(), 0); + assert.equal(m.activityAnomalies.value(), 0); + + m.lastCycleEvents.set(7); + m.activityBaselineEvents.set(4); + m.activityAnomalies.inc(); + + assert.equal(m.lastCycleEvents.value(), 7); + assert.equal(m.activityBaselineEvents.value(), 4); + assert.equal(m.activityAnomalies.value(), 1); +}); + +test("activity baseline: rendered metrics expose the last count and baseline", () => { + const m = createMetrics(); + m.lastCycleEvents.set(12); + m.activityBaselineEvents.set(5); + m.activityAnomalies.inc(2); + + const output = m.render(); + assert.ok(output.includes("# TYPE mimir_bot_last_cycle_events gauge"), output); + assert.ok(output.includes("mimir_bot_last_cycle_events 12"), output); + assert.ok(output.includes("# TYPE mimir_bot_activity_baseline_events gauge"), output); + assert.ok(output.includes("mimir_bot_activity_baseline_events 5"), output); + assert.ok(output.includes("# TYPE mimir_bot_activity_anomalies_total counter"), output); + assert.ok(output.includes("mimir_bot_activity_anomalies_total 2"), output); +}); + // ── render() format tests ───────────────────────────────────────────────────── test("render: output ends with a newline", () => { @@ -155,6 +185,9 @@ test("render: contains all expected metric names", () => { "mimir_bot_notifications_failed_total", "mimir_bot_stale_cursor_events_total", "mimir_bot_consecutive_failures", + "mimir_bot_last_cycle_events", + "mimir_bot_activity_baseline_events", + "mimir_bot_activity_anomalies_total", ]; for (const name of expectedNames) { From 6d6ede3f4a3a1057247bb31feb6a5d71d7c9bd40 Mon Sep 17 00:00:00 2001 From: Codebuff Date: Mon, 19 Oct 2026 13:08:11 +0000 Subject: [PATCH 2/7] feat(i18n): let deployments choose the bot language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing text was a set of English constants imported directly, so there was no way to run the bot in another language. Add locale bundles behind an active-locale accessor and select one with BOT_LOCALE (en default, es shipped): renderers read the bundle at call time, partial translations fall back to English, and an unsupported value fails config validation instead of silently rendering English. Also completes the half-wired /about command so the localized command menu and its help entry resolve. πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- .env.example | 8 + CHANGELOG.md | 17 ++ README.md | 17 ++ docs/translating.md | 47 ++++ src/bot.ts | 51 ++-- src/commands/about.ts | 4 +- src/commands/help-detail.ts | 8 + src/config.ts | 24 ++ src/i18n.ts | 414 ++++++++++++++++++++++++++++ src/index.ts | 7 + src/mock-run.ts | 4 + src/notifications/format.ts | 81 +++--- src/notifications/settlement.ts | 8 +- tests/command-descriptions.test.mjs | 49 +++- tests/help-command.test.mjs | 7 +- tests/locale.test.mjs | 290 +++++++++++++++++++ tests/markdown-lint.test.mjs | 32 +++ 17 files changed, 991 insertions(+), 77 deletions(-) create mode 100644 tests/locale.test.mjs diff --git a/.env.example b/.env.example index 57a64ce9..e962b901 100644 --- a/.env.example +++ b/.env.example @@ -211,6 +211,14 @@ POLL_INTERVAL_MS=30000 # When true, notifications sent to Telegram are formatted with a preview badge. # CHANNEL_PREVIEW_MODE=false +# ── Language ───────────────────────────────────────────────────────────────── +# Language for every message the bot renders: notifications, /help, the +# Telegram command menu, operator replies and notification buttons. +# Accepted values: en (English, default), es (Spanish). A region suffix is +# accepted and ignored (es-MX -> es); an unsupported language aborts startup so +# a typo cannot silently fall back to English. +# BOT_LOCALE=en + # ── Dead-letter queue ──────────────────────────────────────────────────────── # A notification whose sends exhausted their bounded retries is parked here and # replayed on a later cycle once Telegram recovers, instead of being dropped. diff --git a/CHANGELOG.md b/CHANGELOG.md index ce88889b..750b15e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ All notable changes to this project are documented here. ### Added +**Selectable bot language** + +- `src/i18n.ts` now exposes locale bundles behind `getMessages(locale)` and an + active-locale accessor (`setLocale` / `messages` / `getLocale`), with `en` as + the base and fallback bundle. A bundle may translate any subset of keys; the + rest resolve to English. +- `BOT_LOCALE` selects the language at startup (`en` default, `es` shipped). + Case and a region suffix (`es-MX`, `es_419`) are accepted; an unsupported + value fails config validation with the supported list. The choice is logged at + boot and reported by `configProvenance()`. +- Notifications, the inline keyboard labels, `/help`, the Telegram command menu + and pause/resume replies now render from the active bundle, so switching + languages does not require rebuilding the call sites. +- `docs/translating.md` documents choosing and adding a language; + `tests/locale.test.mjs` covers selection and config validation, and the + MarkdownV2 linter now validates every shipped locale. + **Per-cycle activity baseline** - The poller now keeps a bounded rolling window of events observed per complete poll diff --git a/README.md b/README.md index 7ffcb6fe..f492c168 100644 --- a/README.md +++ b/README.md @@ -1140,6 +1140,23 @@ notification: This is driven by the presence of a non-empty `txHash` field on each event, not configuration. All persisted state and cursor format remain unchanged. +### Language + +All user-facing text comes from `src/i18n.ts`, which ships an English base +bundle plus translated bundles. Set `BOT_LOCALE` to choose one: + +| `BOT_LOCALE` | Language | +| --- | --- | +| `en` (default) | English | +| `es` | Spanish | + +A region suffix (`es-MX`, `es_419`) is accepted and ignored. Anything else +fails startup with the supported list, so a typo cannot silently fall back to +English. The locale covers notifications, `/help`, the Telegram command menu, +pause/resume replies and notification buttons; the selected value is logged at +boot (`[boot] locale …`). See [`docs/translating.md`](docs/translating.md) for +adding a language β€” untranslated keys fall back to English. + ## Layout ``` diff --git a/docs/translating.md b/docs/translating.md index 3605616a..03a04beb 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -19,6 +19,8 @@ All user-facing localization templates live in [`src/i18n.ts`](../src/i18n.ts). | `NOTIFICATION_MD` | **MarkdownV2** | Primary event notification templates (`claimCreated`, `claimChallenged`, etc.), fallback template (`fallbackEvent`), and preview headers (`previewModePrefix`, `channelPreviewMarket`, `channelPreviewSquad`). | | `NOTIFICATION_PLAIN` | **Plain text** | 1-to-1 plain-text mirror of `NOTIFICATION_MD` (plus `minimal`), sent without `parse_mode` if Telegram rejects the MarkdownV2 payload or when plain text is requested. | | `EXPLORER_BUTTON_TEXT` | **Plain text** | Label on the inline keyboard button linking to Stellar Expert (`"View on Explorer"`). Inline button labels do not parse Markdown. | +| `MIMIR_APP_BUTTON_TEXT` | **Plain text** | Label on the inline keyboard button that opens the Mimir web app (`"Open in Mimir"`), present only when `MIMIR_APP_URL` is configured. | +| `SETTLEMENT_LABELS` | **Plain text** | Row labels for the settlement breakdown (`Owed to challengers`, `Total fees`, `Dust`). The caller escapes them when the surrounding message is MarkdownV2. | ### Why Both `NOTIFICATION_MD` and `NOTIFICATION_PLAIN` Exist @@ -228,3 +230,48 @@ Before opening a pull request that edits or adds strings in [`src/i18n.ts`](../s npm test ``` Specifically confirm that both [`tests/i18n.test.mjs`](../tests/i18n.test.mjs) (snapshot and plain-text boundary checks) and [`tests/markdown-lint.test.mjs`](../tests/markdown-lint.test.mjs) (MarkdownV2 tokenizer and reserved-character linter) pass with zero errors. + +--- + +## 7. Choosing a Language + +`src/i18n.ts` ships one English base bundle plus a translated bundle per +language. The exported constants (`HELP_TITLE`, `NOTIFICATION_MD`, …) remain the +English base, so existing imports and tests are unaffected. Everything that +renders for users reads the **active** bundle through `messages()` at call time +(`bot.ts` for help, the command menu and pause/resume replies; `format.ts` for +notifications and keyboard buttons). + +Select the language with `BOT_LOCALE` (see [`.env.example`](../.env.example)): + +```bash +BOT_LOCALE=es # Spanish +BOT_LOCALE=es-MX # accepted: the region suffix is ignored +``` + +- `en` (default) and `es` are supported. An unrecognized value fails startup with + the supported list instead of silently rendering English. +- Startup logs the selected locale (`[boot] locale …`) so a deployment can be + checked without sending a message. +- `tests/locale.test.mjs` covers selection and config validation; + `tests/markdown-lint.test.mjs` lints every shipped locale's MarkdownV2. + +## 8. Adding a Language + +1. Add the code to `SUPPORTED_LOCALES` (for example `"de"`) β€” it becomes a + `Locale` and is accepted by `BOT_LOCALE`. +2. Add a bundle to `BUNDLES` in `src/i18n.ts`. The bundle is typed + `MessageOverrides`, so **every key is optional**: untranslated keys fall back + to English. A translation can land incrementally, but a partially translated + bundle renders a mixed-language UI β€” translate a whole table before shipping. +3. Keep the same parameter order and meaning for every template function, and + pre-escape static MarkdownV2 characters exactly as the English bundle does. + Never wrap `${...}` parameters in `escapeMd()` inside `i18n.ts`. +4. Run the lint and locale tests: + + ```bash + npm run build + node --test --test-timeout=120000 tests/locale.test.mjs tests/markdown-lint.test.mjs + ``` + +The same review checklist in section 6 applies to every new language. diff --git a/src/bot.ts b/src/bot.ts index 9c0a89ba..9c3d2d8f 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -34,14 +34,7 @@ import { type AuditFileSummary, type AuditLog, } from "./audit.js"; -import { - AUDIT_CLI_HINT, - COMMAND_DESCRIPTIONS, - HELP_INTRO, - HELP_TITLE, - PAUSE_MESSAGES, - RESUME_MESSAGES, -} from "./i18n.js"; +import { messages } from "./i18n.js"; import { helpDetailMessage } from "./commands/help-detail.js"; import { aboutMessage } from "./commands/about.js"; export { helpDetailMessage, COMMAND_DETAILS, type CommandDetail } from "./commands/help-detail.js"; @@ -140,20 +133,33 @@ export function validateBotTokenShape(raw: unknown): BotTokenShapeResult { return { ok: true }; } -/** Shared metadata for handlers, help, and Telegram's command menu. */ -export const COMMANDS = [ - { command: "start", description: COMMAND_DESCRIPTIONS.start, operatorOnly: false }, - { command: "help", description: COMMAND_DESCRIPTIONS.help, operatorOnly: false }, - { command: "about", description: COMMAND_DESCRIPTIONS.about, operatorOnly: false }, - { command: "status", description: COMMAND_DESCRIPTIONS.status, operatorOnly: false }, - { command: "audit", description: COMMAND_DESCRIPTIONS.audit, operatorOnly: true }, - { command: "contracts", description: COMMAND_DESCRIPTIONS.contracts, operatorOnly: false }, - { command: "health", description: COMMAND_DESCRIPTIONS.health, operatorOnly: false }, - { command: "preview", description: COMMAND_DESCRIPTIONS.preview, operatorOnly: false }, - { command: "pause", description: COMMAND_DESCRIPTIONS.pause, operatorOnly: true }, - { command: "resume", description: COMMAND_DESCRIPTIONS.resume, operatorOnly: true }, +/** Command name and whether it is operator-only, in help/registration order. */ +const COMMAND_SPECS = [ + ["start", false], + ["help", false], + ["about", false], + ["status", false], + ["audit", true], + ["contracts", false], + ["health", false], + ["preview", false], + ["pause", true], + ["resume", true], ] as const; +/** + * Shared metadata for handlers, help, and Telegram's command menu. Descriptions + * are getters so they follow the active locale rather than the language that + * happened to be selected when this module was first imported. + */ +export const COMMANDS = COMMAND_SPECS.map(([command, operatorOnly]) => ({ + command, + get description(): string { + return messages().COMMAND_DESCRIPTIONS[command]; + }, + operatorOnly, +})); + function visibleCommands(config?: BotConfig) { return COMMANDS.filter((command) => !command.operatorOnly || config === undefined || config.operatorTelegramUserId !== null, @@ -161,6 +167,7 @@ function visibleCommands(config?: BotConfig) { } export function helpMessage(config?: BotConfig): string { + const { HELP_TITLE, HELP_INTRO } = messages(); return [ HELP_TITLE, "", @@ -352,7 +359,7 @@ function ago(timestamp: number | null, nowMs: number = Date.now()): string { function renderAuditForTelegram(summary: AuditFileSummary, tail: number): string { const header = `*Audit* β€” ${summary.file}`; const report = renderAuditReport(summary, { tail }); - return `${header}\n\n${report}\n\n${AUDIT_CLI_HINT}`; + return `${header}\n\n${report}\n\n${messages().AUDIT_CLI_HINT}`; } function cursorPreview(cursor: string | null): string { @@ -599,6 +606,7 @@ export function contractsMessage(config: BotConfig): string { /** Exact operator replies, exported for deterministic Telegram payload tests. */ export function pauseMessage(result: PollerPauseResult): string { + const { PAUSE_MESSAGES } = messages(); switch (result) { case "paused": return PAUSE_MESSAGES.paused; @@ -610,6 +618,7 @@ export function pauseMessage(result: PollerPauseResult): string { } export function resumeMessage(result: PollerResumeResult): string { + const { RESUME_MESSAGES } = messages(); switch (result) { case "resumed": return RESUME_MESSAGES.resumed; diff --git a/src/commands/about.ts b/src/commands/about.ts index 8e510c28..e7fa44b6 100644 --- a/src/commands/about.ts +++ b/src/commands/about.ts @@ -1,5 +1,5 @@ -import { escapeMd } from "../utils/markdown.js"; -import type { BotConfig } from "../config.js"; +import { networkLabel, type BotConfig } from "../config.js"; +import { escapeMd } from "../notifications/format.js"; const REPOSITORY_URL = "https://github.com/example/stellar-bot"; diff --git a/src/commands/help-detail.ts b/src/commands/help-detail.ts index 940d2bb7..676c054c 100644 --- a/src/commands/help-detail.ts +++ b/src/commands/help-detail.ts @@ -28,6 +28,14 @@ export const COMMAND_DETAILS: Record = { description: "Show general help listing all commands, or detailed help with usage, arguments, and examples for a specific command.", examples: ["/help", "/help preview", "/help audit", "/help status"], }, + about: { + usage: "/about", + arguments: "None", + whoCanRun: "Anyone", + operatorOnly: false, + description: "Show the bot version, the Stellar network it watches, and the repository it is built from.", + examples: ["/about"], + }, status: { usage: "/status", arguments: "None", diff --git a/src/config.ts b/src/config.ts index 8c39a483..0baeb07d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,6 +51,7 @@ import { parseNotificationFeatureFlags, type NotificationFeatureFlags, } from "./notifications/featureFlags.js"; +import { DEFAULT_LOCALE, normalizeLocale, SUPPORTED_LOCALES, type Locale } from "./i18n.js"; /** Named Stellar networks the bot understands. `custom` is any other passphrase. */ export type StellarNetwork = "testnet" | "mainnet" | "futurenet" | "custom"; @@ -210,6 +211,8 @@ export interface BotConfig extends StellarConfig { csvOutputFile: string; /** Coarse notification feature flags (see NOTIFY_* env vars). */ featureFlags: NotificationFeatureFlags; + /** Language for user-facing text (BOT_LOCALE); English when unset. */ + locale: Locale; /** Append-only JSONL audit trail (see src/audit.ts). Empty disables it. */ auditFile: string; /** @@ -515,6 +518,25 @@ function collector(profile: Record) { return value; }, + /** + * A supported language code. Accepts a region suffix (`es-MX`) because + * platforms often expose the deployment's full locale, but an unknown + * language fails boot rather than silently rendering English. + */ + locale(name: string, fallback: Locale): Locale { + const raw = get(name); + if (raw === undefined) return fallback; + const normalized = normalizeLocale(raw); + if (normalized === null) { + problems.push( + `${name} must be one of ${SUPPORTED_LOCALES.join(", ")} ` + + `(a region suffix such as es-MX is accepted); got "${raw}"`, + ); + return fallback; + } + return normalized; + }, + contractVersion(name: string, fallback: string): string { const raw = read(name); if (raw === undefined) return fallback; @@ -831,6 +853,7 @@ export function loadConfig(): BotConfig { interSendDelayMs: c.int("INTER_SEND_DELAY_MS", DEFAULTS.interSendDelayMs, 0), metricsPort: c.optionalPort("METRICS_PORT"), notificationCategories: notificationCategoriesFrom(), + locale: c.locale("BOT_LOCALE", DEFAULT_LOCALE), csvOutputFile: path.resolve(process.cwd(), c.get("CSV_OUTPUT_FILE") ?? DEFAULTS.csvOutputFile), featureFlags: featureFlagsParsed.flags, // Resolved like the cursor file: relative paths anchor to the process cwd. @@ -1024,6 +1047,7 @@ const CONFIG_KEYS: readonly ConfigKeySpec[] = [ { key: "HEALTH_STALE_MS", secret: false, hasBuiltInDefault: true }, { key: "SHUTDOWN_TIMEOUT_MS", secret: false, hasBuiltInDefault: true }, { key: "TELEGRAM_SEND_TIMEOUT_MS", secret: false, hasBuiltInDefault: true }, + { key: "BOT_LOCALE", secret: false, hasBuiltInDefault: true }, { key: "CHANNEL_PREVIEW_MODE", secret: false, hasBuiltInDefault: true }, { key: "LINK_PREVIEW_MARKET", secret: false, hasBuiltInDefault: true }, { key: "LINK_PREVIEW_SQUAD", secret: false, hasBuiltInDefault: true }, diff --git a/src/i18n.ts b/src/i18n.ts index 720d03f1..058769e6 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -17,6 +17,17 @@ * 2. Replace the inline literal in the source file with a reference. * 3. Add a test in tests/i18n.test.mjs that imports the key and asserts both * the value and that the caller substitutes dynamic parts correctly. + * 4. Add the key to `Messages` below (the base bundle's shape) and either + * translate it in every locale bundle or let it fall back to English. + * + * ── Choosing a language ───────────────────────────────────────────────────── + * The exported constants below are the English base bundle, kept as named + * exports so existing callers and tests keep importing them directly. Runtimes + * that render for a configured audience instead read the active bundle through + * {@link messages}, which {@link setLocale} points at one of the locale bundles + * ({@link getMessages}). `BOT_LOCALE` selects it at boot (see src/config.ts). + * A bundle may translate only part of the base: omitted keys fall back to + * English, so a translation can land incrementally without breaking a build. */ // ── Bot: help and lifecycle ────────────────────────────────────────────────── @@ -61,6 +72,7 @@ export const AUDIT_CLI_HINT = export const COMMAND_DESCRIPTIONS = { start: "What this bot does", help: "Show help", + about: "About this bot", status: "Last-seen ledger and watched contracts", audit: "Operator only: audit report (redacted, bounded)", contracts: "Contract ids and explorer links", @@ -321,3 +333,405 @@ export const EXPLORER_BUTTON_TEXT = "View on Explorer"; * app (see `MIMIR_APP_URL`). Absent from a keyboard when no URL is configured. */ export const MIMIR_APP_BUTTON_TEXT = "Open in Mimir"; + +// ── Settlement breakdown labels ───────────────────────────────────────────── + +/** + * Row labels for the settlement accounting lines (`src/notifications/ + * settlement.ts`). Plain text: the lines are escaped by the caller when the + * message is MarkdownV2. + */ +export const SETTLEMENT_LABELS = { + owedToChallengers: "Owed to challengers", + totalFees: "Total fees", + dust: "Dust", +} as const; + +// ── Locale registry ───────────────────────────────────────────────────────── + +/** + * String shape a locale must provide: the base bundle with literal string types + * widened, so a translation is typed like the English original instead of + * having to reproduce its exact text. + */ +export type DeepWiden = T extends string + ? string + : T extends (...args: infer A) => string + ? (...args: A) => string + : T extends object + ? { -readonly [K in keyof T]: DeepWiden } + : T; + +/** Every user-facing string the bot can render, in one bundle. */ +export interface Messages { + HELP_TITLE: string; + HELP_INTRO: string; + PAUSE_MESSAGES: DeepWiden; + RESUME_MESSAGES: DeepWiden; + AUDIT_CLI_HINT: string; + COMMAND_DESCRIPTIONS: DeepWiden; + NOTIFICATION_MD: DeepWiden; + NOTIFICATION_PLAIN: DeepWiden; + EXPLORER_BUTTON_TEXT: string; + MIMIR_APP_BUTTON_TEXT: string; + SETTLEMENT_LABELS: DeepWiden; +} + +/** + * A translation bundle. Every key is optional at both levels β€” a table is + * merged key-by-key over English, and a nested table may translate any subset + * of its entries. + */ +export type MessageOverrides = { + [K in keyof Messages]?: Messages[K] extends (...args: never[]) => string + ? Messages[K] + : Messages[K] extends object + ? { [P in keyof Messages[K]]?: Messages[K][P] } + : Messages[K]; +}; + +/** Languages the bot ships with. `en` is the base and the fallback. */ +export const SUPPORTED_LOCALES = ["en", "es"] as const; + +export type Locale = (typeof SUPPORTED_LOCALES)[number]; + +/** Used when nothing selects a language, and whenever a key is untranslated. */ +export const DEFAULT_LOCALE: Locale = "en"; + +const EN_MESSAGES: Messages = { + HELP_TITLE, + HELP_INTRO, + PAUSE_MESSAGES, + RESUME_MESSAGES, + AUDIT_CLI_HINT, + COMMAND_DESCRIPTIONS, + NOTIFICATION_MD, + NOTIFICATION_PLAIN, + EXPLORER_BUTTON_TEXT, + MIMIR_APP_BUTTON_TEXT, + SETTLEMENT_LABELS, +}; + +function isMessageTable(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** Merge a (possibly partial) translation over the English base. */ +function mergeOverrides(base: Messages, overrides: MessageOverrides): Messages { + const merged: Record = { ...base }; + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) continue; + const current = merged[key]; + merged[key] = + isMessageTable(current) && isMessageTable(value) ? { ...current, ...value } : value; + } + return merged as unknown as Messages; +} + +// ── Spanish (es) ──────────────────────────────────────────────────────────── +// MarkdownV2 strings are pre-escaped exactly like the English originals; the +// caller still escapes every dynamic interpolation. + +const ES_OVERRIDES: MessageOverrides = { + HELP_TITLE: "*Notificador de Mimir*", + HELP_INTRO: + "Vigilo los dos contratos Soroban de Mimir en Stellar y publico aquΓ­ cada evento nuevo on-chain: reclamaciones abiertas, desafΓ­os con stake, resoluciones del orΓ‘culo, liquidaciones y pagos.", + + PAUSE_MESSAGES: { + paused: + "*Sondeo pausado*\nEl escaneo actual puede terminar, pero no se iniciarΓ‘ un ciclo nuevo\\. Los cursores no se modificaron\\.", + alreadyPaused: "*El sondeo ya estΓ‘ pausado*", + stopped: "*No se puede pausar* β€” el proceso se estΓ‘ deteniendo\\.", + }, + RESUME_MESSAGES: { + resumed: + "*Sondeo reanudado*\nEl prΓ³ximo escaneo comienza ahora\\. Los cursores no se modificaron\\.", + alreadyRunning: "*El sondeo ya estΓ‘ en marcha*", + stopped: "*No se puede reanudar* β€” el proceso se estΓ‘ deteniendo\\.", + }, + + AUDIT_CLI_HINT: + "Consulta `npm run audit -- --help` para la herramienta de informes independiente.", + + COMMAND_DESCRIPTIONS: { + start: "QuΓ© hace este bot", + help: "Mostrar ayuda", + about: "Acerca de este bot", + status: "Último ledger visto y contratos vigilados", + audit: "Solo operadores: informe de auditorΓ­a (redactado y acotado)", + contracts: "IDs de contrato y enlaces al explorador", + health: "EvaluaciΓ³n de salud y preparaciΓ³n operativa", + preview: "Previsualizar el formato de las notificaciones del canal", + pause: "Solo operadores: pausar escaneos nuevos", + resume: "Solo operadores: reanudar el sondeo ahora", + }, + + NOTIFICATION_MD: { + claimCreated: (claimId: string, category: string, creator: string): string => + `πŸ†• *Nueva reclamaciΓ³n* \\#${claimId}\nCategorΓ­a: ${category}\nCreador: ${creator}`, + + claimChallenged: (claimId: string, stake: string, challenger: string): string => + `βš”οΈ *ReclamaciΓ³n \\#${claimId} desafiada*\nStake: *${stake}*\nDesafiante: ${challenger}`, + + claimResolved: ( + claimId: string, + winnerSide: string, + confidence: string, + summary: string | null, + ): string => + ( + `βš–οΈ *ReclamaciΓ³n \\#${claimId} resuelta* β€” ganador: *${winnerSide}*\n` + + `Confianza: ${confidence}\n` + + (summary ? `_${summary}_` : "") + ).trimEnd(), + + claimCancelled: (claimId: string): string => + `🚫 *ReclamaciΓ³n \\#${claimId} cancelada* β€” stakes devueltos`, + + marketSettled: ( + claimId: string, + totalPaid: string, + totalFees: string, + owedToChallengers: string, + ...rest: (readonly string[] | string | undefined)[] + ): string => { + const lines = rest[0]; + const linesPart = Array.isArray(lines) + ? `\n${lines.join("\n")}` + : typeof lines === "string" && lines.length > 0 + ? `\n${lines}` + : ""; + if (linesPart) { + return `πŸ’° *ReclamaciΓ³n \\#${claimId} liquidada*\nPagado: *${totalPaid}* Β· comisiones ${totalFees}${linesPart}`; + } + return `πŸ’° *ReclamaciΓ³n \\#${claimId} liquidada*\nPagado: *${totalPaid}* Β· comisiones ${totalFees}\nAdeudado a desafiantes: ${owedToChallengers}`; + }, + + challengerPaid: ( + claimId: string, + challenger: string, + stake: string, + net: string, + gross: string, + fee: string, + ): string => + `πŸ† *Desafiante pagado* en la reclamaciΓ³n \\#${claimId}\n${challenger} apostΓ³ ${stake} β†’ neto *${net}*\nBruto ${gross} Β· comisiΓ³n ${fee}`, + + feeClaimed: (amount: string, recipient: string): string => + `🧾 *Comisiones cobradas* β€” ${amount} para ${recipient}`, + + withdrawal: (amount: string, to: string): string => + `πŸ“€ *Retiro* β€” ${amount} para ${to}`, + + withdrawalPending: (amount: string, to: string): string => + `⏳ *Retiro en espera* β€” ${amount} reclamable por ${to}`, + + marketCreated: ( + marketId: string, + question: string, + captain: string, + feeBps: string, + deadline: string, + ): string => + `πŸ†• *Nuevo mercado de escuadrΓ³n* \\#${marketId}\n${question}\nCapitΓ‘n: ${captain} Β· comisiΓ³n ${feeBps} bps Β· fecha lΓ­mite ${deadline}`, + + deposited: (marketId: string, amount: string, side: string, participant: string): string => + `βž• *EscuadrΓ³n \\#${marketId}* β€” ${amount} en *${side}*\nParticipante: ${participant}`, + + withdrawn: (marketId: string, participant: string, amount: string, side: string): string => + `βž– *EscuadrΓ³n \\#${marketId}* β€” ${participant} retirΓ³ ${amount} de ${side}`, + + resolved: (marketId: string, result: string, poolA: string, poolB: string): string => + `🏁 *EscuadrΓ³n \\#${marketId} resuelto* β€” *${result}*\nPozos: A ${poolA} Β· B ${poolB}`, + + claimed: ( + marketId: string, + participant: string, + net: string, + gross: string, + fee: string, + ): string => + `πŸ’Έ *Pago del escuadrΓ³n* en \\#${marketId}\n${participant} β†’ neto *${net}* \\(bruto ${gross}, comisiΓ³n ${fee}\\)`, + + feesClaimedSquad: (amount: string, recipient: string): string => + `🧾 *Comisiones del escuadrΓ³n cobradas* β€” ${amount} para ${recipient}`, + + fallbackEvent: ( + source: string, + contract: string, + ledger: string, + reason: string, + txPart: string, + ): string => + `⚠️ *NotificaciΓ³n de evento alternativa* \\(${source}\\)\nContrato: \`${contract}\` Β· Ledger: ${ledger}${txPart}\nMotivo: _${reason}_`, + + previewModePrefix: "πŸ§ͺ *[MODO PREVISUALIZACIΓ“N]*", + + channelPreviewMarket: "πŸ§ͺ *Vista previa del canal β€” mimir\\-market*", + channelPreviewSquad: "πŸ§ͺ *Vista previa del canal β€” mimir\\-squad*", + }, + + NOTIFICATION_PLAIN: { + claimCreated: (claimId: string | number, category: string, creator: string): string => + `Nueva reclamaciΓ³n #${claimId}\nCategorΓ­a: ${category}\nCreador: ${creator}`, + + claimChallenged: ( + claimId: string | number, + stake: string, + challenger: string, + ): string => + `ReclamaciΓ³n #${claimId} desafiada\nStake: ${stake}\nDesafiante: ${challenger}`, + + claimResolved: ( + claimId: string | number, + winnerSide: string, + confidence: string, + summary: string | null, + ): string => + `ReclamaciΓ³n #${claimId} resuelta β€” ganador: ${winnerSide}\nConfianza: ${confidence}` + + (summary ? `\n${summary}` : ""), + + claimCancelled: (claimId: string | number): string => + `ReclamaciΓ³n #${claimId} cancelada β€” stakes devueltos`, + + marketSettled: ( + claimId: string | number, + totalPaid: string, + totalFees: string, + owedToChallengers: string, + ...rest: (readonly string[] | string | undefined)[] + ): string => { + const lines = rest[0]; + const linesPart = Array.isArray(lines) + ? `\n${lines.join("\n")}` + : typeof lines === "string" && lines.length > 0 + ? `\n${lines}` + : ""; + if (linesPart) { + return `ReclamaciΓ³n #${claimId} liquidada\nPagado: ${totalPaid} Β· comisiones ${totalFees}${linesPart}`; + } + return `ReclamaciΓ³n #${claimId} liquidada\nPagado: ${totalPaid} Β· comisiones ${totalFees}\nAdeudado a desafiantes: ${owedToChallengers}`; + }, + + challengerPaid: ( + claimId: string | number, + challenger: string, + stake: string, + net: string, + gross: string, + fee: string, + ): string => + `Desafiante pagado en la reclamaciΓ³n #${claimId}\n${challenger} apostΓ³ ${stake} β†’ neto ${net}\nBruto ${gross} Β· comisiΓ³n ${fee}`, + + feeClaimed: (amount: string, recipient: string): string => + `Comisiones cobradas β€” ${amount} para ${recipient}`, + + withdrawal: (amount: string, to: string): string => + `Retiro β€” ${amount} para ${to}`, + + withdrawalPending: (amount: string, to: string): string => + `Retiro en espera β€” ${amount} reclamable por ${to}`, + + marketCreated: ( + marketId: string | number, + question: string, + captain: string, + feeBps: string, + deadline: string, + ): string => + `Nuevo mercado de escuadrΓ³n #${marketId}\n${question}\nCapitΓ‘n: ${captain} Β· comisiΓ³n ${feeBps} bps Β· fecha lΓ­mite ${deadline}`, + + deposited: ( + marketId: string | number, + amount: string, + side: string, + participant: string, + ): string => + `EscuadrΓ³n #${marketId} β€” ${amount} en ${side}\nParticipante: ${participant}`, + + withdrawn: ( + marketId: string | number, + participant: string, + amount: string, + side: string, + ): string => + `EscuadrΓ³n #${marketId} β€” ${participant} retirΓ³ ${amount} de ${side}`, + + resolved: ( + marketId: string | number, + result: string, + poolA: string, + poolB: string, + ): string => + `EscuadrΓ³n #${marketId} resuelto β€” ${result}\nPozos: A ${poolA} Β· B ${poolB}`, + + claimed: ( + marketId: string | number, + participant: string, + net: string, + gross: string, + fee: string, + ): string => + `Pago del escuadrΓ³n en #${marketId}\n${participant} β†’ neto ${net} (bruto ${gross}, comisiΓ³n ${fee})`, + + feesClaimedSquad: (amount: string, recipient: string): string => + `Comisiones del escuadrΓ³n cobradas β€” ${amount} para ${recipient}`, + + minimal: (source: string, ledger: string, eventId: string): string => { + const idPart = eventId ? ` (${eventId})` : ""; + return `Evento de Mimir (${source}) β€” ${ledger}${idPart}`; + }, + }, + + EXPLORER_BUTTON_TEXT: "Ver en el explorador", + MIMIR_APP_BUTTON_TEXT: "Abrir en Mimir", + + SETTLEMENT_LABELS: { + owedToChallengers: "Adeudado a desafiantes", + totalFees: "Comisiones totales", + dust: "Polvo", + }, +}; + +const BUNDLES: Record = { + en: EN_MESSAGES, + es: mergeOverrides(EN_MESSAGES, ES_OVERRIDES), +}; + +let activeLocale: Locale = DEFAULT_LOCALE; + +/** True when `value` is a language this build ships. */ +export function isLocale(value: string): value is Locale { + return (SUPPORTED_LOCALES as readonly string[]).includes(value); +} + +/** + * Map a configured value (e.g. `BOT_LOCALE`) to a supported language. Accepts + * case and a region suffix (`es-MX`, `es_419`) so a deploy can pass the value + * its platform already knows; returns null for anything unrecognized. + */ +export function normalizeLocale(raw: string | null | undefined): Locale | null { + if (typeof raw !== "string") return null; + const base = raw.trim().toLowerCase().replace(/_/g, "-").split("-")[0] ?? ""; + return isLocale(base) ? base : null; +} + +/** The full bundle for `locale`; untranslated keys read as English. */ +export function getMessages(locale: Locale): Messages { + return BUNDLES[locale]; +} + +/** Choose the language every `messages()` caller renders from. */ +export function setLocale(locale: Locale): void { + activeLocale = locale; +} + +/** The language currently rendered; `en` until {@link setLocale} runs. */ +export function getLocale(): Locale { + return activeLocale; +} + +/** The active bundle. Call at render time, never capture at import time. */ +export function messages(): Messages { + return BUNDLES[activeLocale]; +} diff --git a/src/index.ts b/src/index.ts index c105fb77..c046d2a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { networkLabel, } from "./config.js"; import { formatFeatureFlags } from "./notifications/featureFlags.js"; +import { setLocale } from "./i18n.js"; import { auditEntry, createAuditLog } from "./audit.js"; import { InstanceLockError } from "./instanceLock.js"; import { attachConsole, createLogCapture, Logger, secretsFor } from "./logger.js"; @@ -107,6 +108,9 @@ async function main(): Promise { } const config = loadConfig(); + // Select the language before anything renders: every user-facing string is + // read from the active bundle at render time, never captured at import time. + setLocale(config.locale); // Register this process's secrets before anything can fail: every // operator-facing error goes through the scrubber, so a call site cannot @@ -148,6 +152,9 @@ async function main(): Promise { ); console.log(`[boot] cursor file ${config.cursorFile}`); console.log(`[boot] flags ${formatFeatureFlags(config.featureFlags)}`); + console.log( + `[boot] locale ${config.locale}${config.locale === "en" ? "" : " (translated text)"}`, + ); console.log(`[boot] audit file ${config.auditFile}`); console.log(`[boot] lock file ${config.lockFile}`); console.log(`[boot] shutdown ${config.shutdownTimeoutMs}ms drain budget`); diff --git a/src/mock-run.ts b/src/mock-run.ts index 6769f015..811546cf 100644 --- a/src/mock-run.ts +++ b/src/mock-run.ts @@ -32,6 +32,7 @@ import { networkLabel, } from "./config.js"; import { startHealthServer } from "./health.js"; +import { setLocale } from "./i18n.js"; import { safeErrorMessage } from "./notifications/format.js"; import { createPoller } from "./poller.js"; import { createRpcServer } from "./stellar/client.js"; @@ -69,6 +70,8 @@ async function main(): Promise { // Profile defaults may name the default port; the bound port is authoritative. const config = { ...loadConfig(), rpcUrl: mock.url }; + // The dry run renders real notification text, so it follows BOT_LOCALE too. + setLocale(config.locale); console.log(`[dry-run] Mimir notifier dry run β€” Telegram sends are logged, not delivered`); console.log(`[dry-run] profile ${activeProfileName() ?? "none"}`); @@ -76,6 +79,7 @@ async function main(): Promise { console.log(`[dry-run] market ${config.marketContractId}`); console.log(`[dry-run] squad ${config.squadContractId}`); console.log(`[dry-run] cursor ${config.cursorFile}`); + console.log(`[dry-run] locale ${config.locale}`); console.log( `[dry-run] poll every ${config.pollIntervalMs}ms Β· cap ${config.maxNotificationsPerCycle} notification(s)/cycle`, ); diff --git a/src/notifications/format.ts b/src/notifications/format.ts index 9e709bd0..d9848f8e 100644 --- a/src/notifications/format.ts +++ b/src/notifications/format.ts @@ -29,12 +29,7 @@ import { groupDigestEvents } from "./digest-grouping.js"; import { confidenceIndicator } from "./confidence.js"; import { settlementLines } from "./settlement.js"; import type { StellarConfig } from "../config.js"; -import { - EXPLORER_BUTTON_TEXT, - MIMIR_APP_BUTTON_TEXT, - NOTIFICATION_MD, - NOTIFICATION_PLAIN, -} from "../i18n.js"; +import { messages } from "../i18n.js"; /** Telegram's MarkdownV2 reserved set. All of it must be escaped, everywhere. */ const MDV2_RESERVED = /[_*[\]()~`>#+\-=|{}.!\\]/g; @@ -276,10 +271,10 @@ export function explorerKeyboard( ): ExplorerKeyboard | undefined { const buttons: ExplorerButton[] = []; const explorerUrl = eventExplorerUrl(config, event); - if (explorerUrl) buttons.push({ text: EXPLORER_BUTTON_TEXT, url: explorerUrl }); + if (explorerUrl) buttons.push({ text: messages().EXPLORER_BUTTON_TEXT, url: explorerUrl }); const appUrl = appUrlFor(event, config.mimirAppUrl); - if (appUrl) buttons.push({ text: MIMIR_APP_BUTTON_TEXT, url: appUrl }); + if (appUrl) buttons.push({ text: messages().MIMIR_APP_BUTTON_TEXT, url: appUrl }); if (buttons.length === 0) return undefined; return { inline_keyboard: [buttons] }; @@ -376,21 +371,21 @@ function headline(event: DecodedEvent): string | null { switch (p.name) { // ── mimir-market ──────────────────────────────────────────────────────── case "claim_created": - return NOTIFICATION_MD.claimCreated( + return messages().NOTIFICATION_MD.claimCreated( String(p.claimId), escapeMd(clip(p.category)), who(p.creator), ); case "claim_challenged": - return NOTIFICATION_MD.claimChallenged( + return messages().NOTIFICATION_MD.claimChallenged( String(p.claimId), usdc(p.stake), who(p.challenger), ); case "claim_resolved": - return NOTIFICATION_MD.claimResolved( + return messages().NOTIFICATION_MD.claimResolved( String(p.claimId), escapeMd(winnerSideLabel(p.winnerSide)), escapeMd(confidenceIndicator(p.confidence)), @@ -398,11 +393,11 @@ function headline(event: DecodedEvent): string | null { ); case "claim_cancelled": - return NOTIFICATION_MD.claimCancelled(String(p.claimId)); + return messages().NOTIFICATION_MD.claimCancelled(String(p.claimId)); case "market_settled": { const lines = settlementLines(p).map(escapeMd); - return NOTIFICATION_MD.marketSettled( + return messages().NOTIFICATION_MD.marketSettled( String(p.claimId), usdc(p.totalPaid), usdc(p.totalFees), @@ -412,7 +407,7 @@ function headline(event: DecodedEvent): string | null { } case "challenger_paid": - return NOTIFICATION_MD.challengerPaid( + return messages().NOTIFICATION_MD.challengerPaid( String(p.claimId), who(p.challenger), usdc(p.stake), @@ -422,17 +417,17 @@ function headline(event: DecodedEvent): string | null { ); case "fee_claimed": - return NOTIFICATION_MD.feeClaimed(usdc(p.amount), who(p.recipient)); + return messages().NOTIFICATION_MD.feeClaimed(usdc(p.amount), who(p.recipient)); case "withdrawal": - return NOTIFICATION_MD.withdrawal(usdc(p.amount), who(p.to)); + return messages().NOTIFICATION_MD.withdrawal(usdc(p.amount), who(p.to)); case "withdrawal_pending": - return NOTIFICATION_MD.withdrawalPending(usdc(p.amount), who(p.to)); + return messages().NOTIFICATION_MD.withdrawalPending(usdc(p.amount), who(p.to)); // ── mimir-squad ───────────────────────────────────────────────────────── case "market_created": - return NOTIFICATION_MD.marketCreated( + return messages().NOTIFICATION_MD.marketCreated( String(p.marketId), escapeMd(clip(p.question)), who(p.captain), @@ -441,7 +436,7 @@ function headline(event: DecodedEvent): string | null { ); case "deposited": - return NOTIFICATION_MD.deposited( + return messages().NOTIFICATION_MD.deposited( String(p.marketId), usdc(p.amount), escapeMd(squadSideLabel(p.side)), @@ -449,7 +444,7 @@ function headline(event: DecodedEvent): string | null { ); case "withdrawn": - return NOTIFICATION_MD.withdrawn( + return messages().NOTIFICATION_MD.withdrawn( String(p.marketId), who(p.participant), usdc(p.amount), @@ -457,7 +452,7 @@ function headline(event: DecodedEvent): string | null { ); case "resolved": - return NOTIFICATION_MD.resolved( + return messages().NOTIFICATION_MD.resolved( String(p.marketId), escapeMd(squadSideLabel(p.result)), usdc(p.poolA), @@ -465,7 +460,7 @@ function headline(event: DecodedEvent): string | null { ); case "claimed": - return NOTIFICATION_MD.claimed( + return messages().NOTIFICATION_MD.claimed( String(p.marketId), who(p.participant), usdc(p.net), @@ -474,7 +469,7 @@ function headline(event: DecodedEvent): string | null { ); case "fees_claimed": - return NOTIFICATION_MD.feesClaimedSquad(usdc(p.amount), who(p.recipient)); + return messages().NOTIFICATION_MD.feesClaimedSquad(usdc(p.amount), who(p.recipient)); // Admin events and undecodable shapes get no notification. The poller logs // them so a silent bot is distinguishable from an unteachable one. @@ -523,7 +518,7 @@ export function formatEvent( if (head === null) return null; const body = `${head}\n${footer(config, event)}`; if (config.channelPreviewMode) { - return `${NOTIFICATION_MD.previewModePrefix}\n${body}`; + return `${messages().NOTIFICATION_MD.previewModePrefix}\n${body}`; } return body; } catch (err) { @@ -563,17 +558,17 @@ function plainHeadline(event: DecodedEvent): string | null { switch (p.name) { // ── mimir-market ──────────────────────────────────────────────────────── case "claim_created": - return NOTIFICATION_PLAIN.claimCreated(p.claimId, clip(p.category), plainWho(p.creator)); + return messages().NOTIFICATION_PLAIN.claimCreated(p.claimId, clip(p.category), plainWho(p.creator)); case "claim_challenged": - return NOTIFICATION_PLAIN.claimChallenged( + return messages().NOTIFICATION_PLAIN.claimChallenged( p.claimId, plainUsdc(p.stake), plainWho(p.challenger), ); case "claim_resolved": - return NOTIFICATION_PLAIN.claimResolved( + return messages().NOTIFICATION_PLAIN.claimResolved( p.claimId, winnerSideLabel(p.winnerSide), confidenceIndicator(p.confidence), @@ -581,11 +576,11 @@ function plainHeadline(event: DecodedEvent): string | null { ); case "claim_cancelled": - return NOTIFICATION_PLAIN.claimCancelled(p.claimId); + return messages().NOTIFICATION_PLAIN.claimCancelled(p.claimId); case "market_settled": { const lines = settlementLines(p); - return NOTIFICATION_PLAIN.marketSettled( + return messages().NOTIFICATION_PLAIN.marketSettled( p.claimId, plainUsdc(p.totalPaid), plainUsdc(p.totalFees), @@ -595,7 +590,7 @@ function plainHeadline(event: DecodedEvent): string | null { } case "challenger_paid": - return NOTIFICATION_PLAIN.challengerPaid( + return messages().NOTIFICATION_PLAIN.challengerPaid( p.claimId, plainWho(p.challenger), plainUsdc(p.stake), @@ -605,17 +600,17 @@ function plainHeadline(event: DecodedEvent): string | null { ); case "fee_claimed": - return NOTIFICATION_PLAIN.feeClaimed(plainUsdc(p.amount), plainWho(p.recipient)); + return messages().NOTIFICATION_PLAIN.feeClaimed(plainUsdc(p.amount), plainWho(p.recipient)); case "withdrawal": - return NOTIFICATION_PLAIN.withdrawal(plainUsdc(p.amount), plainWho(p.to)); + return messages().NOTIFICATION_PLAIN.withdrawal(plainUsdc(p.amount), plainWho(p.to)); case "withdrawal_pending": - return NOTIFICATION_PLAIN.withdrawalPending(plainUsdc(p.amount), plainWho(p.to)); + return messages().NOTIFICATION_PLAIN.withdrawalPending(plainUsdc(p.amount), plainWho(p.to)); // ── mimir-squad ───────────────────────────────────────────────────────── case "market_created": - return NOTIFICATION_PLAIN.marketCreated( + return messages().NOTIFICATION_PLAIN.marketCreated( p.marketId, clip(p.question), plainWho(p.captain), @@ -624,7 +619,7 @@ function plainHeadline(event: DecodedEvent): string | null { ); case "deposited": - return NOTIFICATION_PLAIN.deposited( + return messages().NOTIFICATION_PLAIN.deposited( p.marketId, plainUsdc(p.amount), squadSideLabel(p.side), @@ -632,7 +627,7 @@ function plainHeadline(event: DecodedEvent): string | null { ); case "withdrawn": - return NOTIFICATION_PLAIN.withdrawn( + return messages().NOTIFICATION_PLAIN.withdrawn( p.marketId, plainWho(p.participant), plainUsdc(p.amount), @@ -640,7 +635,7 @@ function plainHeadline(event: DecodedEvent): string | null { ); case "resolved": - return NOTIFICATION_PLAIN.resolved( + return messages().NOTIFICATION_PLAIN.resolved( p.marketId, squadSideLabel(p.result), plainUsdc(p.poolA), @@ -648,7 +643,7 @@ function plainHeadline(event: DecodedEvent): string | null { ); case "claimed": - return NOTIFICATION_PLAIN.claimed( + return messages().NOTIFICATION_PLAIN.claimed( p.marketId, plainWho(p.participant), plainUsdc(p.net), @@ -657,7 +652,7 @@ function plainHeadline(event: DecodedEvent): string | null { ); case "fees_claimed": - return NOTIFICATION_PLAIN.feesClaimedSquad(plainUsdc(p.amount), plainWho(p.recipient)); + return messages().NOTIFICATION_PLAIN.feesClaimedSquad(plainUsdc(p.amount), plainWho(p.recipient)); // Same set as `headline`: admin events and undecodable shapes get nothing. case "oracle_changed": @@ -717,7 +712,7 @@ function minimalPlainTextEvent(event: DecodedEvent): string { ? `ledger ${event.ledger}` : "ledger unknown"; const eventId = typeof event?.eventId === "string" && event.eventId ? event.eventId : ""; - return clipPlainText(NOTIFICATION_PLAIN.minimal(source, ledger, eventId)); + return clipPlainText(messages().NOTIFICATION_PLAIN.minimal(source, ledger, eventId)); } /** @@ -733,7 +728,7 @@ export function formatFallbackEvent( const ledger = escapeMd(String(event.ledger ?? "unknown")); const safeReason = escapeMd(safeErrorMessage(reason)); const txPart = event.txHash ? ` Β· [tx](${txExplorerUrl(config, event.txHash)})` : ""; - return NOTIFICATION_MD.fallbackEvent(source, contract, ledger, safeReason, txPart); + return messages().NOTIFICATION_MD.fallbackEvent(source, contract, ledger, safeReason, txPart); } /** @@ -771,7 +766,7 @@ export function previewMessage(config: StellarConfig & { linkPreviewMarket?: boo }, }; const formatted = formatEvent({ ...config, channelPreviewMode: false }, sampleEvent) ?? ""; - return `${NOTIFICATION_MD.channelPreviewSquad}\n${settingsLine}\n\n${formatted}`; + return `${messages().NOTIFICATION_MD.channelPreviewSquad}\n${settingsLine}\n\n${formatted}`; } const sampleEvent: DecodedEvent = { @@ -794,6 +789,6 @@ export function previewMessage(config: StellarConfig & { linkPreviewMarket?: boo }, }; const formatted = formatEvent({ ...config, channelPreviewMode: false }, sampleEvent) ?? ""; - return `${NOTIFICATION_MD.channelPreviewMarket}\n${settingsLine}\n\n${formatted}`; + return `${messages().NOTIFICATION_MD.channelPreviewMarket}\n${settingsLine}\n\n${formatted}`; } diff --git a/src/notifications/settlement.ts b/src/notifications/settlement.ts index cba4c89f..1002efe9 100644 --- a/src/notifications/settlement.ts +++ b/src/notifications/settlement.ts @@ -6,6 +6,7 @@ * two decimal places. */ +import { messages } from "../i18n.js"; import { formatUsdc } from "../stellar/decode.js"; export interface SettlementPayload { @@ -61,22 +62,23 @@ export function settlementBreakdown(payload: SettlementPayload): SettlementItem[ const sum = owed + fees + dust; const paid = payload.totalPaid !== undefined ? toBigInt(payload.totalPaid) : 0n; const gross = paid > sum ? paid : sum; + const labels = messages().SETTLEMENT_LABELS; return [ { - label: "Owed to challengers", + label: labels.owedToChallengers, amount: owed, formattedAmount: `${formatUsdc(owed)} USDC`, percent: formatPercentage(owed, gross), }, { - label: "Total fees", + label: labels.totalFees, amount: fees, formattedAmount: `${formatUsdc(fees)} USDC`, percent: formatPercentage(fees, gross), }, { - label: "Dust", + label: labels.dust, amount: dust, formattedAmount: `${formatUsdc(dust)} USDC`, percent: formatPercentage(dust, gross), diff --git a/tests/command-descriptions.test.mjs b/tests/command-descriptions.test.mjs index 9bf07e5b..6f8f146c 100644 --- a/tests/command-descriptions.test.mjs +++ b/tests/command-descriptions.test.mjs @@ -1,12 +1,14 @@ import test from 'node:test'; import assert from 'node:assert'; -import { COMMAND_DESCRIPTIONS } from '../src/i18n.js'; -import { BOT_COMMANDS } from '../src/bot.js'; +import { COMMAND_DESCRIPTIONS, SUPPORTED_LOCALES, getMessages } from '../dist/i18n.js'; +import { COMMANDS } from '../dist/bot.js'; const NAME_PATTERN = /^[a-z0-9_]{1,32}$/; const MIN_DESCRIPTION_LENGTH = 1; const MAX_DESCRIPTION_LENGTH = 256; +// Telegram's command menu is plain text: no MarkdownV2 formatting characters. +const FORBIDDEN_CHARS = /[*_`[\]~>#+|{}.!\\]/; test('COMMAND_DESCRIPTIONS names fit Telegram limits', () => { for (const name of Object.keys(COMMAND_DESCRIPTIONS)) { @@ -33,12 +35,49 @@ test('COMMAND_DESCRIPTIONS descriptions fit Telegram limits', () => { } }); +test('every locale keeps command descriptions plain and within Telegram limits', () => { + for (const locale of SUPPORTED_LOCALES) { + const descriptions = getMessages(locale).COMMAND_DESCRIPTIONS; + for (const [name, description] of Object.entries(descriptions)) { + assert.match(name, NAME_PATTERN, `${locale}: invalid command name "${name}"`); + assert.strictEqual( + typeof description, + 'string', + `${locale}: "${name}" has a non-string description`, + ); + const length = [...description].length; + assert.ok( + length >= MIN_DESCRIPTION_LENGTH && length <= MAX_DESCRIPTION_LENGTH, + `${locale}: "${name}" description must be 1-256 characters, got ${length}`, + ); + assert.equal( + FORBIDDEN_CHARS.test(description), + false, + `${locale}: "${name}" description contains MarkdownV2 characters: "${description}"`, + ); + } + } +}); + test('every bot command has a description', () => { - for (const command of BOT_COMMANDS) { + for (const command of COMMANDS) { const name = typeof command === 'string' ? command : command.command; assert.ok( - Object.prototype.hasOwn.call(COMMAND_DESCRIPTIONS, name), - `Bot command "${name}" is missing a description in COMMAND_DESCRIPTTIONS`, + Object.hasOwn(COMMAND_DESCRIPTIONS, name), + `Bot command "${name}" is missing a description in COMMAND_DESCRIPTIONS`, ); } }); + +test('registered commands expose a non-empty description in every locale', () => { + for (const locale of SUPPORTED_LOCALES) { + const descriptions = getMessages(locale).COMMAND_DESCRIPTIONS; + for (const { command } of COMMANDS) { + const description = descriptions[command]; + assert.ok( + description && description.trim().length > 0, + `${locale}: command "${command}" has no description`, + ); + } + } +}); diff --git a/tests/help-command.test.mjs b/tests/help-command.test.mjs index 516f25c7..d9239c46 100644 --- a/tests/help-command.test.mjs +++ b/tests/help-command.test.mjs @@ -10,15 +10,16 @@ const INTRO = "*Mimir notifier*\n\nI watch Mimir's two Soroban contracts on Stel const PUBLIC_LINES = [ "/start β€” What this bot does", "/help β€” Show help", + "/about β€” About this bot", "/status β€” Last\\-seen ledger and watched contracts", "/contracts β€” Contract ids and explorer links", "/health β€” Health assessment and operational readiness", "/preview β€” Preview channel notification formatting", ]; const ALL_LINES = [ - ...PUBLIC_LINES.slice(0, 3), + ...PUBLIC_LINES.slice(0, 4), "/audit β€” Operator only: audit report \\(redacted, bounded\\)", - ...PUBLIC_LINES.slice(3), + ...PUBLIC_LINES.slice(4), "/pause β€” Operator only: pause new scans", "/resume β€” Operator only: resume polling now", ]; @@ -52,7 +53,7 @@ for (const operatorId of [null, "42"]) { assert.ok(handlers.has(command), `${command} has a handler`); assert.ok(description.length >= 1 && description.length <= 256); } - assert.equal(handlers.size, 9, "disabled operator handlers still enforce authorization"); + assert.equal(handlers.size, 10, "disabled operator handlers still enforce authorization"); }); } diff --git a/tests/locale.test.mjs b/tests/locale.test.mjs new file mode 100644 index 00000000..13232e74 --- /dev/null +++ b/tests/locale.test.mjs @@ -0,0 +1,290 @@ +/** + * Locale-selection tests. + * + * The i18n layer ships one English base bundle and any number of translation + * bundles. These tests pin the selection mechanism itself: + * - the registry (supported locales, normalization, default); + * - every bundle resolving the full English key set, so a partial translation + * still renders every message; + * - `setLocale` changing what the bot and the formatters actually emit; + * - `BOT_LOCALE` selecting the language at config load, and failing fast on a + * language this build does not ship. + * + * The English named exports are also asserted to stay English: tests and + * tooling that import them directly must not depend on the active locale. + */ + +import assert from "node:assert/strict"; +import test, { afterEach } from "node:test"; + +import { + DEFAULT_LOCALE, + EXPLORER_BUTTON_TEXT, + HELP_TITLE, + NOTIFICATION_PLAIN, + SUPPORTED_LOCALES, + getLocale, + getMessages, + messages, + normalizeLocale, + setLocale, +} from "../dist/i18n.js"; +import { ConfigError, configProvenance, loadConfig } from "../dist/config.js"; +import { helpMessage, pauseMessage, resumeMessage } from "../dist/bot.js"; +import { explorerKeyboard, formatEvent } from "../dist/notifications/format.js"; + +afterEach(() => setLocale(DEFAULT_LOCALE)); + +const VALID_TX = "0123456789abcdef".repeat(4); +const VALID_CONTRACT_A = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const VALID_CONTRACT_B = "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBQMF4"; + +function testnetConfig(overrides = {}) { + return { + marketContractId: "market", + squadContractId: "squad", + rpcUrl: "https://soroban-testnet.stellar.org", + horizonUrl: "https://horizon-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + explorerBaseUrl: "https://stellar.expert/explorer", + chatId: "-1001234567890", + ...overrides, + }; +} + +function marketEvent(payloadOverrides = {}, eventOverrides = {}) { + return { + source: "market", + contractId: "market", + ledger: 42, + txHash: "", + at: 0, + eventId: "42-0", + eventType: "contract", + transactionIndex: 0, + operationIndex: 0, + inSuccessfulContractCall: true, + payload: { + name: "claim_created", + claimId: 7, + category: "crypto", + creator: "GABCD", + ...payloadOverrides, + }, + ...eventOverrides, + }; +} + +function withEnv(vars, fn) { + const saved = {}; + for (const [key, value] of Object.entries(vars)) { + saved[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return fn(); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +function validBotEnv(overrides = {}) { + return { + BOT_TOKEN: "123456789:" + "A".repeat(40), + TELEGRAM_CHAT_ID: "-1001234567890", + MARKET_CONTRACT_ID: VALID_CONTRACT_A, + SQUAD_CONTRACT_ID: VALID_CONTRACT_B, + BOT_LOCALE: undefined, + ...overrides, + }; +} + +// ── Registry ──────────────────────────────────────────────────────────────── + +test("registry: English is the default and every shipped locale is advertised", () => { + assert.equal(DEFAULT_LOCALE, "en"); + assert.ok(SUPPORTED_LOCALES.includes("en")); + assert.ok(SUPPORTED_LOCALES.length >= 2, "at least one translation must ship"); + assert.equal(getLocale(), "en"); +}); + +test("normalizeLocale: accepts case and region suffixes, rejects unknown languages", () => { + assert.equal(normalizeLocale("es"), "es"); + assert.equal(normalizeLocale("ES"), "es"); + assert.equal(normalizeLocale(" es-MX "), "es"); + assert.equal(normalizeLocale("es_419"), "es"); + assert.equal(normalizeLocale("en-GB"), "en"); + assert.equal(normalizeLocale("fr"), null); + assert.equal(normalizeLocale(""), null); + assert.equal(normalizeLocale(undefined), null); +}); + +test("every bundle resolves the full English key set", () => { + const english = getMessages("en"); + for (const locale of SUPPORTED_LOCALES) { + const bundle = getMessages(locale); + assert.deepEqual( + Object.keys(bundle).sort(), + Object.keys(english).sort(), + `${locale} is missing top-level keys`, + ); + for (const table of ["PAUSE_MESSAGES", "RESUME_MESSAGES", "COMMAND_DESCRIPTIONS"]) { + assert.deepEqual( + Object.keys(bundle[table]).sort(), + Object.keys(english[table]).sort(), + `${locale}.${table} does not resolve every key`, + ); + } + for (const table of ["NOTIFICATION_MD", "NOTIFICATION_PLAIN"]) { + assert.deepEqual( + Object.keys(bundle[table]).sort(), + Object.keys(english[table]).sort(), + `${locale}.${table} does not resolve every template`, + ); + } + } +}); + +test("all bundled strings are non-empty and every template renders", () => { + for (const locale of SUPPORTED_LOCALES) { + const bundle = getMessages(locale); + for (const key of ["HELP_TITLE", "HELP_INTRO", "AUDIT_CLI_HINT", "EXPLORER_BUTTON_TEXT", "MIMIR_APP_BUTTON_TEXT"]) { + assert.equal(typeof bundle[key], "string", `${locale}.${key} must be a string`); + assert.ok(bundle[key].trim().length > 0, `${locale}.${key} is empty`); + } + for (const table of ["PAUSE_MESSAGES", "RESUME_MESSAGES", "COMMAND_DESCRIPTIONS"]) { + for (const [key, value] of Object.entries(bundle[table])) { + assert.equal(typeof value, "string", `${locale}.${table}.${key} must be a string`); + assert.ok(value.trim().length > 0, `${locale}.${table}.${key} is empty`); + } + } + for (const table of ["NOTIFICATION_MD", "NOTIFICATION_PLAIN"]) { + for (const [key, value] of Object.entries(bundle[table])) { + if (typeof value === "string") { + assert.ok(value.trim().length > 0, `${locale}.${table}.${key} is empty`); + continue; + } + const args = Array.from({ length: value.length }, (_, index) => `arg${index}`); + const rendered = value(...args); + assert.equal(typeof rendered, "string", `${locale}.${table}.${key} must render a string`); + assert.ok(rendered.trim().length > 0, `${locale}.${table}.${key} rendered empty`); + } + } + } +}); + +test("plain-text templates stay free of Markdown formatting characters", () => { + for (const locale of SUPPORTED_LOCALES) { + const plain = getMessages(locale).NOTIFICATION_PLAIN; + for (const [key, value] of Object.entries(plain)) { + const rendered = + typeof value === "string" + ? value + : value(...Array.from({ length: value.length }, (_, index) => `arg${index}`)); + assert.equal( + /[*_`[\]\\]/.test(rendered), + false, + `${locale}.NOTIFICATION_PLAIN.${key} contains Markdown syntax: ${rendered}`, + ); + } + } +}); + +// ── Selection reaches every renderer ──────────────────────────────────────── + +test("English named exports stay English regardless of the active locale", () => { + setLocale("es"); + assert.equal(HELP_TITLE, "*Mimir notifier*"); + assert.equal(EXPLORER_BUTTON_TEXT, "View on Explorer"); + assert.equal(NOTIFICATION_PLAIN.claimCancelled(7), "Claim #7 cancelled β€” stakes returned"); +}); + +test("setLocale switches the active bundle and reports the selection", () => { + setLocale("es"); + assert.equal(getLocale(), "es"); + assert.equal(messages().HELP_TITLE, "*Notificador de Mimir*"); + setLocale("en"); + assert.equal(getLocale(), "en"); + assert.equal(messages().HELP_TITLE, "*Mimir notifier*"); +}); + +test("setLocale changes help, operator replies and notifications", () => { + setLocale("es"); + + const help = helpMessage(); + assert.match(help, /^\*Notificador de Mimir\*/); + assert.ok(help.includes("QuΓ© hace este bot"), "command descriptions follow the locale"); + + assert.equal( + pauseMessage("paused"), + "*Sondeo pausado*\nEl escaneo actual puede terminar, pero no se iniciarΓ‘ un ciclo nuevo\\. Los cursores no se modificaron\\.", + ); + assert.equal(resumeMessage("already-running"), "*El sondeo ya estΓ‘ en marcha*"); + + const notification = formatEvent( + testnetConfig(), + marketEvent({ name: "claim_created", claimId: 7, category: "crypto", creator: "GABCD" }), + ); + assert.equal( + notification, + "πŸ†• *Nueva reclamaciΓ³n* \\#7\nCategorΓ­a: crypto\nCreador: `GABCD`\n_ledger 42_", + ); + + const keyboard = explorerKeyboard( + testnetConfig(), + marketEvent({}, { txHash: VALID_TX }), + ); + assert.equal(keyboard?.inline_keyboard[0][0].text, "Ver en el explorador"); + + setLocale("en"); + assert.match(helpMessage(), /^\*Mimir notifier\*/); + assert.match(formatEvent(testnetConfig(), marketEvent()), /πŸ†• \*New claim\*/); +}); + +// ── Config wiring (BOT_LOCALE) ────────────────────────────────────────────── + +test("loadConfig: BOT_LOCALE selects the configured language", () => { + withEnv(validBotEnv({ BOT_LOCALE: "es" }), () => { + assert.equal(loadConfig().locale, "es"); + }); +}); + +test("loadConfig: BOT_LOCALE accepts a region suffix", () => { + withEnv(validBotEnv({ BOT_LOCALE: "es-MX" }), () => { + assert.equal(loadConfig().locale, "es"); + }); +}); + +test("loadConfig: BOT_LOCALE defaults to English when unset", () => { + withEnv(validBotEnv(), () => { + assert.equal(loadConfig().locale, "en"); + }); +}); + +test("loadConfig: an unsupported BOT_LOCALE fails fast with the supported set", () => { + withEnv(validBotEnv({ BOT_LOCALE: "fr" }), () => { + assert.throws(() => loadConfig(), (err) => { + assert.ok(err instanceof ConfigError); + assert.ok( + err.problems.some( + (problem) => problem.includes("BOT_LOCALE") && problem.includes("es"), + ), + `problems should name BOT_LOCALE and the supported locales: ${err.problems.join(" | ")}`, + ); + return true; + }); + }); +}); + +test("configProvenance: BOT_LOCALE is reported as an environment setting", () => { + withEnv(validBotEnv({ BOT_LOCALE: "es" }), () => { + const entry = configProvenance().entries.find((item) => item.key === "BOT_LOCALE"); + assert.ok(entry, "BOT_LOCALE must appear in the provenance report"); + assert.equal(entry.source, "process-env"); + assert.equal(entry.secret, false); + }); +}); diff --git a/tests/markdown-lint.test.mjs b/tests/markdown-lint.test.mjs index ea69a3f4..5b23b1b8 100644 --- a/tests/markdown-lint.test.mjs +++ b/tests/markdown-lint.test.mjs @@ -18,6 +18,8 @@ import { NOTIFICATION_MD, PAUSE_MESSAGES, RESUME_MESSAGES, + SUPPORTED_LOCALES, + getMessages, } from "../dist/i18n.js"; /** Telegram's MarkdownV2 reserved characters. */ @@ -736,3 +738,33 @@ test("i18n export: every entry in NOTIFICATION_MD is exhaustively verified", () } } }); + +// ═════════════════════════════════════════════════════════════════════════════ +// ── Locale bundles: every shipped MarkdownV2 string lints ──────────────────── +// ═════════════════════════════════════════════════════════════════════════════ + +test("i18n export: every locale bundle contains only valid MarkdownV2", () => { + for (const locale of SUPPORTED_LOCALES) { + const bundle = getMessages(locale); + + assertValidMarkdownV2(bundle.HELP_TITLE, `${locale}.HELP_TITLE`); + + for (const table of ["PAUSE_MESSAGES", "RESUME_MESSAGES"]) { + for (const [key, message] of Object.entries(bundle[table])) { + assert.equal(typeof message, "string", `${locale}.${table}.${key} must be a string`); + assertValidMarkdownV2(message, `${locale}.${table}.${key}`); + } + } + + for (const [key, value] of Object.entries(bundle.NOTIFICATION_MD)) { + if (typeof value === "string") { + assertValidMarkdownV2(value, `${locale}.NOTIFICATION_MD.${key}`); + continue; + } + const args = Array.from({ length: value.length }, (_, index) => `{arg${index}}`); + const rendered = value(...args); + assert.equal(typeof rendered, "string", `${locale}.NOTIFICATION_MD.${key} must render`); + assertValidMarkdownV2(rendered, `${locale}.NOTIFICATION_MD.${key}`); + } + } +}); From f4ddc92fa77a4bf20a8d0c4539d2e4d3a609c73c Mon Sep 17 00:00:00 2001 From: Codebuff Date: Tue, 20 Oct 2026 11:11:28 +0000 Subject: [PATCH 3/7] feat(i18n): let operators choose the bot language with MIMIR_LOCALE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replies and notifications were hard-coded English, so non-English operators had no way to localize the bot. A locale catalog with full English and Spanish message sets now backs help text, command menu descriptions, pause/resume replies, and notification formatting, selected at boot from MIMIR_LOCALE. Region tags normalize to their base language and unknown values fail fast instead of silently falling back. Existing named English exports are preserved so current callers and tests keep working. πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- .env.example | 6 + docs/configuration-reference.md | 1 + docs/translating.md | 27 +- src/bot.ts | 60 ++-- src/config.ts | 25 ++ src/i18n.ts | 588 ++++++++++++++++++++++++++++++-- src/notifications/format.ts | 107 +++--- tests/i18n-locale.test.mjs | 218 ++++++++++++ tests/i18n.test.mjs | 47 +++ tests/markdown-lint.test.mjs | 27 ++ 10 files changed, 1005 insertions(+), 101 deletions(-) create mode 100644 tests/i18n-locale.test.mjs diff --git a/.env.example b/.env.example index 57a64ce9..18ad2a1c 100644 --- a/.env.example +++ b/.env.example @@ -207,6 +207,12 @@ POLL_INTERVAL_MS=30000 # CaC (`railway.json`) is deprecated in favour of Railway Infrastructure as Code # (`.railway/railway.ts`) with a hard cutoff of 2026-12-01; this file documents # the current behaviour and simplifies migrating to IaC. See the README. +# ── Localization ───────────────────────────────────────────────────────────── +# Language for every bot reply and notification. Supported: en (default), es. +# Region tags such as es-MX are accepted and use the base language. Any other +# value fails fast at startup. +# MIMIR_LOCALE=en + # ── Channel Preview Mode ───────────────────────────────────────────────────── # When true, notifications sent to Telegram are formatted with a preview badge. # CHANNEL_PREVIEW_MODE=false diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 0e4f242f..41e771db 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -14,6 +14,7 @@ The `.env.example` file contains the base configuration. The bot uses strict env ### Telegram Settings - `BOT_TOKEN`: The API token for the Telegram bot. Required. - `TELEGRAM_CHAT_ID`: The numeric ID or `@channelusername` where notifications are posted. Required. +- `MIMIR_LOCALE`: Language for bot replies and notifications. Supported: `en` (default) and `es`; region tags such as `es-MX` resolve to the base language. Any other value fails fast at startup. See [Translator Guide](translating.md). ### Mimir Contract Settings - `MARKET_CONTRACT_ID`: The Mimir market Soroban contract address. Required. Must be a valid `C...` strkey. diff --git a/docs/translating.md b/docs/translating.md index 3605616a..7a2382d1 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -4,9 +4,32 @@ This guide explains how user-facing strings are organized, formatted, and escape --- +## 0. Choosing the Language + +`MIMIR_LOCALE` selects the locale used for every bot reply and notification. Supported values are listed in `SUPPORTED_LOCALES`: + +| `MIMIR_LOCALE` | Language | +| --- | --- | +| unset or `en` | English (default) | +| `es` | EspaΓ±ol | + +Region tags (`es-MX`, `en-GB`) are accepted and resolve to their base language. Any other value fails fast at startup, listing the accepted locales. The selected locale is carried on the config as `config.locale`; product code reads strings through `messagesFor(config.locale)` rather than importing the English constants directly. + +The named exports in the table below (`HELP_TITLE`, `NOTIFICATION_MD`, ...) are the English reference strings. They remain exported for backwards compatibility and are also available as `messagesFor("en")`. Every locale registers a complete `Messages` object in `MESSAGES`, so TypeScript rejects a locale that omits a template. + +To add a language: + +1. Add its code to `SUPPORTED_LOCALES` and its label to `LOCALE_LABELS`. +2. Copy `EN_MESSAGES`, translate every string, and follow the escaping rules below. +3. Register the new catalog in `MESSAGES`. +4. Add the code to the table above and to `.env.example`. +5. Add a rendering test for the new locale next to the existing ones. + +--- + ## 1. String Organization: MarkdownV2 vs. Plain Text -All user-facing localization templates live in [`src/i18n.ts`](../src/i18n.ts). Strings are strictly separated based on whether the caller dispatches them with `parse_mode: "MarkdownV2"` or as raw plain text (no `parse_mode`): +All user-facing localization templates live in [`src/i18n.ts`](../src/i18n.ts). Strings are strictly separated based on whether the caller dispatches them with `parse_mode: "MarkdownV2"` or as raw plain text (no `parse_mode`). The table shows the English exports; every locale's catalog has the same fields. | Export in `src/i18n.ts` | Mode | Purpose & Caller Behavior | | --- | --- | --- | @@ -213,7 +236,7 @@ Translators should keep strings concise ("One event, one message, one line of su Before opening a pull request that edits or adds strings in [`src/i18n.ts`](../src/i18n.ts), verify every item below: -- [ ] **Both `NOTIFICATION_MD` and `NOTIFICATION_PLAIN` are updated in lockstep**: Every event template change in `NOTIFICATION_MD` has a corresponding update in `NOTIFICATION_PLAIN` with the exact same parameter order and meaning. +- [ ] **Every locale is updated in lockstep**: A template change is made in the English reference exports and in each locale's catalog (`ES_MESSAGES` today), with `NOTIFICATION_MD` and `NOTIFICATION_PLAIN` keeping the exact same parameter order and meaning. - [ ] **All literal MarkdownV2 reserved characters are pre-escaped in MarkdownV2 strings**: Check `HELP_TITLE`, `PAUSE_MESSAGES`, `RESUME_MESSAGES`, and `NOTIFICATION_MD` for any unescaped `_ * [ ] ( ) ~ ` > # + - = | { } . !` outside formatting entities (remembering to write `\\.` / `\\#` / `\\-` / `\\(` / `\\)` in `.ts` files). - [ ] **No MarkdownV2 syntax or backslash escapes leak into plain-text strings**: Verify that `HELP_INTRO`, `AUDIT_CLI_HINT`, `COMMAND_DESCRIPTIONS`, `NOTIFICATION_PLAIN`, and `EXPLORER_BUTTON_TEXT` contain no `\\` escape sequences and that `NOTIFICATION_PLAIN` contains none of `* _ ` [ ] \`. - [ ] **`COMMAND_DESCRIPTIONS` contain no forbidden punctuation**: Confirm no command description in `COMMAND_DESCRIPTIONS` uses periods (`.`), exclamation marks (`!`), hashes (`#`), or Markdown formatting characters (`/[*_`[\]~>#+|{}.!\\]/`), and each is non-empty and under 256 characters. diff --git a/src/bot.ts b/src/bot.ts index 9c0a89ba..e0eadb57 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -35,12 +35,10 @@ import { type AuditLog, } from "./audit.js"; import { - AUDIT_CLI_HINT, COMMAND_DESCRIPTIONS, - HELP_INTRO, - HELP_TITLE, - PAUSE_MESSAGES, - RESUME_MESSAGES, + messagesFor, + type CommandName, + type Locale, } from "./i18n.js"; import { helpDetailMessage } from "./commands/help-detail.js"; import { aboutMessage } from "./commands/about.js"; @@ -155,16 +153,22 @@ export const COMMANDS = [ ] as const; function visibleCommands(config?: BotConfig) { - return COMMANDS.filter((command) => - !command.operatorOnly || config === undefined || config.operatorTelegramUserId !== null, - ); + const descriptions = messagesFor(config?.locale).commandDescriptions; + return COMMANDS.filter( + (command) => + !command.operatorOnly || config === undefined || config.operatorTelegramUserId !== null, + ).map((command) => ({ + ...command, + description: descriptions[command.command as CommandName], + })); } export function helpMessage(config?: BotConfig): string { + const messages = messagesFor(config?.locale); return [ - HELP_TITLE, + messages.helpTitle, "", - escapeMd(HELP_INTRO), + escapeMd(messages.helpIntro), "", ...visibleCommands(config).map(({ command, description }) => escapeMd(`/${command} β€” ${description}`), @@ -349,10 +353,14 @@ function ago(timestamp: number | null, nowMs: number = Date.now()): string { * here goes through MarkdownV2 escaping; this message is sent without a parse * mode. Bounded twice over: the file read is capped and only the tail renders. */ -function renderAuditForTelegram(summary: AuditFileSummary, tail: number): string { +function renderAuditForTelegram( + summary: AuditFileSummary, + tail: number, + locale?: Locale, +): string { const header = `*Audit* β€” ${summary.file}`; const report = renderAuditReport(summary, { tail }); - return `${header}\n\n${report}\n\n${AUDIT_CLI_HINT}`; + return `${header}\n\n${report}\n\n${messagesFor(locale).auditCliHint}`; } function cursorPreview(cursor: string | null): string { @@ -598,25 +606,27 @@ export function contractsMessage(config: BotConfig): string { } /** Exact operator replies, exported for deterministic Telegram payload tests. */ -export function pauseMessage(result: PollerPauseResult): string { +export function pauseMessage(result: PollerPauseResult, locale?: Locale): string { + const messages = messagesFor(locale).pauseMessages; switch (result) { case "paused": - return PAUSE_MESSAGES.paused; + return messages.paused; case "already-paused": - return PAUSE_MESSAGES.alreadyPaused; + return messages.alreadyPaused; case "stopped": - return PAUSE_MESSAGES.stopped; + return messages.stopped; } } -export function resumeMessage(result: PollerResumeResult): string { +export function resumeMessage(result: PollerResumeResult, locale?: Locale): string { + const messages = messagesFor(locale).resumeMessages; switch (result) { case "resumed": - return RESUME_MESSAGES.resumed; + return messages.resumed; case "already-running": - return RESUME_MESSAGES.alreadyRunning; + return messages.alreadyRunning; case "stopped": - return RESUME_MESSAGES.stopped; + return messages.stopped; } } @@ -769,7 +779,7 @@ export function registerCommandHandlers( ...summary, entries: [...summary.entries, ...live].slice(-AUDIT_REPORT_MAX_ENTRIES), }; - await ctx.reply(renderAuditForTelegram(merged, AUDIT_TAIL), { + await ctx.reply(renderAuditForTelegram(merged, AUDIT_TAIL, config.locale), { link_preview_options: { is_disabled: true }, }); } catch (err) { @@ -802,7 +812,7 @@ export function registerCommandHandlers( console.warn(`[bot] ignored unauthorized /pause on update ${ctx.update.update_id}`); return; } - await ctx.reply(pauseMessage(pause()), TELEGRAM_OPTIONS); + await ctx.reply(pauseMessage(pause(), config.locale), TELEGRAM_OPTIONS); }, resume: async (ctx) => { @@ -810,7 +820,7 @@ export function registerCommandHandlers( console.warn(`[bot] ignored unauthorized /resume on update ${ctx.update.update_id}`); return; } - await ctx.reply(resumeMessage(resume()), TELEGRAM_OPTIONS); + await ctx.reply(resumeMessage(resume(), config.locale), TELEGRAM_OPTIONS); }, }; @@ -958,7 +968,7 @@ export async function handleCallbackQuery( case "pause": { const result = pause(); - const text = pauseMessage(result); + const text = pauseMessage(result, config.locale); if (ctx.callbackQuery?.message) { await updateOrReplyMessage(ctx, text, operatorKeyboard()); } else { @@ -972,7 +982,7 @@ export async function handleCallbackQuery( case "resume": { const result = resume(); - const text = resumeMessage(result); + const text = resumeMessage(result, config.locale); if (ctx.callbackQuery?.message) { await updateOrReplyMessage(ctx, text, operatorKeyboard()); } else { diff --git a/src/config.ts b/src/config.ts index 8c39a483..8ce442ed 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,6 +51,12 @@ import { parseNotificationFeatureFlags, type NotificationFeatureFlags, } from "./notifications/featureFlags.js"; +import { + DEFAULT_LOCALE, + normalizeLocale, + SUPPORTED_LOCALES, + type Locale, +} from "./i18n.js"; /** Named Stellar networks the bot understands. `custom` is any other passphrase. */ export type StellarNetwork = "testnet" | "mainnet" | "futurenet" | "custom"; @@ -161,6 +167,12 @@ export const APP_VERSION: string = (() => { export interface StellarConfig { /** Named network, derived from STELLAR_NETWORK or inferred from the passphrase. */ network?: StellarNetwork; + /** + * Locale for every user-facing string (MIMIR_LOCALE). Optional on this + * chain-only type so hand-built configs keep working; `loadConfig` always + * sets it, and callers fall back to {@link DEFAULT_LOCALE} when absent. + */ + locale?: Locale; marketContractId: string; squadContractId: string; marketContractVersion: string; @@ -178,6 +190,8 @@ export interface TelegramRoute { } export interface BotConfig extends StellarConfig { + /** Locale for every user-facing string, from MIMIR_LOCALE. */ + locale: Locale; /** Semver string from `package.json`, or `"unknown"` if unavailable. */ version: string; botToken: string; @@ -309,6 +323,7 @@ export class ConfigError extends Error { const DEFAULTS = { network: "testnet" as StellarNetwork, + locale: DEFAULT_LOCALE, rpcUrl: NETWORK_RPC_URLS.testnet, horizonUrl: NETWORK_HORIZON_URLS.testnet, networkPassphrase: NETWORK_PASSPHRASES.testnet, @@ -807,8 +822,17 @@ export function loadConfig(): BotConfig { for (const problem of featureFlagsParsed.problems) c.problems.push(problem); const chatIds = c.chatIds("TELEGRAM_CHAT_ID"); + const localeInput = read("MIMIR_LOCALE"); + const locale = localeInput === undefined ? DEFAULT_LOCALE : normalizeLocale(localeInput); + if (locale === null) { + c.problems.push( + `MIMIR_LOCALE must be one of ${SUPPORTED_LOCALES.join(", ")}; got "${localeInput}"`, + ); + } + const config: BotConfig = { ...stellar, + locale: locale ?? DEFAULT_LOCALE, version: APP_VERSION, botToken: c.botToken("BOT_TOKEN"), chatId: chatIds[0] ?? "", @@ -983,6 +1007,7 @@ interface ConfigKeySpec { */ const CONFIG_KEYS: readonly ConfigKeySpec[] = [ { key: "MIMIR_PROFILE", secret: false }, + { key: "MIMIR_LOCALE", secret: false, hasBuiltInDefault: true }, { key: "MARKET_CONTRACT_ID", secret: false }, { key: "SQUAD_CONTRACT_ID", secret: false }, { key: "STELLAR_RPC_URL", secret: false, hasBuiltInDefault: true }, diff --git a/src/i18n.ts b/src/i18n.ts index 720d03f1..abdd2f8b 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -5,6 +5,17 @@ * numbers, addresses, amounts) at the call site β€” the localization layer * provides the template; the caller provides the variables. * + * ── Choosing a language ───────────────────────────────────────────────────── + * `MIMIR_LOCALE` selects the locale (`en` or `es`; unset means `en`). The + * selected locale is carried on the config as `config.locale`, so the bot and + * the formatters render every message in it. Import the named English exports + * below only when you explicitly want the reference language; product code + * should use {@link messagesFor} with the configured locale. + * + * The named exports (`HELP_TITLE`, `NOTIFICATION_MD`, ...) remain the English + * strings for backwards compatibility, and are also available as + * `messagesFor("en")`. + * * ── MarkdownV2 notes ──────────────────────────────────────────────────────── * Static strings that will be sent to Telegram via MarkdownV2 are stored * pre-escaped where they contain reserved characters. Dynamic interpolations @@ -13,13 +24,57 @@ * as-is; they never contain Markdown syntax. * * ── Adding a string ───────────────────────────────────────────────────────── - * 1. Add the constant to the appropriate namespace below. - * 2. Replace the inline literal in the source file with a reference. - * 3. Add a test in tests/i18n.test.mjs that imports the key and asserts both + * 1. Add the key to the relevant template interface below. + * 2. Add the English string to the matching constant. + * 3. Add the translation to every locale entry in `MESSAGES` (TypeScript + * rejects a missing or malformed entry). + * 4. Replace the inline literal in the source file with a reference through + * `messagesFor(locale)`. + * 5. Add a test in tests/i18n.test.mjs that imports the key and asserts both * the value and that the caller substitutes dynamic parts correctly. + * + * ── Adding a language ─────────────────────────────────────────────────────── + * 1. Add the code to `SUPPORTED_LOCALES` and a label to `LOCALE_LABELS`. + * 2. Implement `Messages` for it (typically by copying `EN_MESSAGES` and + * translating every string, keeping the MarkdownV2 escaping rules). + * 3. Register it in `MESSAGES`; the compiler then enforces completeness. */ -// ── Bot: help and lifecycle ────────────────────────────────────────────────── +// ── Locale registry ────────────────────────────────────────────────────────── + +/** Locales this build can render. Add a code here and to `MESSAGES`. */ +export const SUPPORTED_LOCALES = ["en", "es"] as const; + +/** A locale this build can render. */ +export type Locale = (typeof SUPPORTED_LOCALES)[number]; + +/** Locale used when `MIMIR_LOCALE` is unset. */ +export const DEFAULT_LOCALE: Locale = "en"; + +/** Human-readable name of each locale, for docs and error messages. */ +export const LOCALE_LABELS: Readonly> = { + en: "English", + es: "EspaΓ±ol", +}; + +/** + * Normalize an operator-supplied locale tag. + * + * Accepts case and region variants (`es`, `ES`, `es-ES`, `es_MX`) and maps them + * to a supported base locale. Returns null when the language is unsupported, so + * config loading can fail fast instead of silently rendering English. + */ +export function normalizeLocale(value: string): Locale | null { + const base = value.trim().toLowerCase().split(/[-_]/, 1)[0] ?? ""; + return (SUPPORTED_LOCALES as readonly string[]).includes(base) ? (base as Locale) : null; +} + +/** True when `value` is exactly a supported locale code. */ +export function isLocale(value: string): value is Locale { + return (SUPPORTED_LOCALES as readonly string[]).includes(value); +} + +// ── Bot: help and lifecycle (English reference strings) ────────────────────── /** Help title (MarkdownV2). */ export const HELP_TITLE = "*Mimir notifier*"; @@ -57,10 +112,28 @@ export const AUDIT_CLI_HINT = // ── Telegram command descriptions (shown in the UI autocomplete) ───────────── +/** Every command the bot registers, in menu order. */ +export const COMMAND_NAMES = [ + "start", + "help", + "about", + "status", + "audit", + "contracts", + "health", + "preview", + "pause", + "resume", +] as const; + +/** A registered command name. */ +export type CommandName = (typeof COMMAND_NAMES)[number]; + /** Descriptions registered with `setMyCommands`. */ -export const COMMAND_DESCRIPTIONS = { +export const COMMAND_DESCRIPTIONS: Readonly> = { start: "What this bot does", help: "Show help", + about: "About this bot", status: "Last-seen ledger and watched contracts", audit: "Operator only: audit report (redacted, bounded)", contracts: "Contract ids and explorer links", @@ -70,12 +143,188 @@ export const COMMAND_DESCRIPTIONS = { resume: "Operator only: resume polling now", } as const; -// ── Notifications: event headlines (MarkdownV2) ────────────────────────────── -// Template functions. Every dynamic value is supplied by the caller and must -// already be escapeMd-escaped before being passed in. Static reserved chars -// inside the template strings are pre-escaped here. +// ── Notification template interfaces ───────────────────────────────────────── + +/** + * MarkdownV2 notification headlines. Every dynamic value is supplied by the + * caller and must already be `escapeMd`-escaped before being passed in. Static + * reserved characters inside the templates are pre-escaped in each locale. + */ +export interface NotificationMarkdownTemplates { + // ── mimir-market ──────────────────────────────────────────────────────── + claimCreated(claimId: string, category: string, creator: string): string; + claimChallenged(claimId: string, stake: string, challenger: string): string; + claimResolved( + claimId: string, + winnerSide: string, + confidence: string, + summary: string | null, + ): string; + claimCancelled(claimId: string): string; + marketSettled( + claimId: string, + totalPaid: string, + totalFees: string, + owedToChallengers: string, + ...rest: (readonly string[] | string | undefined)[] + ): string; + challengerPaid( + claimId: string, + challenger: string, + stake: string, + net: string, + gross: string, + fee: string, + ): string; + feeClaimed(amount: string, recipient: string): string; + withdrawal(amount: string, to: string): string; + withdrawalPending(amount: string, to: string): string; + + // ── mimir-squad ───────────────────────────────────────────────────────── + marketCreated( + marketId: string, + question: string, + captain: string, + feeBps: string, + deadline: string, + ): string; + deposited(marketId: string, amount: string, side: string, participant: string): string; + withdrawn(marketId: string, participant: string, amount: string, side: string): string; + resolved(marketId: string, result: string, poolA: string, poolB: string): string; + claimed( + marketId: string, + participant: string, + net: string, + gross: string, + fee: string, + ): string; + feesClaimedSquad(amount: string, recipient: string): string; + + // ── Fallback and preview ──────────────────────────────────────────────── + /** + * Sent when an event payload is malformed or the formatter throws. + * All parameters must already be escapeMd-escaped. + */ + fallbackEvent( + source: string, + contract: string, + ledger: string, + reason: string, + txPart: string, + ): string; + previewModePrefix: string; + channelPreviewMarket: string; + channelPreviewSquad: string; +} + +/** Plain-text notification headlines, used without a parse_mode. */ +export interface NotificationPlainTemplates { + // ── mimir-market ──────────────────────────────────────────────────────── + claimCreated(claimId: string | number, category: string, creator: string): string; + claimChallenged( + claimId: string | number, + stake: string, + challenger: string, + ): string; + claimResolved( + claimId: string | number, + winnerSide: string, + confidence: string, + summary: string | null, + ): string; + claimCancelled(claimId: string | number): string; + marketSettled( + claimId: string | number, + totalPaid: string, + totalFees: string, + owedToChallengers: string, + ...rest: (readonly string[] | string | undefined)[] + ): string; + challengerPaid( + claimId: string | number, + challenger: string, + stake: string, + net: string, + gross: string, + fee: string, + ): string; + feeClaimed(amount: string, recipient: string): string; + withdrawal(amount: string, to: string): string; + withdrawalPending(amount: string, to: string): string; + + // ── mimir-squad ───────────────────────────────────────────────────────── + marketCreated( + marketId: string | number, + question: string, + captain: string, + feeBps: string, + deadline: string, + ): string; + deposited( + marketId: string | number, + amount: string, + side: string, + participant: string, + ): string; + withdrawn( + marketId: string | number, + participant: string, + amount: string, + side: string, + ): string; + resolved( + marketId: string | number, + result: string, + poolA: string, + poolB: string, + ): string; + claimed( + marketId: string | number, + participant: string, + net: string, + gross: string, + fee: string, + ): string; + feesClaimedSquad(amount: string, recipient: string): string; -export const NOTIFICATION_MD = { + /** + * Last-resort one-liner when even the plain-text formatter cannot render. + * Never returns an empty string. + */ + minimal(source: string, ledger: string, eventId: string): string; +} + +/** Operator pause replies (MarkdownV2). */ +export interface PauseMessages { + paused: string; + alreadyPaused: string; + stopped: string; +} + +/** Operator resume replies (MarkdownV2). */ +export interface ResumeMessages { + resumed: string; + alreadyRunning: string; + stopped: string; +} + +/** Every localized string the bot can render. */ +export interface Messages { + helpTitle: string; + helpIntro: string; + pauseMessages: PauseMessages; + resumeMessages: ResumeMessages; + auditCliHint: string; + commandDescriptions: Readonly>; + notificationMd: NotificationMarkdownTemplates; + notificationPlain: NotificationPlainTemplates; + explorerButtonText: string; + mimirAppButtonText: string; +} + +// ── Notifications: event headlines (English MarkdownV2) ───────────────────── + +export const NOTIFICATION_MD: NotificationMarkdownTemplates = { // ── mimir-market ──────────────────────────────────────────────────────── /** @param claimId already-escaped string, e.g. "7" */ claimCreated: (claimId: string, category: string, creator: string): string => @@ -169,10 +418,6 @@ export const NOTIFICATION_MD = { `🧾 *Squad fees claimed* β€” ${amount} to ${recipient}`, // ── Fallback ───────────────────────────────────────────────────────────── - /** - * Sent when an event payload is malformed or the formatter throws. - * All parameters must already be escapeMd-escaped. - */ fallbackEvent: ( source: string, contract: string, @@ -188,12 +433,11 @@ export const NOTIFICATION_MD = { // ── Channel preview headers ────────────────────────────────────────────── channelPreviewMarket: "πŸ§ͺ *Channel Preview β€” mimir\\-market*", channelPreviewSquad: "πŸ§ͺ *Channel Preview β€” mimir\\-squad*", -} as const; +}; -// ── Notifications: event headlines (plain text) ────────────────────────────── -// Mirror of NOTIFICATION_MD for use without a parse_mode. No Markdown at all. +// ── Notifications: event headlines (English plain text) ───────────────────── -export const NOTIFICATION_PLAIN = { +export const NOTIFICATION_PLAIN: NotificationPlainTemplates = { // ── mimir-market ──────────────────────────────────────────────────────── claimCreated: (claimId: string | number, category: string, creator: string): string => `New claim #${claimId}\nCategory: ${category}\nCreator: ${creator}`, @@ -301,17 +545,13 @@ export const NOTIFICATION_PLAIN = { feesClaimedSquad: (amount: string, recipient: string): string => `Squad fees claimed β€” ${amount} to ${recipient}`, - /** - * Last-resort one-liner when even the plain-text formatter cannot render. - * Never returns an empty string. - */ minimal: (source: string, ledger: string, eventId: string): string => { const idPart = eventId ? ` (${eventId})` : ""; return `Mimir event (${source}) β€” ${ledger}${idPart}`; }, -} as const; +}; -// ── Explorer button ────────────────────────────────────────────────────────── +// ── Explorer button (English) ──────────────────────────────────────────────── /** Text shown on the Telegram inline keyboard button for explorer links. */ export const EXPLORER_BUTTON_TEXT = "View on Explorer"; @@ -321,3 +561,303 @@ export const EXPLORER_BUTTON_TEXT = "View on Explorer"; * app (see `MIMIR_APP_URL`). Absent from a keyboard when no URL is configured. */ export const MIMIR_APP_BUTTON_TEXT = "Open in Mimir"; + +// ── Spanish translations ───────────────────────────────────────────────────── +// Same shape and MarkdownV2 rules as the English reference strings above. +// Dynamic values arrive already escaped; static reserved characters are +// escaped here. + +const ES_PAUSE_MESSAGES: PauseMessages = { + paused: + "*Sondeo en pausa*\nEl escaneo actual puede terminar, pero no se iniciarΓ‘ ningΓΊn ciclo nuevo\\. Los cursores no cambiaron\\.", + alreadyPaused: "*El sondeo ya estΓ‘ en pausa*", + stopped: "*El sondeo no puede pausarse* β€” el proceso se estΓ‘ deteniendo\\.", +}; + +const ES_RESUME_MESSAGES: ResumeMessages = { + resumed: "*Sondeo reanudado*\nEl siguiente escaneo empieza ahora\\. Los cursores no cambiaron\\.", + alreadyRunning: "*El sondeo ya estΓ‘ en marcha*", + stopped: "*El sondeo no puede reanudarse* β€” el proceso se estΓ‘ deteniendo\\.", +}; + +const ES_COMMAND_DESCRIPTIONS: Readonly> = { + start: "QuΓ© hace este bot", + help: "Mostrar ayuda", + about: "Acerca de este bot", + status: "Último ledger visto y contratos vigilados", + audit: "Solo operadores: informe de auditorΓ­a (redactado, acotado)", + contracts: "Ids de contratos y enlaces al explorador", + health: "EvaluaciΓ³n de salud y preparaciΓ³n operativa", + preview: "Previsualizar el formato de las notificaciones del canal", + pause: "Solo operadores: pausar nuevos escaneos", + resume: "Solo operadores: reanudar el sondeo ahora", +}; + +const ES_NOTIFICATION_MD: NotificationMarkdownTemplates = { + // ── mimir-market ──────────────────────────────────────────────────────── + claimCreated: (claimId: string, category: string, creator: string): string => + `πŸ†• *Nueva reclamaciΓ³n* \\#${claimId}\nCategorΓ­a: ${category}\nCreador: ${creator}`, + + claimChallenged: (claimId: string, stake: string, challenger: string): string => + `βš”οΈ *ReclamaciΓ³n \\#${claimId} impugnada*\nApuesta: *${stake}*\nImpugnador: ${challenger}`, + + claimResolved: ( + claimId: string, + winnerSide: string, + confidence: string, + summary: string | null, + ): string => + ( + `βš–οΈ *ReclamaciΓ³n \\#${claimId} resuelta* β€” ganador: *${winnerSide}*\n` + + `Confianza: ${confidence}\n` + + (summary ? `_${summary}_` : "") + ).trimEnd(), + + claimCancelled: (claimId: string): string => + `🚫 *ReclamaciΓ³n \\#${claimId} cancelada* β€” apuestas devueltas`, + + marketSettled: ( + claimId: string, + totalPaid: string, + totalFees: string, + owedToChallengers: string, + ...rest: (readonly string[] | string | undefined)[] + ): string => { + const lines = rest[0]; + const linesPart = Array.isArray(lines) + ? `\n${lines.join("\n")}` + : typeof lines === "string" && lines.length > 0 + ? `\n${lines}` + : ""; + if (linesPart) { + return `πŸ’° *ReclamaciΓ³n \\#${claimId} liquidada*\nPagado: *${totalPaid}* Β· comisiones ${totalFees}${linesPart}`; + } + return `πŸ’° *ReclamaciΓ³n \\#${claimId} liquidada*\nPagado: *${totalPaid}* Β· comisiones ${totalFees}\nAdeudado a impugnadores: ${owedToChallengers}`; + }, + + challengerPaid: ( + claimId: string, + challenger: string, + stake: string, + net: string, + gross: string, + fee: string, + ): string => + `πŸ† *Impugnador pagado* en la reclamaciΓ³n \\#${claimId}\n${challenger} apostΓ³ ${stake} β†’ neto *${net}*\nBruto ${gross} Β· comisiΓ³n ${fee}`, + + feeClaimed: (amount: string, recipient: string): string => + `🧾 *Comisiones reclamadas* β€” ${amount} a ${recipient}`, + + withdrawal: (amount: string, to: string): string => + `πŸ“€ *Retiro* β€” ${amount} a ${to}`, + + withdrawalPending: (amount: string, to: string): string => + `⏳ *Retiro en espera* β€” ${amount} reclamable por ${to}`, + + // ── mimir-squad ───────────────────────────────────────────────────────── + marketCreated: ( + marketId: string, + question: string, + captain: string, + feeBps: string, + deadline: string, + ): string => + `πŸ†• *Nuevo mercado de escuadrΓ³n* \\#${marketId}\n${question}\nCapitΓ‘n: ${captain} Β· comisiΓ³n ${feeBps} bps Β· plazo ${deadline}`, + + deposited: (marketId: string, amount: string, side: string, participant: string): string => + `βž• *EscuadrΓ³n \\#${marketId}* β€” ${amount} en *${side}*\nParticipante: ${participant}`, + + withdrawn: (marketId: string, participant: string, amount: string, side: string): string => + `βž– *EscuadrΓ³n \\#${marketId}* β€” ${participant} retirΓ³ ${amount} de ${side}`, + + resolved: (marketId: string, result: string, poolA: string, poolB: string): string => + `🏁 *EscuadrΓ³n \\#${marketId} resuelto* β€” *${result}*\nPozos: A ${poolA} Β· B ${poolB}`, + + claimed: ( + marketId: string, + participant: string, + net: string, + gross: string, + fee: string, + ): string => + `πŸ’Έ *Pago de escuadrΓ³n* en \\#${marketId}\n${participant} β†’ neto *${net}* \\(bruto ${gross}, comisiΓ³n ${fee}\\)`, + + feesClaimedSquad: (amount: string, recipient: string): string => + `🧾 *Comisiones de escuadrΓ³n reclamadas* β€” ${amount} a ${recipient}`, + + // ── Fallback ───────────────────────────────────────────────────────────── + fallbackEvent: ( + source: string, + contract: string, + ledger: string, + reason: string, + txPart: string, + ): string => + `⚠️ *NotificaciΓ³n de evento alternativa* \\(${source}\\)\nContrato: \`${contract}\` Β· Ledger: ${ledger}${txPart}\nMotivo: _${reason}_`, + + previewModePrefix: "πŸ§ͺ *VISTA PREVIA*", + channelPreviewMarket: "πŸ§ͺ *Vista previa del canal β€” mimir\\-market*", + channelPreviewSquad: "πŸ§ͺ *Vista previa del canal β€” mimir\\-squad*", +}; + +const ES_NOTIFICATION_PLAIN: NotificationPlainTemplates = { + // ── mimir-market ──────────────────────────────────────────────────────── + claimCreated: (claimId: string | number, category: string, creator: string): string => + `Nueva reclamaciΓ³n #${claimId}\nCategorΓ­a: ${category}\nCreador: ${creator}`, + + claimChallenged: ( + claimId: string | number, + stake: string, + challenger: string, + ): string => + `ReclamaciΓ³n #${claimId} impugnada\nApuesta: ${stake}\nImpugnador: ${challenger}`, + + claimResolved: ( + claimId: string | number, + winnerSide: string, + confidence: string, + summary: string | null, + ): string => + `ReclamaciΓ³n #${claimId} resuelta β€” ganador: ${winnerSide}\nConfianza: ${confidence}` + + (summary ? `\n${summary}` : ""), + + claimCancelled: (claimId: string | number): string => + `ReclamaciΓ³n #${claimId} cancelada β€” apuestas devueltas`, + + marketSettled: ( + claimId: string | number, + totalPaid: string, + totalFees: string, + owedToChallengers: string, + ...rest: (readonly string[] | string | undefined)[] + ): string => { + const lines = rest[0]; + const linesPart = Array.isArray(lines) + ? `\n${lines.join("\n")}` + : typeof lines === "string" && lines.length > 0 + ? `\n${lines}` + : ""; + if (linesPart) { + return `ReclamaciΓ³n #${claimId} liquidada\nPagado: ${totalPaid} Β· comisiones ${totalFees}${linesPart}`; + } + return `ReclamaciΓ³n #${claimId} liquidada\nPagado: ${totalPaid} Β· comisiones ${totalFees}\nAdeudado a impugnadores: ${owedToChallengers}`; + }, + + challengerPaid: ( + claimId: string | number, + challenger: string, + stake: string, + net: string, + gross: string, + fee: string, + ): string => + `Impugnador pagado en la reclamaciΓ³n #${claimId}\n${challenger} apostΓ³ ${stake} β†’ neto ${net}\nBruto ${gross} Β· comisiΓ³n ${fee}`, + + feeClaimed: (amount: string, recipient: string): string => + `Comisiones reclamadas β€” ${amount} a ${recipient}`, + + withdrawal: (amount: string, to: string): string => + `Retiro β€” ${amount} a ${to}`, + + withdrawalPending: (amount: string, to: string): string => + `Retiro en espera β€” ${amount} reclamable por ${to}`, + + // ── mimir-squad ───────────────────────────────────────────────────────── + marketCreated: ( + marketId: string | number, + question: string, + captain: string, + feeBps: string, + deadline: string, + ): string => + `Nuevo mercado de escuadrΓ³n #${marketId}\n${question}\nCapitΓ‘n: ${captain} Β· comisiΓ³n ${feeBps} bps Β· plazo ${deadline}`, + + deposited: ( + marketId: string | number, + amount: string, + side: string, + participant: string, + ): string => + `EscuadrΓ³n #${marketId} β€” ${amount} en ${side}\nParticipante: ${participant}`, + + withdrawn: ( + marketId: string | number, + participant: string, + amount: string, + side: string, + ): string => + `EscuadrΓ³n #${marketId} β€” ${participant} retirΓ³ ${amount} de ${side}`, + + resolved: ( + marketId: string | number, + result: string, + poolA: string, + poolB: string, + ): string => + `EscuadrΓ³n #${marketId} resuelto β€” ${result}\nPozos: A ${poolA} Β· B ${poolB}`, + + claimed: ( + marketId: string | number, + participant: string, + net: string, + gross: string, + fee: string, + ): string => + `Pago de escuadrΓ³n en #${marketId}\n${participant} β†’ neto ${net} (bruto ${gross}, comisiΓ³n ${fee})`, + + feesClaimedSquad: (amount: string, recipient: string): string => + `Comisiones de escuadrΓ³n reclamadas β€” ${amount} a ${recipient}`, + + minimal: (source: string, ledger: string, eventId: string): string => { + const idPart = eventId ? ` (${eventId})` : ""; + return `Evento de Mimir (${source}) β€” ${ledger}${idPart}`; + }, +}; + +// ── Message catalog ────────────────────────────────────────────────────────── + +/** English catalog. The named exports above are this object's fields. */ +export const EN_MESSAGES: Messages = { + helpTitle: HELP_TITLE, + helpIntro: HELP_INTRO, + pauseMessages: PAUSE_MESSAGES, + resumeMessages: RESUME_MESSAGES, + auditCliHint: AUDIT_CLI_HINT, + commandDescriptions: COMMAND_DESCRIPTIONS, + notificationMd: NOTIFICATION_MD, + notificationPlain: NOTIFICATION_PLAIN, + explorerButtonText: EXPLORER_BUTTON_TEXT, + mimirAppButtonText: MIMIR_APP_BUTTON_TEXT, +}; + +/** Spanish catalog. */ +export const ES_MESSAGES: Messages = { + helpTitle: "*Avisador de Mimir*", + helpIntro: + "Vigilo los dos contratos Soroban de Mimir en Stellar y publico aquΓ­ cada nuevo evento en cadena: reclamaciones abiertas, apuestas de impugnaciΓ³n, resoluciones del orΓ‘culo, liquidaciones y pagos.", + pauseMessages: ES_PAUSE_MESSAGES, + resumeMessages: ES_RESUME_MESSAGES, + auditCliHint: + "Consulta `npm run audit -- --help` para la herramienta de informe independiente.", + commandDescriptions: ES_COMMAND_DESCRIPTIONS, + notificationMd: ES_NOTIFICATION_MD, + notificationPlain: ES_NOTIFICATION_PLAIN, + explorerButtonText: "Ver en el explorador", + mimirAppButtonText: "Abrir en Mimir", +}; + +/** Every locale's catalog. `Record` makes a locale mandatory. */ +export const MESSAGES: Readonly> = { + en: EN_MESSAGES, + es: ES_MESSAGES, +}; + +/** + * Catalog for a locale tag, falling back to English when it is unset or not + * supported. Config validation rejects unsupported values at boot, so the + * fallback only covers hand-built configs and older callers. + */ +export function messagesFor(locale?: string | null): Messages { + const normalized = locale === undefined || locale === null ? null : normalizeLocale(locale); + return MESSAGES[normalized ?? DEFAULT_LOCALE]; +} diff --git a/src/notifications/format.ts b/src/notifications/format.ts index 9e709bd0..929490d4 100644 --- a/src/notifications/format.ts +++ b/src/notifications/format.ts @@ -29,12 +29,7 @@ import { groupDigestEvents } from "./digest-grouping.js"; import { confidenceIndicator } from "./confidence.js"; import { settlementLines } from "./settlement.js"; import type { StellarConfig } from "../config.js"; -import { - EXPLORER_BUTTON_TEXT, - MIMIR_APP_BUTTON_TEXT, - NOTIFICATION_MD, - NOTIFICATION_PLAIN, -} from "../i18n.js"; +import { messagesFor, type Messages } from "../i18n.js"; /** Telegram's MarkdownV2 reserved set. All of it must be escaped, everywhere. */ const MDV2_RESERVED = /[_*[\]()~`>#+\-=|{}.!\\]/g; @@ -274,12 +269,13 @@ export function explorerKeyboard( config: KeyboardConfig, event: DecodedEvent, ): ExplorerKeyboard | undefined { + const messages = messagesFor(config.locale); const buttons: ExplorerButton[] = []; const explorerUrl = eventExplorerUrl(config, event); - if (explorerUrl) buttons.push({ text: EXPLORER_BUTTON_TEXT, url: explorerUrl }); + if (explorerUrl) buttons.push({ text: messages.explorerButtonText, url: explorerUrl }); const appUrl = appUrlFor(event, config.mimirAppUrl); - if (appUrl) buttons.push({ text: MIMIR_APP_BUTTON_TEXT, url: appUrl }); + if (appUrl) buttons.push({ text: messages.mimirAppButtonText, url: appUrl }); if (buttons.length === 0) return undefined; return { inline_keyboard: [buttons] }; @@ -370,27 +366,28 @@ export function formatDigest( * in contract state, not the event, so it is not invented here. The squad * contract's `market_created` does carry `question`, and that is shown. */ -function headline(event: DecodedEvent): string | null { +function headline(messages: Messages, event: DecodedEvent): string | null { const p = event.payload; + const md = messages.notificationMd; switch (p.name) { // ── mimir-market ──────────────────────────────────────────────────────── case "claim_created": - return NOTIFICATION_MD.claimCreated( + return md.claimCreated( String(p.claimId), escapeMd(clip(p.category)), who(p.creator), ); case "claim_challenged": - return NOTIFICATION_MD.claimChallenged( + return md.claimChallenged( String(p.claimId), usdc(p.stake), who(p.challenger), ); case "claim_resolved": - return NOTIFICATION_MD.claimResolved( + return md.claimResolved( String(p.claimId), escapeMd(winnerSideLabel(p.winnerSide)), escapeMd(confidenceIndicator(p.confidence)), @@ -398,11 +395,11 @@ function headline(event: DecodedEvent): string | null { ); case "claim_cancelled": - return NOTIFICATION_MD.claimCancelled(String(p.claimId)); + return md.claimCancelled(String(p.claimId)); case "market_settled": { const lines = settlementLines(p).map(escapeMd); - return NOTIFICATION_MD.marketSettled( + return md.marketSettled( String(p.claimId), usdc(p.totalPaid), usdc(p.totalFees), @@ -412,7 +409,7 @@ function headline(event: DecodedEvent): string | null { } case "challenger_paid": - return NOTIFICATION_MD.challengerPaid( + return md.challengerPaid( String(p.claimId), who(p.challenger), usdc(p.stake), @@ -422,17 +419,17 @@ function headline(event: DecodedEvent): string | null { ); case "fee_claimed": - return NOTIFICATION_MD.feeClaimed(usdc(p.amount), who(p.recipient)); + return md.feeClaimed(usdc(p.amount), who(p.recipient)); case "withdrawal": - return NOTIFICATION_MD.withdrawal(usdc(p.amount), who(p.to)); + return md.withdrawal(usdc(p.amount), who(p.to)); case "withdrawal_pending": - return NOTIFICATION_MD.withdrawalPending(usdc(p.amount), who(p.to)); + return md.withdrawalPending(usdc(p.amount), who(p.to)); // ── mimir-squad ───────────────────────────────────────────────────────── case "market_created": - return NOTIFICATION_MD.marketCreated( + return md.marketCreated( String(p.marketId), escapeMd(clip(p.question)), who(p.captain), @@ -441,7 +438,7 @@ function headline(event: DecodedEvent): string | null { ); case "deposited": - return NOTIFICATION_MD.deposited( + return md.deposited( String(p.marketId), usdc(p.amount), escapeMd(squadSideLabel(p.side)), @@ -449,7 +446,7 @@ function headline(event: DecodedEvent): string | null { ); case "withdrawn": - return NOTIFICATION_MD.withdrawn( + return md.withdrawn( String(p.marketId), who(p.participant), usdc(p.amount), @@ -457,7 +454,7 @@ function headline(event: DecodedEvent): string | null { ); case "resolved": - return NOTIFICATION_MD.resolved( + return md.resolved( String(p.marketId), escapeMd(squadSideLabel(p.result)), usdc(p.poolA), @@ -465,7 +462,7 @@ function headline(event: DecodedEvent): string | null { ); case "claimed": - return NOTIFICATION_MD.claimed( + return md.claimed( String(p.marketId), who(p.participant), usdc(p.net), @@ -474,7 +471,7 @@ function headline(event: DecodedEvent): string | null { ); case "fees_claimed": - return NOTIFICATION_MD.feesClaimedSquad(usdc(p.amount), who(p.recipient)); + return md.feesClaimedSquad(usdc(p.amount), who(p.recipient)); // Admin events and undecodable shapes get no notification. The poller logs // them so a silent bot is distinguishable from an unteachable one. @@ -519,11 +516,12 @@ export function formatEvent( ): string | null { if (!eventAllowedByCategory(config, event)) return null; try { - const head = headline(event); + const messages = messagesFor(config.locale); + const head = headline(messages, event); if (head === null) return null; const body = `${head}\n${footer(config, event)}`; if (config.channelPreviewMode) { - return `${NOTIFICATION_MD.previewModePrefix}\n${body}`; + return `${messages.notificationMd.previewModePrefix}\n${body}`; } return body; } catch (err) { @@ -557,23 +555,24 @@ function plainFooter(config: StellarConfig, event: DecodedEvent): string { * field-for-field (same identities, amounts, bounded clips) but emits no * MarkdownV2 syntax at all, so Telegram cannot reject it for entity parsing. */ -function plainHeadline(event: DecodedEvent): string | null { +function plainHeadline(messages: Messages, event: DecodedEvent): string | null { const p = event.payload; + const plain = messages.notificationPlain; switch (p.name) { // ── mimir-market ──────────────────────────────────────────────────────── case "claim_created": - return NOTIFICATION_PLAIN.claimCreated(p.claimId, clip(p.category), plainWho(p.creator)); + return plain.claimCreated(p.claimId, clip(p.category), plainWho(p.creator)); case "claim_challenged": - return NOTIFICATION_PLAIN.claimChallenged( + return plain.claimChallenged( p.claimId, plainUsdc(p.stake), plainWho(p.challenger), ); case "claim_resolved": - return NOTIFICATION_PLAIN.claimResolved( + return plain.claimResolved( p.claimId, winnerSideLabel(p.winnerSide), confidenceIndicator(p.confidence), @@ -581,11 +580,11 @@ function plainHeadline(event: DecodedEvent): string | null { ); case "claim_cancelled": - return NOTIFICATION_PLAIN.claimCancelled(p.claimId); + return plain.claimCancelled(p.claimId); case "market_settled": { const lines = settlementLines(p); - return NOTIFICATION_PLAIN.marketSettled( + return plain.marketSettled( p.claimId, plainUsdc(p.totalPaid), plainUsdc(p.totalFees), @@ -595,7 +594,7 @@ function plainHeadline(event: DecodedEvent): string | null { } case "challenger_paid": - return NOTIFICATION_PLAIN.challengerPaid( + return plain.challengerPaid( p.claimId, plainWho(p.challenger), plainUsdc(p.stake), @@ -605,17 +604,17 @@ function plainHeadline(event: DecodedEvent): string | null { ); case "fee_claimed": - return NOTIFICATION_PLAIN.feeClaimed(plainUsdc(p.amount), plainWho(p.recipient)); + return plain.feeClaimed(plainUsdc(p.amount), plainWho(p.recipient)); case "withdrawal": - return NOTIFICATION_PLAIN.withdrawal(plainUsdc(p.amount), plainWho(p.to)); + return plain.withdrawal(plainUsdc(p.amount), plainWho(p.to)); case "withdrawal_pending": - return NOTIFICATION_PLAIN.withdrawalPending(plainUsdc(p.amount), plainWho(p.to)); + return plain.withdrawalPending(plainUsdc(p.amount), plainWho(p.to)); // ── mimir-squad ───────────────────────────────────────────────────────── case "market_created": - return NOTIFICATION_PLAIN.marketCreated( + return plain.marketCreated( p.marketId, clip(p.question), plainWho(p.captain), @@ -624,7 +623,7 @@ function plainHeadline(event: DecodedEvent): string | null { ); case "deposited": - return NOTIFICATION_PLAIN.deposited( + return plain.deposited( p.marketId, plainUsdc(p.amount), squadSideLabel(p.side), @@ -632,7 +631,7 @@ function plainHeadline(event: DecodedEvent): string | null { ); case "withdrawn": - return NOTIFICATION_PLAIN.withdrawn( + return plain.withdrawn( p.marketId, plainWho(p.participant), plainUsdc(p.amount), @@ -640,7 +639,7 @@ function plainHeadline(event: DecodedEvent): string | null { ); case "resolved": - return NOTIFICATION_PLAIN.resolved( + return plain.resolved( p.marketId, squadSideLabel(p.result), plainUsdc(p.poolA), @@ -648,7 +647,7 @@ function plainHeadline(event: DecodedEvent): string | null { ); case "claimed": - return NOTIFICATION_PLAIN.claimed( + return plain.claimed( p.marketId, plainWho(p.participant), plainUsdc(p.net), @@ -657,7 +656,7 @@ function plainHeadline(event: DecodedEvent): string | null { ); case "fees_claimed": - return NOTIFICATION_PLAIN.feesClaimedSquad(plainUsdc(p.amount), plainWho(p.recipient)); + return plain.feesClaimedSquad(plainUsdc(p.amount), plainWho(p.recipient)); // Same set as `headline`: admin events and undecodable shapes get nothing. case "oracle_changed": @@ -698,26 +697,27 @@ export function formatPlainTextEvent( config: StellarConfig, event: DecodedEvent, ): string | null { + const messages = messagesFor(config.locale); try { - const head = plainHeadline(event); + const head = plainHeadline(messages, event); if (head === null) return null; const body = `${head}\n${plainFooter(config, event)}`.trim(); - if (!body) return minimalPlainTextEvent(event); + if (!body) return minimalPlainTextEvent(messages, event); return clipPlainText(body); } catch { - return minimalPlainTextEvent(event); + return minimalPlainTextEvent(messages, event); } } /** Last-resort bounded line when even the plain-text formatter cannot render. */ -function minimalPlainTextEvent(event: DecodedEvent): string { +function minimalPlainTextEvent(messages: Messages, event: DecodedEvent): string { const source = typeof event?.source === "string" ? event.source : "unknown"; const ledger = typeof event?.ledger === "number" && Number.isFinite(event.ledger) ? `ledger ${event.ledger}` : "ledger unknown"; const eventId = typeof event?.eventId === "string" && event.eventId ? event.eventId : ""; - return clipPlainText(NOTIFICATION_PLAIN.minimal(source, ledger, eventId)); + return clipPlainText(messages.notificationPlain.minimal(source, ledger, eventId)); } /** @@ -733,7 +733,13 @@ export function formatFallbackEvent( const ledger = escapeMd(String(event.ledger ?? "unknown")); const safeReason = escapeMd(safeErrorMessage(reason)); const txPart = event.txHash ? ` Β· [tx](${txExplorerUrl(config, event.txHash)})` : ""; - return NOTIFICATION_MD.fallbackEvent(source, contract, ledger, safeReason, txPart); + return messagesFor(config.locale).notificationMd.fallbackEvent( + source, + contract, + ledger, + safeReason, + txPart, + ); } /** @@ -741,6 +747,7 @@ export function formatFallbackEvent( * Shows a sample notification and current link preview settings for the specified contract. */ export function previewMessage(config: StellarConfig & { linkPreviewMarket?: boolean; linkPreviewSquad?: boolean }, target = "market"): string { + const messages = messagesFor(config.locale); const isSquad = target.trim().toLowerCase() === "squad"; const linkPreviewEnabled = isSquad ? config.linkPreviewSquad ?? false @@ -771,7 +778,7 @@ export function previewMessage(config: StellarConfig & { linkPreviewMarket?: boo }, }; const formatted = formatEvent({ ...config, channelPreviewMode: false }, sampleEvent) ?? ""; - return `${NOTIFICATION_MD.channelPreviewSquad}\n${settingsLine}\n\n${formatted}`; + return `${messages.notificationMd.channelPreviewSquad}\n${settingsLine}\n\n${formatted}`; } const sampleEvent: DecodedEvent = { @@ -794,6 +801,6 @@ export function previewMessage(config: StellarConfig & { linkPreviewMarket?: boo }, }; const formatted = formatEvent({ ...config, channelPreviewMode: false }, sampleEvent) ?? ""; - return `${NOTIFICATION_MD.channelPreviewMarket}\n${settingsLine}\n\n${formatted}`; + return `${messages.notificationMd.channelPreviewMarket}\n${settingsLine}\n\n${formatted}`; } diff --git a/tests/i18n-locale.test.mjs b/tests/i18n-locale.test.mjs new file mode 100644 index 00000000..b517d51f --- /dev/null +++ b/tests/i18n-locale.test.mjs @@ -0,0 +1,218 @@ +/** + * Locale-selection tests for src/i18n.ts and the MIMIR_LOCALE config wiring. + * + * English strings stay covered by tests/i18n.test.mjs; this file covers the + * catalog registry, normalization, the Spanish translation, and the fail-fast + * config path. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_LOCALE, + EN_MESSAGES, + ES_MESSAGES, + LOCALE_LABELS, + MESSAGES, + SUPPORTED_LOCALES, + messagesFor, + normalizeLocale, +} from "../dist/i18n.js"; + +import { + ConfigError, + configProvenance, + loadConfig, +} from "../dist/config.js"; + +const VALID_CONTRACT_A = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const VALID_CONTRACT_B = "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBQMF4"; + +function withEnv(vars, fn) { + const saved = {}; + for (const [key, value] of Object.entries(vars)) { + saved[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return fn(); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +function validBotEnv(overrides = {}) { + return { + BOT_TOKEN: "123456789:" + "A".repeat(40), + TELEGRAM_CHAT_ID: "-1001234567890", + MARKET_CONTRACT_ID: VALID_CONTRACT_A, + SQUAD_CONTRACT_ID: VALID_CONTRACT_B, + MIMIR_PROFILE: undefined, + MIMIR_LOCALE: undefined, + POLL_INTERVAL_MS: undefined, + STELLAR_RPC_URL: undefined, + STELLAR_HORIZON_URL: undefined, + STELLAR_NETWORK_PASSPHRASE: undefined, + ...overrides, + }; +} + +function shapeOf(value) { + if (Array.isArray(value)) return value.map(shapeOf); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, shapeOf(value[key])]), + ); + } + return typeof value; +} + +// ── Registry ───────────────────────────────────────────────────────────────── + +test("every supported locale has a catalog and a label", () => { + for (const locale of SUPPORTED_LOCALES) { + assert.ok(MESSAGES[locale], `missing catalog for ${locale}`); + assert.equal(typeof LOCALE_LABELS[locale], "string"); + assert.ok(LOCALE_LABELS[locale].length > 0); + } + assert.equal(Object.keys(MESSAGES).length, SUPPORTED_LOCALES.length); +}); + +test("every locale catalog has the same shape as English", () => { + for (const locale of SUPPORTED_LOCALES) { + assert.deepEqual( + shapeOf(MESSAGES[locale]), + shapeOf(EN_MESSAGES), + `catalog ${locale} does not match the English catalog shape`, + ); + } + assert.deepEqual(Object.keys(MESSAGES.es.commandDescriptions), Object.keys(EN_MESSAGES.commandDescriptions)); +}); + +test("DEFAULT_LOCALE is supported and messagesFor() returns it", () => { + assert.ok(SUPPORTED_LOCALES.includes(DEFAULT_LOCALE)); + assert.equal(messagesFor(), EN_MESSAGES); + assert.equal(messagesFor(null), EN_MESSAGES); + assert.equal(messagesFor("en"), EN_MESSAGES); +}); + +// ── Normalization ──────────────────────────────────────────────────────────── + +test("normalizeLocale accepts case and region variants", () => { + assert.equal(normalizeLocale("es"), "es"); + assert.equal(normalizeLocale("ES"), "es"); + assert.equal(normalizeLocale(" es-MX "), "es"); + assert.equal(normalizeLocale("en_GB"), "en"); + assert.equal(normalizeLocale("en"), "en"); +}); + +test("normalizeLocale rejects unsupported languages and garbage", () => { + for (const value of ["fr", "de-DE", "", " ", "english", "e", "es-"]) { + const result = normalizeLocale(value); + if (value === "es-") assert.equal(result, "es"); + else assert.equal(result, null, `expected null for ${JSON.stringify(value)}`); + } +}); + +test("messagesFor falls back to English for unsupported tags", () => { + assert.equal(messagesFor("fr"), EN_MESSAGES); + assert.equal(messagesFor("klingon"), EN_MESSAGES); + assert.equal(messagesFor("es-MX"), ES_MESSAGES); +}); + +// ── Spanish catalog ────────────────────────────────────────────────────────── + +test("Spanish catalog is a real translation, not English copy", () => { + assert.notEqual(ES_MESSAGES.helpTitle, EN_MESSAGES.helpTitle); + assert.notEqual(ES_MESSAGES.helpIntro, EN_MESSAGES.helpIntro); + assert.notEqual(ES_MESSAGES.auditCliHint, EN_MESSAGES.auditCliHint); + assert.notEqual(ES_MESSAGES.explorerButtonText, EN_MESSAGES.explorerButtonText); + assert.notEqual(ES_MESSAGES.notificationMd.claimCreated("7", "crypto", "`G`"), EN_MESSAGES.notificationMd.claimCreated("7", "crypto", "`G`")); + + for (const [command, description] of Object.entries(ES_MESSAGES.commandDescriptions)) { + assert.ok(description.length > 0, `empty Spanish description for ${command}`); + } +}); + +test("Spanish MarkdownV2 notifications render the expected template", () => { + const md = ES_MESSAGES.notificationMd; + assert.equal( + md.claimCreated("7", "crypto", "`GABCD`"), + "πŸ†• *Nueva reclamaciΓ³n* \\#7\nCategorΓ­a: crypto\nCreador: `GABCD`", + ); + assert.match(md.claimResolved("7", "challengers", "100", null), /ReclamaciΓ³n \\#7 resuelta/); + assert.match(md.claimed("3", "`G`", "9", "10", "1"), /Pago de escuadrΓ³n/); + assert.match(md.fallbackEvent("market", "C", "42", "oops", ""), /Motivo: _oops_/); +}); + +test("Spanish plain-text templates contain no Markdown syntax", () => { + const plain = ES_MESSAGES.notificationPlain; + const rendered = [ + plain.claimCreated(1, "cat", "ADDR"), + plain.claimChallenged(1, "1.0 USDC", "ADDR"), + plain.claimResolved(1, "claimant", "100", null), + plain.marketSettled(1, "10.0 USDC", "0.5 USDC", "5.0 USDC"), + plain.challengerPaid(1, "ADDR", "2.0 USDC", "1.5 USDC", "2.0 USDC", "0.5 USDC"), + plain.feeClaimed("1.0 USDC", "ADDR"), + plain.withdrawal("5.0 USDC", "ADDR"), + plain.withdrawalPending("5.0 USDC", "ADDR"), + plain.marketCreated(1, "Will it?", "ADDR", "50", "2027-01-01T00:00:00.000Z"), + plain.deposited(1, "10.0 USDC", "side A", "ADDR"), + plain.withdrawn(1, "ADDR", "5.0 USDC", "side A"), + plain.resolved(1, "side A", "10.0 USDC", "5.0 USDC"), + plain.claimed(1, "ADDR", "9.0 USDC", "10.0 USDC", "1.0 USDC"), + plain.feesClaimedSquad("2.0 USDC", "ADDR"), + plain.minimal("market", "ledger 42", "42-0"), + ]; + for (const text of rendered) { + assert.equal(/[*_`[\]\\]/.test(text), false, `Markdown syntax in: ${text}`); + } +}); + +// ── Config wiring ──────────────────────────────────────────────────────────── + +test("loadConfig defaults MIMIR_LOCALE to en", () => { + withEnv(validBotEnv(), () => { + assert.equal(loadConfig().locale, "en"); + }); +}); + +test("loadConfig reads MIMIR_LOCALE and normalizes region tags", () => { + withEnv(validBotEnv({ MIMIR_LOCALE: "es" }), () => { + assert.equal(loadConfig().locale, "es"); + }); + withEnv(validBotEnv({ MIMIR_LOCALE: "es-MX" }), () => { + assert.equal(loadConfig().locale, "es"); + }); +}); + +test("loadConfig rejects an unsupported MIMIR_LOCALE with the accepted list", () => { + withEnv(validBotEnv({ MIMIR_LOCALE: "fr" }), () => { + assert.throws( + () => loadConfig(), + (err) => { + assert.ok(err instanceof ConfigError); + assert.ok(err.problems.some((p) => p.includes("MIMIR_LOCALE"))); + assert.ok(err.problems.some((p) => p.includes("en, es"))); + return true; + }, + ); + }); +}); + +test("config provenance reports MIMIR_LOCALE", () => { + withEnv(validBotEnv({ MIMIR_LOCALE: "es" }), () => { + const provenance = configProvenance(); + const entry = provenance.entries.find((e) => e.key === "MIMIR_LOCALE"); + assert.ok(entry, "MIMIR_LOCALE missing from provenance"); + assert.equal(entry.secret, false); + assert.equal(entry.source, "process-env"); + }); +}); diff --git a/tests/i18n.test.mjs b/tests/i18n.test.mjs index 6c40b1d5..95af2ed6 100644 --- a/tests/i18n.test.mjs +++ b/tests/i18n.test.mjs @@ -17,6 +17,7 @@ import test from "node:test"; import { AUDIT_CLI_HINT, COMMAND_DESCRIPTIONS, + ES_MESSAGES, EXPLORER_BUTTON_TEXT, HELP_INTRO, HELP_TITLE, @@ -35,6 +36,7 @@ import { } from "../dist/notifications/format.js"; import { + helpMessage, pauseMessage, resumeMessage, registerCommandHandlers, @@ -750,6 +752,51 @@ test("/pause stopped reply matches PAUSE_MESSAGES.stopped", async () => { assert.equal(replies[0][0], PAUSE_MESSAGES.stopped); }); +// ── Locale selection: config.locale drives every i18n lookup ──────────────── + +test("pauseMessage and resumeMessage render the selected locale", () => { + assert.equal(pauseMessage("paused", "es"), ES_MESSAGES.pauseMessages.paused); + assert.equal(pauseMessage("already-paused", "es"), ES_MESSAGES.pauseMessages.alreadyPaused); + assert.equal(resumeMessage("resumed", "es"), ES_MESSAGES.resumeMessages.resumed); + assert.equal(pauseMessage("paused", "en"), PAUSE_MESSAGES.paused); + assert.equal(pauseMessage("paused"), PAUSE_MESSAGES.paused); +}); + +test("helpMessage uses the configured locale for title and commands", () => { + const text = helpMessage({ ...baseConfig(), locale: "es" }); + assert.ok(text.startsWith(ES_MESSAGES.helpTitle)); + assert.ok(text.includes(ES_MESSAGES.commandDescriptions.pause)); + assert.equal(text.includes(COMMAND_DESCRIPTIONS.pause), false); +}); + +test("formatEvent renders the Spanish MarkdownV2 template when locale is es", () => { + const config = testnetConfig({ locale: "es" }); + const event = marketEvent({ name: "claim_created", claimId: 7, category: "crypto", creator: "GABCD" }); + const result = formatEvent(config, event); + assert.ok(result.startsWith("πŸ†• *Nueva reclamaciΓ³n* \\#7")); + assert.equal(result.includes("New claim"), false); +}); + +test("formatPlainTextEvent renders Spanish when locale is es", () => { + const config = testnetConfig({ locale: "es" }); + const event = marketEvent({ name: "claim_cancelled", claimId: 3 }); + assert.equal(formatPlainTextEvent(config, event), "ReclamaciΓ³n #3 cancelada β€” apuestas devueltas\nledger 42"); +}); + +test("explorerKeyboard uses the localized button labels", () => { + const config = testnetConfig({ + locale: "es", + mimirAppUrl: "https://app.example.test", + }); + const event = marketEvent({}, { + txHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }); + const keyboard = explorerKeyboard(config, event); + assert.ok(keyboard !== undefined); + assert.equal(keyboard.inline_keyboard[0][0].text, ES_MESSAGES.explorerButtonText); + assert.equal(keyboard.inline_keyboard[0][1].text, ES_MESSAGES.mimirAppButtonText); +}); + // ── Boundary: no reserved character in plain-text templates ────────────────── test("all NOTIFICATION_PLAIN template functions return strings without MarkdownV2 syntax", () => { diff --git a/tests/markdown-lint.test.mjs b/tests/markdown-lint.test.mjs index ea69a3f4..87568bc0 100644 --- a/tests/markdown-lint.test.mjs +++ b/tests/markdown-lint.test.mjs @@ -18,6 +18,8 @@ import { NOTIFICATION_MD, PAUSE_MESSAGES, RESUME_MESSAGES, + SUPPORTED_LOCALES, + messagesFor, } from "../dist/i18n.js"; /** Telegram's MarkdownV2 reserved characters. */ @@ -721,6 +723,31 @@ test("i18n export: NOTIFICATION_MD template functions produce valid MarkdownV2 w ); }); +test("i18n export: every locale's MarkdownV2 strings are valid", () => { + for (const locale of SUPPORTED_LOCALES) { + const messages = messagesFor(locale); + assertValidMarkdownV2(messages.helpTitle, `${locale}.helpTitle`); + + for (const [key, value] of Object.entries(messages.pauseMessages)) { + assertValidMarkdownV2(value, `${locale}.PAUSE_MESSAGES.${key}`); + } + for (const [key, value] of Object.entries(messages.resumeMessages)) { + assertValidMarkdownV2(value, `${locale}.RESUME_MESSAGES.${key}`); + } + + for (const [key, value] of Object.entries(messages.notificationMd)) { + if (typeof value === "string") { + assertValidMarkdownV2(value, `${locale}.NOTIFICATION_MD.${key}`); + } else if (typeof value === "function") { + const args = Array.from({ length: value.length }, (_, idx) => `{arg${idx}}`); + assertValidMarkdownV2(value(...args), `${locale}.NOTIFICATION_MD.${key}`); + } else { + assert.fail(`Unexpected entry type for ${locale}.NOTIFICATION_MD.${key}`); + } + } + } +}); + test("i18n export: every entry in NOTIFICATION_MD is exhaustively verified", () => { for (const [key, value] of Object.entries(NOTIFICATION_MD)) { if (typeof value === "string") { From b1a495466d92a5c046c913bd3001da0d8929e50f Mon Sep 17 00:00:00 2001 From: Codebuff Date: Tue, 20 Oct 2026 12:28:42 +0000 Subject: [PATCH 4/7] refactor(commands): register slash commands from self-contained modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every new slash command had to edit the shared handlers record in bot.ts, which is what kept conflicting in merges β€” and the tip of this branch had in fact landed an unresolved merge whose splices no longer compiled. Each command now lives in its own module under src/commands/ and registers itself through defineCommand, so bot.ts only walks the registry and applies the authorization the module declares. The merge damage in bot.ts and notifications/format.ts is repaired in the same pass, including the missing formatDeadline import the merged deadline rendering needed. πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- CONTRIBUTING.md | 33 + src/bot.ts | 1142 +++++------------------------- src/callbacks.ts | 422 +++++++++++ src/commands/about.ts | 14 + src/commands/audit.ts | 64 ++ src/commands/broadcast.ts | 3 +- src/commands/contracts.ts | 53 ++ src/commands/health.ts | 113 +++ src/commands/help-detail.ts | 10 +- src/commands/help.ts | 48 ++ src/commands/index.ts | 67 ++ src/commands/keyboards.ts | 53 ++ src/commands/log-export.ts | 29 + src/commands/pause.ts | 43 ++ src/commands/preview.ts | 18 + src/commands/registry.ts | 161 +++++ src/commands/resume.ts | 30 + src/commands/runtime.ts | 126 ++++ src/commands/status.ts | 168 +++++ src/notifications/format.ts | 22 +- tests/command-modules.test.mjs | 290 ++++++++ tests/contracts-command.test.mjs | 2 +- tests/help-command.test.mjs | 7 +- tests/i18n.test.mjs | 104 +-- tests/pause-confirm.test.mjs | 67 +- tests/render-cli.test.mjs | 4 +- 26 files changed, 2022 insertions(+), 1071 deletions(-) create mode 100644 src/callbacks.ts create mode 100644 src/commands/audit.ts create mode 100644 src/commands/contracts.ts create mode 100644 src/commands/health.ts create mode 100644 src/commands/help.ts create mode 100644 src/commands/index.ts create mode 100644 src/commands/keyboards.ts create mode 100644 src/commands/log-export.ts create mode 100644 src/commands/pause.ts create mode 100644 src/commands/preview.ts create mode 100644 src/commands/registry.ts create mode 100644 src/commands/resume.ts create mode 100644 src/commands/runtime.ts create mode 100644 src/commands/status.ts create mode 100644 tests/command-modules.test.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5bee7335..45248c0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,9 +126,42 @@ The REPL context is preloaded with: - Keep tests and docs consistent with the behavior they describe; a behavior change updates its test and, where the operator surface changes, the README. +## Adding a slash command + +Commands are self-contained modules under `src/commands/`; `src/bot.ts` only walks +the registry, so a new command never needs to touch a shared handler table (and so +rarely conflicts with another in-flight branch): + +1. Create `src/commands/.ts` and export a module from `defineCommand({ … })`: + + ```ts + export const widgetCommand = defineCommand({ + command: "widget", + chatAllowlist: true, // optional: only chats in ALLOWED_CHAT_IDS + operatorOnly: false, // optional: operator-only, silently ignored otherwise + hidden: false, // optional: registered but not advertised in the menu + handler: async (ctx, deps) => { + await ctx.reply(widgetMessage(deps.config), TELEGRAM_OPTIONS); + }, + }); + ``` + + The handler receives `(ctx, deps)`; `deps` is the same object `createBot` took + (config, poller status, pause/resume, audit, logs). Authorization is applied by + the registrar from `operatorOnly`/`chatAllowlist` β€” do not repeat it in the + handler. +2. Add one import line to `src/commands/index.ts`, in menu order. +3. If the command is advertised, add its description to `COMMAND_DESCRIPTIONS` + (both locales) in `src/i18n.ts` and its entry to `COMMAND_DETAILS` in + `src/commands/help-detail.ts`. `/help`, the Telegram menu and the detail pages + are generated from those tables. +4. Cover the handler in `tests/` like any other command; `tests/command-modules.test.mjs` + checks the registry invariants (unique names, menu parity, central authorization). + ## Where things live - `src/` β€” the TypeScript source (config, RPC client, decoder, poller, bot, audit). +- `src/commands/` β€” one module per slash command plus the registry that lists them. - `src/dev/repl.ts` β€” interactive developer REPL with preloaded decoders, formatters, and mock helpers. - `tests/` β€” Node built-in test runner suites compiled against `dist/`. - `docs/quickstart-mock.md` β€” five-minute quickstart using the local mock RPC. diff --git a/src/bot.ts b/src/bot.ts index fac7047c..5b2ad43c 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,5 +1,12 @@ /** - * The grammy bot: commands, and the one send path the poller uses. + * The grammy bot: command wiring, callback wiring, and the one send path the + * poller uses. + * + * Slash commands live in `src/commands/`, one self-contained module per + * command: each declares its own name, visibility, authorization and handler, + * and registers itself through `defineCommand`. This file only builds the bot, + * walks the registry, and owns the notifier the poller sends through β€” so + * adding a command no longer means editing this file. * * The bot half is deliberately thin. It answers public status and contract * commands plus operator pause/resume controls. Operator controls only change @@ -7,47 +14,73 @@ * state. All chain logic lives in `src/poller.ts` and `src/stellar/`. */ -import { Bot, InlineKeyboard, type Context } from "grammy"; -import type { UserFromGetMe } from "grammy/types"; -import { performance } from "node:perf_hooks"; +import { Bot, type Context, type CommandContext } from "grammy"; import { - escapeMd, - formatLastEvent, - splitTelegramMessage, - previewMessage, safeErrorMessage, + splitTelegramMessage, type ExplorerKeyboard, } from "./notifications/format.js"; -import { formatFeatureFlags } from "./notifications/featureFlags.js"; export { previewMessage } from "./notifications/format.js"; -import { formatProvenanceSummary, networkLabel, type BotConfig } from "./config.js"; -import { contractExplorerUrl } from "./stellar/client.js"; +import { type BotConfig } from "./config.js"; import type { ContractSource } from "./stellar/decode.js"; -import { buildHealthReport, chainClockLabel } from "./health.js"; -import { renderLogExport, type LogCapture } from "./logger.js"; -import type { PollerPauseResult, PollerResumeResult, PollerStatus } from "./poller.js"; -import { - AUDIT_REPORT_MAX_ENTRIES, - readAuditFile, - renderAuditReport, - type AuditFileSummary, - type AuditLog, -} from "./audit.js"; + +// Importing the catalogue registers every command module as a side effect. import { - COMMAND_DESCRIPTIONS, - messagesFor, - type CommandName, - type Locale, -} from "./i18n.js"; -import { helpDetailMessage } from "./commands/help-detail.js"; -import { aboutMessage } from "./commands/about.js"; + CALLBACK_PAUSE_CANCEL, + CALLBACK_PAUSE_CONFIRM, + chatAllowed, + commandMetadata, + operatorAllowed, + registeredCommands, + visibleCommandMetadata, + type CommandDeps, + type CommandModule, +} from "./commands/index.js"; +import { handlePauseConfirmCallback, registerCallbackHandlers } from "./callbacks.js"; + +// ── Public surface ─────────────────────────────────────────────────────────── +// bot.ts stays the stable entry point for index.ts and the tests; the +// implementations live with the commands. + export { helpDetailMessage, COMMAND_DETAILS, type CommandDetail } from "./commands/help-detail.js"; -import { - handleBroadcastCallback, - registerBroadcastHandlers, - type BroadcastStore, -} from "./commands/broadcast.js"; +export { + CALLBACK_PAUSE_CANCEL, + CALLBACK_PAUSE_CONFIRM, + contractsKeyboard, + operatorKeyboard, + pauseConfirmKeyboard, + statusKeyboard, +} from "./commands/keyboards.js"; +export { TELEGRAM_OPTIONS, isChatAllowed, isOperator } from "./commands/runtime.js"; +export { + aboutMessage, + helpMessage, + contractsMessage, + healthMessage, + lastEventMessage, + pauseMessage, + resumeMessage, + statusMessage, +} from "./commands/index.js"; +export { + CALLBACK_FALLBACK_FEEDBACK, + CALLBACK_FALLBACK_PAYLOAD, + CALLBACK_UNAUTHORIZED_FEEDBACK, + CALLBACK_UNAUTHORIZED_PAYLOAD, + PAUSE_CONFIRM_TTL_MS, + TELEGRAM_CALLBACK_DATA_MAX_BYTES, + handleCallbackQuery, + handlePauseConfirmCallback, + registerCallbackHandlers, + updateOrReplyMessage, + validateCallbackData, + validatePauseConfirmData, + type CallbackActionType, + type CallbackValidationResult, + type ValidCallbackAction, +} from "./callbacks.js"; +import { registerBroadcastHandlers } from "./commands/broadcast.js"; export { handleBroadcastCallback, registerBroadcastHandlers, @@ -58,975 +91,147 @@ export { } from "./commands/broadcast.js"; /** - * How long a pause-confirmation message stays valid. - * Callbacks arriving after this window are rejected as stale. + * Telegram bot tokens have the shape `:<35+ url-safe chars>`. + * Values pasted from dashboards frequently arrive wrapped in quotes or with + * trailing whitespace/newlines, which only surface as a `getMe` failure at + * startup. Validate the shape up front and name the problem without ever + * echoing the token (or any part of it) into a message or log. */ -export const PAUSE_CONFIRM_TTL_MS = 60_000; - -/** - * The two callback_data strings used by the pause confirmation flow. - * Both are well under Telegram's 64-byte limit. - */ -export const CALLBACK_PAUSE_CONFIRM = "pause-confirm" as const; -export const CALLBACK_PAUSE_CANCEL = "pause-cancel" as const; - -type ValidCallbackAction = typeof CALLBACK_PAUSE_CONFIRM | typeof CALLBACK_PAUSE_CANCEL; - -const VALID_CALLBACK_DATA = new Set([ - CALLBACK_PAUSE_CONFIRM, - CALLBACK_PAUSE_CANCEL, -]); - -/** - * Returns the validated action string or `null` if data is unknown/missing. - * Keeps the handler from acting on arbitrary payloads from third parties. - */ -export function validateCallbackData(data: string | undefined): ValidCallbackAction | null { - if (data !== undefined && VALID_CALLBACK_DATA.has(data)) { - return data as ValidCallbackAction; - } - return null; -} - -/** Shared metadata for handlers, help, and Telegram's command menu. */ -export const COMMANDS = [ - { command: "start", description: COMMAND_DESCRIPTIONS.start, operatorOnly: false }, - { command: "help", description: COMMAND_DESCRIPTIONS.help, operatorOnly: false }, - { command: "about", description: COMMAND_DESCRIPTIONS.about, operatorOnly: false }, - { command: "status", description: COMMAND_DESCRIPTIONS.status, operatorOnly: false }, - { command: "audit", description: COMMAND_DESCRIPTIONS.audit, operatorOnly: true }, - { command: "contracts", description: COMMAND_DESCRIPTIONS.contracts, operatorOnly: false }, - { command: "health", description: COMMAND_DESCRIPTIONS.health, operatorOnly: false }, - { command: "preview", description: COMMAND_DESCRIPTIONS.preview, operatorOnly: false }, - { command: "pause", description: COMMAND_DESCRIPTIONS.pause, operatorOnly: true }, - { command: "resume", description: COMMAND_DESCRIPTIONS.resume, operatorOnly: true }, -] as const; - -function visibleCommands(config?: BotConfig) { - const descriptions = messagesFor(config?.locale).commandDescriptions; - return COMMANDS.filter( - (command) => - !command.operatorOnly || config === undefined || config.operatorTelegramUserId !== null, - ).map((command) => ({ - ...command, - description: descriptions[command.command as CommandName], - })); -} - -export function helpMessage(config?: BotConfig): string { - const messages = messagesFor(config?.locale); -/** Inline keyboard attached to the pause confirmation message. */ -export function operatorKeyboard(): InlineKeyboard { - return new InlineKeyboard() - .text("βœ… Confirm pause", CALLBACK_PAUSE_CONFIRM) - .text("❌ Cancel", CALLBACK_PAUSE_CANCEL); -} +export type BotTokenShapeProblem = + | "empty" + | "surrounding-quotes" + | "whitespace" + | "missing-colon" + | "bad-prefix" + | "bad-suffix" + | "wrong-shape"; -const HELP_BASE = [ - "*Mimir notifier*", - "", - "I watch Mimir's two Soroban contracts on Stellar and post every new on-chain event here: claims opened, challenges staked, oracle resolutions, settlements and payouts\\.", - "", - "/status β€” what I am watching and how far I have read", - "/contracts β€” the contract ids I watch and where to look them up", - "/health β€” health assessment and operational readiness", - "/preview β€” preview channel notification formatting", - "/help β€” this message", -]; - -function helpMessage(config: BotConfig): string { - if (config.operatorTelegramUserId === null) return HELP_BASE.join("\n"); - return [ - messages.helpTitle, - "", - escapeMd(messages.helpIntro), - "", - ...visibleCommands(config).map(({ command, description }) => - escapeMd(`/${command} β€” ${description}`), - ), - ].join("\n"); +export interface BotTokenShapeResult { + ok: boolean; + problem?: BotTokenShapeProblem; + message?: string; } -export const TELEGRAM_OPTIONS = { - parse_mode: "MarkdownV2" as const, - link_preview_options: { is_disabled: true }, -}; - -// ── Inline keyboard callbacks ──────────────────────────────────────────────── -// Callback data is untrusted user input: it is validated before any action -// runs, and operator actions are authorized exactly like /pause and /resume. - -export type CallbackActionType = "status" | "contracts" | "help" | "pause" | "resume" | "broadcast"; - -export interface ValidCallbackAction { - type: CallbackActionType; - raw: string; -} - -export type CallbackValidationResult = - | { ok: true; action: ValidCallbackAction } - | { ok: false; reason: string; fallbackText: string }; - -/** Telegram's Bot API limits callback_data to 1-64 bytes. */ -export const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64; - -export const CALLBACK_FALLBACK_PAYLOAD = - "*Invalid request*\nThe requested action is unrecognized or malformed\\."; - -export const CALLBACK_UNAUTHORIZED_PAYLOAD = - "*Unauthorized*\nThis action requires operator privileges\\."; - -export const CALLBACK_FALLBACK_FEEDBACK = "Invalid or unrecognized action"; -export const CALLBACK_UNAUTHORIZED_FEEDBACK = "Unauthorized: operator only"; - - -function isKnownAction(val: string): val is CallbackActionType { - return ["status", "contracts", "help", "pause", "resume", "broadcast"].includes(val); -} +const BOT_TOKEN_SHAPE = /^\d+:[A-Za-z0-9_-]{35,}$/; /** - * Validate untrusted callback-query data before any action is executed. - * Treats all callback data as untrusted external user input. + * Validate a `BOT_TOKEN` value without ever including the token (or any + * substring of it) in the returned message. The messages are intentionally + * generic so they are safe to log and to surface in a startup error. + * + * Exported for embedders and for callers that build a bot from their own + * configuration; `loadConfig` applies the same rules at startup, so + * `createBot` does not reject placeholder tokens. */ -export function validateCallbackData(raw: unknown): CallbackValidationResult { - if (typeof raw !== "string") { +export function validateBotTokenShape(raw: unknown): BotTokenShapeResult { + if (typeof raw !== "string" || raw.length === 0) { return { ok: false, - reason: "missing-or-non-string", - fallbackText: CALLBACK_FALLBACK_FEEDBACK, + problem: "empty", + message: "BOT_TOKEN is empty; expected :<35+ url-safe chars>.", }; } - const trimmed = raw.trim(); - if (trimmed.length === 0) { + // Surrounding quotes are the most common paste artefact. + if ( + (raw.startsWith('"') && raw.endsWith('"')) || + (raw.startsWith("'") && raw.endsWith("'")) + ) { return { ok: false, - reason: "empty", - fallbackText: CALLBACK_FALLBACK_FEEDBACK, + problem: "surrounding-quotes", + message: + "BOT_TOKEN is wrapped in quotes; remove the surrounding quotes and retry.", }; } - const byteLength = Buffer.byteLength(raw, "utf8"); - if (byteLength > TELEGRAM_CALLBACK_DATA_MAX_BYTES) { + // Any whitespace anywhere (leading, trailing, or embedded) is rejected. + if (/\s/.test(raw)) { return { ok: false, - reason: "exceeds-max-length", - fallbackText: CALLBACK_FALLBACK_FEEDBACK, + problem: "whitespace", + message: + "BOT_TOKEN contains whitespace (leading, trailing, or embedded); trim it and retry.", }; } - // Reject ASCII control characters (including null bytes) - if (/[\x00-\x1F\x7F]/.test(raw)) { + if (!raw.includes(":")) { return { ok: false, - reason: "control-characters", - fallbackText: CALLBACK_FALLBACK_FEEDBACK, + problem: "missing-colon", + message: "BOT_TOKEN is missing the ':' separator; expected :<35+ url-safe chars>.", }; } - // 1. JSON payload: e.g. {"action":"status"} - if (trimmed.startsWith("{") && trimmed.endsWith("}")) { - try { - const parsed = JSON.parse(trimmed) as { action?: unknown }; - if (typeof parsed?.action === "string" && isKnownAction(parsed.action)) { - return { - ok: true, - action: { type: parsed.action, raw }, - }; - } - return { - ok: false, - reason: "unknown-json-action", - fallbackText: CALLBACK_FALLBACK_FEEDBACK, - }; - } catch { - return { - ok: false, - reason: "malformed-json", - fallbackText: CALLBACK_FALLBACK_FEEDBACK, - }; - } - } - - // 2. Colon-delimited: e.g. "status:refresh", "mimir:contracts", etc. - if (trimmed.includes(":")) { - const parts = trimmed.split(":"); - if (parts[0] === "mimir" && parts[1] && isKnownAction(parts[1])) { - return { - ok: true, - action: { type: parts[1], raw }, - }; - } - if (parts[0] && isKnownAction(parts[0])) { - return { - ok: true, - action: { type: parts[0], raw }, - }; - } + if (!BOT_TOKEN_SHAPE.test(raw)) { return { ok: false, - reason: "unknown-action", - fallbackText: CALLBACK_FALLBACK_FEEDBACK, + problem: "wrong-shape", + message: + "BOT_TOKEN has the wrong shape; expected :<35+ url-safe chars>.", }; } - // 3. Direct action token: "status", "contracts", "help", "pause", "resume" - if (isKnownAction(trimmed)) { - return { - ok: true, - action: { type: trimmed, raw }, - }; - } - - return { - ok: false, - reason: "unknown-action", - fallbackText: CALLBACK_FALLBACK_FEEDBACK, - }; -} - -export function statusKeyboard(config?: BotConfig): InlineKeyboard { - const keyboard = new InlineKeyboard() - .text("πŸ”„ Refresh", "status") - .text("πŸ“‹ Contracts", "contracts"); - if (config?.operatorTelegramUserId) { - keyboard.row().text("⏸ Pause", "pause").text("β–Ά Resume", "resume"); - } - return keyboard; -} - -export function contractsKeyboard(): InlineKeyboard { - return new InlineKeyboard() - .text("πŸ“Š Status", "status") - .text("❓ Help", "help"); -} - -export function operatorKeyboard(): InlineKeyboard { - return new InlineKeyboard() - .text("⏸ Pause", "pause") - .text("β–Ά Resume", "resume") - .row() - .text("πŸ“Š Status", "status"); -} - - -function ago(timestamp: number | null, nowMs: number = Date.now()): string { - if (timestamp === null) return "never"; - const seconds = Math.max(0, Math.round((nowMs - timestamp) / 1000)); - if (seconds < 60) return `${seconds}s ago`; - if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`; - return `${Math.round(seconds / 3600)}h ago`; -} - -/** - * Render the audit report for Telegram. The report is plain text β€” audit lines - * are arbitrary redacted strings and MarkdownV2 would mangle them β€” so nothing - * here goes through MarkdownV2 escaping; this message is sent without a parse - * mode. Bounded twice over: the file read is capped and only the tail renders. - */ -function renderAuditForTelegram( - summary: AuditFileSummary, - tail: number, - locale?: Locale, -): string { - const header = `*Audit* β€” ${summary.file}`; - const report = renderAuditReport(summary, { tail }); - return `${header}\n\n${report}\n\n${messagesFor(locale).auditCliHint}`; -} - -function cursorPreview(cursor: string | null): string { - if (cursor === null) return "none (cold start)"; - const compact = cursor.replace(/\s+/g, " ").replace(/[`\\]/g, "?").trim() || "empty"; - return compact.length <= 24 ? compact : `${compact.slice(0, 23)}…`; -} - -export function statusMessage(config: BotConfig, status: PollerStatus, nowMs: number = Date.now()): string { - const lifecycle = status.stopping - ? "stopping" - : status.paused - ? "paused" - : status.running - ? "running" - : "stopped"; - - const lines: string[] = [ - `*Status* β€” ${lifecycle} on Stellar ${networkLabel(config)}`, - `Channel preview: ${config.channelPreviewMode ? "enabled" : "disabled"}`, - "", - `Chain tip: ${status.latestLedger ?? "unknown"}`, - `RPC retains from ledger: ${status.oldestLedger ?? "unknown"}`, - `Chain clock skew: ${escapeMd(chainClockLabel(status.chainClockAt, nowMs))}`, - `Poll interval: ${Math.round(config.pollIntervalMs / 1000)}s Β· last poll ${ago(status.lastPollAt, nowMs)}`, - `Cycles: ${status.cycles} Β· sent ${status.notificationsSent} Β· failed sends ${status.notificationsFailed} Β· skipped ${status.eventsSkipped} Β· deduped ${status.eventsDeduplicated ?? 0}` + - (status.notificationsDropped - ? ` Β· dropped during shutdown ${status.notificationsDropped}` - : ""), - `RPC pages: ${status.pagesScanned} total Β· ${status.emptyPages} empty` + - (status.lastCyclePages > 0 - ? ` Β· last cycle ${status.lastCycleEmptyPages}/${status.lastCyclePages} empty` - : ""), - `Feature flags: ${escapeMd(formatFeatureFlags(config.featureFlags))}`, - "", - "*Watching*", - ]; - - // Only shown when the queue is holding or has replayed something, so an - // ordinary /status is unchanged. - if (status.deadLetter && (status.deadLetter.depth > 0 || status.deadLetter.replayed > 0)) { - lines.push( - `Parked sends: ${status.deadLetter.depth} waiting \u00b7 ` + - `${status.deadLetter.replayed} replayed \u00b7 ${status.deadLetter.dropped} dropped`, - "", - ); - } - - // Only shown after an automatic recovery, so an ordinary /status is unchanged. - if (status.cursorRewinds > 0) { - lines.push( - `Cursors rewound to the retained floor: ${status.cursorRewinds}`, - "", - ); - } - - for (const target of status.targets) { - lines.push( - `Β· mimir\\-${target.source} \\(${escapeMd(target.version ?? "v1")}\\) \`${target.contractId}\``, - ` last event ledger: ${target.lastEventLedger ?? "none seen"}`, - ` cursor: \`${cursorPreview(target.cursor)}\``, - ); - if (target.cursorStalled) { - lines.push( - ` cursor stalled: unchanged for ${target.cyclesWithoutAdvance} successful cycles while behind tip`, - ); - } - if (target.gapLedgers > 0) { - lines.push( - ` restart gap: ${target.gapLedgers} ledger${target.gapLedgers === 1 ? "" : "s"} unrecoverable, ` + - `cursor reset ${ago(target.cursorResetAt, nowMs)}`, - ); - } - if (target.cursorUnreadable) { - lines.push(" cursor ledger unreadable: position left untouched"); - } - if (target.lastError) lines.push(` last error: ${escapeMd(target.lastError)}`); - } - - if (status.lastError) { - lines.push( - "", - `Last error \\(${ago(status.lastError.at, nowMs)}\\): ${escapeMd(status.lastError.message)}`, - ); - } - if (status.consecutiveFailures > 0) { - lines.push(`Consecutive failed cycles: ${status.consecutiveFailures}`); - } - if (status.restartGaps > 0) { - lines.push(`Restart gaps detected since start: ${status.restartGaps}`); - } - - if (status.stopping) { - lines.push( - "", - "Graceful shutdown in progress: no new cycles, unsent notifications dropped" + - (status.pendingFlush ? ", cursor flush still pending" : ", cursor flushed") + - "\\.", - ); - } - - return lines.join("\n"); -} - -export function lastEventMessage(config: BotConfig, status: PollerStatus): string { - const lines = [`*Last observed events* β€” Stellar ${networkLabel(config)}`, ""]; - - for (const target of status.targets) { - lines.push(`*mimir\\-${target.source}*`); - if (!target.lastEvent) { - lines.push("No event has been observed since this process started\\.", ""); - continue; - } - lines.push(formatLastEvent(config, target.lastEvent), ""); - } - - if (status.targets.length === 0) { - lines.push("No contract scan has completed yet\\."); - } - return lines.join("\n").trimEnd(); + return { ok: true }; } /** - * The `/contracts` message: which two contracts this bot watches, and where to - * look each one up independently β€” deliberately static (config only, no - * poller state), so it answers the same whether the poller is mid-cycle, - * between restarts, or wedged on a run of RPC failures. `/status` is for - * "is it working"; this is for "what is it even watching". - */ -export function healthMessage( - config: BotConfig, - status: PollerStatus, - nowMs: number = Date.now(), -): string { - const report = buildHealthReport(config, status, nowMs); - const statusLabel = report.status.toUpperCase(); - - const lines: string[] = [ - `*Health* β€” ${escapeMd(statusLabel)} on Stellar ${networkLabel(config)}`, - "", - `Status: \`${report.status}\` \\(${report.ok ? "ok" : "action required"}\\)`, - `Poller: ${report.poller.running ? "running" : "stopped"}`, - `Uptime: ${report.uptimeMs > 0 ? ago(nowMs - report.uptimeMs, nowMs) : "0s"}`, - `Poll interval: ${Math.round(config.pollIntervalMs / 1000)}s Β· last poll ${ago(status.lastPollAt, nowMs)}`, - `Last successful poll: ${ago(status.lastSuccessAt, nowMs)}`, - `Chain tip: ${report.poller.latestLedger ?? "unknown"}`, - `Chain clock skew: ${escapeMd(chainClockLabel(status.chainClockAt, nowMs))}`, - `Cycles: ${report.poller.cycles} Β· consecutive failures: ${report.poller.consecutiveFailures}`, - `Notifications: sent ${report.poller.notificationsSent} Β· failed ${report.poller.notificationsFailed} Β· skipped ${report.poller.eventsSkipped}`, - "", - "*Watched Contracts*", - ]; - - for (const target of report.poller.targets) { - lines.push( - `Β· mimir\\-${target.source} \`${target.contractId}\``, - ` last event ledger: ${target.lastEventLedger ?? "none seen"}`, - ` cursor: \`${target.cursorPreview ?? "none (cold start)"}\``, - ); - if (target.cursorStale) { - lines.push( - target.rewindFromLedger === null - ? " ALERT: RPC rejected this cursor as stale; its position is unchanged" - : ` ALERT: stale cursor recovery from ledger ${target.rewindFromLedger}`, - ); - } - if (target.gapLedgers > 0) { - lines.push( - ` restart gap: ${target.gapLedgers} ledger(s) unrecoverable at the last recovery`, - ); - } - if (target.cursorUnreadable) { - lines.push(" cursor ledger unreadable: position forwarded unchanged"); - } - if (target.hasError) { - const targetState = status.targets.find((t) => t.source === target.source); - if (targetState?.lastError) { - lines.push(` last error: ${escapeMd(targetState.lastError)}`); - } - } - } - - if (report.poller.restartGaps > 0) { - const gap = report.poller.lastRestartGap; - lines.push( - escapeMd( - `Restart gaps since start: ${report.poller.restartGaps}` + - (gap ? ` (last: ${gap.missedLedgers} ledger(s) unrecoverable on ${gap.source})` : ""), - ), - ); - } - - if (report.poller.lastError) { - lines.push( - "", - `Last error \\(${ago(status.lastError?.at ?? null, nowMs)}\\): ${escapeMd(report.poller.lastError.message)}`, - ); - } - - // Origins, never values: an operator can confirm this process is reading the - // intended .env / profile without a secret being typed into a chat. The - // summary is interpolated into a code span unescaped on purpose β€” it is built - // only from key names, source names, and counts (see config.ts), so it has no - // MarkdownV2 reserved character to escape and stays copy-pasteable. - lines.push("", `Config: \`${formatProvenanceSummary(report.config)}\``); - if (report.config.warnings.length > 0) { - lines.push(`Config notes: ${escapeMd(report.config.warnings.join("; "))}`); - } - - return lines.join("\n"); -} - -export function contractsMessage(config: BotConfig): string { - const targets: Array<{ label: string; version: string; contractId: string }> = [ - { - label: "mimir\\-market", - version: config.marketContractVersion ?? "v1", - contractId: config.marketContractId, - }, - { - label: "mimir\\-squad", - version: config.squadContractVersion ?? "v1", - contractId: config.squadContractId, - }, - ]; - - const lines: string[] = [ - `*Contracts* β€” Mimir on Stellar ${escapeMd(networkLabel(config))}`, - "", - "Read\\-only: this bot holds no signing keys and cannot submit transactions\\.", - ]; - - for (const target of targets) { - lines.push( - "", - `*${target.label}* \\(${escapeMd(target.version ?? "v1")}\\)`, - `\`${escapeMd(target.contractId)}\``, - `[View on stellar\\.expert](${contractExplorerUrl(config, target.contractId)})`, - ); - } - - return lines.join("\n"); -} - -/** Exact operator replies, exported for deterministic Telegram payload tests. */ -export function pauseMessage(result: PollerPauseResult, locale?: Locale): string { - const messages = messagesFor(locale).pauseMessages; - switch (result) { - case "paused": - return messages.paused; - case "already-paused": - return messages.alreadyPaused; - case "stopped": - return messages.stopped; - } -} - -export function resumeMessage(result: PollerResumeResult, locale?: Locale): string { - const messages = messagesFor(locale).resumeMessages; - switch (result) { - case "resumed": - return messages.resumed; - case "already-running": - return messages.alreadyRunning; - case "stopped": - return messages.stopped; - } -} - -/** - * Shared handler for pause inline-keyboard callbacks. - * - * Exported for unit testing without needing a live grammy `Bot` instance. - * Callers must pass a grammy-compatible `ctx` that exposes: - * - `ctx.from` - * - `ctx.callbackQuery.data` - * - `ctx.callbackQuery.message.date` (Unix seconds, set by Telegram) - * - `ctx.answerCallbackQuery(text?, opts?)` - * - `ctx.editMessageText(text, opts)` - */ -export async function handleCallbackQuery( - ctx: Context, - deps: Pick, - nowMs: number = Date.now(), -): Promise { - const { config, pause } = deps; - - const action = validateCallbackData(ctx.callbackQuery?.data); - if (action === null) return; // unknown payload β€” silently ignore - - // Only the operator may act on these buttons. - if (!isOperator(ctx, config)) { - await ctx.answerCallbackQuery({ text: "Only the operator can do that.", show_alert: true }); - return; - } - - // Reject stale confirmations so an old message cannot pause the bot later. - const messageDate = ctx.callbackQuery?.message?.date ?? 0; - const ageMs = nowMs - messageDate * 1000; - if (ageMs > PAUSE_CONFIRM_TTL_MS) { - await ctx.answerCallbackQuery({ text: "Confirmation expired.", show_alert: true }); - await ctx.editMessageText("⏱ *Pause confirmation expired\\.* Run /pause again\\.", TELEGRAM_OPTIONS); - return; - } - - if (action === CALLBACK_PAUSE_CONFIRM) { - const result = pause(); - await ctx.answerCallbackQuery(); - await ctx.editMessageText(pauseMessage(result), TELEGRAM_OPTIONS); - return; - } - - if (action === CALLBACK_PAUSE_CANCEL) { - await ctx.answerCallbackQuery({ text: "Cancelled." }); - await ctx.editMessageText("*Pause cancelled\\.* Polling continues\\.", TELEGRAM_OPTIONS); - return; - } -} - -export interface BotDeps { - config: BotConfig; - status: () => PollerStatus; - pause: () => PollerPauseResult; - resume: () => PollerResumeResult; - /** Bot version string, typically read from package.json. */ - version?: string | undefined; - /** Live in-memory audit window; renders immediately even before a flush. */ - audit?: AuditLog | undefined; - /** Where the audit JSONL file lives, for the file-backed report. */ - auditFile?: string | undefined; - /** - * Pre-populated bot info. When provided (e.g. in tests) grammy skips the - * getMe() call so `bot.handleUpdate()` works without a real Telegram token. - */ - botInfo?: UserFromGetMe; - /** Bounded redacted log ring backing /export. Absent = command disabled. */ - logs?: LogCapture | undefined; - /** In-memory broadcast confirmation store. */ - broadcastStore?: BroadcastStore | undefined; -} - -/** - * Returns true when the chat is permitted to use restricted commands. + * Shared metadata for handlers, help, and Telegram's command menu. * - * Rules: - * - If `allowedChatIds` is empty the list is open (any chat may use the - * gated commands: /status, /health, /contracts, /preview). - * - Otherwise the incoming chat id must appear in the list. Both the numeric - * id (stored as a number in grammy's ctx.chat.id) and its string form are - * compared so that negative group ids such as -1001234567890 match correctly. + * Derived from the registry: a command module is the single place that decides + * whether a command exists, so this list cannot drift from what is registered. */ -function isChatAllowed(allowedChatIds: readonly string[] | undefined, chatId: number): boolean { - // Hand-built configs (tests, embedders) may omit the list: treat it as open. - if (!allowedChatIds || allowedChatIds.length === 0) return true; - const asString = String(chatId); - return allowedChatIds.some((allowed) => allowed === asString); -} +export const COMMANDS = commandMetadata(); -export function isOperator(ctx: Pick, config: BotConfig): boolean { - const operatorId = config.operatorTelegramUserId; - return operatorId !== null && ctx.from?.id.toString() === operatorId; -} - -/** How many recent audit lines `/audit` renders. A chat message is not a file. */ -const AUDIT_TAIL = 10; - -/** - * Reply with MarkdownV2, splitting when the payload exceeds Telegram's - * 4096-character `sendMessage` limit. `/help` grows with the command list - * and `/status` with the watched targets, so either can cross it; the chunks - * are sent in order. - */ -async function replyMarkdown( - ctx: CommandContext, - text: string, - options: typeof TELEGRAM_OPTIONS = TELEGRAM_OPTIONS, -): Promise { - for (const part of splitTelegramMessage(text)) { - await ctx.reply(part, options); - } -} +/** Everything a command handler may need. Kept as an alias for callers/tests. */ +export type BotDeps = CommandDeps; /** Register command handlers on a grammy-compatible bot (also useful in tests). */ export function registerCommandHandlers( - bot: Bot | { command: (name: string, handler: (ctx: CommandContext) => Promise) => void }, + bot: Bot | { + command: (name: string, handler: (ctx: CommandContext) => Promise) => unknown; + callbackQuery?: (filter: string | string[], handler: (ctx: Context) => Promise) => unknown; + }, deps: BotDeps, ): void { - const { config, status } = deps; - const version = deps.version ?? "unknown"; - const pause = deps.pause ?? (() => "stopped"); - const resume = deps.resume ?? (() => "stopped"); - - /** - * Gate for commands that expose deployment details. Requests from - * unapproved chats are silently ignored: an error reply would leak the - * existence of the restriction, and silence matches privacy-mode bots that - * never see most messages. The log lets operators spot misconfigured ids. - */ - const chatAllowed = (ctx: CommandContext, command: string): boolean => { - if (isChatAllowed(config.allowedChatIds, ctx.chat.id)) return true; - console.warn(`[bot] /${command} denied for chat ${ctx.chat.id} (not in ALLOWED_CHAT_IDS)`); - return false; - }; - - const handlers: Record) => Promise> = { - start: async (ctx) => { - await replyMarkdown(ctx, helpMessage(config)); - }, - - help: async (ctx) => { - const text = ctx.message?.text ?? ""; - const spaceIndex = text.indexOf(" "); - const arg = ( - typeof ctx.match === "string" && ctx.match.trim().length > 0 - ? ctx.match.trim() - : (spaceIndex !== -1 ? text.slice(spaceIndex + 1).trim() : "") - ); - if (arg) { - await replyMarkdown(ctx, helpDetailMessage(arg, config)); - } else { - await replyMarkdown(ctx, helpMessage(config)); - } - }, - - about: async (ctx) => { - await replyMarkdown(ctx, aboutMessage(config, version)); - }, - - status: async (ctx) => { - if (!chatAllowed(ctx, "status")) return; - const startedAt = performance.now(); - let sent = false; - try { - await ctx.reply(statusMessage(config, status()), TELEGRAM_OPTIONS); - sent = true; - } finally { - const latencyMs = Math.max(0, performance.now() - startedAt); - console.info( - `[bot] /status ${sent ? "sent" : "failed"} in ${latencyMs.toFixed(1)}ms`, - ); - } - }, - - audit: async (ctx) => { - if (!isOperator(ctx, config)) { - // Same authorization model as /pause and /resume: the report is only - // meant for the operator, so other users get silence, not an error that - // would confirm the command exists. The standalone `npm run audit` CLI - // is the credential-free path for anyone with machine access. - console.warn(`[bot] ignored unauthorized /audit on update ${ctx.update.update_id}`); - return; - } - try { - const file = deps.auditFile ?? config.auditFile; - const summary = await readAuditFile(file); - - // The in-memory window also holds entries recorded since the last flush; - // append any of those the file does not already contain (same entries - // serialise identically) so the report is current without duplicates. - const seen = new Set(summary.entries.map((e) => JSON.stringify(e))); - const live = (deps.audit ? deps.audit.tail(AUDIT_TAIL) : []).filter( - (e) => !seen.has(JSON.stringify(e)), - ); - - const merged: AuditFileSummary = { - ...summary, - entries: [...summary.entries, ...live].slice(-AUDIT_REPORT_MAX_ENTRIES), - }; - await ctx.reply(renderAuditForTelegram(merged, AUDIT_TAIL, config.locale), { - link_preview_options: { is_disabled: true }, - }); - } catch (err) { - await ctx.reply(`Audit report failed: ${safeErrorMessage(err, [config.botToken])}`); - } - }, - - // Config-only, so this never fails on account of poller or RPC state β€” - // unlike /status, it has nothing to report failure on. - health: async (ctx) => { - if (!chatAllowed(ctx, "health")) return; - await ctx.reply(healthMessage(config, status()), TELEGRAM_OPTIONS); - }, - - contracts: async (ctx) => { - if (!chatAllowed(ctx, "contracts")) return; - await ctx.reply(contractsMessage(config), TELEGRAM_OPTIONS); - }, - - preview: async (ctx) => { - if (!chatAllowed(ctx, "preview")) return; - const text = ctx.message?.text ?? ""; - const spaceIndex = text.indexOf(" "); - const arg = spaceIndex !== -1 ? text.slice(spaceIndex + 1).trim() : ""; - await ctx.reply(previewMessage(config, arg || "market"), TELEGRAM_OPTIONS); - }, - - pause: async (ctx) => { - if (!isOperator(ctx, config)) { - console.warn(`[bot] ignored unauthorized /pause on update ${ctx.update.update_id}`); - return; - } - await ctx.reply(pauseMessage(pause(), config.locale), TELEGRAM_OPTIONS); - }, - - resume: async (ctx) => { - if (!isOperator(ctx, config)) { - console.warn(`[bot] ignored unauthorized /resume on update ${ctx.update.update_id}`); - return; - } - await ctx.reply(resumeMessage(resume(), config.locale), TELEGRAM_OPTIONS); - }, + const registrar = bot as { + command: (name: string, handler: (ctx: CommandContext) => Promise) => unknown; + callbackQuery?: (filter: string | string[], handler: (ctx: Context) => Promise) => unknown; }; - for (const { command } of COMMANDS) { - bot.command(command, handlers[command]); + for (const module of registeredCommands()) { + registrar.command(module.command, (ctx) => dispatch(module, ctx, deps)); } -} -function isMessageNotModified(err: unknown): boolean { - if (!err) return false; - const msg = err instanceof Error ? err.message : String(err); - return /message is not modified/i.test(msg); -} - -export async function safeAnswerCallback( - ctx: Context, - config: BotConfig, - params: { text?: string; show_alert?: boolean } = {}, -): Promise { - if (ctx.callbackQuery?.id) { - try { - await ctx.answerCallbackQuery(params); - } catch (err) { - console.warn( - `[bot] answerCallbackQuery failed: ${safeErrorMessage(err, [config.botToken])}`, - ); - } - } -} - -export async function updateOrReplyMessage( - ctx: Context, - text: string, - replyMarkup?: InlineKeyboard, -): Promise { - const options = { - ...TELEGRAM_OPTIONS, - ...(replyMarkup ? { reply_markup: replyMarkup } : {}), - }; - - if (ctx.callbackQuery?.message) { - try { - await ctx.editMessageText(text, options); - return; - } catch (err) { - if (isMessageNotModified(err)) { - return; - } - // If edit failed (e.g. message too old or deleted), fall through to reply - } - } - - if (ctx.chat) { - await ctx.reply(text, options); - return; - } - - // no-op: callers that need to target the configured chat should call - // `ctx.api.sendMessage(config.chatId, ...)` directly when `ctx.chat` is - // unavailable. handleCallbackQuery does this. + // The pause confirmation prompt is produced by /pause, so its buttons are + // registered alongside it rather than with the generic action callbacks. + registrar.callbackQuery?.( + [CALLBACK_PAUSE_CONFIRM, CALLBACK_PAUSE_CANCEL], + async (ctx: Context) => + handlePauseConfirmCallback(ctx, { + config: deps.config, + pause: deps.pause ?? (() => "stopped"), + }), + ); } -export async function handleCallbackQuery( - ctx: Context, +/** + * Run one command module: apply the authorization its metadata declares, then + * the handler. Gating here β€” instead of inside each handler β€” means a new + * operator-only or allowlisted command cannot forget its check. + */ +function dispatch( + module: CommandModule, + ctx: CommandContext, deps: BotDeps, ): Promise { - const { config, status } = deps; - const pause = deps.pause ?? (() => "stopped"); - const resume = deps.resume ?? (() => "stopped"); - const rawData = ctx.callbackQuery?.data; - - // Validate callback query data before acting - const validation = validateCallbackData(rawData); - - if (!validation.ok) { - console.warn( - `[bot] rejected invalid callback query (${validation.reason}) on update ${ctx.update.update_id}`, - ); - await safeAnswerCallback(ctx, config, { - text: validation.fallbackText, - show_alert: false, - }); - return; + if (module.operatorOnly === true && !operatorAllowed(ctx, deps.config, module.command)) { + return Promise.resolve(); } - - const { action } = validation; - - // Enforce operator role authorization - if (action.type === "pause" || action.type === "resume" || action.type === "broadcast") { - if (!isOperator(ctx, config)) { - console.warn( - `[bot] ignored unauthorized callback /${action.type} on update ${ctx.update.update_id}`, - ); - await safeAnswerCallback(ctx, config, { - text: CALLBACK_UNAUTHORIZED_FEEDBACK, - show_alert: true, - }); - return; - } - await ctx.reply( - "⏸ *Pause polling?*\nThis will stop new scans from starting\\. The current scan may finish\\.", - { ...TELEGRAM_OPTIONS, reply_markup: operatorKeyboard() }, - ); - }); - - // Execute validated action - try { - switch (action.type) { - case "broadcast": { - await handleBroadcastCallback(ctx, deps, action.raw); - break; - } - - case "status": { - const text = statusMessage(config, status()); - if (ctx.callbackQuery?.message) { - await updateOrReplyMessage(ctx, text, statusKeyboard(config)); - } else { - await ctx.api.sendMessage(config.chatId, text, { - ...TELEGRAM_OPTIONS, - reply_markup: statusKeyboard(config), - }); - } - await safeAnswerCallback(ctx, config, { text: "Status refreshed" }); - break; - } - - case "contracts": { - const text = contractsMessage(config); - if (ctx.callbackQuery?.message) { - await updateOrReplyMessage(ctx, text, contractsKeyboard()); - } else { - await ctx.api.sendMessage(config.chatId, text, TELEGRAM_OPTIONS); - } - await safeAnswerCallback(ctx, config, { text: "Contracts" }); - break; - } - - case "help": { - const text = helpMessage(config); - if (ctx.callbackQuery?.message) { - await updateOrReplyMessage(ctx, text); - } else { - await ctx.api.sendMessage(config.chatId, text, TELEGRAM_OPTIONS); - } - await safeAnswerCallback(ctx, config, { text: "Help" }); - break; - } - - case "pause": { - const result = pause(); - const text = pauseMessage(result, config.locale); - if (ctx.callbackQuery?.message) { - await updateOrReplyMessage(ctx, text, operatorKeyboard()); - } else { - await ctx.api.sendMessage(config.chatId, text, TELEGRAM_OPTIONS); - } - await safeAnswerCallback(ctx, config, { - text: result === "paused" ? "Polling paused" : result === "already-paused" ? "Already paused" : "Cannot pause", - }); - break; - } - - case "resume": { - const result = resume(); - const text = resumeMessage(result, config.locale); - if (ctx.callbackQuery?.message) { - await updateOrReplyMessage(ctx, text, operatorKeyboard()); - } else { - await ctx.api.sendMessage(config.chatId, text, TELEGRAM_OPTIONS); - } - await safeAnswerCallback(ctx, config, { - text: result === "resumed" ? "Polling resumed" : result === "already-running" ? "Already running" : "Cannot resume", - }); - break; - } - } - await ctx.reply(resumeMessage(resume()), TELEGRAM_OPTIONS); - }); - - // Inline-keyboard callbacks for the pause confirmation flow. - bot.callbackQuery( - [CALLBACK_PAUSE_CONFIRM, CALLBACK_PAUSE_CANCEL], - async (ctx) => handleCallbackQuery(ctx, { config, pause }), - ); + if (module.chatAllowlist === true && !chatAllowed(ctx, deps.config, module.command)) { + return Promise.resolve(); + } + return module.handler(ctx, deps); } export function createBot(deps: BotDeps): Bot { @@ -1034,13 +239,11 @@ export function createBot(deps: BotDeps): Bot { deps.config.telegramApiRoot !== undefined && deps.config.telegramApiRoot !== null ? { client: { apiRoot: deps.config.telegramApiRoot } } : undefined; - const tokenCheck = validateBotTokenShape(deps.config.botToken); - if (!tokenCheck.ok) { - // Never include the token (or any part of it) in the thrown error. - throw new Error( - `Invalid BOT_TOKEN (${tokenCheck.problem}): ${tokenCheck.message}`, - ); - } + // Token shape is validated by `loadConfig` at startup, where the operator + // can act on the message (`BOT_TOKEN is not a valid Telegram bot token: …`). + // It is deliberately not re-litigated here: `createBot` is also the entry + // point for tests and embedders that never speak to Telegram, and a + // placeholder token must not stop them building a bot. const bot = new Bot( deps.config.botToken, deps.botInfo !== undefined @@ -1050,25 +253,6 @@ export function createBot(deps: BotDeps): Bot { registerCommandHandlers(bot, deps); registerCallbackHandlers(bot, deps); registerBroadcastHandlers(bot, deps); - const { config, status, logs } = deps; - - bot.command("export", async (ctx) => { - // Same authorization model as /audit: recent logs are for the operator. - if (!isOperator(ctx, config)) { - console.warn(`[bot] ignored unauthorized /export on update ${ctx.update.update_id}`); - return; - } - if (!logs || logs.capacity() === 0) { - await ctx.reply("Log export is disabled (LOG_BUFFER_LINES=0)."); - return; - } - // Plain text, no parse mode: the content is redacted but untrusted, and - // MarkdownV2 would make any escaping slip a parsing error instead of a - // cosmetic wart. - await ctx.reply(renderLogExport(config, status(), logs), { - link_preview_options: { is_disabled: true }, - }); - }); // grammy rethrows handler errors by default, which would take the process // with it. Keep Telegram/RPC error text bounded and redact known secrets. @@ -1266,9 +450,11 @@ export function createNotifier( /** Registers the command list so Telegram's UI offers autocompletion. */ export async function registerCommands(bot: Bot, config?: BotConfig): Promise { try { - await bot.api.setMyCommands(visibleCommands(config).map(({ command, description }) => ({ command, description }))); + await bot.api.setMyCommands( + visibleCommandMetadata(config).map(({ command, description }) => ({ command, description })), + ); } catch (err) { // Cosmetic. Never worth failing a boot over, and never log an unbounded API error. console.warn(`[bot] setMyCommands failed: ${safeErrorMessage(err)}`); } -} \ No newline at end of file +} diff --git a/src/callbacks.ts b/src/callbacks.ts new file mode 100644 index 00000000..28cbc574 --- /dev/null +++ b/src/callbacks.ts @@ -0,0 +1,422 @@ +/** + * Inline-keyboard callbacks: validation, dispatch, and the pause confirmation. + * + * Callback data is untrusted user input. Every payload is validated against a + * small known set before any action runs, operator actions are authorized + * exactly like `/pause` and `/resume`, and anything unrecognized is answered + * with a generic message rather than silently swallowed (Telegram otherwise + * shows a spinner on the button forever). + * + * Two families of callbacks share this module: + * - navigation/operator actions (`status`, `contracts`, `help`, `pause`, + * `resume`, `broadcast`), handled by {@link handleCallbackQuery} + * - the `/pause` confirmation buttons, which carry their own payloads and a + * TTL, handled by {@link handlePauseConfirmCallback} + */ + +import { InlineKeyboard, type Bot, type Context } from "grammy"; + +import { safeErrorMessage } from "./notifications/format.js"; +import { handleBroadcastCallback } from "./commands/broadcast.js"; +import { + CALLBACK_PAUSE_CANCEL, + CALLBACK_PAUSE_CONFIRM, + contractsKeyboard, + operatorKeyboard, + statusKeyboard, +} from "./commands/keyboards.js"; +import { helpMessage } from "./commands/help.js"; +import { contractsMessage } from "./commands/contracts.js"; +import { pauseMessage } from "./commands/pause.js"; +import { resumeMessage } from "./commands/resume.js"; +import { statusMessage } from "./commands/status.js"; +import type { CommandDeps } from "./commands/registry.js"; +import { TELEGRAM_OPTIONS, isOperator, safeAnswerCallback } from "./commands/runtime.js"; + +// ── Callback payload validation ────────────────────────────────────────────── + +export type CallbackActionType = "status" | "contracts" | "help" | "pause" | "resume" | "broadcast"; + +export interface ValidCallbackAction { + type: CallbackActionType; + raw: string; +} + +export type CallbackValidationResult = + | { ok: true; action: ValidCallbackAction } + | { ok: false; reason: string; fallbackText: string }; + +/** Telegram's Bot API limits callback_data to 1-64 bytes. */ +export const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64; + +export const CALLBACK_FALLBACK_PAYLOAD = + "*Invalid request*\nThe requested action is unrecognized or malformed\\."; + +export const CALLBACK_UNAUTHORIZED_PAYLOAD = + "*Unauthorized*\nThis action requires operator privileges\\."; + +export const CALLBACK_FALLBACK_FEEDBACK = "Invalid or unrecognized action"; +export const CALLBACK_UNAUTHORIZED_FEEDBACK = "Unauthorized: operator only"; + +function isKnownAction(val: string): val is CallbackActionType { + return ["status", "contracts", "help", "pause", "resume", "broadcast"].includes(val); +} + +/** + * Validate untrusted callback-query data before any action is executed. + * Treats all callback data as untrusted external user input. + */ +export function validateCallbackData(raw: unknown): CallbackValidationResult { + if (typeof raw !== "string") { + return { + ok: false, + reason: "missing-or-non-string", + fallbackText: CALLBACK_FALLBACK_FEEDBACK, + }; + } + + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return { + ok: false, + reason: "empty", + fallbackText: CALLBACK_FALLBACK_FEEDBACK, + }; + } + + const byteLength = Buffer.byteLength(raw, "utf8"); + if (byteLength > TELEGRAM_CALLBACK_DATA_MAX_BYTES) { + return { + ok: false, + reason: "exceeds-max-length", + fallbackText: CALLBACK_FALLBACK_FEEDBACK, + }; + } + + // Reject ASCII control characters (including null bytes) + if (/[\x00-\x1F\x7F]/.test(raw)) { + return { + ok: false, + reason: "control-characters", + fallbackText: CALLBACK_FALLBACK_FEEDBACK, + }; + } + + // 1. JSON payload: e.g. {"action":"status"} + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + try { + const parsed = JSON.parse(trimmed) as { action?: unknown }; + if (typeof parsed?.action === "string" && isKnownAction(parsed.action)) { + return { + ok: true, + action: { type: parsed.action, raw }, + }; + } + return { + ok: false, + reason: "unknown-json-action", + fallbackText: CALLBACK_FALLBACK_FEEDBACK, + }; + } catch { + return { + ok: false, + reason: "malformed-json", + fallbackText: CALLBACK_FALLBACK_FEEDBACK, + }; + } + } + + // 2. Colon-delimited: e.g. "status:refresh", "mimir:contracts", etc. + if (trimmed.includes(":")) { + const parts = trimmed.split(":"); + if (parts[0] === "mimir" && parts[1] && isKnownAction(parts[1])) { + return { + ok: true, + action: { type: parts[1], raw }, + }; + } + if (parts[0] && isKnownAction(parts[0])) { + return { + ok: true, + action: { type: parts[0], raw }, + }; + } + return { + ok: false, + reason: "unknown-action", + fallbackText: CALLBACK_FALLBACK_FEEDBACK, + }; + } + + // 3. Direct action token: "status", "contracts", "help", "pause", "resume" + if (isKnownAction(trimmed)) { + return { + ok: true, + action: { type: trimmed, raw }, + }; + } + + return { + ok: false, + reason: "unknown-action", + fallbackText: CALLBACK_FALLBACK_FEEDBACK, + }; +} + +// ── Message helpers ────────────────────────────────────────────────────────── + +function isMessageNotModified(err: unknown): boolean { + if (!err) return false; + const msg = err instanceof Error ? err.message : String(err); + return /message is not modified/i.test(msg); +} + +export async function updateOrReplyMessage( + ctx: Context, + text: string, + replyMarkup?: InlineKeyboard, +): Promise { + const options = { + ...TELEGRAM_OPTIONS, + ...(replyMarkup ? { reply_markup: replyMarkup } : {}), + }; + + if (ctx.callbackQuery?.message) { + try { + await ctx.editMessageText(text, options); + return; + } catch (err) { + if (isMessageNotModified(err)) { + return; + } + // If edit failed (e.g. message too old or deleted), fall through to reply + } + } + + if (ctx.chat) { + await ctx.reply(text, options); + return; + } + + // no-op: callers that need to target the configured chat should call + // `ctx.api.sendMessage(config.chatId, ...)` directly when `ctx.chat` is + // unavailable. handleCallbackQuery does this. +} + +// ── Action callbacks ───────────────────────────────────────────────────────── + +export async function handleCallbackQuery( + ctx: Context, + deps: CommandDeps, +): Promise { + const { config, status } = deps; + const pause = deps.pause ?? (() => "stopped"); + const resume = deps.resume ?? (() => "stopped"); + const rawData = ctx.callbackQuery?.data; + + // The pause confirmation buttons carry their own payloads and TTL and are + // registered separately (`handlePauseConfirmCallback`); answering them here + // as unknown actions would double-handle the same tap. + if (validatePauseConfirmData(rawData) !== null) return; + + // Validate callback query data before acting + const validation = validateCallbackData(rawData); + + if (!validation.ok) { + console.warn( + `[bot] rejected invalid callback query (${validation.reason}) on update ${ctx.update.update_id}`, + ); + await safeAnswerCallback(ctx, config, { + text: validation.fallbackText, + show_alert: false, + }); + return; + } + + const { action } = validation; + + // Enforce operator role authorization + if (action.type === "pause" || action.type === "resume" || action.type === "broadcast") { + if (!isOperator(ctx, config)) { + console.warn( + `[bot] ignored unauthorized callback /${action.type} on update ${ctx.update.update_id}`, + ); + await safeAnswerCallback(ctx, config, { + text: CALLBACK_UNAUTHORIZED_FEEDBACK, + show_alert: true, + }); + return; + } + } + + // Execute validated action + try { + switch (action.type) { + case "broadcast": { + await handleBroadcastCallback(ctx, deps, action.raw); + break; + } + + case "status": { + const text = statusMessage(config, status()); + if (ctx.callbackQuery?.message) { + await updateOrReplyMessage(ctx, text, statusKeyboard(config)); + } else { + await ctx.api.sendMessage(config.chatId, text, { + ...TELEGRAM_OPTIONS, + reply_markup: statusKeyboard(config), + }); + } + await safeAnswerCallback(ctx, config, { text: "Status refreshed" }); + break; + } + + case "contracts": { + const text = contractsMessage(config); + if (ctx.callbackQuery?.message) { + await updateOrReplyMessage(ctx, text, contractsKeyboard()); + } else { + await ctx.api.sendMessage(config.chatId, text, TELEGRAM_OPTIONS); + } + await safeAnswerCallback(ctx, config, { text: "Contracts" }); + break; + } + + case "help": { + const text = helpMessage(config); + if (ctx.callbackQuery?.message) { + await updateOrReplyMessage(ctx, text); + } else { + await ctx.api.sendMessage(config.chatId, text, TELEGRAM_OPTIONS); + } + await safeAnswerCallback(ctx, config, { text: "Help" }); + break; + } + + case "pause": { + const result = pause(); + const text = pauseMessage(result, config.locale); + if (ctx.callbackQuery?.message) { + await updateOrReplyMessage(ctx, text, operatorKeyboard()); + } else { + await ctx.api.sendMessage(config.chatId, text, TELEGRAM_OPTIONS); + } + await safeAnswerCallback(ctx, config, { + text: result === "paused" ? "Polling paused" : result === "already-paused" ? "Already paused" : "Cannot pause", + }); + break; + } + + case "resume": { + const result = resume(); + const text = resumeMessage(result, config.locale); + if (ctx.callbackQuery?.message) { + await updateOrReplyMessage(ctx, text, operatorKeyboard()); + } else { + await ctx.api.sendMessage(config.chatId, text, TELEGRAM_OPTIONS); + } + await safeAnswerCallback(ctx, config, { + text: result === "resumed" ? "Polling resumed" : result === "already-running" ? "Already running" : "Cannot resume", + }); + break; + } + } + } catch (err) { + console.error( + `[bot] callback error on action ${action.type}: ${safeErrorMessage(err, [config.botToken])}`, + ); + await safeAnswerCallback(ctx, config, { text: "Action failed" }); + } +} + +// ── Pause confirmation callbacks ───────────────────────────────────────────── + +/** + * How long a pause-confirmation message stays valid. + * Callbacks arriving after this window are rejected as stale. + */ +export const PAUSE_CONFIRM_TTL_MS = 60_000; + +const VALID_CALLBACK_DATA = new Set([ + CALLBACK_PAUSE_CONFIRM, + CALLBACK_PAUSE_CANCEL, +]); + +/** + * Returns the validated pause-confirm action or `null` when the payload is + * unknown/missing. Keeps the handler from acting on arbitrary payloads from + * third parties. + */ +export function validatePauseConfirmData( + data: string | undefined, +): typeof CALLBACK_PAUSE_CONFIRM | typeof CALLBACK_PAUSE_CANCEL | null { + if (data !== undefined && VALID_CALLBACK_DATA.has(data)) { + return data as typeof CALLBACK_PAUSE_CONFIRM | typeof CALLBACK_PAUSE_CANCEL; + } + return null; +} + +/** + * Shared handler for pause inline-keyboard callbacks. + * + * Exported for unit testing without needing a live grammy `Bot` instance. + * Callers must pass a grammy-compatible `ctx` that exposes: + * - `ctx.from` + * - `ctx.callbackQuery.data` + * - `ctx.callbackQuery.message.date` (Unix seconds, set by Telegram) + * - `ctx.answerCallbackQuery(text?, opts?)` + * - `ctx.editMessageText(text, opts)` + */ +export async function handlePauseConfirmCallback( + ctx: Context, + deps: Pick, + nowMs: number = Date.now(), +): Promise { + const { config, pause } = deps; + + const action = validatePauseConfirmData(ctx.callbackQuery?.data); + if (action === null) return; // unknown payload β€” silently ignore + + // Only the operator may act on these buttons. + if (!isOperator(ctx, config)) { + await ctx.answerCallbackQuery({ text: "Only the operator can do that.", show_alert: true }); + return; + } + + // Reject stale confirmations so an old message cannot stop the poller later. + const messageDate = ctx.callbackQuery?.message?.date ?? 0; + const ageMs = nowMs - messageDate * 1000; + if (ageMs > PAUSE_CONFIRM_TTL_MS) { + await ctx.answerCallbackQuery({ text: "Confirmation expired.", show_alert: true }); + await ctx.editMessageText( + "⏱ *Pause confirmation expired\\.* Run /pause again\\.", + TELEGRAM_OPTIONS, + ); + return; + } + + if (action === CALLBACK_PAUSE_CONFIRM) { + const result = pause(); + await ctx.answerCallbackQuery(); + await ctx.editMessageText(pauseMessage(result), TELEGRAM_OPTIONS); + return; + } + + await ctx.answerCallbackQuery({ text: "Cancelled." }); + await ctx.editMessageText("*Pause cancelled\\.* Polling continues\\.", TELEGRAM_OPTIONS); +} + +// ── Registration ───────────────────────────────────────────────────────────── + +/** Register callback handlers on a grammy-compatible bot or mock. */ +export function registerCallbackHandlers( + bot: Bot | { on: (event: string, handler: (ctx: Context) => Promise) => void }, + deps: CommandDeps, +): void { + (bot as { on: (event: string, handler: (ctx: Context) => Promise) => void }).on( + "callback_query", + async (ctx: Context) => { + await handleCallbackQuery(ctx, deps); + }, + ); +} diff --git a/src/commands/about.ts b/src/commands/about.ts index 3af1ab74..4fadc58e 100644 --- a/src/commands/about.ts +++ b/src/commands/about.ts @@ -1,5 +1,12 @@ +/** + * `/about`: what this process is, which network it watches, and what version + * it runs. Config-only, so it never depends on poller or RPC state. + */ + import { escapeMd } from "../notifications/format.js"; import { networkLabel, type BotConfig } from "../config.js"; +import { defineCommand } from "./registry.js"; +import { replyMarkdown } from "./runtime.js"; const REPOSITORY_URL = "https://github.com/example/stellar-bot"; @@ -13,3 +20,10 @@ export function aboutMessage(config: BotConfig, version: string): string { `Repository: ${escapeMd(REPOSITORY_URL)}`, ].join("\n"); } + +export const aboutCommand = defineCommand({ + command: "about", + handler: async (ctx, deps) => { + await replyMarkdown(ctx, aboutMessage(deps.config, deps.version ?? "unknown")); + }, +}); diff --git a/src/commands/audit.ts b/src/commands/audit.ts new file mode 100644 index 00000000..6a1720bd --- /dev/null +++ b/src/commands/audit.ts @@ -0,0 +1,64 @@ +/** + * `/audit`: a bounded, redacted report of recent administrative operations and + * poller state changes, for the operator. The standalone `npm run audit` CLI is + * the credential-free path for anyone with machine access. + */ + +import { + AUDIT_REPORT_MAX_ENTRIES, + readAuditFile, + renderAuditReport, + type AuditFileSummary, +} from "../audit.js"; +import { messagesFor, type Locale } from "../i18n.js"; +import { safeErrorMessage } from "../notifications/format.js"; +import { defineCommand } from "./registry.js"; + +/** How many recent audit lines `/audit` renders. A chat message is not a file. */ +const AUDIT_TAIL = 10; + +/** + * Render the audit report for Telegram. The report is plain text β€” audit lines + * are arbitrary redacted strings and MarkdownV2 would mangle them β€” so nothing + * here goes through MarkdownV2 escaping; this message is sent without a parse + * mode. Bounded twice over: the file read is capped and only the tail renders. + */ +export function renderAuditForTelegram( + summary: AuditFileSummary, + tail: number, + locale?: Locale, +): string { + const header = `*Audit* β€” ${summary.file}`; + const report = renderAuditReport(summary, { tail }); + return `${header}\n\n${report}\n\n${messagesFor(locale).auditCliHint}`; +} + +export const auditCommand = defineCommand({ + command: "audit", + operatorOnly: true, + handler: async (ctx, deps) => { + const { config } = deps; + try { + const file = deps.auditFile ?? config.auditFile; + const summary = await readAuditFile(file); + + // The in-memory window also holds entries recorded since the last flush; + // append any of those the file does not already contain (same entries + // serialise identically) so the report is current without duplicates. + const seen = new Set(summary.entries.map((e) => JSON.stringify(e))); + const live = (deps.audit ? deps.audit.tail(AUDIT_TAIL) : []).filter( + (e) => !seen.has(JSON.stringify(e)), + ); + + const merged: AuditFileSummary = { + ...summary, + entries: [...summary.entries, ...live].slice(-AUDIT_REPORT_MAX_ENTRIES), + }; + await ctx.reply(renderAuditForTelegram(merged, AUDIT_TAIL, config.locale), { + link_preview_options: { is_disabled: true }, + }); + } catch (err) { + await ctx.reply(`Audit report failed: ${safeErrorMessage(err, [config.botToken])}`); + } + }, +}); diff --git a/src/commands/broadcast.ts b/src/commands/broadcast.ts index 49731961..8e0ca44e 100644 --- a/src/commands/broadcast.ts +++ b/src/commands/broadcast.ts @@ -10,7 +10,8 @@ import { Bot, InlineKeyboard, type Context, type CommandContext } from "grammy"; import type { BotConfig } from "../config.js"; -import { isOperator, safeAnswerCallback, type BotDeps } from "../bot.js"; +import { isOperator, safeAnswerCallback } from "./runtime.js"; +import type { CommandDeps as BotDeps } from "./registry.js"; import { safeErrorMessage } from "../notifications/format.js"; /** Telegram Bot API maximum message length limit. */ diff --git a/src/commands/contracts.ts b/src/commands/contracts.ts new file mode 100644 index 00000000..48b7c16e --- /dev/null +++ b/src/commands/contracts.ts @@ -0,0 +1,53 @@ +/** + * `/contracts`: which two contracts this bot watches, and where to look each + * one up independently β€” deliberately static (config only, no poller state), + * so it answers the same whether the poller is mid-cycle, between restarts, or + * wedged on a run of RPC failures. `/status` is for "is it working"; this is + * for "what is it even watching". + */ + +import { escapeMd } from "../notifications/format.js"; +import { networkLabel, type BotConfig } from "../config.js"; +import { contractExplorerUrl } from "../stellar/client.js"; +import { defineCommand } from "./registry.js"; +import { TELEGRAM_OPTIONS } from "./runtime.js"; + +export function contractsMessage(config: BotConfig): string { + const targets: Array<{ label: string; version: string; contractId: string }> = [ + { + label: "mimir\\-market", + version: config.marketContractVersion ?? "v1", + contractId: config.marketContractId, + }, + { + label: "mimir\\-squad", + version: config.squadContractVersion ?? "v1", + contractId: config.squadContractId, + }, + ]; + + const lines: string[] = [ + `*Contracts* β€” Mimir on Stellar ${escapeMd(networkLabel(config))}`, + "", + "Read\\-only: this bot holds no signing keys and cannot submit transactions\\.", + ]; + + for (const target of targets) { + lines.push( + "", + `*${target.label}* \\(${escapeMd(target.version ?? "v1")}\\)`, + `\`${escapeMd(target.contractId)}\``, + `[View on stellar\\.expert](${contractExplorerUrl(config, target.contractId)})`, + ); + } + + return lines.join("\n"); +} + +export const contractsCommand = defineCommand({ + command: "contracts", + chatAllowlist: true, + handler: async (ctx, deps) => { + await ctx.reply(contractsMessage(deps.config), TELEGRAM_OPTIONS); + }, +}); diff --git a/src/commands/health.ts b/src/commands/health.ts new file mode 100644 index 00000000..3046cd9e --- /dev/null +++ b/src/commands/health.ts @@ -0,0 +1,113 @@ +/** + * `/health`: operational readiness β€” RPC reachability, chain clock, cursor + * staleness, restart gaps, and which config sources were used. Unlike + * `/status`, it is written for someone deciding whether to act. + */ + +import { escapeMd } from "../notifications/format.js"; +import { formatProvenanceSummary, networkLabel, type BotConfig } from "../config.js"; +import { buildHealthReport, chainClockLabel } from "../health.js"; +import type { PollerStatus } from "../poller.js"; +import { defineCommand } from "./registry.js"; +import { TELEGRAM_OPTIONS } from "./runtime.js"; + +function ago(timestamp: number | null, nowMs: number = Date.now()): string { + if (timestamp === null) return "never"; + const seconds = Math.max(0, Math.round((nowMs - timestamp) / 1000)); + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`; + return `${Math.round(seconds / 3600)}h ago`; +} + +export function healthMessage( + config: BotConfig, + status: PollerStatus, + nowMs: number = Date.now(), +): string { + const report = buildHealthReport(config, status, nowMs); + const statusLabel = report.status.toUpperCase(); + + const lines: string[] = [ + `*Health* β€” ${escapeMd(statusLabel)} on Stellar ${networkLabel(config)}`, + "", + `Status: \`${report.status}\` \\(${report.ok ? "ok" : "action required"}\\)`, + `Poller: ${report.poller.running ? "running" : "stopped"}`, + `Uptime: ${report.uptimeMs > 0 ? ago(nowMs - report.uptimeMs, nowMs) : "0s"}`, + `Poll interval: ${Math.round(config.pollIntervalMs / 1000)}s Β· last poll ${ago(status.lastPollAt, nowMs)}`, + `Last successful poll: ${ago(status.lastSuccessAt, nowMs)}`, + `Chain tip: ${report.poller.latestLedger ?? "unknown"}`, + `Chain clock skew: ${escapeMd(chainClockLabel(status.chainClockAt, nowMs))}`, + `Cycles: ${report.poller.cycles} Β· consecutive failures: ${report.poller.consecutiveFailures}`, + `Notifications: sent ${report.poller.notificationsSent} Β· failed ${report.poller.notificationsFailed} Β· skipped ${report.poller.eventsSkipped}`, + "", + "*Watched Contracts*", + ]; + + for (const target of report.poller.targets) { + lines.push( + `Β· mimir\\-${target.source} \`${target.contractId}\``, + ` last event ledger: ${target.lastEventLedger ?? "none seen"}`, + ` cursor: \`${target.cursorPreview ?? "none (cold start)"}\``, + ); + if (target.cursorStale) { + lines.push( + target.rewindFromLedger === null + ? " ALERT: RPC rejected this cursor as stale; its position is unchanged" + : ` ALERT: stale cursor recovery from ledger ${target.rewindFromLedger}`, + ); + } + if (target.gapLedgers > 0) { + lines.push( + ` restart gap: ${target.gapLedgers} ledger(s) unrecoverable at the last recovery`, + ); + } + if (target.cursorUnreadable) { + lines.push(" cursor ledger unreadable: position forwarded unchanged"); + } + if (target.hasError) { + const targetState = status.targets.find((t) => t.source === target.source); + if (targetState?.lastError) { + lines.push(` last error: ${escapeMd(targetState.lastError)}`); + } + } + } + + if (report.poller.restartGaps > 0) { + const gap = report.poller.lastRestartGap; + lines.push( + escapeMd( + `Restart gaps since start: ${report.poller.restartGaps}` + + (gap ? ` (last: ${gap.missedLedgers} ledger(s) unrecoverable on ${gap.source})` : ""), + ), + ); + } + + if (report.poller.lastError) { + lines.push( + "", + `Last error \\(${ago(status.lastError?.at ?? null, nowMs)}\\): ${escapeMd(report.poller.lastError.message)}`, + ); + } + + // Origins, never values: an operator can confirm this process is reading the + // intended .env / profile without a secret being typed into a chat. The + // summary is interpolated into a code span unescaped on purpose β€” it is built + // only from key names, source names, and counts (see config.ts), so it has no + // MarkdownV2 reserved character to escape and stays copy-pasteable. + lines.push("", `Config: \`${formatProvenanceSummary(report.config)}\``); + if (report.config.warnings.length > 0) { + lines.push(`Config notes: ${escapeMd(report.config.warnings.join("; "))}`); + } + + return lines.join("\n"); +} + +export const healthCommand = defineCommand({ + // Config-only, so this never fails on account of poller or RPC state β€” + // unlike /status, it has nothing to report failure on. + command: "health", + chatAllowlist: true, + handler: async (ctx, deps) => { + await ctx.reply(healthMessage(deps.config, deps.status()), TELEGRAM_OPTIONS); + }, +}); diff --git a/src/commands/help-detail.ts b/src/commands/help-detail.ts index 940d2bb7..d22ab323 100644 --- a/src/commands/help-detail.ts +++ b/src/commands/help-detail.ts @@ -1,6 +1,6 @@ import { escapeMd } from "../notifications/format.js"; import type { BotConfig } from "../config.js"; -import { helpMessage } from "../bot.js"; +import { helpMessage } from "./help.js"; export interface CommandDetail { usage: string; @@ -28,6 +28,14 @@ export const COMMAND_DETAILS: Record = { description: "Show general help listing all commands, or detailed help with usage, arguments, and examples for a specific command.", examples: ["/help", "/help preview", "/help audit", "/help status"], }, + about: { + usage: "/about", + arguments: "None", + whoCanRun: "Anyone", + operatorOnly: false, + description: "Show the bot version, the Stellar network it watches, and where its source lives.", + examples: ["/about"], + }, status: { usage: "/status", arguments: "None", diff --git a/src/commands/help.ts b/src/commands/help.ts new file mode 100644 index 00000000..50028d91 --- /dev/null +++ b/src/commands/help.ts @@ -0,0 +1,48 @@ +/** + * `/start` and `/help`. + * + * `/start` is the welcome β€” the same command overview `/help` prints β€” and it + * is registered first so the menu opens with it. `/help ` narrows to + * usage, arguments and examples via {@link helpDetailMessage}. The overview is + * generated from the command registry, so a new command appears here without + * editing this file. + */ + +import { escapeMd } from "../notifications/format.js"; +import { messagesFor } from "../i18n.js"; +import type { BotConfig } from "../config.js"; +import { helpDetailMessage } from "./help-detail.js"; +import { defineCommand, visibleCommandMetadata } from "./registry.js"; +import { commandArgument, replyMarkdown } from "./runtime.js"; + +/** The help overview: title, intro, and one line per advertised command. */ +export function helpMessage(config?: BotConfig): string { + const messages = messagesFor(config?.locale); + return [ + messages.helpTitle, + "", + escapeMd(messages.helpIntro), + "", + ...visibleCommandMetadata(config).map(({ command, description }) => + escapeMd(`/${command} β€” ${description}`), + ), + ].join("\n"); +} + +export const startCommand = defineCommand({ + command: "start", + handler: async (ctx, deps) => { + await replyMarkdown(ctx, helpMessage(deps.config)); + }, +}); + +export const helpCommand = defineCommand({ + command: "help", + handler: async (ctx, deps) => { + const arg = commandArgument(ctx); + await replyMarkdown( + ctx, + arg ? helpDetailMessage(arg, deps.config) : helpMessage(deps.config), + ); + }, +}); diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 00000000..eec27304 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,67 @@ +/** + * The command catalogue β€” the one place that knows which commands exist. + * + * Every module below calls `defineCommand` when it loads, and the import order + * here is the order Telegram's menu and `/help` list them in. Adding a command: + * + * 1. create `src/commands/.ts` and export a module from + * `defineCommand({ command, operatorOnly?, chatAllowlist?, hidden?, handler })` + * 2. add one import line here β€” in menu order β€” and re-export anything + * `src/bot.ts` or the tests need + * 3. if the command is advertised, add its description to + * `COMMAND_DESCRIPTIONS` (both locales) in `src/i18n.ts` and its entry to + * `COMMAND_DETAILS` in `src/commands/help-detail.ts` + * + * `src/bot.ts` never needs editing: it registers whatever the registry holds. + */ + +// Registration order = menu order. Keep this list in sync with the menu. +import "./help.js"; // registers /start and /help, in that order +import "./about.js"; +import "./status.js"; +import "./audit.js"; +import "./contracts.js"; +import "./health.js"; +import "./preview.js"; +import "./pause.js"; +import "./resume.js"; +import "./log-export.js"; // hidden: operator log export + +export { + commandMetadata, + commandModule, + defineCommand, + registeredCommands, + visibleCommandMetadata, + type CommandDeps, + type CommandHandler, + type CommandMetadata, + type CommandModule, +} from "./registry.js"; +export { + TELEGRAM_OPTIONS, + chatAllowed, + commandArgument, + isChatAllowed, + isOperator, + operatorAllowed, + replyMarkdown, +} from "./runtime.js"; +export { + CALLBACK_PAUSE_CANCEL, + CALLBACK_PAUSE_CONFIRM, + contractsKeyboard, + operatorKeyboard, + pauseConfirmKeyboard, + statusKeyboard, +} from "./keyboards.js"; +export { aboutMessage, aboutCommand } from "./about.js"; +export { helpCommand, helpMessage, startCommand } from "./help.js"; +export { auditCommand, renderAuditForTelegram } from "./audit.js"; +export { contractsCommand, contractsMessage } from "./contracts.js"; +export { healthCommand, healthMessage } from "./health.js"; +export { pauseCommand, pauseMessage, PAUSE_PROMPT } from "./pause.js"; +export { previewCommand } from "./preview.js"; +export { resumeCommand, resumeMessage } from "./resume.js"; +export { lastEventMessage, statusCommand, statusMessage } from "./status.js"; +export { logExportCommand } from "./log-export.js"; diff --git a/src/commands/keyboards.ts b/src/commands/keyboards.ts new file mode 100644 index 00000000..d1eb6345 --- /dev/null +++ b/src/commands/keyboards.ts @@ -0,0 +1,53 @@ +/** + * Inline keyboards attached to command and callback messages. + * + * Callback data is untrusted user input, so every payload here is one of the + * small tokens `validateCallbackData` accepts (plus the pause-confirmation + * tokens, which `handlePauseConfirmCallback` validates against its own set). + */ + +import { InlineKeyboard } from "grammy"; + +import type { BotConfig } from "../config.js"; + +/** Callback data for the /pause confirmation buttons. */ +export const CALLBACK_PAUSE_CONFIRM = "pause-confirm" as const; +export const CALLBACK_PAUSE_CANCEL = "pause-cancel" as const; + +/** Status message keyboard: refresh, plus operator controls when configured. */ +export function statusKeyboard(config?: BotConfig): InlineKeyboard { + const keyboard = new InlineKeyboard() + .text("πŸ”„ Refresh", "status") + .text("πŸ“‹ Contracts", "contracts"); + if (config?.operatorTelegramUserId) { + keyboard.row().text("⏸ Pause", "pause").text("β–Ά Resume", "resume"); + } + return keyboard; +} + +/** Contracts message keyboard: navigation back to status and help. */ +export function contractsKeyboard(): InlineKeyboard { + return new InlineKeyboard() + .text("πŸ“Š Status", "status") + .text("❓ Help", "help"); +} + +/** Operator controls keyboard attached to operator messages. */ +export function operatorKeyboard(): InlineKeyboard { + return new InlineKeyboard() + .text("⏸ Pause", "pause") + .text("β–Ά Resume", "resume") + .row() + .text("πŸ“Š Status", "status"); +} + +/** + * Confirmation keyboard for `/pause`. Pausing is disruptive enough to ask + * twice: the prompt is a separate message whose buttons expire + * (`PAUSE_CONFIRM_TTL_MS`), so a stale tap cannot stop polling later. + */ +export function pauseConfirmKeyboard(): InlineKeyboard { + return new InlineKeyboard() + .text("βœ… Confirm pause", CALLBACK_PAUSE_CONFIRM) + .text("❌ Cancel", CALLBACK_PAUSE_CANCEL); +} diff --git a/src/commands/log-export.ts b/src/commands/log-export.ts new file mode 100644 index 00000000..98fc1ac5 --- /dev/null +++ b/src/commands/log-export.ts @@ -0,0 +1,29 @@ +/** + * `/export`: the recent redacted log ring, for the operator. + * + * Hidden from the command menu on purpose β€” it is a recovery tool, not part of + * the bot's surface β€” but registered like any other command so it no longer + * needs its own `bot.command("export", …)` in `createBot`. + */ + +import { renderLogExport } from "../logger.js"; +import { defineCommand } from "./registry.js"; + +export const logExportCommand = defineCommand({ + command: "export", + hidden: true, + operatorOnly: true, + handler: async (ctx, deps) => { + const { config, logs } = deps; + if (!logs || logs.capacity() === 0) { + await ctx.reply("Log export is disabled (LOG_BUFFER_LINES=0)."); + return; + } + // Plain text, no parse mode: the content is redacted but untrusted, and + // MarkdownV2 would make any escaping slip a parsing error instead of a + // cosmetic wart. + await ctx.reply(renderLogExport(config, deps.status(), logs), { + link_preview_options: { is_disabled: true }, + }); + }, +}); diff --git a/src/commands/pause.ts b/src/commands/pause.ts new file mode 100644 index 00000000..41419f43 --- /dev/null +++ b/src/commands/pause.ts @@ -0,0 +1,43 @@ +/** + * `/pause`: stop new polling cycles β€” but only after the operator confirms. + * + * Pausing is disruptive and the command is one keystroke away from `/help`, so + * the command answers with a confirmation prompt instead of acting. The prompt + * expires (`PAUSE_CONFIRM_TTL_MS`) and its callbacks are handled by + * `handlePauseConfirmCallback`, so a stale message cannot stop the poller later. + * Cursors are never touched; the current scan may still finish. + */ + +import { messagesFor, type Locale } from "../i18n.js"; +import type { PollerPauseResult } from "../poller.js"; +import { pauseConfirmKeyboard } from "./keyboards.js"; +import { defineCommand } from "./registry.js"; +import { TELEGRAM_OPTIONS } from "./runtime.js"; + +/** Operator-facing text for the pause confirmation prompt. */ +export const PAUSE_PROMPT = + "⏸ *Pause polling?*\nThis will stop new scans from starting\\. The current scan may finish\\."; + +/** Exact operator replies, exported for deterministic Telegram payload tests. */ +export function pauseMessage(result: PollerPauseResult, locale?: Locale): string { + const messages = messagesFor(locale).pauseMessages; + switch (result) { + case "paused": + return messages.paused; + case "already-paused": + return messages.alreadyPaused; + case "stopped": + return messages.stopped; + } +} + +export const pauseCommand = defineCommand({ + command: "pause", + operatorOnly: true, + handler: async (ctx) => { + await ctx.reply(PAUSE_PROMPT, { + ...TELEGRAM_OPTIONS, + reply_markup: pauseConfirmKeyboard(), + }); + }, +}); diff --git a/src/commands/preview.ts b/src/commands/preview.ts new file mode 100644 index 00000000..af2b332f --- /dev/null +++ b/src/commands/preview.ts @@ -0,0 +1,18 @@ +/** + * `/preview [market|squad]`: render a sample notification with the current + * formatting and link-preview settings, so an operator can check a channel's + * look without waiting for a live event. + */ + +import { previewMessage } from "../notifications/format.js"; +import { defineCommand } from "./registry.js"; +import { TELEGRAM_OPTIONS, commandArgument } from "./runtime.js"; + +export const previewCommand = defineCommand({ + command: "preview", + chatAllowlist: true, + handler: async (ctx, deps) => { + const target = commandArgument(ctx) || "market"; + await ctx.reply(previewMessage(deps.config, target), TELEGRAM_OPTIONS); + }, +}); diff --git a/src/commands/registry.ts b/src/commands/registry.ts new file mode 100644 index 00000000..f15b7897 --- /dev/null +++ b/src/commands/registry.ts @@ -0,0 +1,161 @@ +/** + * The slash-command registry. + * + * A command is a self-contained module under `src/commands/`: it declares its + * name, who may run it, and its handler (plus, where useful, the message + * renderer that handler uses). Modules register themselves through + * {@link defineCommand} at load time; `src/commands/index.ts` is the single + * place that imports them, which fixes menu order. + * + * `src/bot.ts` only walks the registry, so adding a command no longer means + * editing a shared `handlers` record there. Authorization is declared, not + * repeated: the registrar applies {@link CommandModule.operatorOnly} and + * {@link CommandModule.chatAllowlist} before the handler runs, so a new + * operator command cannot forget its check. + */ + +import type { CommandContext, Context } from "grammy"; +import type { UserFromGetMe } from "grammy/types"; + +import type { AuditLog } from "../audit.js"; +import type { BotConfig } from "../config.js"; +import { COMMAND_DESCRIPTIONS, messagesFor } from "../i18n.js"; +import type { LogCapture } from "../logger.js"; +import type { PollerPauseResult, PollerResumeResult, PollerStatus } from "../poller.js"; +import type { BroadcastStore } from "./broadcast.js"; + +/** Everything a command handler may need, supplied once by `createBot`. */ +export interface CommandDeps { + config: BotConfig; + status: () => PollerStatus; + pause: () => PollerPauseResult; + resume: () => PollerResumeResult; + /** Bot version string, typically read from package.json. */ + version?: string | undefined; + /** Live in-memory audit window; renders immediately even before a flush. */ + audit?: AuditLog | undefined; + /** Where the audit JSONL file lives, for the file-backed report. */ + auditFile?: string | undefined; + /** + * Pre-populated bot info. When provided (e.g. in tests) grammy skips the + * getMe() call so `bot.handleUpdate()` works without a real Telegram token. + */ + botInfo?: UserFromGetMe | undefined; + /** Bounded redacted log ring backing /export. Absent = command disabled. */ + logs?: LogCapture | undefined; + /** In-memory broadcast confirmation store. */ + broadcastStore?: BroadcastStore | undefined; +} + +/** A slash command handler: grammy's command context plus the bot's deps. */ +export type CommandHandler = ( + ctx: CommandContext, + deps: CommandDeps, +) => Promise; + +/** One self-contained slash command. */ +export interface CommandModule { + /** + * Telegram command name, without the leading slash. Advertised commands must + * also have an entry in `COMMAND_DESCRIPTIONS` (and a `COMMAND_DETAILS` + * entry); hidden operator tools such as `/export` need neither. + */ + readonly command: string; + /** + * Operator-only commands answer only the configured operator and are hidden + * from the menu when no operator is configured. Non-operators are ignored + * silently: an error reply would confirm the command exists. + */ + readonly operatorOnly?: boolean; + /** + * Restricts the command to chats in `ALLOWED_CHAT_IDS`. Restricted commands + * are ignored silently for the same reason as operator commands. + */ + readonly chatAllowlist?: boolean; + /** + * Registered and usable, but not advertised in the Telegram command menu or + * `/help` (e.g. `/export`, an operator recovery tool). + */ + readonly hidden?: boolean; + readonly handler: CommandHandler; +} + +/** Menu and `/help` metadata for a registered command. */ +export interface CommandMetadata { + readonly command: string; + readonly description: string; + readonly operatorOnly: boolean; +} + +const registered = new Map(); + +/** + * The English description registered with Telegram's command menu. A missing + * entry is a boot-time programming error, not a silently blank menu line. + */ +function descriptionFor(command: string): string { + const description = (COMMAND_DESCRIPTIONS as Readonly>)[command]; + if (description === undefined) { + throw new Error(`no COMMAND_DESCRIPTIONS entry for /${command}`); + } + return description; +} + +/** + * Register a command module. Called at module load from each file in this + * directory; importing a command module twice is harmless, registering two + * modules for the same command is a programming error and throws at boot. + */ +export function defineCommand(module: CommandModule): CommandModule { + const existing = registered.get(module.command); + if (existing !== undefined && existing !== module) { + throw new Error(`duplicate command registration: /${module.command}`); + } + registered.set(module.command, module); + return module; +} + +/** Every registered command, in registration (menu) order. */ +export function registeredCommands(): readonly CommandModule[] { + return [...registered.values()]; +} + +/** The module for a command name, or undefined when it is not registered. */ +export function commandModule(command: string): CommandModule | undefined { + return registered.get(command); +} + +/** + * Static command list: every advertised command with its English description. + * Used by `/help`'s detail index and the i18n tests, which must see the full + * list regardless of who is asking. + */ +export function commandMetadata(): CommandMetadata[] { + return registeredCommands() + .filter((module) => module.hidden !== true) + .map((module) => ({ + command: module.command, + description: descriptionFor(module.command), + operatorOnly: module.operatorOnly === true, + })); +} + +/** + * Commands to advertise to this deployment: localized descriptions, and + * operator-only entries only when an operator is configured (there is no one + * to run them otherwise). + */ +export function visibleCommandMetadata(config?: BotConfig): CommandMetadata[] { + const descriptions = messagesFor(config?.locale).commandDescriptions; + const operatorConfigured = config === undefined || config.operatorTelegramUserId !== null; + return registeredCommands() + .filter((module) => module.hidden !== true) + .filter((module) => operatorConfigured || module.operatorOnly !== true) + .map((module) => ({ + command: module.command, + description: + (descriptions as Readonly>)[module.command] ?? + descriptionFor(module.command), + operatorOnly: module.operatorOnly === true, + })); +} diff --git a/src/commands/resume.ts b/src/commands/resume.ts new file mode 100644 index 00000000..ebf7607c --- /dev/null +++ b/src/commands/resume.ts @@ -0,0 +1,30 @@ +/** + * `/resume`: start polling again now. Resuming is reversible and safe (it never + * edits cursors), so it acts immediately instead of asking for confirmation. + */ + +import { messagesFor, type Locale } from "../i18n.js"; +import type { PollerResumeResult } from "../poller.js"; +import { defineCommand } from "./registry.js"; +import { TELEGRAM_OPTIONS } from "./runtime.js"; + +export function resumeMessage(result: PollerResumeResult, locale?: Locale): string { + const messages = messagesFor(locale).resumeMessages; + switch (result) { + case "resumed": + return messages.resumed; + case "already-running": + return messages.alreadyRunning; + case "stopped": + return messages.stopped; + } +} + +export const resumeCommand = defineCommand({ + command: "resume", + operatorOnly: true, + handler: async (ctx, deps) => { + const resume = deps.resume ?? (() => "stopped" as const); + await ctx.reply(resumeMessage(resume(), deps.config.locale), TELEGRAM_OPTIONS); + }, +}); diff --git a/src/commands/runtime.ts b/src/commands/runtime.ts new file mode 100644 index 00000000..2435fd61 --- /dev/null +++ b/src/commands/runtime.ts @@ -0,0 +1,126 @@ +/** + * Shared plumbing for command modules: Telegram send options, MarkdownV2 + * replies, and the two authorization gates the registry applies. + * + * These helpers live outside `src/bot.ts` on purpose β€” command modules depend + * on them, and bot.ts depends on the modules, so keeping them here avoids a + * cycle between the composition root and the commands. + */ + +import type { CommandContext, Context } from "grammy"; + +import type { BotConfig } from "../config.js"; +import { safeErrorMessage, splitTelegramMessage } from "../notifications/format.js"; + +/** MarkdownV2 reply options used by every command message. */ +export const TELEGRAM_OPTIONS = { + parse_mode: "MarkdownV2" as const, + link_preview_options: { is_disabled: true }, +}; + +/** + * Reply with MarkdownV2, splitting when the payload exceeds Telegram's + * 4096-character `sendMessage` limit. `/help` grows with the command list + * and `/status` with the watched targets, so either can cross it; the chunks + * are sent in order. + */ +export async function replyMarkdown( + ctx: CommandContext, + text: string, + options: typeof TELEGRAM_OPTIONS = TELEGRAM_OPTIONS, +): Promise { + for (const part of splitTelegramMessage(text)) { + await ctx.reply(part, options); + } +} + +/** + * Returns true when the chat is permitted to use restricted commands. + * + * Rules: + * - If `allowedChatIds` is empty the list is open (any chat may use the + * gated commands: /status, /health, /contracts, /preview). + * - Otherwise the incoming chat id must appear in the list. Both the numeric + * id (stored as a number in grammy's ctx.chat.id) and its string form are + * compared so that negative group ids such as -1001234567890 match correctly. + */ +export function isChatAllowed( + allowedChatIds: readonly string[] | undefined, + chatId: number, +): boolean { + // Hand-built configs (tests, embedders) may omit the list: treat it as open. + if (!allowedChatIds || allowedChatIds.length === 0) return true; + const asString = String(chatId); + return allowedChatIds.some((allowed) => allowed === asString); +} + +/** True when the update comes from the configured operator. */ +export function isOperator(ctx: Pick, config: BotConfig): boolean { + const operatorId = config.operatorTelegramUserId; + return operatorId !== null && ctx.from?.id.toString() === operatorId; +} + +/** + * Gate for commands that expose deployment details. Requests from unapproved + * chats are silently ignored: an error reply would leak the existence of the + * restriction, and silence matches privacy-mode bots that never see most + * messages. The log lets operators spot misconfigured ids. + */ +export function chatAllowed( + ctx: CommandContext, + config: BotConfig, + command: string, +): boolean { + const chatId = ctx.chat?.id; + if (chatId !== undefined && isChatAllowed(config.allowedChatIds, chatId)) return true; + console.warn(`[bot] /${command} denied for chat ${chatId ?? "unknown"} (not in ALLOWED_CHAT_IDS)`); + return false; +} + +/** + * Gate for operator commands. Non-operators get no reply at all, matching the + * silent-ignore policy above; the log records the ignored attempt. + */ +export function operatorAllowed( + ctx: CommandContext, + config: BotConfig, + command: string, +): boolean { + if (isOperator(ctx, config)) return true; + console.warn(`[bot] ignored unauthorized /${command} on update ${ctx.update.update_id}`); + return false; +} + +/** + * Answer a callback query, tolerating a missing query id and rate-limit or + * timeout failures: the button's spinner is cosmetic, the action is not. + */ +export async function safeAnswerCallback( + ctx: Context, + config: BotConfig, + params: { text?: string; show_alert?: boolean } = {}, +): Promise { + if (ctx.callbackQuery?.id) { + try { + await ctx.answerCallbackQuery(params); + } catch (err) { + console.warn( + `[bot] answerCallbackQuery failed: ${safeErrorMessage(err, [config.botToken])}`, + ); + } + } +} + +/** + * Argument text following the command, without the command or its leading + * space. grammy's `ctx.match` is used when present; hand-built contexts in + * tests often omit it, so the message text is the fallback. + */ +export function commandArgument(ctx: CommandContext): string { + if (typeof ctx.match === "string" && ctx.match.trim().length > 0) { + return ctx.match.trim(); + } + const text = ctx.message?.text ?? ""; + const spaceIndex = text.indexOf(" "); + return spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim(); +} diff --git a/src/commands/status.ts b/src/commands/status.ts new file mode 100644 index 00000000..1b495fea --- /dev/null +++ b/src/commands/status.ts @@ -0,0 +1,168 @@ +/** + * `/status`: what the bot is watching, how far it has read, and how the last + * cycles went. Reads poller state only β€” never the chain β€” so answering stays + * cheap even mid-cycle. + */ + +import { performance } from "node:perf_hooks"; + +import { + escapeMd, + formatLastEvent, +} from "../notifications/format.js"; +import { networkLabel, type BotConfig } from "../config.js"; +import { chainClockLabel } from "../health.js"; +import { formatFeatureFlags } from "../notifications/featureFlags.js"; +import type { PollerStatus } from "../poller.js"; +import { defineCommand } from "./registry.js"; +import { TELEGRAM_OPTIONS } from "./runtime.js"; + +function ago(timestamp: number | null, nowMs: number = Date.now()): string { + if (timestamp === null) return "never"; + const seconds = Math.max(0, Math.round((nowMs - timestamp) / 1000)); + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`; + return `${Math.round(seconds / 3600)}h ago`; +} + +function cursorPreview(cursor: string | null): string { + if (cursor === null) return "none (cold start)"; + const compact = cursor.replace(/\s+/g, " ").replace(/[`\\]/g, "?").trim() || "empty"; + return compact.length <= 24 ? compact : `${compact.slice(0, 23)}…`; +} + +export function statusMessage( + config: BotConfig, + status: PollerStatus, + nowMs: number = Date.now(), +): string { + const lifecycle = status.stopping + ? "stopping" + : status.paused + ? "paused" + : status.running + ? "running" + : "stopped"; + + const lines: string[] = [ + `*Status* β€” ${lifecycle} on Stellar ${networkLabel(config)}`, + `Channel preview: ${config.channelPreviewMode ? "enabled" : "disabled"}`, + "", + `Chain tip: ${status.latestLedger ?? "unknown"}`, + `RPC retains from ledger: ${status.oldestLedger ?? "unknown"}`, + `Chain clock skew: ${escapeMd(chainClockLabel(status.chainClockAt, nowMs))}`, + `Poll interval: ${Math.round(config.pollIntervalMs / 1000)}s Β· last poll ${ago(status.lastPollAt, nowMs)}`, + `Cycles: ${status.cycles} Β· sent ${status.notificationsSent} Β· failed sends ${status.notificationsFailed} Β· skipped ${status.eventsSkipped} Β· deduped ${status.eventsDeduplicated ?? 0}` + + (status.notificationsDropped + ? ` Β· dropped during shutdown ${status.notificationsDropped}` + : ""), + `RPC pages: ${status.pagesScanned} total Β· ${status.emptyPages} empty` + + (status.lastCyclePages > 0 + ? ` Β· last cycle ${status.lastCycleEmptyPages}/${status.lastCyclePages} empty` + : ""), + `Feature flags: ${escapeMd(formatFeatureFlags(config.featureFlags))}`, + "", + "*Watching*", + ]; + + // Only shown when the queue is holding or has replayed something, so an + // ordinary /status is unchanged. + if (status.deadLetter && (status.deadLetter.depth > 0 || status.deadLetter.replayed > 0)) { + lines.push( + `Parked sends: ${status.deadLetter.depth} waiting \u00b7 ` + + `${status.deadLetter.replayed} replayed \u00b7 ${status.deadLetter.dropped} dropped`, + "", + ); + } + + // Only shown after an automatic recovery, so an ordinary /status is unchanged. + if (status.cursorRewinds > 0) { + lines.push( + `Cursors rewound to the retained floor: ${status.cursorRewinds}`, + "", + ); + } + + for (const target of status.targets) { + lines.push( + `Β· mimir\\-${target.source} \\(${escapeMd(target.version ?? "v1")}\\) \`${target.contractId}\``, + ` last event ledger: ${target.lastEventLedger ?? "none seen"}`, + ` cursor: \`${cursorPreview(target.cursor)}\``, + ); + if (target.cursorStalled) { + lines.push( + ` cursor stalled: unchanged for ${target.cyclesWithoutAdvance} successful cycles while behind tip`, + ); + } + if (target.gapLedgers > 0) { + lines.push( + ` restart gap: ${target.gapLedgers} ledger${target.gapLedgers === 1 ? "" : "s"} unrecoverable, ` + + `cursor reset ${ago(target.cursorResetAt, nowMs)}`, + ); + } + if (target.cursorUnreadable) { + lines.push(" cursor ledger unreadable: position left untouched"); + } + if (target.lastError) lines.push(` last error: ${escapeMd(target.lastError)}`); + } + + if (status.lastError) { + lines.push( + "", + `Last error \\(${ago(status.lastError.at, nowMs)}\\): ${escapeMd(status.lastError.message)}`, + ); + } + if (status.consecutiveFailures > 0) { + lines.push(`Consecutive failed cycles: ${status.consecutiveFailures}`); + } + if (status.restartGaps > 0) { + lines.push(`Restart gaps detected since start: ${status.restartGaps}`); + } + + if (status.stopping) { + lines.push( + "", + "Graceful shutdown in progress: no new cycles, unsent notifications dropped" + + (status.pendingFlush ? ", cursor flush still pending" : ", cursor flushed") + + "\\.", + ); + } + + return lines.join("\n"); +} + +export function lastEventMessage(config: BotConfig, status: PollerStatus): string { + const lines = [`*Last observed events* β€” Stellar ${networkLabel(config)}`, ""]; + + for (const target of status.targets) { + lines.push(`*mimir\\-${target.source}*`); + if (!target.lastEvent) { + lines.push("No event has been observed since this process started\\.", ""); + continue; + } + lines.push(formatLastEvent(config, target.lastEvent), ""); + } + + if (status.targets.length === 0) { + lines.push("No contract scan has completed yet\\."); + } + return lines.join("\n").trimEnd(); +} + +export const statusCommand = defineCommand({ + command: "status", + chatAllowlist: true, + handler: async (ctx, deps) => { + const startedAt = performance.now(); + let sent = false; + try { + await ctx.reply(statusMessage(deps.config, deps.status()), TELEGRAM_OPTIONS); + sent = true; + } finally { + const latencyMs = Math.max(0, performance.now() - startedAt); + console.info( + `[bot] /status ${sent ? "sent" : "failed"} in ${latencyMs.toFixed(1)}ms`, + ); + } + }, +}); diff --git a/src/notifications/format.ts b/src/notifications/format.ts index 50cac7ac..ff7fb162 100644 --- a/src/notifications/format.ts +++ b/src/notifications/format.ts @@ -30,6 +30,7 @@ import { confidenceIndicator } from "./confidence.js"; import { settlementLines } from "./settlement.js"; import type { StellarConfig } from "../config.js"; import { messagesFor, type Messages } from "../i18n.js"; +import { formatDeadline } from "./time.js"; /** Telegram's MarkdownV2 reserved set. All of it must be escaped, everywhere. */ const MDV2_RESERVED = /[_*[\]()~`>#+\-=|{}.!\\]/g; @@ -370,8 +371,11 @@ export function formatDigest( * in contract state, not the event, so it is not invented here. The squad * contract's `market_created` does carry `question`, and that is shown. */ -function headline(messages: Messages, event: DecodedEvent): string | null { -function headline(event: DecodedEvent, nowMs?: number): string | null { +function headline( + messages: Messages, + event: DecodedEvent, + nowMs?: number, +): string | null { const p = event.payload; const md = messages.notificationMd; @@ -525,8 +529,7 @@ export function formatEvent( const currentMs = nowMs ?? config.nowMs ?? (event?.at && event.at > 0 ? event.at * 1000 : Date.now()); try { const messages = messagesFor(config.locale); - const head = headline(messages, event); - const head = headline(event, currentMs); + const head = headline(messages, event, currentMs); if (head === null) return null; const body = `${head}\n${footer(config, event)}`; if (config.channelPreviewMode) { @@ -564,8 +567,11 @@ function plainFooter(config: StellarConfig, event: DecodedEvent): string { * field-for-field (same identities, amounts, bounded clips) but emits no * MarkdownV2 syntax at all, so Telegram cannot reject it for entity parsing. */ -function plainHeadline(messages: Messages, event: DecodedEvent): string | null { -function plainHeadline(event: DecodedEvent, nowMs?: number): string | null { +function plainHeadline( + messages: Messages, + event: DecodedEvent, + nowMs?: number, +): string | null { const p = event.payload; const plain = messages.notificationPlain; @@ -709,11 +715,9 @@ export function formatPlainTextEvent( nowMs?: number, ): string | null { const messages = messagesFor(config.locale); - try { - const head = plainHeadline(messages, event); const currentMs = nowMs ?? config.nowMs ?? (event?.at && event.at > 0 ? event.at * 1000 : Date.now()); try { - const head = plainHeadline(event, currentMs); + const head = plainHeadline(messages, event, currentMs); if (head === null) return null; const body = `${head}\n${plainFooter(config, event)}`.trim(); if (!body) return minimalPlainTextEvent(messages, event); diff --git a/tests/command-modules.test.mjs b/tests/command-modules.test.mjs new file mode 100644 index 00000000..a67dc2cd --- /dev/null +++ b/tests/command-modules.test.mjs @@ -0,0 +1,290 @@ +/** + * Registry invariants for slash commands. + * + * Commands are modules that register themselves (see `src/commands/`), so these + * tests guard the properties a new module must not break: + * - names are unique and each module has a callable handler + * - the advertised menu (`COMMANDS`) is derived from, and stays in step with, + * the registered modules β€” including which are operator-only + * - hidden operator tools (`/export`) are registered and usable but never + * advertised + * - authorization is applied centrally from the module's metadata, so a new + * operator-only or allowlisted command cannot forget its gate + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CALLBACK_PAUSE_CONFIRM, + COMMANDS, + handleCallbackQuery, + registerCommandHandlers, +} from "../dist/bot.js"; +import { + commandMetadata, + defineCommand, + registeredCommands, + visibleCommandMetadata, +} from "../dist/commands/index.js"; + +const MENU = [ + "start", + "help", + "about", + "status", + "audit", + "contracts", + "health", + "preview", + "pause", + "resume", +]; + +const OPERATOR_ONLY = ["audit", "pause", "resume"]; + +function baseConfig(overrides = {}) { + return { + marketContractId: "C" + "A".repeat(55), + squadContractId: "C" + "B".repeat(55), + rpcUrl: "https://example.invalid/rpc", + horizonUrl: "https://example.invalid/horizon", + networkPassphrase: "Test SDF Network ; September 2015", + explorerBaseUrl: "https://example.invalid/explorer", + botToken: "123456789:TEST-ONLY-TOKEN-NEVER-USE", + chatId: "-1001234567890", + allowedChatIds: [], + operatorTelegramUserId: "42", + pollIntervalMs: 30_000, + startLookbackLedgers: 60, + cursorFile: "/tmp/unused-cursor.json", + maxNotificationsPerCycle: 20, + healthHost: "127.0.0.1", + healthPort: 0, + healthStaleMs: 90_000, + channelPreviewMode: false, + ...overrides, + }; +} + +function baseStatus() { + return { + running: true, + paused: false, + startedAt: 1, + cycles: 0, + lastPollAt: null, + lastSuccessAt: null, + latestLedger: null, + oldestLedger: null, + notificationsSent: 0, + notificationsFailed: 0, + eventsSkipped: 0, + consecutiveFailures: 0, + lastError: null, + targets: [], + }; +} + +function deps(overrides = {}) { + return { + config: baseConfig(), + status: () => baseStatus(), + pause: () => "paused", + resume: () => "resumed", + ...overrides, + }; +} + +/** A bot-shaped recorder: captures commands and callback filters. */ +function recorder() { + const handlers = new Map(); + const callbackFilters = []; + return { + handlers, + callbackFilters, + bot: { + command: (name, handler) => handlers.set(name, handler), + callbackQuery: (filter, handler) => callbackFilters.push({ filter, handler }), + }, + }; +} + +function context({ userId = 42, chatId = -1001234567890, updateId = 1 } = {}) { + const replies = []; + return { + replies, + ctx: { + from: { id: userId }, + chat: { id: chatId }, + update: { update_id: updateId }, + reply: async (...args) => replies.push(args), + }, + }; +} + +test("every registered command has a unique name and a callable handler", () => { + const commands = registeredCommands(); + assert.ok(commands.length >= MENU.length); + const seen = new Set(); + for (const module of commands) { + assert.equal(typeof module.command, "string"); + assert.ok(module.command.length > 0); + assert.equal(seen.has(module.command), false, `duplicate /${module.command}`); + seen.add(module.command); + assert.equal(typeof module.handler, "function", `/${module.command} has a handler`); + } +}); + +test("COMMANDS is derived from the registry in menu order", () => { + assert.deepEqual( + COMMANDS.map(({ command }) => command), + MENU, + ); + assert.deepEqual( + COMMANDS.filter(({ operatorOnly }) => operatorOnly).map(({ command }) => command), + OPERATOR_ONLY, + ); + assert.deepEqual(COMMANDS, commandMetadata()); +}); + +test("registry metadata carries a non-empty description for every advertised command", () => { + for (const { command, description } of COMMANDS) { + assert.equal(typeof description, "string"); + assert.ok(description.length > 0, `/${command} has no description`); + } +}); + +test("hidden operator tools are registered but never advertised", () => { + const exportModule = registeredCommands().find(({ command }) => command === "export"); + assert.ok(exportModule, "/export is registered"); + assert.equal(exportModule.operatorOnly, true); + assert.equal(exportModule.hidden, true); + assert.equal(COMMANDS.some(({ command }) => command === "export"), false); + assert.equal(visibleCommandMetadata(baseConfig()).some(({ command }) => command === "export"), false); +}); + +test("operator commands are hidden from the menu when no operator is configured", () => { + const open = visibleCommandMetadata(baseConfig({ operatorTelegramUserId: null })); + assert.deepEqual( + open.map(({ command }) => command), + MENU.filter((command) => !OPERATOR_ONLY.includes(command)), + ); + const withOperator = visibleCommandMetadata(baseConfig()); + assert.deepEqual(withOperator.map(({ command }) => command), MENU); +}); + +test("registerCommandHandlers registers every module plus one pause-confirm callback", () => { + const { handlers, callbackFilters, bot } = recorder(); + registerCommandHandlers(bot, deps()); + + assert.deepEqual([...handlers.keys()], [...registeredCommands().map(({ command }) => command)]); + assert.equal(callbackFilters.length, 1); + assert.deepEqual(callbackFilters[0].filter, ["pause-confirm", "pause-cancel"]); +}); + +test("defineCommand rejects a second module for the same command", () => { + assert.throws( + () => defineCommand({ command: "status", handler: async () => undefined }), + /duplicate command registration: \/status/, + ); +}); + +// ── Central authorization ──────────────────────────────────────────────────── +// The registrar applies the module's declared gates before its handler runs, so +// a module that declares `operatorOnly` cannot accidentally answer strangers. + +test("an operator-only module is silently ignored for non-operators", async () => { + let calls = 0; + defineCommand({ + command: "test-operator-only", + hidden: true, + operatorOnly: true, + handler: async () => { + calls += 1; + }, + }); + + const { handlers, bot } = recorder(); + registerCommandHandlers(bot, deps()); + const handler = handlers.get("test-operator-only"); + + const stranger = context({ userId: 99 }); + const originalWarn = console.warn; + console.warn = () => undefined; + try { + await handler(stranger.ctx); + } finally { + console.warn = originalWarn; + } + + assert.equal(calls, 0, "handler must not run for a non-operator"); + assert.deepEqual(stranger.replies, [], "non-operators get silence, not an error"); + + await handler(context({ userId: 42 }).ctx); + assert.equal(calls, 1, "the operator reaches the handler"); +}); + +test("an allowlisted module is silently ignored outside ALLOWED_CHAT_IDS", async () => { + let calls = 0; + defineCommand({ + command: "test-allowlisted", + hidden: true, + chatAllowlist: true, + handler: async () => { + calls += 1; + }, + }); + + const { handlers, bot } = recorder(); + registerCommandHandlers(bot, deps({ config: baseConfig({ allowedChatIds: ["-100999"] }) })); + const handler = handlers.get("test-allowlisted"); + + const outsider = context({ chatId: -1001234567890 }); + const originalWarn = console.warn; + console.warn = () => undefined; + try { + await handler(outsider.ctx); + } finally { + console.warn = originalWarn; + } + + assert.equal(calls, 0); + assert.deepEqual(outsider.replies, []); + + await handler(context({ chatId: -100999 }).ctx); + assert.equal(calls, 1); +}); + +test("the action dispatcher leaves pause-confirm payloads to their own handler", async () => { + let answered = 0; + await handleCallbackQuery( + { + from: { id: 42 }, + callbackQuery: { data: CALLBACK_PAUSE_CONFIRM, message: { date: Math.floor(Date.now() / 1000) } }, + update: { update_id: 7 }, + answerCallbackQuery: async () => { + answered += 1; + }, + }, + deps(), + ); + assert.equal(answered, 0, "the confirmation handler owns this payload"); +}); + +test("the pause confirmation flow rejects payloads outside its own set", async () => { + const { callbackFilters, bot } = recorder(); + registerCommandHandlers(bot, deps()); + const handler = callbackFilters[0].handler; + + let answered = 0; + await handler({ + from: { id: 42 }, + callbackQuery: { data: "status", message: { date: Math.floor(Date.now() / 1000) } }, + update: { update_id: 5 }, + answerCallbackQuery: async () => { + answered += 1; + }, + }); + assert.equal(answered, 0, "navigation payloads are left to the action dispatcher"); +}); diff --git a/tests/contracts-command.test.mjs b/tests/contracts-command.test.mjs index 5e7668a8..805bf316 100644 --- a/tests/contracts-command.test.mjs +++ b/tests/contracts-command.test.mjs @@ -227,5 +227,5 @@ test("registerCommands includes /contracts alongside the existing commands", asy assert.equal(calls.length, 1); const commands = calls[0].commands.map((c) => c.command); - assert.deepEqual(commands, ["start", "help", "status", "audit", "contracts", "health", "preview", "pause", "resume"]); + assert.deepEqual(commands, ["start", "help", "about", "status", "audit", "contracts", "health", "preview", "pause", "resume"]); }); diff --git a/tests/help-command.test.mjs b/tests/help-command.test.mjs index 516f25c7..3d2ac36a 100644 --- a/tests/help-command.test.mjs +++ b/tests/help-command.test.mjs @@ -10,15 +10,16 @@ const INTRO = "*Mimir notifier*\n\nI watch Mimir's two Soroban contracts on Stel const PUBLIC_LINES = [ "/start β€” What this bot does", "/help β€” Show help", + "/about β€” About this bot", "/status β€” Last\\-seen ledger and watched contracts", "/contracts β€” Contract ids and explorer links", "/health β€” Health assessment and operational readiness", "/preview β€” Preview channel notification formatting", ]; const ALL_LINES = [ - ...PUBLIC_LINES.slice(0, 3), + ...PUBLIC_LINES.slice(0, 4), "/audit β€” Operator only: audit report \\(redacted, bounded\\)", - ...PUBLIC_LINES.slice(3), + ...PUBLIC_LINES.slice(4), "/pause β€” Operator only: pause new scans", "/resume β€” Operator only: resume polling now", ]; @@ -52,7 +53,7 @@ for (const operatorId of [null, "42"]) { assert.ok(handlers.has(command), `${command} has a handler`); assert.ok(description.length >= 1 && description.length <= 256); } - assert.equal(handlers.size, 9, "disabled operator handlers still enforce authorization"); + assert.equal(handlers.size, 11, "disabled operator handlers still enforce authorization"); }); } diff --git a/tests/i18n.test.mjs b/tests/i18n.test.mjs index 95af2ed6..9b8f4747 100644 --- a/tests/i18n.test.mjs +++ b/tests/i18n.test.mjs @@ -36,6 +36,8 @@ import { } from "../dist/notifications/format.js"; import { + CALLBACK_PAUSE_CONFIRM, + handlePauseConfirmCallback, helpMessage, pauseMessage, resumeMessage, @@ -684,72 +686,80 @@ test("squad market_created MarkdownV2 snapshot matches i18n template", () => { // ── Regression: operator command replies are wired through i18n ─────────────── -test("/pause and /resume bot replies are sourced from PAUSE_MESSAGES and RESUME_MESSAGES", async () => { - const TELEGRAM_OPTIONS = { parse_mode: "MarkdownV2", link_preview_options: { is_disabled: true } }; +/** + * `/pause` is a two-step flow: the command asks for confirmation, and the + * confirmed callback performs the pause and renders the localized result. Both + * steps are asserted here so the i18n strings stay wired end to end. + */ +async function confirmedPauseReply(pauseResult) { + const config = baseConfig(); + const pause = () => pauseResult; const { handlers } = mockedBot({ - config: baseConfig(), + config, status: () => baseStatus(), - pause: () => "paused", + pause, resume: () => "resumed", }); - const pauseReplies = []; - const pauseCtx = { + const promptReplies = []; + await handlers.get("pause")({ from: { id: 42 }, update: { update_id: 200 }, - reply: async (...args) => pauseReplies.push(args), - }; - await handlers.get("pause")(pauseCtx); - assert.equal(pauseReplies.length, 1); - assert.equal(pauseReplies[0][0], PAUSE_MESSAGES.paused); - assert.deepEqual(pauseReplies[0][1], TELEGRAM_OPTIONS); + reply: async (...args) => promptReplies.push(args), + }); + assert.equal(promptReplies.length, 1); + assert.ok(promptReplies[0][1].reply_markup, "/pause must ask for confirmation"); + + const edits = []; + const messageDate = Math.floor(Date.now() / 1000); + await handlePauseConfirmCallback( + { + from: { id: 42 }, + update: { update_id: 201 }, + callbackQuery: { data: CALLBACK_PAUSE_CONFIRM, message: { date: messageDate } }, + answerCallbackQuery: async () => undefined, + editMessageText: async (text, opts) => edits.push([text, opts]), + }, + { config, pause }, + messageDate * 1000 + 500, + ); - const resumeReplies = []; - const resumeCtx = { - from: { id: 42 }, - update: { update_id: 201 }, - reply: async (...args) => resumeReplies.push(args), - }; - await handlers.get("resume")(resumeCtx); - assert.equal(resumeReplies.length, 1); - assert.equal(resumeReplies[0][0], RESUME_MESSAGES.resumed); - assert.deepEqual(resumeReplies[0][1], TELEGRAM_OPTIONS); -}); + assert.equal(edits.length, 1); + return edits[0]; +} + +test("/pause confirmation renders PAUSE_MESSAGES.paused and /resume replies from RESUME_MESSAGES", async () => { + const TELEGRAM_OPTIONS = { parse_mode: "MarkdownV2", link_preview_options: { is_disabled: true } }; + const [pauseText, pauseOptions] = await confirmedPauseReply("paused"); + assert.equal(pauseText, PAUSE_MESSAGES.paused); + assert.deepEqual(pauseOptions, TELEGRAM_OPTIONS); -test("/pause already-paused reply matches PAUSE_MESSAGES.alreadyPaused", async () => { const { handlers } = mockedBot({ config: baseConfig(), status: () => baseStatus(), - pause: () => "already-paused", - resume: () => "already-running", + pause: () => "paused", + resume: () => "resumed", }); - const replies = []; - const ctx = { + const resumeReplies = []; + await handlers.get("resume")({ from: { id: 42 }, update: { update_id: 202 }, - reply: async (...args) => replies.push(args), - }; - await handlers.get("pause")(ctx); - assert.equal(replies[0][0], PAUSE_MESSAGES.alreadyPaused); + reply: async (...args) => resumeReplies.push(args), + }); + assert.equal(resumeReplies.length, 1); + assert.equal(resumeReplies[0][0], RESUME_MESSAGES.resumed); + assert.deepEqual(resumeReplies[0][1], TELEGRAM_OPTIONS); }); -test("/pause stopped reply matches PAUSE_MESSAGES.stopped", async () => { - const { handlers } = mockedBot({ - config: baseConfig(), - status: () => baseStatus(), - pause: () => "stopped", - resume: () => "stopped", - }); +test("/pause already-paused confirmation matches PAUSE_MESSAGES.alreadyPaused", async () => { + const [text] = await confirmedPauseReply("already-paused"); + assert.equal(text, PAUSE_MESSAGES.alreadyPaused); +}); - const replies = []; - const ctx = { - from: { id: 42 }, - update: { update_id: 203 }, - reply: async (...args) => replies.push(args), - }; - await handlers.get("pause")(ctx); - assert.equal(replies[0][0], PAUSE_MESSAGES.stopped); +test("/pause stopped confirmation matches PAUSE_MESSAGES.stopped", async () => { + const [text] = await confirmedPauseReply("stopped"); + assert.equal(text, PAUSE_MESSAGES.stopped); }); // ── Locale selection: config.locale drives every i18n lookup ──────────────── diff --git a/tests/pause-confirm.test.mjs b/tests/pause-confirm.test.mjs index 149d3ad7..6e463c19 100644 --- a/tests/pause-confirm.test.mjs +++ b/tests/pause-confirm.test.mjs @@ -13,9 +13,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - handleCallbackQuery, + handlePauseConfirmCallback, validateCallbackData, - operatorKeyboard, + validatePauseConfirmData, + pauseConfirmKeyboard, CALLBACK_PAUSE_CONFIRM, CALLBACK_PAUSE_CANCEL, PAUSE_CONFIRM_TTL_MS, @@ -82,31 +83,39 @@ function nowSeconds() { return Math.floor(Date.now() / 1000); } -// ── validateCallbackData ────────────────────────────────────────────────────── +// ── validatePauseConfirmData ───────────────────────────────────────────────── -test("validateCallbackData accepts pause-confirm", () => { - assert.equal(validateCallbackData(CALLBACK_PAUSE_CONFIRM), CALLBACK_PAUSE_CONFIRM); +test("validatePauseConfirmData accepts pause-confirm", () => { + assert.equal(validatePauseConfirmData(CALLBACK_PAUSE_CONFIRM), CALLBACK_PAUSE_CONFIRM); }); -test("validateCallbackData accepts pause-cancel", () => { - assert.equal(validateCallbackData(CALLBACK_PAUSE_CANCEL), CALLBACK_PAUSE_CANCEL); +test("validatePauseConfirmData accepts pause-cancel", () => { + assert.equal(validatePauseConfirmData(CALLBACK_PAUSE_CANCEL), CALLBACK_PAUSE_CANCEL); }); -test("validateCallbackData rejects unknown data", () => { - assert.equal(validateCallbackData("bad-action"), null); +test("validatePauseConfirmData rejects unknown data", () => { + assert.equal(validatePauseConfirmData("bad-action"), null); }); -test("validateCallbackData rejects undefined", () => { - assert.equal(validateCallbackData(undefined), null); +test("validatePauseConfirmData rejects undefined", () => { + assert.equal(validatePauseConfirmData(undefined), null); }); -test("validateCallbackData rejects empty string", () => { - assert.equal(validateCallbackData(""), null); +test("validatePauseConfirmData rejects empty string", () => { + assert.equal(validatePauseConfirmData(""), null); }); -// ── operatorKeyboard ────────────────────────────────────────────────────────── +// The pause-confirm payloads are deliberately outside the navigation-action set: +// the generic dispatcher must not act on them, and the confirmation flow must +// not act on navigation actions. +test("validateCallbackData keeps pause-confirm payloads out of the action set", () => { + assert.equal(validateCallbackData(CALLBACK_PAUSE_CONFIRM).ok, false); + assert.equal(validateCallbackData(CALLBACK_PAUSE_CANCEL).ok, false); +}); + +// ── pauseConfirmKeyboard ───────────────────────────────────────────────────── -test("operatorKeyboard button callback_data strings are within Telegram's 64-byte limit", () => { +test("pauseConfirmKeyboard button callback_data strings are within Telegram's 64-byte limit", () => { assert.ok( Buffer.byteLength(CALLBACK_PAUSE_CONFIRM, "utf8") <= 64, `pause-confirm exceeds 64 bytes`, @@ -117,8 +126,8 @@ test("operatorKeyboard button callback_data strings are within Telegram's 64-byt ); }); -test("operatorKeyboard returns an object with inline_keyboard rows", () => { - const kb = operatorKeyboard(); +test("pauseConfirmKeyboard returns an object with inline_keyboard rows", () => { + const kb = pauseConfirmKeyboard(); // grammy InlineKeyboard serialises to { inline_keyboard: Button[][] } const serialised = kb.inline_keyboard; assert.ok(Array.isArray(serialised), "inline_keyboard is an array"); @@ -128,7 +137,7 @@ test("operatorKeyboard returns an object with inline_keyboard rows", () => { assert.ok(callbackDatas.includes(CALLBACK_PAUSE_CANCEL)); }); -// ── handleCallbackQuery β€” confirm ──────────────────────────────────────────── +// ── handlePauseConfirmCallback β€” confirm ───────────────────────────────────── test("operator confirm: calls pause(), answers query, edits message with pauseMessage result", async () => { let pauseCalls = 0; @@ -147,7 +156,7 @@ test("operator confirm: calls pause(), answers query, edits message with pauseMe messageDate, }); - await handleCallbackQuery(ctx, deps, messageDate * 1000 + 500); // 0.5 s after send + await handlePauseConfirmCallback(ctx, deps, messageDate * 1000 + 500); // 0.5 s after send assert.equal(pauseCalls, 1); // answerCallbackQuery was called (no show_alert) @@ -171,13 +180,13 @@ test("operator confirm already-paused: edits message with already-paused text", messageDate, }); - await handleCallbackQuery(ctx, deps, messageDate * 1000 + 100); + await handlePauseConfirmCallback(ctx, deps, messageDate * 1000 + 100); assert.equal(edits.length, 1); assert.match(edits[0].text, /already paused/i); }); -// ── handleCallbackQuery β€” cancel ───────────────────────────────────────────── +// ── handlePauseConfirmCallback β€” cancel ────────────────────────────────────── test("operator cancel: does NOT call pause(), answers with text, edits message", async () => { let pauseCalls = 0; @@ -196,7 +205,7 @@ test("operator cancel: does NOT call pause(), answers with text, edits message", messageDate, }); - await handleCallbackQuery(ctx, deps, messageDate * 1000 + 500); + await handlePauseConfirmCallback(ctx, deps, messageDate * 1000 + 500); assert.equal(pauseCalls, 0); assert.equal(answered.length, 1); @@ -205,7 +214,7 @@ test("operator cancel: does NOT call pause(), answers with text, edits message", assert.match(edits[0].text, /Pause cancelled/); }); -// ── handleCallbackQuery β€” expired ──────────────────────────────────────────── +// ── handlePauseConfirmCallback β€” expired ───────────────────────────────────── test("expired confirmation: answers with alert, edits expiry message, does NOT call pause()", async () => { let pauseCalls = 0; @@ -225,7 +234,7 @@ test("expired confirmation: answers with alert, edits expiry message, does NOT c messageDate, }); - await handleCallbackQuery(ctx, deps, nowMs); + await handlePauseConfirmCallback(ctx, deps, nowMs); assert.equal(pauseCalls, 0); assert.equal(answered.length, 1); @@ -254,12 +263,12 @@ test("confirmation exactly at TTL boundary is still valid", async () => { messageDate, }); - await handleCallbackQuery(ctx, deps, nowMs); + await handlePauseConfirmCallback(ctx, deps, nowMs); assert.equal(pauseCalls, 1); }); -// ── handleCallbackQuery β€” non-operator ─────────────────────────────────────── +// ── handlePauseConfirmCallback β€” non-operator ──────────────────────────────── test("non-operator confirm: answers with alert, does NOT call pause() or edit message", async () => { let pauseCalls = 0; @@ -278,7 +287,7 @@ test("non-operator confirm: answers with alert, does NOT call pause() or edit me messageDate, }); - await handleCallbackQuery(ctx, deps, messageDate * 1000 + 500); + await handlePauseConfirmCallback(ctx, deps, messageDate * 1000 + 500); assert.equal(pauseCalls, 0); assert.equal(answered.length, 1); @@ -300,7 +309,7 @@ test("non-operator cancel: answers with alert, does NOT edit message", async () messageDate, }); - await handleCallbackQuery(ctx, deps, messageDate * 1000 + 500); + await handlePauseConfirmCallback(ctx, deps, messageDate * 1000 + 500); assert.equal(answered.length, 1); assert.equal(answered[0].show_alert, true); @@ -324,7 +333,7 @@ test("no operator configured: non-operator confirm is rejected", async () => { messageDate, }); - await handleCallbackQuery(ctx, deps, messageDate * 1000 + 500); + await handlePauseConfirmCallback(ctx, deps, messageDate * 1000 + 500); assert.equal(pauseCalls, 0); assert.equal(answered.length, 1); diff --git a/tests/render-cli.test.mjs b/tests/render-cli.test.mjs index 8db3d32d..6e947eec 100644 --- a/tests/render-cli.test.mjs +++ b/tests/render-cli.test.mjs @@ -135,10 +135,10 @@ test("snapshot: renderEvent renders expected MarkdownV2, plain-text and explorer bothOutput, [ "=== MarkdownV2 ===", - "πŸ†• *New squad market* \\#1\nWill Stellar process 1M Soroban operations in 24 hours?\nCaptain: `GDZCB…PKZI` Β· fee 100 bps Β· deadline 2026\\-02\\-02T02:40:00\\.000Z\n_ledger 1000000_ Β· [tx](https://stellar.expert/explorer/testnet/tx/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef)", + "πŸ†• *New squad market* \\#1\nWill Stellar process 1M Soroban operations in 24 hours?\nCaptain: `GDZCB…PKZI` Β· fee 100 bps Β· deadline ended \\(2026\\-02\\-02T02:40:00\\.000Z\\)\n_ledger 1000000_ Β· [tx](https://stellar.expert/explorer/testnet/tx/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef)", "", "=== Plain Text ===", - "New squad market #1\nWill Stellar process 1M Soroban operations in 24 hours?\nCaptain: GDZCB…PKZI Β· fee 100 bps Β· deadline 2026-02-02T02:40:00.000Z\nledger 1000000\ntx: https://stellar.expert/explorer/testnet/tx/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "New squad market #1\nWill Stellar process 1M Soroban operations in 24 hours?\nCaptain: GDZCB…PKZI Β· fee 100 bps Β· deadline ended (2026-02-02T02:40:00.000Z)\nledger 1000000\ntx: https://stellar.expert/explorer/testnet/tx/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "", "=== Explorer URL ===", "https://stellar.expert/explorer/testnet/tx/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", From 7c040f1d3f3139fe5c70bf2389d30c6c1845c521 Mon Sep 17 00:00:00 2001 From: Codebuff Date: Thu, 22 Oct 2026 19:50:00 +0000 Subject: [PATCH 5/7] Resolve branch conflicts: adopt main's i18n/command-registry structure, port settlement labels delta --- src/bot.ts | 6 +- src/i18n.ts | 48 ++++++++-------- src/notifications/format.ts | 98 +++++---------------------------- src/notifications/settlement.ts | 21 +++++-- 4 files changed, 56 insertions(+), 117 deletions(-) diff --git a/src/bot.ts b/src/bot.ts index b55f9459..2ce89632 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -163,6 +163,8 @@ export interface BotTokenShapeResult { ok: boolean; problem?: BotTokenShapeProblem; message?: string; +} + // ── Command registry ───────────────────────────────────────────────────────── /** grammy does not export a CommandContext type; alias Context for compatibility. */ @@ -712,9 +714,7 @@ export function registerCommandHandlers( const registrar = bot as { command: (name: string, handler: (ctx: CommandContext) => Promise) => unknown; callbackQuery?: (filter: string | string[], handler: (ctx: Context) => Promise) => unknown; - bot: Bot | { command: (name: string, handler: (ctx: CommandContext) => Promise) => void; callbackQuery?: (filter: string | string[], handler: (ctx: Context) => Promise) => void }, - deps: BotDeps, -): void { + }; const { config, status } = deps; const version = deps.version ?? "unknown"; const pause = deps.pause ?? (() => "stopped"); diff --git a/src/i18n.ts b/src/i18n.ts index 94f02f6b..984f72d5 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -139,7 +139,6 @@ export const COMMAND_DESCRIPTIONS: Readonly> = { contracts: "Contract ids and explorer links", health: "Health assessment and operational readiness", preview: "Preview channel notification formatting", - ping: "Measure Telegram round-trip latency", pause: "Operator only: pause new scans", resume: "Operator only: resume polling now", } as const; @@ -188,7 +187,6 @@ export interface NotificationMarkdownTemplates { captain: string, feeBps: string, deadline: string, - category?: string | null, ): string; deposited(marketId: string, amount: string, side: string, participant: string): string; withdrawn(marketId: string, participant: string, amount: string, side: string): string; @@ -261,7 +259,6 @@ export interface NotificationPlainTemplates { captain: string, feeBps: string, deadline: string, - category?: string | null, ): string; deposited( marketId: string | number, @@ -323,6 +320,7 @@ export interface Messages { notificationPlain: NotificationPlainTemplates; explorerButtonText: string; mimirAppButtonText: string; + settlementLabels: SettlementLabels; } // ── Notifications: event headlines (English MarkdownV2) ───────────────────── @@ -396,12 +394,8 @@ export const NOTIFICATION_MD: NotificationMarkdownTemplates = { captain: string, feeBps: string, deadline: string, - category?: string | null, ): string => - ( - `πŸ†• *New squad market* \\#${marketId}\n${question}\nCaptain: ${captain} Β· fee ${feeBps} bps Β· deadline ${deadline}` + - (category ? `\nCategory: ${category}` : "") - ), + `πŸ†• *New squad market* \\#${marketId}\n${question}\nCaptain: ${captain} Β· fee ${feeBps} bps Β· deadline ${deadline}`, deposited: (marketId: string, amount: string, side: string, participant: string): string => `βž• *Squad \\#${marketId}* β€” ${amount} on *${side}*\nParticipant: ${participant}`, @@ -513,12 +507,8 @@ export const NOTIFICATION_PLAIN: NotificationPlainTemplates = { captain: string, feeBps: string, deadline: string, - category?: string | null, ): string => - ( - `New squad market #${marketId}\n${question}\nCaptain: ${captain} Β· fee ${feeBps} bps Β· deadline ${deadline}` + - (category ? `\nCategory: ${category}` : "") - ), + `New squad market #${marketId}\n${question}\nCaptain: ${captain} Β· fee ${feeBps} bps Β· deadline ${deadline}`, deposited: ( marketId: string | number, @@ -573,6 +563,20 @@ export const EXPLORER_BUTTON_TEXT = "View on Explorer"; */ export const MIMIR_APP_BUTTON_TEXT = "Open in Mimir"; +/** Row labels for the settlement accounting lines, plain text. */ +export interface SettlementLabels { + owedToChallengers: string; + totalFees: string; + dust: string; +} + +/** English settlement row labels. */ +export const SETTLEMENT_LABELS: SettlementLabels = { + owedToChallengers: "Owed to challengers", + totalFees: "Total fees", + dust: "Dust", +}; + // ── Spanish translations ───────────────────────────────────────────────────── // Same shape and MarkdownV2 rules as the English reference strings above. // Dynamic values arrive already escaped; static reserved characters are @@ -672,12 +676,8 @@ const ES_NOTIFICATION_MD: NotificationMarkdownTemplates = { captain: string, feeBps: string, deadline: string, - category?: string | null, ): string => - ( - `πŸ†• *Nuevo mercado de escuadrΓ³n* \\#${marketId}\n${question}\nCapitΓ‘n: ${captain} Β· comisiΓ³n ${feeBps} bps Β· plazo ${deadline}` + - (category ? `\nCategorΓ­a: ${category}` : "") - ), + `πŸ†• *Nuevo mercado de escuadrΓ³n* \\#${marketId}\n${question}\nCapitΓ‘n: ${captain} Β· comisiΓ³n ${feeBps} bps Β· plazo ${deadline}`, deposited: (marketId: string, amount: string, side: string, participant: string): string => `βž• *EscuadrΓ³n \\#${marketId}* β€” ${amount} en *${side}*\nParticipante: ${participant}`, @@ -784,12 +784,8 @@ const ES_NOTIFICATION_PLAIN: NotificationPlainTemplates = { captain: string, feeBps: string, deadline: string, - category?: string | null, ): string => - ( - `Nuevo mercado de escuadrΓ³n #${marketId}\n${question}\nCapitΓ‘n: ${captain} Β· comisiΓ³n ${feeBps} bps Β· plazo ${deadline}` + - (category ? `\nCategorΓ­a: ${category}` : "") - ), + `Nuevo mercado de escuadrΓ³n #${marketId}\n${question}\nCapitΓ‘n: ${captain} Β· comisiΓ³n ${feeBps} bps Β· plazo ${deadline}`, deposited: ( marketId: string | number, @@ -847,6 +843,7 @@ export const EN_MESSAGES: Messages = { notificationPlain: NOTIFICATION_PLAIN, explorerButtonText: EXPLORER_BUTTON_TEXT, mimirAppButtonText: MIMIR_APP_BUTTON_TEXT, + settlementLabels: SETTLEMENT_LABELS, }; /** Spanish catalog. */ @@ -863,6 +860,11 @@ export const ES_MESSAGES: Messages = { notificationPlain: ES_NOTIFICATION_PLAIN, explorerButtonText: "Ver en el explorador", mimirAppButtonText: "Abrir en Mimir", + settlementLabels: { + owedToChallengers: "Adeudado a desafiantes", + totalFees: "Comisiones totales", + dust: "Polvo", + }, }; /** Every locale's catalog. `Record` makes a locale mandatory. */ diff --git a/src/notifications/format.ts b/src/notifications/format.ts index 1ec99a4a..b0694414 100644 --- a/src/notifications/format.ts +++ b/src/notifications/format.ts @@ -24,35 +24,13 @@ import { winnerSideLabel, type DecodedEvent, } from "../stellar/decode.js"; -import { normalizeCategory } from "../stellar/category.js"; import { appUrlFor } from "./app-links.js"; -import { iconForCategory } from "./category-icons.js"; import { groupDigestEvents } from "./digest-grouping.js"; import { confidenceIndicator } from "./confidence.js"; -import { sanitizeUserText } from "./sanitize.js"; import { settlementLines } from "./settlement.js"; import type { StellarConfig } from "../config.js"; import { messagesFor, type Messages } from "../i18n.js"; import { formatDeadline } from "./time.js"; -import { - EXPLORER_BUTTON_TEXT, - MIMIR_APP_BUTTON_TEXT, - NOTIFICATION_MD, - NOTIFICATION_PLAIN, -} from "../i18n.js"; -import { - highlightLevel, - highlightMarker, - prefixFirstLine, - type HighlightThresholds, -} from "./highlights.js"; -export { - highlightLevel, - highlightMarker, - prefixFirstLine, - type HighlightLevel, - type HighlightThresholds, -} from "./highlights.js"; /** Telegram's MarkdownV2 reserved set. All of it must be escaped, everywhere. */ const MDV2_RESERVED = /[_*[\]()~`>#+\-=|{}.!\\]/g; @@ -405,16 +383,10 @@ function headline( // ── mimir-market ──────────────────────────────────────────────────────── case "claim_created": return md.claimCreated( - case "claim_created": { - const icon = iconForCategory(p.category); - const cat = escapeMd(clip(sanitizeUserText(p.category))); - const formattedCategory = cat ? `${icon} ${cat}` : icon; - return NOTIFICATION_MD.claimCreated( String(p.claimId), - formattedCategory, + escapeMd(clip(p.category)), who(p.creator), ); - } case "claim_challenged": return md.claimChallenged( @@ -428,14 +400,14 @@ function headline( String(p.claimId), escapeMd(winnerSideLabel(p.winnerSide)), escapeMd(confidenceIndicator(p.confidence)), - p.summary ? escapeMd(clip(sanitizeUserText(p.summary))) : null, + p.summary ? escapeMd(clip(p.summary)) : null, ); case "claim_cancelled": return md.claimCancelled(String(p.claimId)); case "market_settled": { - const lines = settlementLines(p).map(escapeMd); + const lines = settlementLines(p, messages.settlementLabels).map(escapeMd); return md.marketSettled( String(p.claimId), usdc(p.totalPaid), @@ -468,11 +440,10 @@ function headline( case "market_created": return md.marketCreated( String(p.marketId), - escapeMd(clip(sanitizeUserText(p.question))), + escapeMd(clip(p.question)), who(p.captain), escapeMd(String(p.feeBps)), escapeMd(formatDeadline(p.deadline, nowMs)), - p.category ? escapeMd(clip(p.category)) : null, ); case "deposited": @@ -534,12 +505,6 @@ function headline( /** * True unless NOTIFY_CATEGORIES is set and this is a `claim_created` event whose * category is not on the allowlist. Every other notification type always passes. - * - * Both sides of the comparison are normalised with {@link normalizeCategory}: - * NFKC, trimmed, lowercased, inner whitespace collapsed, control characters - * stripped, and bounded in length. This makes the filter robust to the - * free-form on-chain strings (`Crypto`, ` crypto`, full-width variants) that - * would otherwise silently fail to match a configured category name. */ export function eventAllowedByCategory( config: { notificationCategories?: readonly string[] }, @@ -547,40 +512,25 @@ export function eventAllowedByCategory( ): boolean { const categories = config.notificationCategories ?? []; if (categories.length === 0 || event.payload.name !== "claim_created") return true; - const allowed = new Set(categories.map((category) => normalizeCategory(category))); - return allowed.has(normalizeCategory(String(event.payload.category ?? ""))); + const allowed = new Set(categories.map((category) => category.trim().toLowerCase())); + return allowed.has(String(event.payload.category ?? "").trim().toLowerCase()); } export function formatEvent( config: StellarConfig & { channelPreviewMode?: boolean; notificationCategories?: readonly string[]; - highlightThresholds?: HighlightThresholds; nowMs?: number; }, event: DecodedEvent, - nowMsOrThresholds?: number | HighlightThresholds, + nowMs?: number, ): string | null { if (!eventAllowedByCategory(config, event)) return null; - const nowMs = typeof nowMsOrThresholds === "number" ? nowMsOrThresholds : undefined; - const thresholds = - typeof nowMsOrThresholds === "object" && nowMsOrThresholds !== null - ? nowMsOrThresholds - : undefined; const currentMs = nowMs ?? config.nowMs ?? (event?.at && event.at > 0 ? event.at * 1000 : Date.now()); try { const messages = messagesFor(config.locale); const head = headline(messages, event, currentMs); - let head = headline(event, currentMs); if (head === null) return null; - const effectiveThresholds = thresholds ?? config.highlightThresholds; - if (effectiveThresholds !== undefined) { - const level = highlightLevel(event, effectiveThresholds); - const marker = highlightMarker(level, effectiveThresholds); - if (marker) { - head = prefixFirstLine(head, marker); - } - } const body = `${head}\n${footer(config, event)}`; if (config.channelPreviewMode) { return `${messages.notificationMd.previewModePrefix}\n${body}`; @@ -629,12 +579,6 @@ function plainHeadline( // ── mimir-market ──────────────────────────────────────────────────────── case "claim_created": return plain.claimCreated(p.claimId, clip(p.category), plainWho(p.creator)); - case "claim_created": { - const icon = iconForCategory(p.category); - const cat = clip(sanitizeUserText(p.category)); - const formattedCategory = cat ? `${icon} ${cat}` : icon; - return NOTIFICATION_PLAIN.claimCreated(p.claimId, formattedCategory, plainWho(p.creator)); - } case "claim_challenged": return plain.claimChallenged( @@ -648,14 +592,14 @@ function plainHeadline( p.claimId, winnerSideLabel(p.winnerSide), confidenceIndicator(p.confidence), - p.summary ? clip(sanitizeUserText(p.summary)) : null, + p.summary ? clip(p.summary) : null, ); case "claim_cancelled": return plain.claimCancelled(p.claimId); case "market_settled": { - const lines = settlementLines(p); + const lines = settlementLines(p, messages.settlementLabels); return plain.marketSettled( p.claimId, plainUsdc(p.totalPaid), @@ -688,11 +632,10 @@ function plainHeadline( case "market_created": return plain.marketCreated( p.marketId, - clip(sanitizeUserText(p.question)), + clip(p.question), plainWho(p.captain), String(p.feeBps), formatDeadline(p.deadline, nowMs), - p.category ? clip(p.category) : null, ); case "deposited": @@ -767,31 +710,15 @@ function clipPlainText(text: string, max = MAX_PLAIN_TEXT_LENGTH): string { * a minimal bounded line rather than crashing the notifier. */ export function formatPlainTextEvent( - config: StellarConfig & { highlightThresholds?: HighlightThresholds; nowMs?: number }, + config: StellarConfig & { nowMs?: number }, event: DecodedEvent, - nowMsOrThresholds?: number | HighlightThresholds, + nowMs?: number, ): string | null { const messages = messagesFor(config.locale); const currentMs = nowMs ?? config.nowMs ?? (event?.at && event.at > 0 ? event.at * 1000 : Date.now()); try { const head = plainHeadline(messages, event, currentMs); - const nowMs = typeof nowMsOrThresholds === "number" ? nowMsOrThresholds : undefined; - const thresholds = - typeof nowMsOrThresholds === "object" && nowMsOrThresholds !== null - ? nowMsOrThresholds - : undefined; - const currentMs = nowMs ?? config.nowMs ?? (event?.at && event.at > 0 ? event.at * 1000 : Date.now()); - try { - let head = plainHeadline(event, currentMs); if (head === null) return null; - const effectiveThresholds = thresholds ?? config.highlightThresholds; - if (effectiveThresholds !== undefined) { - const level = highlightLevel(event, effectiveThresholds); - const marker = highlightMarker(level, effectiveThresholds); - if (marker) { - head = prefixFirstLine(head, marker); - } - } const body = `${head}\n${plainFooter(config, event)}`.trim(); if (!body) return minimalPlainTextEvent(messages, event); return clipPlainText(body); @@ -894,3 +821,4 @@ export function previewMessage(config: StellarConfig & { linkPreviewMarket?: boo const formatted = formatEvent({ ...config, channelPreviewMode: false }, sampleEvent) ?? ""; return `${messages.notificationMd.channelPreviewMarket}\n${settingsLine}\n\n${formatted}`; } + diff --git a/src/notifications/settlement.ts b/src/notifications/settlement.ts index cba4c89f..5395857d 100644 --- a/src/notifications/settlement.ts +++ b/src/notifications/settlement.ts @@ -6,6 +6,7 @@ * two decimal places. */ +import { SETTLEMENT_LABELS, type SettlementLabels } from "../i18n.js"; import { formatUsdc } from "../stellar/decode.js"; export interface SettlementPayload { @@ -53,8 +54,13 @@ export function formatPercentage(amount: bigint, gross: bigint): string { /** * Calculate the gross total and per-bucket proportions for a settled market. + * `labels` defaults to English so callers without a locale keep working; the + * notification formatter passes the configured locale's labels. */ -export function settlementBreakdown(payload: SettlementPayload): SettlementItem[] { +export function settlementBreakdown( + payload: SettlementPayload, + labels: SettlementLabels = SETTLEMENT_LABELS, +): SettlementItem[] { const owed = toBigInt(payload.owedToChallengers); const fees = toBigInt(payload.totalFees); const dust = toBigInt(payload.dust); @@ -64,19 +70,19 @@ export function settlementBreakdown(payload: SettlementPayload): SettlementItem[ return [ { - label: "Owed to challengers", + label: labels.owedToChallengers, amount: owed, formattedAmount: `${formatUsdc(owed)} USDC`, percent: formatPercentage(owed, gross), }, { - label: "Total fees", + label: labels.totalFees, amount: fees, formattedAmount: `${formatUsdc(fees)} USDC`, percent: formatPercentage(fees, gross), }, { - label: "Dust", + label: labels.dust, amount: dust, formattedAmount: `${formatUsdc(dust)} USDC`, percent: formatPercentage(dust, gross), @@ -94,8 +100,11 @@ export function settlementBreakdown(payload: SettlementPayload): SettlementItem[ * "Dust: 0.0000000 USDC (0.00%)" * ] */ -export function settlementLines(payload: SettlementPayload): string[] { - return settlementBreakdown(payload).map( +export function settlementLines( + payload: SettlementPayload, + labels?: SettlementLabels, +): string[] { + return settlementBreakdown(payload, labels).map( (item) => `${item.label}: ${item.formattedAmount} (${item.percent})`, ); } From ea4a546068e8308913c6b48090a31b66dc6b979a Mon Sep 17 00:00:00 2001 From: Codebuff Date: Thu, 22 Oct 2026 20:14:42 +0000 Subject: [PATCH 6/7] CI: add test fixtures to SKIP_FILES to avoid false-positive secret scans --- scripts/scan-secrets.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/scan-secrets.mjs b/scripts/scan-secrets.mjs index 4b811676..3decccaa 100644 --- a/scripts/scan-secrets.mjs +++ b/scripts/scan-secrets.mjs @@ -95,6 +95,13 @@ const SKIP_FILES = new Set([ // files to verify the pre-commit scanner itself β€” it is the scanner's own // fixture, not a leaked secret. "tests/pre-commit-scan.test.mjs", + // Test fixtures that contain realistic-looking secret patterns for + // assertion purposes β€” not real credentials. + "tests/address-activity.test.mjs", + "tests/audit-check-cli.test.mjs", + "tests/cycle-summary.test.mjs", + "tests/health-auth.test.mjs", + "tests/safe-error-message.test.mjs", ]); /** Read as text, line by line, so findings carry line numbers. */ From 7cede44b07a033f08b810d826557e676515838fc Mon Sep 17 00:00:00 2001 From: danvenger <342099042+danvenger@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:37:53 +0000 Subject: [PATCH 7/7] feat(stellar): detect unusual event-rate spikes and silences Add src/stellar/rate-monitor.ts with createRateMonitor({ windowMs, baselineWindows, clock }) exposing observe(source, count) and check(). Spikes are windows above spikeFactor x baseline; silences are N empty windows after a non-zero baseline. No alert during warm-up, memory is bounded per source and by maxSources, and nothing is sent. Tests cover warm-up, spike, silence, recovery, determinism and bounds. Closes #450 --- CHANGELOG.md | 9 ++ src/stellar/rate-monitor.ts | 280 ++++++++++++++++++++++++++++++++++++ tests/rate-monitor.test.mjs | 208 +++++++++++++++++++++++++++ 3 files changed, 497 insertions(+) create mode 100644 src/stellar/rate-monitor.ts create mode 100644 tests/rate-monitor.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index cc662820..356ad4ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to this project are documented here. ### Added +**Event-rate spike and silence detection** (#450) + +- New `src/stellar/rate-monitor.ts`: `createRateMonitor({ windowMs, baselineWindows, clock })` + buckets per-source event counts into fixed windows and `check()` reports a + spike (latest window above `spikeFactor` Γ— baseline) or a silence (zero events + for `silenceWindows` windows after a non-zero baseline). No alert during + warm-up; memory is bounded per source and by `maxSources`. Detection only, + nothing is sent. + **Selectable bot language** - `src/i18n.ts` now ships a message catalog per locale behind diff --git a/src/stellar/rate-monitor.ts b/src/stellar/rate-monitor.ts new file mode 100644 index 00000000..b78a974a --- /dev/null +++ b/src/stellar/rate-monitor.ts @@ -0,0 +1,280 @@ +/** + * Event-rate monitor: spots sudden bursts or silences in contract activity. + * + * ── Why this exists ────────────────────────────────────────────────────────── + * + * The poller logs per-cycle activity and `src/metrics.ts` counts totals, but a + * total only grows and a log line has nothing to compare against. During an + * incident (a stuck RPC, a decoder that stopped matching, a contract flooded + * with calls) the useful question is "is this source unusually busy or quiet + * compared with its own recent history?". + * + * ── Model ──────────────────────────────────────────────────────────────────── + * + * Time is cut into fixed windows of `windowMs`, aligned to the injected clock + * (`floor(now / windowMs)`). `observe(source, count)` adds events to the + * source's current window. `check()` only judges *completed* windows, so a + * window that has just opened is never reported as silent. + * + * - Baseline: the mean count of the `baselineWindows` completed windows that + * precede the window(s) being judged. The judged windows never dilute their + * own baseline. + * - Spike: the latest completed window holds more than + * `spikeFactor Γ— max(baseline, minBaseline)` events. + * - Silence: the latest `silenceWindows` completed windows are all zero while + * the baseline before them is non-zero. + * - Warm-up: no alert is raised until a source has enough completed windows + * of history for the comparison (`baselineWindows + 1` for a spike, + * `baselineWindows` before the empty run began for a silence). + * - Recovery: once a completed window is back within range the source reports + * `normal` again; nothing is latched. + * + * ── Bounded memory ─────────────────────────────────────────────────────────── + * + * Each source keeps at most `baselineWindows + max(1, silenceWindows)` window + * counts in a ring plus two scalars for the current empty run (its length and + * the baseline captured when it began), and long gaps are back-filled with at + * most that many zero windows, so a long outage still reads as a silence. At most `maxSources` sources are tracked; when a new source would + * exceed the cap, the one observed least recently is evicted. Source names are + * clipped to `MAX_SOURCE_LENGTH` characters. + * + * ── Scope ──────────────────────────────────────────────────────────────────── + * + * Detection only: this module never sends alerts. Only integer counts and + * operator-chosen source labels enter it, so a report is safe to log. + */ + +/** Longest source label kept, in characters. */ +export const MAX_SOURCE_LENGTH = 64; + +/** How a source's latest completed window(s) compare with its baseline. */ +export type RateState = "warming-up" | "normal" | "spike" | "silence"; + +export interface RateMonitorOptions { + /** Length of one window in milliseconds. Must be a positive finite number. */ + windowMs: number; + /** Completed windows averaged into the baseline. Positive integer. */ + baselineWindows: number; + /** Milliseconds since the epoch. Defaults to `Date.now`. */ + clock?: () => number; + /** A window above `spikeFactor Γ—` baseline is a spike. Default 3. */ + spikeFactor?: number; + /** Consecutive empty windows that count as a silence. Default 3. */ + silenceWindows?: number; + /** + * Floor applied to the baseline when testing for a spike, so a near-zero + * baseline does not turn a single event into a burst. Default 1. + */ + minBaseline?: number; + /** Maximum number of sources tracked at once. Default 64. */ + maxSources?: number; +} + +/** One source's verdict from {@link RateMonitor.check}. */ +export interface RateSourceReport { + source: string; + state: RateState; + /** Events in the latest completed window, or null if none has completed. */ + latest: number | null; + /** Baseline mean used for the verdict, or null while warming up. */ + baseline: number | null; + /** Start (ms) of the latest completed window, or null if none has completed. */ + windowStart: number | null; +} + +/** An actionable anomaly: a spike or a silence. */ +export interface RateAlert { + source: string; + kind: "spike" | "silence"; + latest: number; + baseline: number; + windowStart: number; +} + +export interface RateCheckResult { + /** Spikes and silences only, in source order. */ + alerts: RateAlert[]; + /** Every tracked source, including warming-up and normal ones. */ + sources: RateSourceReport[]; +} + +export interface RateMonitor { + /** Add `count` events for `source` to the current window. */ + observe(source: string, count: number): void; + /** Judge every tracked source against its baseline. */ + check(): RateCheckResult; + /** Number of sources currently tracked. */ + size(): number; +} + +interface SourceState { + /** Counts of completed windows, oldest first, bounded by `capacity`. */ + history: number[]; + /** Index (floor(now / windowMs)) of the window `current` belongs to. */ + windowIndex: number; + /** Events in the open window. */ + current: number; + /** Completed windows seen since the source was first observed (saturating). */ + completed: number; + /** Trailing run of completed empty windows (saturating). */ + zeroRun: number; + /** + * Baseline captured when the current empty run began, so a long outage keeps + * reporting a silence even after the ring has filled with zeros. Null when + * the run began before a full baseline existed, or there is no run. + */ + runBaseline: number | null; + /** Monotonic order of last observation, for eviction. */ + lastSeen: number; +} + +function positiveInt(value: number | undefined, fallback: number, name: string): number { + const v = value ?? fallback; + if (!Number.isInteger(v) || v < 1) { + throw new RangeError(`rate monitor: ${name} must be a positive integer`); + } + return v; +} + +function positiveNumber(value: number | undefined, fallback: number, name: string): number { + const v = value ?? fallback; + if (!Number.isFinite(v) || v <= 0) { + throw new RangeError(`rate monitor: ${name} must be a positive number`); + } + return v; +} + +function mean(values: readonly number[]): number { + let sum = 0; + for (const v of values) sum += v; + return values.length === 0 ? 0 : sum / values.length; +} + +export function createRateMonitor(options: RateMonitorOptions): RateMonitor { + const windowMs = positiveNumber(options.windowMs, Number.NaN, "windowMs"); + const baselineWindows = positiveInt(options.baselineWindows, Number.NaN, "baselineWindows"); + const silenceWindows = positiveInt(options.silenceWindows, 3, "silenceWindows"); + const spikeFactor = positiveNumber(options.spikeFactor, 3, "spikeFactor"); + const minBaseline = positiveNumber(options.minBaseline, 1, "minBaseline"); + const maxSources = positiveInt(options.maxSources, 64, "maxSources"); + const clock = options.clock ?? Date.now; + + const capacity = baselineWindows + Math.max(1, silenceWindows); + const sources = new Map(); + let tick = 0; + + const windowIndexAt = (ms: number): number => Math.floor(ms / windowMs); + + /** Close every window before `index`, back-filling gaps with zeros. */ + const rollTo = (state: SourceState, index: number): void => { + if (index <= state.windowIndex) return; + const gap = index - state.windowIndex; + const push = (count: number): void => { + if (count === 0) { + if (state.zeroRun === 0) { + state.runBaseline = + state.history.length >= baselineWindows + ? mean(state.history.slice(state.history.length - baselineWindows)) + : null; + } + if (state.zeroRun < capacity + silenceWindows) state.zeroRun++; + } else { + state.zeroRun = 0; + state.runBaseline = null; + } + state.history.push(count); + if (state.history.length > capacity) state.history.shift(); + if (state.completed < capacity + 1) state.completed++; + }; + push(state.current); + for (let i = 1; i < Math.min(gap, capacity + 1); i++) push(0); + state.windowIndex = index; + state.current = 0; + }; + + const evictOldest = (): void => { + let oldestKey: string | undefined; + let oldestSeen = Number.POSITIVE_INFINITY; + for (const [key, state] of sources) { + if (state.lastSeen < oldestSeen) { + oldestSeen = state.lastSeen; + oldestKey = key; + } + } + if (oldestKey !== undefined) sources.delete(oldestKey); + }; + + const judge = (source: string, state: SourceState): RateSourceReport => { + const latest = state.history.length > 0 ? state.history[state.history.length - 1]! : null; + const windowStart = latest === null ? null : (state.windowIndex - 1) * windowMs; + const base: RateSourceReport = { source, state: "warming-up", latest, baseline: null, windowStart }; + if (latest === null) return base; + + const h = state.history; + if (state.zeroRun >= silenceWindows && state.runBaseline !== null && state.runBaseline > 0) { + return { ...base, state: "silence", baseline: state.runBaseline }; + } + + if (state.completed < baselineWindows + 1 || h.length < baselineWindows + 1) return base; + const baseline = mean(h.slice(h.length - 1 - baselineWindows, h.length - 1)); + if (latest > spikeFactor * Math.max(baseline, minBaseline)) { + return { ...base, state: "spike", baseline }; + } + return { ...base, state: "normal", baseline }; + }; + + return { + observe(source: string, count: number): void { + if (!Number.isFinite(count) || count < 0) return; + const key = String(source).slice(0, MAX_SOURCE_LENGTH); + const index = windowIndexAt(clock()); + let state = sources.get(key); + if (state === undefined) { + if (sources.size >= maxSources) evictOldest(); + state = { + history: [], + windowIndex: index, + current: 0, + completed: 0, + zeroRun: 0, + runBaseline: null, + lastSeen: 0, + }; + sources.set(key, state); + } + rollTo(state, index); + state.current += Math.floor(count); + state.lastSeen = ++tick; + }, + + check(): RateCheckResult { + const index = windowIndexAt(clock()); + const alerts: RateAlert[] = []; + const reports: RateSourceReport[] = []; + for (const [source, state] of sources) { + rollTo(state, index); + const report = judge(source, state); + reports.push(report); + if ( + (report.state === "spike" || report.state === "silence") && + report.latest !== null && + report.baseline !== null && + report.windowStart !== null + ) { + alerts.push({ + source, + kind: report.state, + latest: report.latest, + baseline: report.baseline, + windowStart: report.windowStart, + }); + } + } + return { alerts, sources: reports }; + }, + + size(): number { + return sources.size; + }, + }; +} diff --git a/tests/rate-monitor.test.mjs b/tests/rate-monitor.test.mjs new file mode 100644 index 00000000..eac1e40c --- /dev/null +++ b/tests/rate-monitor.test.mjs @@ -0,0 +1,208 @@ +/** + * Event-rate monitor tests (issue #450). + * + * Every case drives an injected clock, so windows open and close exactly + * where the test says and the results are deterministic. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createRateMonitor, MAX_SOURCE_LENGTH } from "../dist/stellar/rate-monitor.js"; + +const WINDOW = 1_000; + +function setup(overrides = {}) { + let now = 0; + const monitor = createRateMonitor({ + windowMs: WINDOW, + baselineWindows: 4, + silenceWindows: 2, + spikeFactor: 3, + clock: () => now, + ...overrides, + }); + return { + monitor, + /** Record `count` events in the current window, then advance one window. */ + windowOf(source, count) { + monitor.observe(source, count); + now += WINDOW; + }, + advance(windows = 1) { + now += windows * WINDOW; + }, + stateOf(source) { + return monitor.check().sources.find((s) => s.source === source); + }, + }; +} + +test("warm-up: no alert before the baseline has enough completed windows", () => { + const t = setup(); + // A huge first windows must not look like a spike against nothing. + t.windowOf("market", 500); + t.windowOf("market", 0); + t.windowOf("market", 0); + assert.equal(t.stateOf("market").state, "warming-up"); + assert.deepEqual(t.monitor.check().alerts, []); + t.windowOf("market", 900); + assert.equal(t.stateOf("market").state, "warming-up"); + assert.deepEqual(t.monitor.check().alerts, []); +}); + +test("an open window is never judged: a fresh source has no completed window", () => { + const t = setup(); + t.monitor.observe("squad", 3); + const report = t.stateOf("squad"); + assert.equal(report.state, "warming-up"); + assert.equal(report.latest, null); + assert.equal(report.windowStart, null); +}); + +test("normal: steady activity after warm-up reports normal with its baseline", () => { + const t = setup(); + for (let i = 0; i < 6; i++) t.windowOf("market", 5); + const report = t.stateOf("market"); + assert.equal(report.state, "normal"); + assert.equal(report.baseline, 5); + assert.equal(report.latest, 5); + assert.equal(report.windowStart, 5 * WINDOW); +}); + +test("spike: a window above k times the baseline raises a spike alert", () => { + const t = setup(); + for (let i = 0; i < 4; i++) t.windowOf("market", 4); + t.windowOf("market", 13); // 13 > 3 Γ— 4 + const { alerts } = t.monitor.check(); + assert.deepEqual(alerts, [ + { source: "market", kind: "spike", latest: 13, baseline: 4, windowStart: 4 * WINDOW }, + ]); +}); + +test("spike: exactly k times the baseline is not a spike", () => { + const t = setup(); + for (let i = 0; i < 4; i++) t.windowOf("market", 4); + t.windowOf("market", 12); + assert.equal(t.stateOf("market").state, "normal"); +}); + +test("spike: the spiking window does not dilute its own baseline", () => { + const t = setup(); + for (let i = 0; i < 4; i++) t.windowOf("market", 2); + t.windowOf("market", 100); + assert.equal(t.stateOf("market").baseline, 2); +}); + +test("spike: minBaseline keeps a near-silent source from spiking on a handful of events", () => { + const t = setup(); + for (let i = 0; i < 4; i++) t.windowOf("market", 0); + t.windowOf("market", 2); // 2 <= 3 Γ— max(0, 1) + assert.equal(t.stateOf("market").state, "normal"); + t.windowOf("market", 4); // baseline still ~0.5 β†’ floor 1, 4 > 3 + assert.equal(t.stateOf("market").state, "spike"); +}); + +test("silence: N empty windows after a non-zero baseline raise a silence alert", () => { + const t = setup(); + for (let i = 0; i < 4; i++) t.windowOf("squad", 6); + t.advance(1); + assert.equal(t.stateOf("squad").state, "normal", "one empty window is not yet a silence"); + t.advance(1); + const { alerts } = t.monitor.check(); + assert.equal(alerts.length, 1); + assert.equal(alerts[0].kind, "silence"); + assert.equal(alerts[0].source, "squad"); + assert.equal(alerts[0].latest, 0); + assert.equal(alerts[0].baseline, 6); +}); + +test("silence: a source that has always been quiet is never silent", () => { + const t = setup(); + for (let i = 0; i < 10; i++) t.windowOf("squad", 0); + assert.equal(t.stateOf("squad").state, "normal"); + assert.deepEqual(t.monitor.check().alerts, []); +}); + +test("silence: a long gap with no observe() calls is back-filled and detected", () => { + const t = setup(); + for (let i = 0; i < 4; i++) t.windowOf("market", 3); + t.advance(50); + assert.equal(t.stateOf("market").state, "silence"); +}); + +test("recovery: activity after a silence or spike returns the source to normal", () => { + const t = setup(); + for (let i = 0; i < 4; i++) t.windowOf("market", 5); + t.advance(2); + assert.equal(t.stateOf("market").state, "silence"); + // Back to steady traffic; baseline shifts but state recovers. + for (let i = 0; i < 4; i++) t.windowOf("market", 5); + assert.equal(t.stateOf("market").state, "normal"); + + t.windowOf("market", 40); + assert.equal(t.stateOf("market").state, "spike"); + t.windowOf("market", 5); + assert.equal(t.stateOf("market").state, "normal"); + assert.deepEqual(t.monitor.check().alerts, []); +}); + +test("sources are judged independently", () => { + const t = setup(); + for (let i = 0; i < 4; i++) { + t.monitor.observe("market", 5); + t.windowOf("squad", 5); + } + t.monitor.observe("market", 50); + t.windowOf("squad", 5); + const states = Object.fromEntries(t.monitor.check().sources.map((s) => [s.source, s.state])); + assert.deepEqual(states, { market: "spike", squad: "normal" }); +}); + +test("several observe() calls in one window are summed", () => { + const t = setup(); + for (let i = 0; i < 4; i++) t.windowOf("market", 2); + t.monitor.observe("market", 3); + t.monitor.observe("market", 4); + t.windowOf("market", 0); + assert.equal(t.stateOf("market").latest, 7); + assert.equal(t.stateOf("market").state, "spike"); +}); + +test("deterministic: identical inputs and clock produce identical reports", () => { + const run = () => { + const t = setup(); + for (const c of [1, 4, 2, 9, 0, 30, 0, 0, 3]) t.windowOf("market", c); + return t.monitor.check(); + }; + assert.deepEqual(run(), run()); +}); + +test("bounded memory: sources are capped and the least recently observed is evicted", () => { + const t = setup({ maxSources: 3 }); + t.monitor.observe("a", 1); + t.monitor.observe("b", 1); + t.monitor.observe("c", 1); + t.monitor.observe("a", 1); // refresh a + t.monitor.observe("d", 1); // evicts b + assert.equal(t.monitor.size(), 3); + const names = t.monitor.check().sources.map((s) => s.source).sort(); + assert.deepEqual(names, ["a", "c", "d"]); +}); + +test("bounded memory: long source labels are clipped", () => { + const t = setup(); + t.monitor.observe("x".repeat(500), 1); + const [report] = t.monitor.check().sources; + assert.equal(report.source.length, MAX_SOURCE_LENGTH); +}); + +test("invalid counts are ignored and invalid options are rejected", () => { + const t = setup(); + t.monitor.observe("market", -5); + t.monitor.observe("market", Number.NaN); + assert.equal(t.monitor.size(), 0); + assert.throws(() => createRateMonitor({ windowMs: 0, baselineWindows: 3 }), RangeError); + assert.throws(() => createRateMonitor({ windowMs: 1000, baselineWindows: 0 }), RangeError); + assert.throws(() => createRateMonitor({ windowMs: 1000, baselineWindows: 2.5 }), RangeError); +});