Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
42c8d2b
feat(bot): add a command module registry so new commands live in thei…
danvenger Oct 23, 2026
583c743
test: build token-shaped fixtures from parts so the secret scan passes
danvenger Oct 23, 2026
e97bedf
fix(config): restore BotConfig fields dropped by the #546 merge
danvenger Oct 23, 2026
6c35974
fix(notifier): restore silent-notification policy lost when the notif…
danvenger Oct 23, 2026
312f7ef
fix: clear remaining strict-mode type errors on main
danvenger Oct 23, 2026
65e57f3
fix(stellar): read ScVal arms with the stellar-sdk 17 accessors in ge…
danvenger Oct 23, 2026
c0e2856
fix(config): let the mock profile's placeholder bot token through sha…
danvenger Oct 23, 2026
c108e29
test: use well-formed fake bot tokens now that config validates their…
danvenger Oct 23, 2026
a95bd32
test: import compiled modules from dist/ like the rest of the suite
danvenger Oct 23, 2026
f67564a
fix(config): list HEALTH_AUTH_TOKEN in the config key catalogue
danvenger Oct 23, 2026
75579de
docs: regenerate commands.md without /ping
danvenger Oct 23, 2026
cfa0cfc
fix(stellar): keep failing closed on non-ScVal event values under ste…
danvenger Oct 23, 2026
d2b0f23
fix(devx): validate the fixture catalog shape the suite actually uses
danvenger Oct 23, 2026
3309234
test: regenerate the golden corpus for category icons
danvenger Oct 23, 2026
0645452
test: align settlement and RPC-shape expectations with #639 and #609
danvenger Oct 23, 2026
fff16f6
test: match eventHistogram's decoded-event input and count-ordered keys
danvenger Oct 23, 2026
192e13e
test: skip tests whose source PRs (#667, #670, #672, #674) were never…
danvenger Oct 23, 2026
0a8ae5e
fix(notifications): escape '-' for MarkdownV2 and reject empty USDC a…
danvenger Oct 23, 2026
69674d7
test: correct the unit arithmetic in amount-display expectations
danvenger Oct 23, 2026
3bad75b
test: pin chainClockLabel's real wording in chain-clock-label
danvenger Oct 23, 2026
b46413b
test: rewrite explorer-keyboard-unusable against the real Stellar API
danvenger Oct 23, 2026
e11d7be
test: match aboutMessage's MarkdownV2 escaping and real network labels
danvenger Oct 23, 2026
d1f2edd
fix(stats): drop the stray '$' from multi-day uptime
danvenger Oct 23, 2026
145b24d
test: assert statsMessage's grouped layout instead of per-counter lines
danvenger Oct 23, 2026
b8106bb
test: use assert.match in activity-summary (assert.matches does not e…
danvenger Oct 23, 2026
fb85252
test: test RpcTiming's real aggregate API in rpc-timing-stats
danvenger Oct 23, 2026
cc90765
fix(schedule): do not fire early on a first start with no recorded run
danvenger Oct 23, 2026
90c618d
test: let the summary-schedule fake clock wait for file I/O; use tmpdir
danvenger Oct 23, 2026
f543d2a
feat(logger): export LOG_LEVELS and LOG_FORMATS
danvenger Oct 23, 2026
38659fe
test: expect lowercase log levels/formats in log-parse
danvenger Oct 23, 2026
7935949
fix(subscriptions): honour maxFollowsPerChat and reject ':' inside a …
danvenger Oct 23, 2026
e578e12
test: assert the follow store's synchronous throws with assert.throws
danvenger Oct 23, 2026
ec43536
test: port pagination tests from Jest to node:test
danvenger Oct 23, 2026
24afd4e
test: rebuild pause-confirm as the union of the #694 and #709 suites
danvenger Oct 23, 2026
b6af692
test: seed fs-fault-injection files before making the directory read-…
danvenger Oct 23, 2026
a17204b
test: stop pointing placeholder cursorFile configs at the repo's data/
danvenger Oct 23, 2026
b1904d6
fix(e2e-smoke): keep the send journal, audit, dead-letter and CSV fil…
danvenger Oct 23, 2026
cc23106
test: align page-dedup's id-less and non-object cases with #609
danvenger Oct 23, 2026
5d6dba0
test: keep every rollback poller state file in its temp dir
danvenger Oct 23, 2026
06c9b00
fix(stellar): treat the all-zero transaction hash as unusable
danvenger Oct 23, 2026
ac7dec1
fix(health): treat an absent healthAuthToken like null (no auth requi…
danvenger Oct 23, 2026
3af0832
test: run the compiled render CLI directly, not 'node run <cli>'
danvenger Oct 23, 2026
55387d8
fix(redact): restore URL and bot-token redaction rules damaged in #654
danvenger Oct 23, 2026
14f1a47
fix(status): bound a missing message to '' instead of throwing
danvenger Oct 23, 2026
11aa97d
test: pin redactChatId's partial mask instead of full masking
danvenger Oct 23, 2026
f2b97c2
test(smoke-setup): keep counters, send journal and CSV out of the rep…
danvenger Oct 23, 2026
4615925
Merge remote-tracking branch 'upstream/main' into feat/command-regist…
danvenger Oct 25, 2026
09a2d44
test(rollback): retry temp-dir cleanup so a late cursor write cannot …
danvenger Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to this project are documented here.

## [Unreleased]

### Changed

**Command module registry**

- Slash commands are `CommandModule`s (`src/commands/types.ts`: `command`,
`description`, `operatorOnly`, `allowlisted`, `hidden`, `handle(ctx, deps)`)
listed in menu order by `COMMAND_MODULES` in `src/commands/index.ts`.
`registerCommandHandlers` and `registerCommands` iterate that registry, so
`/help` and the Telegram command menu follow registry order, and the
operator-only and `ALLOWED_CHAT_IDS` gates are applied centrally from each
module's flags. `/contracts` and `/preview` are written in the full module
shape as examples. Replies and message text are unchanged (#327).
- `CONTRIBUTING.md` documents adding a command as a module;
`tests/command-registry.test.mjs` covers order and authorization.

### Added

**Selectable bot language**
Expand Down
40 changes: 27 additions & 13 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,33 +158,47 @@ The REPL context is preloaded with:

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):
rarely conflicts with another in-flight branch). The module shape is
`CommandModule` in `src/commands/types.ts`:

1. Create `src/commands/<name>.ts` and export a module from `defineCommand({ … })`:
| Field | Meaning |
| -------------- | ----------------------------------------------------------------------- |
| `command` | Telegram command name, without the slash |
| `description` | English menu line; defaults to its `COMMAND_DESCRIPTIONS` entry |
| `operatorOnly` | Only the configured operator; others are silently ignored |
| `allowlisted` | Only chats in `ALLOWED_CHAT_IDS`; others are silently ignored |
| `hidden` | Registered and usable, but not in the menu or `/help` (e.g. `/export`) |
| `handle` | `async (ctx, deps) => …`, called only after the gates above pass |

1. Create `src/commands/<name>.ts` and export a module from `createCommandModule({ … })`
(`src/commands/contracts.ts` and `src/commands/preview.ts` are small examples):

```ts
export const widgetCommand = defineCommand({
export const widgetCommand = createCommandModule({
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) => {
description: COMMAND_DESCRIPTIONS.widget, // optional: this is the default
operatorOnly: false, // optional, default false
allowlisted: true, // optional, default false
hidden: false, // optional, default false
handle: 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.
(config, poller status, pause/resume, audit, logs). Authorization is applied
centrally by the registrar from `operatorOnly`/`allowlisted`, with the usual
silent ignore and log line — do not repeat it in the handler.
2. Add the module to `COMMAND_MODULES` in `src/commands/index.ts`, in menu order.
That array's order is the order of the Telegram menu (`setMyCommands`) and `/help`.
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).
4. Cover the handler in `tests/` like any other command. `tests/command-registry.test.mjs`
pins catalogue order and the central authorization gates, and
`tests/command-modules.test.mjs` checks the registry invariants (unique names, menu parity).

## Where things live

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,11 @@ looks healthy but notifies nobody.
command metadata used to register handlers. Without `OPERATOR_TELEGRAM_USER_ID`,
the help and menu omit `/audit`, `/pause`, and `/resume`; configured operator
commands are labelled "Operator only". Help uses static metadata, so it remains
available when the poller or RPC is unhealthy. Adding a command requires its
metadata and handler in `src/bot.ts`; TypeScript checks that every registry entry
has a handler. This changes no cursor files or deployment settings.
available when the poller or RPC is unhealthy. Each command is a self-contained
module under `src/commands/`, listed in menu order by `COMMAND_MODULES` in
`src/commands/index.ts`; operator-only and `ALLOWED_CHAT_IDS` checks come from the
module's flags (see "Adding a slash command" in `CONTRIBUTING.md`). This changes no
cursor files or deployment settings.

Commands from a user other than `OPERATOR_TELEGRAM_USER_ID` receive no control
response and cannot mutate poller state — this includes `/audit`, whose report
Expand Down
6 changes: 3 additions & 3 deletions src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,13 +273,13 @@ function dispatch(
ctx: CommandContext<Context>,
deps: BotDeps,
): Promise<void> {
if (module.operatorOnly === true && !operatorAllowed(ctx, deps.config, module.command)) {
if (module.operatorOnly && !operatorAllowed(ctx, deps.config, module.command)) {
return Promise.resolve();
}
if (module.chatAllowlist === true && !chatAllowed(ctx, deps.config, module.command)) {
if (module.allowlisted && !chatAllowed(ctx, deps.config, module.command)) {
return Promise.resolve();
}
return module.handler(ctx, deps);
return module.handle(ctx, deps);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/commands/about.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { escapeMd } from "../notifications/format.js";
import { networkLabel, type BotConfig } from "../config.js";
import { defineCommand } from "./registry.js";
import { createCommandModule } from "./registry.js";
import { replyMarkdown } from "./runtime.js";

const REPOSITORY_URL = "https://github.com/example/stellar-bot";
Expand All @@ -21,9 +21,9 @@ export function aboutMessage(config: BotConfig, version: string): string {
].join("\n");
}

export const aboutCommand = defineCommand({
export const aboutCommand = createCommandModule({
command: "about",
handler: async (ctx, deps) => {
handle: async (ctx, deps) => {
await replyMarkdown(ctx, aboutMessage(deps.config, deps.version ?? "unknown"));
},
});
6 changes: 3 additions & 3 deletions src/commands/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "../audit.js";
import { messagesFor, type Locale } from "../i18n.js";
import { safeErrorMessage } from "../notifications/format.js";
import { defineCommand } from "./registry.js";
import { createCommandModule } from "./registry.js";

/** How many recent audit lines `/audit` renders. A chat message is not a file. */
const AUDIT_TAIL = 10;
Expand All @@ -33,10 +33,10 @@ export function renderAuditForTelegram(
return `${header}\n\n${report}\n\n${messagesFor(locale).auditCliHint}`;
}

export const auditCommand = defineCommand({
export const auditCommand = createCommandModule({
command: "audit",
operatorOnly: true,
handler: async (ctx, deps) => {
handle: async (ctx, deps) => {
const { config } = deps;
try {
const file = deps.auditFile ?? config.auditFile;
Expand Down
12 changes: 8 additions & 4 deletions src/commands/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@

import { escapeMd } from "../notifications/format.js";
import { networkLabel, type BotConfig } from "../config.js";
import { COMMAND_DESCRIPTIONS } from "../i18n.js";
import { contractExplorerUrl } from "../stellar/client.js";
import { defineCommand } from "./registry.js";
import { createCommandModule } from "./registry.js";
import { TELEGRAM_OPTIONS } from "./runtime.js";

export function contractsMessage(config: BotConfig): string {
Expand Down Expand Up @@ -44,10 +45,13 @@ export function contractsMessage(config: BotConfig): string {
return lines.join("\n");
}

export const contractsCommand = defineCommand({
export const contractsCommand = createCommandModule({
command: "contracts",
chatAllowlist: true,
handler: async (ctx, deps) => {
description: COMMAND_DESCRIPTIONS.contracts,
operatorOnly: false,
// Deployment details: only answered in ALLOWED_CHAT_IDS.
allowlisted: true,
handle: async (ctx, deps) => {
await ctx.reply(contractsMessage(deps.config), TELEGRAM_OPTIONS);
},
});
8 changes: 4 additions & 4 deletions src/commands/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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 { createCommandModule } from "./registry.js";
import { TELEGRAM_OPTIONS } from "./runtime.js";

function ago(timestamp: number | null, nowMs: number = Date.now()): string {
Expand Down Expand Up @@ -102,12 +102,12 @@ export function healthMessage(
return lines.join("\n");
}

export const healthCommand = defineCommand({
export const healthCommand = createCommandModule({
// 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) => {
allowlisted: true,
handle: async (ctx, deps) => {
await ctx.reply(healthMessage(deps.config, deps.status()), TELEGRAM_OPTIONS);
},
});
10 changes: 5 additions & 5 deletions src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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 { createCommandModule, visibleCommandMetadata } from "./registry.js";
import { commandArgument, replyMarkdown } from "./runtime.js";

/** The help overview: title, intro, and one line per advertised command. */
Expand All @@ -29,16 +29,16 @@ export function helpMessage(config?: BotConfig): string {
].join("\n");
}

export const startCommand = defineCommand({
export const startCommand = createCommandModule({
command: "start",
handler: async (ctx, deps) => {
handle: async (ctx, deps) => {
await replyMarkdown(ctx, helpMessage(deps.config));
},
});

export const helpCommand = defineCommand({
export const helpCommand = createCommandModule({
command: "help",
handler: async (ctx, deps) => {
handle: async (ctx, deps) => {
const arg = commandArgument(ctx);
await replyMarkdown(
ctx,
Expand Down
59 changes: 41 additions & 18 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,61 @@
/**
* 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:
* {@link COMMAND_MODULES} is the ordered list of slash commands; its order is
* the order Telegram's menu and `/help` list them in. Adding a command:
*
* 1. create `src/commands/<name>.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
* 1. create `src/commands/<name>.ts` exporting a module from
* `createCommandModule({ command, description?, operatorOnly?, allowlisted?, hidden?, handle })`
* (see `CommandModule` in `./types.ts`)
* 2. add it to `COMMAND_MODULES` below — 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.
* `src/bot.ts` never needs editing: it registers whatever the registry holds
* and applies `operatorOnly` / `allowlisted` centrally.
*/

// 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
import { aboutCommand } from "./about.js";
import { auditCommand } from "./audit.js";
import { contractsCommand } from "./contracts.js";
import { healthCommand } from "./health.js";
import { helpCommand, startCommand } from "./help.js";
import { logExportCommand } from "./log-export.js";
import { pauseCommand } from "./pause.js";
import { previewCommand } from "./preview.js";
import { registerCommandModules } from "./registry.js";
import { resumeCommand } from "./resume.js";
import { statusCommand } from "./status.js";
import type { CommandModule } from "./types.js";

/** Every slash command, in menu order. */
export const COMMAND_MODULES: readonly CommandModule[] = Object.freeze([
startCommand,
helpCommand,
aboutCommand,
statusCommand,
auditCommand,
contractsCommand,
healthCommand,
previewCommand,
pauseCommand,
resumeCommand,
logExportCommand, // hidden: operator log export
]);

registerCommandModules(COMMAND_MODULES);

export {
commandMetadata,
commandModule,
createCommandModule,
defineCommand,
registerCommandModules,
registeredCommands,
visibleCommandMetadata,
type CommandDefinition,
type CommandDeps,
type CommandHandler,
type CommandMetadata,
Expand Down
6 changes: 3 additions & 3 deletions src/commands/log-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
*/

import { renderLogExport } from "../logger.js";
import { defineCommand } from "./registry.js";
import { createCommandModule } from "./registry.js";

export const logExportCommand = defineCommand({
export const logExportCommand = createCommandModule({
command: "export",
hidden: true,
operatorOnly: true,
handler: async (ctx, deps) => {
handle: async (ctx, deps) => {
const { config, logs } = deps;
if (!logs || logs.capacity() === 0) {
await ctx.reply("Log export is disabled (LOG_BUFFER_LINES=0).");
Expand Down
6 changes: 3 additions & 3 deletions src/commands/pause.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { messagesFor, type Locale } from "../i18n.js";
import type { PollerPauseResult } from "../poller.js";
import { pauseConfirmKeyboard } from "./keyboards.js";
import { defineCommand } from "./registry.js";
import { createCommandModule } from "./registry.js";
import { TELEGRAM_OPTIONS } from "./runtime.js";

/** Operator-facing text for the pause confirmation prompt. */
Expand All @@ -31,10 +31,10 @@ export function pauseMessage(result: PollerPauseResult, locale?: Locale): string
}
}

export const pauseCommand = defineCommand({
export const pauseCommand = createCommandModule({
command: "pause",
operatorOnly: true,
handler: async (ctx) => {
handle: async (ctx) => {
await ctx.reply(PAUSE_PROMPT, {
...TELEGRAM_OPTIONS,
reply_markup: pauseConfirmKeyboard(),
Expand Down
12 changes: 8 additions & 4 deletions src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@
* look without waiting for a live event.
*/

import { COMMAND_DESCRIPTIONS } from "../i18n.js";
import { previewMessage } from "../notifications/format.js";
import { defineCommand } from "./registry.js";
import { createCommandModule } from "./registry.js";
import { TELEGRAM_OPTIONS, commandArgument } from "./runtime.js";

export const previewCommand = defineCommand({
export const previewCommand = createCommandModule({
command: "preview",
chatAllowlist: true,
handler: async (ctx, deps) => {
description: COMMAND_DESCRIPTIONS.preview,
operatorOnly: false,
// Reveals channel formatting settings: only answered in ALLOWED_CHAT_IDS.
allowlisted: true,
handle: async (ctx, deps) => {
const target = commandArgument(ctx) || "market";
await ctx.reply(previewMessage(deps.config, target), TELEGRAM_OPTIONS);
},
Expand Down
Loading
Loading