aurival 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -435,6 +435,151 @@ is single-use, and acking one a second time doesn't throw locally, the server 40
435
435
  The full set of showcase examples (trivia, giveaways, DJ bots, moderation reports…) is in the
436
436
  [cookbook](https://bots.aurival.com/docs/cookbook).
437
437
 
438
+ ## Buttons only the caller can press
439
+
440
+ By default nobody is locked: any member in the chat can press a card's buttons, exactly as
441
+ before this existed. Pass `forUser` to `reply`, `send`, `edit` or `ack` to lock the press to one
442
+ member.
443
+
444
+ ```ts
445
+ await ctx.reply('Question 1 of 5', { buttons, forUser: ctx.sender });
446
+ ```
447
+
448
+ `forUser` takes a `User` (`ctx.sender` is one) or a bare `usr_…` id string. Either one
449
+ serializes to the same id on the wire.
450
+
451
+ Everyone still sees the card and its text. Only the press is gated: a non-caller's buttons
452
+ render at `.56` opacity with no checkmark, are not tappable, and the app shows a `For {name}`
453
+ hint under the row. A link button on a locked card stays pressable by anyone. It never
454
+ round-trips to the server, so there is nothing for the lock to gate.
455
+
456
+ A non-caller who presses anyway is refused by the server with a `403`, before anything is
457
+ spent. `used` stays unset and the card is unchanged. That refusal never reaches your bot: a bot
458
+ never presses a button, so there is no SDK exception for it.
459
+
460
+ Omitting `forUser` on a `reply`/`edit`/`ack` that replaces a card **keeps the existing lock**.
461
+ Passing `forUser: null` **clears** it, and the card opens to everyone. Passing a new id **moves**
462
+ the lock to that member. This matters most on `ack`, where a quiz redraws its own card between
463
+ questions and must not silently unlock itself by leaving `forUser` out.
464
+
465
+ A caller-only quiz, start to finish:
466
+
467
+ ```ts
468
+ import { Button } from 'aurival';
469
+
470
+ bot.command('quiz', async (ctx) => {
471
+ await ctx.reply('Which planet is largest?', {
472
+ buttons: [
473
+ new Button({ label: 'Mars', style: 'secondary' }),
474
+ new Button({ label: 'Jupiter', style: 'secondary' }),
475
+ ],
476
+ forUser: ctx.sender,
477
+ });
478
+ });
479
+
480
+ bot.on('button.pressed', async (ctx) => {
481
+ const correct = ctx.button === 'jupiter';
482
+ const text = correct
483
+ ? 'Correct. Jupiter is about eleven Earths across.'
484
+ : 'Not quite. Jupiter is about eleven Earths across.';
485
+ await ctx.ack({ text, buttons: [] });
486
+ });
487
+ ```
488
+
489
+ `ButtonContext` carries no `forUser` field. The presser is always the locked user by
490
+ construction: the server refuses everyone else before your handler ever runs, so there is
491
+ nothing on the press for it to expose. If you need the lock on a card you are acking, you
492
+ already have it: you set it on the send.
493
+
494
+ ## Cooldowns
495
+
496
+ A cooldown paces a command or a button. You attach it, the SDK keeps the bucket, and the wire
497
+ never carries it — the server does not know one exists.
498
+
499
+ ```ts
500
+ import { Bot, Cooldown } from 'aurival';
501
+
502
+ const bot = new Bot(); // buttonCooldown: Cooldown(1, 2) already
503
+
504
+ bot.command('roll', { cooldown: { rate: 1, per: 5 } }, async (ctx) => { /* … */ });
505
+ bot.command('leaderboard', { cooldown: new Cooldown(3, 60, 'chat') }, async (ctx) => { /* … */ });
506
+ ```
507
+
508
+ **`per` is seconds, not milliseconds.** That is deliberate and it is the one place this SDK
509
+ departs from JS habit: `AurivalAPIError.retryAfter` is already seconds, and a developer who
510
+ catches one and a developer who writes a cooldown should not hold two meanings of one number.
511
+
512
+ The options object is a third form of `command()`. The two you already use —
513
+ `command(name, handler)` and `command(name, description, handler)` — are untouched, and
514
+ `description` is a field on the options object when you want all three.
515
+
516
+ Three buckets, and each one says who shares the limit:
517
+
518
+ | bucket | key | reads as |
519
+ | ------------------ | ------------------------------ | ----------------------------------------------- |
520
+ | `user` (default) | the invoking or pressing user | each person gets one every N seconds |
521
+ | `chat` | the conversation | this chat gets one every N seconds, whoever asks |
522
+ | `global` | nothing | this bot answers one every N seconds, everywhere |
523
+
524
+ A command that is refused never reaches your handler, and a refusal spends nothing — the token
525
+ is taken only when the call passes.
526
+
527
+ ### What the member sees
528
+
529
+ One plain reply per bucket per window, then silence for the rest of it. Somebody who types
530
+ `/roll` eight times in five seconds gets one sentence, not eight:
531
+
532
+ ```
533
+ Slow down. Try /roll again in 3 s.
534
+ ```
535
+
536
+ Replace it, or suppress it, with a hook. A registered hook owns the whole response: the SDK
537
+ sends nothing and the hook either replies or stays quiet. There is no return value to get right.
538
+
539
+ ```ts
540
+ bot.onCooldown(async (ctx, retryAfter) => { // bot-level
541
+ await ctx.reply(`easy, ${Math.ceil(retryAfter)}s`);
542
+ });
543
+
544
+ bot.command('roll', { cooldown: { rate: 1, per: 5 }, onCooldown }, handler); // this one wins
545
+ ```
546
+
547
+ `retryAfter` is seconds remaining, unrounded. The hook is called once per bucket per window too,
548
+ so replacing the sentence does not re-introduce the spam it existed to stop.
549
+
550
+ ### Buttons already have one
551
+
552
+ Every bot ships with `Cooldown(1, 2, 'user')` on its buttons, without asking: one press every two
553
+ seconds per person, one bucket per person per bot. A press inside the window is answered rather
554
+ than handled — the presser's pending ink clears and their device shows a toast — and the button
555
+ stays live, because a cooldown is a wait, not a spend.
556
+
557
+ Override it where it belongs, and precedence is button, then card, then the bot default:
558
+
559
+ ```ts
560
+ const bot = new Bot({ buttonCooldown: { rate: 1, per: 5 } }); // this bot's buttons
561
+ await ctx.reply({ buttons, buttonCooldown: null }); // this card's buttons, off
562
+ new Button({ label: 'Paint', cooldown: null }); // this one button, off
563
+ ```
564
+
565
+ A per-card or per-button cooldown gets its own buckets, keyed on the message, the button id and
566
+ the bucket subject — button ids are unique within a message and nowhere else, so two cards that
567
+ both call a button `roll` never share a limit. `null` disables at any level.
568
+
569
+ A button cooldown is at most 60 seconds and is refused where you write it, not later inside an
570
+ ack you cannot see. Command cooldowns have no cap: `{ rate: 1, per: 3600 }` on a command is a
571
+ legitimate once an hour.
572
+
573
+ Link buttons are outside all of this — a link opens on the device and never round-trips, so it
574
+ cannot carry a cooldown and `Button.link` has no `cooldown` field.
575
+
576
+ ### The caveat, said plainly
577
+
578
+ **Buckets are process memory.** They reset on restart, and they are not shared between
579
+ instances: a bot running two processes has two independent buckets, and a deploy clears every
580
+ bucket it had. Cooldowns pace a conversation. They are not a quota, and they are not the abuse
581
+ bound — the server keeps its own floor underneath them.
582
+
438
583
  ## Upgrading from 0.2.x
439
584
 
440
585
  There is one `Context` per event family now, instead of one class for every event with most of
@@ -561,6 +706,27 @@ await ctx.edit(sent, { embeds: [new Embed({ title: 'Starting in 3…' })] });
561
706
  await ctx.edit(sent, { buttons: [] }); // the row is gone, the plate stays
562
707
  ```
563
708
 
709
+ ### Upgrading from 0.6.x
710
+
711
+ 0.7.0 adds cooldowns and takes nothing away. `Cooldown` is a new export, `command()` gained a
712
+ third options form, `Bot` gained `buttonCooldown`, `reply()`/`send()` gained `buttonCooldown`,
713
+ and `Button` gained `cooldown`. Every one of them has a default that keeps 0.6.0 behaviour, with
714
+ **one exception you should know about**: buttons now carry `Cooldown(1, 2, 'user')` by default,
715
+ so a member cannot press the same bot's buttons faster than once every two seconds. That is
716
+ deliberate and it is on by default. If your bot's buttons are something a member is meant to
717
+ mash, turn it off explicitly:
718
+
719
+ ```ts
720
+ const bot = new Bot({ buttonCooldown: null });
721
+ ```
722
+
723
+ Both existing `command()` overloads keep their exact signatures, so no call you have written
724
+ changes shape.
725
+
726
+ Two new error classes ship with it, `CooldownWithBody` and `CooldownRetryAfterInvalid`, both
727
+ `InvalidRequestError`. You will not normally see either: the SDK builds the cooldown ack itself
728
+ and refuses an out-of-range button cooldown where you write it.
729
+
564
730
  ## Shadowed commands
565
731
 
566
732
  A bot can declare up to 50 commands, the SDK refuses to connect past that.
@@ -576,6 +742,42 @@ aurival warn: /ping is shadowed by another bot in chat_01j…, so it will not re
576
742
  Rename the command, or get the other bot out of that chat. Nothing else in the SDK reacts
577
743
  to it — a shadowed command in one chat is still live in every other.
578
744
 
745
+ ## Command aliases
746
+
747
+ `aliases` on `bot.command(...)` gives one command several spellings, all handled by the same
748
+ function. `/roll` and `/r` fire the same handler, share the same cooldown bucket, and are one
749
+ command everywhere the server or the app talks about it.
750
+
751
+ ```ts
752
+ bot.command('roll', { description: 'Roll dice', aliases: ['r'] }, async (ctx) => {
753
+ await ctx.reply(`/${ctx.invokedAs} ran ${ctx.command}, dice rolled`);
754
+ });
755
+ ```
756
+
757
+ `ctx.command` is always the canonical name, `'roll'`, whichever spelling fired the handler.
758
+ `ctx.invokedAs` is the token the human actually typed, lowercased: `'roll'` or `'r'`. They are
759
+ equal on a canonical call. Code that checks `ctx.command === 'roll'` keeps working no matter
760
+ which spelling reached it.
761
+
762
+ The options object also takes `cooldown`, same as any other command. It is one bucket, keyed on
763
+ the canonical name, shared across every spelling:
764
+
765
+ ```ts
766
+ bot.command('roll', { description: 'Roll dice', aliases: ['r'], cooldown: { rate: 1, per: 5 } }, handler);
767
+ ```
768
+
769
+ At most three aliases per command. A fourth throws locally, before anything is synced. It is the
770
+ same validation path as a bad command name: lowercase letters, digits, hyphens and underscores
771
+ only, and none of the reserved names (`help`, `report`, `block`, `mute`, `kick`, `ban`, `admin`,
772
+ `staff`, `support`, `aurival`).
773
+
774
+ The cooldown notice echoes the typed token: hit the limit through `/r` and you get `Slow down.
775
+ Try /r again in 3 s.`, never `/roll`.
776
+
777
+ In the app, the command picker lists a command's aliases as a muted secondary line under its
778
+ row: `also /r`. Picking a row that matched by alias fills that alias, not the canonical
779
+ spelling: typing `/r` and picking the row inserts `/r `, not `/roll `.
780
+
579
781
  ## Environment
580
782
 
581
783
  | variable | what it does |
package/dist/bot.d.ts CHANGED
@@ -5,8 +5,20 @@ import { Context, Event } from './events.js';
5
5
  import { HttpClient } from './http.js';
6
6
  import type { AnyContext, BotContext, BotEventType, ButtonContext, ButtonEventType, EventContext, MemberContext, MemberEventType, ReactionContext, ReactionEventType } from './events.js';
7
7
  import type { Logger } from './http.js';
8
+ import type { CooldownLike, CooldownOption } from './cooldown.js';
8
9
  /** A `bot.command()` handler. */
9
10
  export type Handler = (ctx: Context) => Promise<void> | void;
11
+ /**
12
+ * A per-command or bot-level cooldown hook (AMENDMENT-08 §4). `retryAfter`
13
+ * is the raw, unrounded seconds `Cooldown.check()` returned — round it
14
+ * yourself (`Math.max(1, Math.ceil(retryAfter))`, §5's rule) to reproduce
15
+ * the built-in sentence's `{n}`. Replaces the built-in "Slow down…" notice
16
+ * entirely: a hook that does nothing suppresses the notice, a hook that
17
+ * replies sends whatever it wants instead. Gated by the same once-per-window
18
+ * ledger as the built-in notice — a hook runs once per bucket per window,
19
+ * not on every refused call.
20
+ */
21
+ export type OnCooldownHook = (ctx: Context, retryAfter: number) => Promise<void> | void;
10
22
  /** A `member.joined` / `member.left` handler. */
11
23
  export type MemberHandler = (ctx: MemberContext) => Promise<void> | void;
12
24
  /** A `bot.added` / `bot.removed` handler. */
@@ -40,6 +52,45 @@ export interface BotOptions {
40
52
  * keeps typing entirely in your hands (`ctx.withTyping()`). Default `true`.
41
53
  */
42
54
  autoTyping?: boolean | undefined;
55
+ /**
56
+ * The default cooldown a button press checks when neither its card nor
57
+ * the button itself carries one (AMENDMENT-08 §3). Defaults to `new
58
+ * Cooldown(1, 2.0, 'user')` — one press per user per two seconds — when
59
+ * omitted; pass `null` to disable it bot-wide, leaving only card/button
60
+ * cooldowns (if any) in effect. Bound by the same 60s cap as every other
61
+ * button-family cooldown, checked at construction. **Its bucket lives in
62
+ * process memory: it resets on every restart, and it is never shared
63
+ * across instances or processes** — a bot running two workers has two
64
+ * independent buckets (see {@link Cooldown}'s own doc comment for why).
65
+ */
66
+ buttonCooldown?: CooldownOption | undefined;
67
+ }
68
+ /**
69
+ * A command's third form: `bot.command(name, { cooldown, onCooldown,
70
+ * description }, handler)`. `description` here is equivalent to the
71
+ * two-argument string overload's — the options object is a third spelling
72
+ * of the same call, not a different feature (R-9's two string overloads are
73
+ * unchanged; this is additive). `cooldown` accepts a `Cooldown` instance or
74
+ * a plain `{ rate, per, bucket? }` literal, normalized at registration.
75
+ * Command cooldowns have no 60s cap (D13) — they never reach the wire.
76
+ */
77
+ export interface CommandOptions {
78
+ description?: string | undefined;
79
+ cooldown?: CooldownLike | undefined;
80
+ onCooldown?: OnCooldownHook | undefined;
81
+ /**
82
+ * AMENDMENT-09 §2.1: alternate spellings that fire this same handler —
83
+ * `/r` and `/dice` for a command registered as `roll`, say. At most
84
+ * {@link MAX_ALIASES_PER_COMMAND}, checked locally at registration; every
85
+ * other rule an alias obeys (the name pattern, the reserved list,
86
+ * uniqueness) is the server's alone (§2.1) — this SDK reuses
87
+ * `invalid_command_name` for those exactly as the server does, by simply
88
+ * not re-validating them here and letting the sync refusal surface.
89
+ * Dispatch, the cooldown bucket and `onCooldown` all stay keyed on the
90
+ * canonical name; an alias only ever changes which typed token reaches
91
+ * that same handler (`ctx.invokedAs`, `Context`).
92
+ */
93
+ aliases?: readonly string[] | undefined;
43
94
  }
44
95
  /**
45
96
  * How long a command handler runs before the chat is told the bot is thinking
@@ -59,6 +110,17 @@ export declare function ensurePaired(http: HttpClient, keyFile: KeyFile, host: s
59
110
  machine: Machine;
60
111
  paired: boolean;
61
112
  }>;
113
+ /**
114
+ * One `PUT /v1/bots/{bot}/commands` row. `aliases` is optional and, when
115
+ * present, always non-empty — the caller omits the key entirely for a
116
+ * command with no aliases (AMENDMENT-09 §2.1: absent and `[]` mean the same
117
+ * thing, so there is no reason to send the empty spelling).
118
+ */
119
+ export interface SyncPayloadEntry {
120
+ name: string;
121
+ description: string;
122
+ aliases?: string[];
123
+ }
62
124
  /**
63
125
  * The one door to command sync, so the lane that syncs is always the lane that
64
126
  * reports. Both `Bot` call sites go through here.
@@ -66,10 +128,7 @@ export declare function ensurePaired(http: HttpClient, keyFile: KeyFile, host: s
66
128
  * Exported for the tests, not from `index.ts` — the package's export list
67
129
  * mirrors Python's `__all__` (SDK-7) and this is not on it.
68
130
  */
69
- export declare function syncCommandsAndReport(http: HttpClient, bot: string, payload: Array<{
70
- name: string;
71
- description: string;
72
- }>, log: Logger): Promise<void>;
131
+ export declare function syncCommandsAndReport(http: HttpClient, bot: string, payload: SyncPayloadEntry[], log: Logger): Promise<void>;
73
132
  /**
74
133
  * One warning line per shadowed chat (S11). Silently dropping `conflicts` is
75
134
  * the silence the field exists to end: the developer whose command never fires.
@@ -93,6 +152,13 @@ export declare class Bot {
93
152
  */
94
153
  command(name: string, handler: Handler): void;
95
154
  command(name: string, description: string, handler: Handler): void;
155
+ /**
156
+ * The options form (AMENDMENT-08 §4/seat ruling): a per-command cooldown
157
+ * is an option here, not a decorator. `{ cooldown, onCooldown }` beats
158
+ * `bot.onCooldown()` for this command alone — only one hook ever runs for
159
+ * a given refusal.
160
+ */
161
+ command(name: string, options: CommandOptions, handler: Handler): void;
96
162
  /**
97
163
  * Register a handler for an event. Which context the handler gets follows
98
164
  * from the type, and your editor knows it (BA-R68):
@@ -127,6 +193,12 @@ export declare class Bot {
127
193
  * handler that threw, a `problem` frame, a backlog overflow.
128
194
  */
129
195
  onError(fn: ErrorHook): void;
196
+ /**
197
+ * The bot-level cooldown hook (AMENDMENT-08 §4) — runs for any refused
198
+ * command that does not carry its own `onCooldown` option. A per-command
199
+ * hook always wins; only one hook ever runs for a given refusal.
200
+ */
201
+ onCooldown(fn: OnCooldownHook): void;
130
202
  /** Installs signal handlers and runs until SIGINT/SIGTERM or something fatal. */
131
203
  run(): Promise<void>;
132
204
  start(signal?: AbortSignal): Promise<void>;
package/dist/bot.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../src/bot.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../src/bot.ts"],"names":[],"mappings":"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"}
package/dist/bot.js CHANGED
@@ -1,8 +1,10 @@
1
1
  /** The developer-facing surface: declare commands, call run(), we hold the socket. */
2
2
  import { Auth, KeyFile, machineLabel, pair, resolveHost } from './auth.js';
3
- import { AurivalError, BotSuspended, RateLimitError, SessionSuperseded } from './errors.js';
3
+ import { AurivalError, BotSuspended, ButtonAlreadyUsed, NotFound, RateLimitError, SessionSuperseded } from './errors.js';
4
4
  import { Context, Event, contextFor } from './events.js';
5
5
  import { DEFAULT_HOST, HttpClient, defaultLogger } from './http.js';
6
+ import { CAP_TOO_MANY_ALIASES, MAX_ALIASES_PER_COMMAND, commandCooldownNotice } from './caps.js';
7
+ import { Cooldown, buttonBucketKey, normalizeCooldownOption, resolveButtonCooldown, retryAfterMs, roundSeconds, subjectKey, } from './cooldown.js';
6
8
  import { EVENT_BACKLOG_OVERFLOWED, EVENT_COMMAND_INVOKED, Socket } from './socket.js';
7
9
  import * as status from './status.js';
8
10
  /** Types `on()` refuses, and why — each is a STRUCTURAL case where a
@@ -118,22 +120,57 @@ export class Bot {
118
120
  #http = null;
119
121
  #quiet;
120
122
  #autoTyping;
123
+ #buttonCooldown;
124
+ #cooldownHook = null;
121
125
  constructor(options = {}) {
122
126
  this.#log = options.logger ?? defaultLogger();
123
127
  this.#host = options.host;
124
128
  this.#keyPath = options.keyPath;
125
129
  this.#quiet = status.isQuiet(options.quiet);
126
130
  this.#autoTyping = options.autoTyping ?? true;
131
+ const resolvedButtonCooldown = normalizeCooldownOption(options.buttonCooldown, {
132
+ boundToButton: true,
133
+ });
134
+ this.#buttonCooldown =
135
+ resolvedButtonCooldown === undefined ? new Cooldown(1, 2.0, 'user') : resolvedButtonCooldown;
127
136
  }
128
137
  command(name, second, third) {
129
- const description = typeof second === 'string' ? second : '';
130
- const handler = typeof second === 'string' ? third : second;
138
+ let description = '';
139
+ let handler;
140
+ let cooldownLike;
141
+ let onCooldownHook;
142
+ let aliasesLike;
143
+ if (typeof second === 'string') {
144
+ description = second;
145
+ handler = third;
146
+ }
147
+ else if (typeof second === 'function') {
148
+ handler = second;
149
+ }
150
+ else {
151
+ description = second.description ?? '';
152
+ cooldownLike = second.cooldown;
153
+ onCooldownHook = second.onCooldown;
154
+ aliasesLike = second.aliases;
155
+ handler = third;
156
+ }
131
157
  if (handler === undefined)
132
158
  throw new AurivalError(`command(${name}) needs a handler`);
159
+ // AMENDMENT-09 §2.1: `[]` in every state but never `undefined` — a
160
+ // `Command` this SDK constructs always carries the list, even empty.
161
+ const aliases = aliasesLike !== undefined ? [...aliasesLike] : [];
162
+ if (aliases.length > MAX_ALIASES_PER_COMMAND) {
163
+ throw new AurivalError(CAP_TOO_MANY_ALIASES);
164
+ }
133
165
  const key = lookupKey(name);
134
166
  if (this.#registered.has(key))
135
167
  status.duplicateCommand(name, this.#quiet);
136
- this.#registered.set(key, { command: { name, description }, handler });
168
+ const registered = { command: { name, description, aliases }, handler };
169
+ if (cooldownLike !== undefined)
170
+ registered.cooldown = Cooldown.from(cooldownLike);
171
+ if (onCooldownHook !== undefined)
172
+ registered.onCooldown = onCooldownHook;
173
+ this.#registered.set(key, registered);
137
174
  }
138
175
  on(type, handler) {
139
176
  const reason = NEVER_DISPATCHED_TO_ON.get(type);
@@ -153,6 +190,14 @@ export class Bot {
153
190
  onError(fn) {
154
191
  this.#errorHook = fn;
155
192
  }
193
+ /**
194
+ * The bot-level cooldown hook (AMENDMENT-08 §4) — runs for any refused
195
+ * command that does not carry its own `onCooldown` option. A per-command
196
+ * hook always wins; only one hook ever runs for a given refusal.
197
+ */
198
+ onCooldown(fn) {
199
+ this.#cooldownHook = fn;
200
+ }
156
201
  // -- running -----------------------------------------------------------
157
202
  /** Installs signal handlers and runs until SIGINT/SIGTERM or something fatal. */
158
203
  async run() {
@@ -209,7 +254,16 @@ export class Bot {
209
254
  onProblem: (problem) => {
210
255
  void this.#callErrorHook(problem, null);
211
256
  },
212
- hasHandler: (type) => this.#onHandlers.has(type),
257
+ // `button.pressed` always claims a handler exists, whether or not a
258
+ // developer ever called `on('button.pressed', ...)` — a press must
259
+ // reach `#handleButtonPress` so the cooldown check (and its ack on
260
+ // refusal) always runs, even for a bot that only sends cards and
261
+ // handles presses nowhere, or not yet (AMENDMENT-08 §1/§5: the button
262
+ // default applies to every bot with no line of code, and the presser
263
+ // must never be left staring at pending ink). A press that passes
264
+ // with no registered handler simply falls through `#handleGeneric`
265
+ // with nothing to call; the socket-level ack still fires either way.
266
+ hasHandler: (type) => type === 'button.pressed' || this.#onHandlers.has(type),
213
267
  logger: this.#log,
214
268
  botName: machine.bot,
215
269
  commandCount: this.#registered.size,
@@ -239,10 +293,16 @@ export class Bot {
239
293
  * and land the sync in the background.
240
294
  */
241
295
  async #syncCommands(http, bot, signal) {
242
- const payload = [...this.#registered.values()].map((r) => ({
243
- name: r.command.name,
244
- description: r.command.description,
245
- }));
296
+ const payload = [...this.#registered.values()].map((r) => {
297
+ const entry = { name: r.command.name, description: r.command.description };
298
+ // AMENDMENT-09 §2.1: the key is present only when the list is
299
+ // non-empty — absent and `[]` mean the same thing on this wire, so
300
+ // there is nothing to gain from sending the empty spelling.
301
+ if (r.command.aliases !== undefined && r.command.aliases.length > 0) {
302
+ entry.aliases = [...r.command.aliases];
303
+ }
304
+ return entry;
305
+ });
246
306
  try {
247
307
  await syncCommandsAndReport(http, bot, payload, this.#log);
248
308
  return null;
@@ -303,6 +363,10 @@ export class Bot {
303
363
  await this.#handleCommand(event);
304
364
  return;
305
365
  }
366
+ if (event.type === 'button.pressed') {
367
+ await this.#handleButtonPress(event);
368
+ return;
369
+ }
306
370
  await this.#handleGeneric(event);
307
371
  }
308
372
  async #handleCommand(event) {
@@ -317,6 +381,13 @@ export class Bot {
317
381
  if (this.#http === null)
318
382
  return;
319
383
  const ctx = Context.fromEvent(event, this.#http);
384
+ // AMENDMENT-08: the cooldown check runs before auto-typing and before the
385
+ // handler — a refused invocation never reaches either.
386
+ if (registered.cooldown !== undefined) {
387
+ const refused = await this.#handleCommandCooldown(ctx, registered);
388
+ if (refused)
389
+ return;
390
+ }
320
391
  const typing = this.#startAutoTyping(ctx);
321
392
  try {
322
393
  await registered.handler(ctx);
@@ -330,6 +401,109 @@ export class Bot {
330
401
  await typing.stop();
331
402
  }
332
403
  }
404
+ /**
405
+ * A registered command's own cooldown (AMENDMENT-08 §4). Returns `true`
406
+ * when the invocation was refused (dispatch stops here). Its bucket key
407
+ * carries no message/button scope (`attachmentScope = ()`) — each
408
+ * command's `Cooldown` is its own instance, so nothing else could collide
409
+ * with it anyway.
410
+ *
411
+ * Once per bucket per window: either the per-command hook, the bot-level
412
+ * one, or — with neither registered — the fixed reply. A hook does not
413
+ * run on every refused call; it shares the same `noticeOnce` gate the
414
+ * built-in notice uses, so mashing a refused command inside one window
415
+ * produces one hook call (or one reply), not a flood of them.
416
+ */
417
+ async #handleCommandCooldown(ctx, registered) {
418
+ const cooldown = registered.cooldown;
419
+ if (cooldown === undefined)
420
+ return false;
421
+ const key = subjectKey(cooldown.bucket, ctx.sender.id, ctx.chat.id);
422
+ const refusedAfterSeconds = cooldown.check(key);
423
+ if (refusedAfterSeconds === null)
424
+ return false;
425
+ if (!cooldown.noticeOnce(key))
426
+ return true;
427
+ // Per-command beats bot-level; only one hook ever runs.
428
+ const hook = registered.onCooldown ?? this.#cooldownHook;
429
+ if (hook !== null && hook !== undefined) {
430
+ try {
431
+ await hook(ctx, refusedAfterSeconds);
432
+ }
433
+ catch (exc) {
434
+ this.#log.error(`cooldown hook for ${JSON.stringify(ctx.command)} threw: ${exc instanceof Error ? (exc.stack ?? exc.message) : String(exc)}`);
435
+ await this.#callErrorHook(exc, ctx);
436
+ }
437
+ return true;
438
+ }
439
+ const n = roundSeconds(refusedAfterSeconds);
440
+ try {
441
+ // AMENDMENT-09 §13.1: names the token the human actually typed, not
442
+ // the canonical spelling — the bucket is still the canonical
443
+ // command's (one bucket, all spellings); only the rendered sentence
444
+ // changes.
445
+ await ctx.reply(commandCooldownNotice(ctx.invokedAs, n));
446
+ }
447
+ catch (exc) {
448
+ this.#log.error(`cooldown notice for ${JSON.stringify(ctx.command)} failed: ${exc instanceof Error ? (exc.stack ?? exc.message) : String(exc)}`);
449
+ await this.#callErrorHook(exc, ctx);
450
+ }
451
+ return true;
452
+ }
453
+ /**
454
+ * AMENDMENT-08 §3: resolve the pressed button's cooldown (button > card >
455
+ * bot default), check it, and — on refusal — send the cooldown ack
456
+ * instead of dispatching to any `on('button.pressed', ...)` handler. The
457
+ * socket still acks the underlying event exactly as it does for any other
458
+ * dispatch; only the handler is skipped.
459
+ */
460
+ async #handleButtonPress(event) {
461
+ if (this.#http === null)
462
+ return;
463
+ const ctx = contextFor(event, this.#http);
464
+ const card = this.#http.cardCooldowns.lookup(ctx.message.id);
465
+ const resolved = resolveButtonCooldown(this.#buttonCooldown, card, ctx.button);
466
+ if (resolved.cooldown !== null) {
467
+ const scope = resolved.scoped ? { messageId: ctx.message.id, buttonId: ctx.button } : null;
468
+ const key = buttonBucketKey(resolved.cooldown, scope, ctx.user.id, ctx.chat.id);
469
+ const refusedAfterSeconds = resolved.cooldown.check(key);
470
+ if (refusedAfterSeconds !== null) {
471
+ await this.#ackButtonCooldown(ctx, retryAfterMs(refusedAfterSeconds));
472
+ return;
473
+ }
474
+ }
475
+ await this.#handleGeneric(event);
476
+ }
477
+ /**
478
+ * The SDK's own automatic answer to a refused press (§5.1). Swallows
479
+ * `ButtonAlreadyUsed`/`NotFound` silently — a debug line, never `onError`,
480
+ * never thrown — because the press already resolved some other way (a
481
+ * double-tap, an expired row) and the cooldown refusal is moot. Every
482
+ * other status still goes through the normal error path.
483
+ */
484
+ async #ackButtonCooldown(ctx, ms) {
485
+ if (this.#http === null)
486
+ return;
487
+ try {
488
+ await this.#http.ackCooldown(ctx.interaction, ms);
489
+ // The ack landed. Without this line a button cooldown is invisible to
490
+ // the bot author: the SDK answers the press itself, the handler never
491
+ // runs, and nothing is written anywhere — so a cooldown firing and a
492
+ // press vanishing look identical from the outside. `info`, not
493
+ // `debug`, because the swallowed-refusal lines below are the ones a
494
+ // reader can ignore; this one is the cooldown working. Byte-mirrors
495
+ // `sdk/python/aurival/bot.py`'s sentence (SDK-7).
496
+ this.#log.info(`cooldown: acked press on ${ctx.message.id}/${ctx.button} for ${ctx.user.id}, retry_after_ms=${ms}`);
497
+ }
498
+ catch (exc) {
499
+ if (exc instanceof ButtonAlreadyUsed || exc instanceof NotFound) {
500
+ this.#log.debug(`cooldown ack for ${JSON.stringify(ctx.interaction)} found the press already resolved: ${exc instanceof Error ? exc.message : String(exc)}`);
501
+ return;
502
+ }
503
+ this.#log.error(`cooldown ack for ${JSON.stringify(ctx.interaction)} failed: ${exc instanceof Error ? (exc.stack ?? exc.message) : String(exc)}`);
504
+ await this.#callErrorHook(exc, ctx);
505
+ }
506
+ }
333
507
  // -- auto-typing (SDK-41) ------------------------------------------------
334
508
  #startAutoTyping(ctx) {
335
509
  if (!this.#autoTyping || ctx.chat.id === '')