@localhostdevs/sdk 0.14.0 → 0.16.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
@@ -22,11 +22,11 @@ const bot = new Bot({
22
22
  // apiKey: process.env.BOT_SECRET, // optional; auto-read from env if unset
23
23
  });
24
24
 
25
- bot.cmd('ping', async (ctx) => {
25
+ bot.cmd({ name: 'ping' }, async (ctx) => {
26
26
  await ctx.reply({ text: 'pong' });
27
27
  });
28
28
 
29
- bot.cmd('echo', async (ctx) => {
29
+ bot.cmd({ name: 'echo' }, async (ctx) => {
30
30
  await ctx.reply({ text: `echo: ${JSON.stringify(ctx.args)}` });
31
31
  });
32
32
 
@@ -53,15 +53,63 @@ The bot stays online for as long as the process runs. The gateway keeps the conn
53
53
  | `serverUrl` | `string` | `wss://bot-api.localhostdevs.com/bot` (override with `LHD_SERVER_URL`) | Production gateway is baked in — most users leave this alone. Only override for local dev or self-hosted gateways. |
54
54
  | `idempotencyLruSize` | `number` | `1024` | How many recent message IDs to remember for deduplication. |
55
55
 
56
- ### `bot.cmd(name, handler)`
56
+ ### `bot.cmd(def, handler)`
57
57
 
58
- Register a handler for a command. Must be called before `connect()`. Handler receives a `CommandContext`:
58
+ Register a handler for a command. Must be called before `connect()`. The first argument is a **command definition object** with the following fields:
59
+
60
+ | Field | Type | Notes |
61
+ | ------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
62
+ | `name` | `string` (required) | Command name consumers dispatch (e.g. `'ping'`, `'topic'`). |
63
+ | `description` | `string` | Human-readable description shown on the marketplace/workflow picker. |
64
+ | `args` | `Record<string, ArgSpec>` | Declare the expected arguments (see below). **Advertise-only** by default — fills the marketplace and workflow picker but does NOT validate at runtime unless `validate: true`. |
65
+ | `returns` | `{ kind: ReplyKind, data?: Record<string, 'string' \| 'number' \| 'boolean'> }` | Declare the reply kind. Enforced: the handler must call the matching reply method or the SDK throws (see Reply-kind enforcement below). |
66
+ | `validate` | `boolean` | When `true`, the SDK validates `ctx.args` against the declared `args` before running your handler. A bad dispatch is rejected with a structured `invalid_args` error. Also narrows TypeScript types: `required` fields become typed-present in `ctx.args`. Without `validate`, all arg fields are typed optional. |
67
+ | `streaming` | `boolean` | Override the bot-level streaming flag for this command only. |
68
+ | `mimeType` | `string` | MIME-type hint advertised in IDENTIFY (advertising-only, doesn't affect runtime). |
69
+
70
+ #### `ArgSpec` shape
71
+
72
+ ```ts
73
+ interface ArgSpec {
74
+ type: 'string' | 'number' | 'boolean' | 'enum';
75
+ required?: boolean;
76
+ description?: string;
77
+ values?: readonly string[]; // required when type === 'enum'
78
+ }
79
+ ```
80
+
81
+ #### `returns.kind` vocabulary
82
+
83
+ `'text'` | `'list'` | `'table'` | `'image'` | `'buttons'` | `'collect'`
84
+
85
+ The declared kind is **enforced at runtime**: if the handler calls a reply method that doesn't match the declaration (e.g. `returns: { kind: 'list' }` but the handler calls `ctx.reply()`), the SDK throws a hard error. TypeScript also narrows `ctx`'s available reply methods to just the ones valid for the declared kind.
86
+
87
+ #### Example
88
+
89
+ ```ts
90
+ bot.cmd({
91
+ name: 'topic',
92
+ description: 'Scrape trends for a query, then pick one',
93
+ args: {
94
+ query: { type: 'string', required: true, description: 'Search query' },
95
+ format: { type: 'string', description: 'e.g. short_30s' },
96
+ voice: { type: 'enum', values: ['on', 'off'] },
97
+ },
98
+ returns: { kind: 'list', data: { jobId: 'string', state: 'string' } },
99
+ validate: false, // opt-in: when true, args are validated AND required fields typed-present
100
+ }, async (ctx) => {
101
+ // ctx.args is typed from `args`; ctx reply methods are narrowed to returns.kind
102
+ await ctx.replyList([{ title: ctx.args.query ?? '' }]);
103
+ });
104
+ ```
105
+
106
+ Handler receives a `CommandContext`:
59
107
 
60
108
  ```ts
61
- bot.cmd('greet', async (ctx) => {
62
- // ctx.args: parsed args from the consumer
109
+ bot.cmd({ name: 'greet' }, async (ctx) => {
110
+ // ctx.args: parsed args from the consumer
63
111
  // ctx.command: 'greet'
64
- // ctx.msgId: unique id for this invocation
112
+ // ctx.msgId: unique id for this invocation
65
113
 
66
114
  await ctx.reply({ text: `Hello, ${ctx.args.name ?? 'world'}!` });
67
115
 
@@ -92,7 +140,7 @@ The gateway pings the bot every 30 seconds and the SDK responds. If the gateway
92
140
  A reply can carry a `dispatch` directive to chain a command to another bot:
93
141
 
94
142
  ```js
95
- bot.cmd('summary', async (ctx) => {
143
+ bot.cmd({ name: 'summary' }, async (ctx) => {
96
144
  await ctx.reply({
97
145
  text: 'fetching price first…',
98
146
  dispatch: { to: 'price-bot', command: 'price', args: { ticker: 'AAPL' } },
@@ -107,11 +155,14 @@ Chains run up to depth 5 with loop detection. The original consumer pays for eve
107
155
  By default `ctx.reply({ text })` sends a plain-text body — the consumer
108
156
  renders it as text. To get richer rendering (source-badge lists, striped
109
157
  tables) on the dashboard Test Console and the public Try widget, return a
110
- **kinded** body via the two new helpers:
158
+ **kinded** body via the helpers:
111
159
 
112
160
  ```js
113
161
  // List output
114
- bot.cmd('headlines', async (ctx) => {
162
+ bot.cmd({
163
+ name: 'headlines',
164
+ returns: { kind: 'list' },
165
+ }, async (ctx) => {
115
166
  await ctx.replyList(
116
167
  [
117
168
  { label: 'AP', title: 'Story one', secondary: '2m', href: '…' },
@@ -122,7 +173,10 @@ bot.cmd('headlines', async (ctx) => {
122
173
  });
123
174
 
124
175
  // Table output
125
- bot.cmd('top5', async (ctx) => {
176
+ bot.cmd({
177
+ name: 'top5',
178
+ returns: { kind: 'table' },
179
+ }, async (ctx) => {
126
180
  await ctx.replyTable({
127
181
  headers: ['ticker', 'price', 'change'],
128
182
  rows: [
@@ -159,10 +213,11 @@ guard flips, so if you catch the error you can still send a corrected
159
213
  further reply calls throw — the SDK guarantees exactly-one reply per
160
214
  command.
161
215
 
162
- **Pair with `output_kind`.** Declare the shape of each command on the bot
163
- detail page so the dashboard can pick the right renderer ahead of time
164
- (`text` / `list` / `table`). Mis-declarations soft-fall-back to text
165
- rendering they don't fail the dispatch.
216
+ **Reply-kind enforcement.** When `returns.kind` is declared on the command
217
+ definition, the SDK enforces it at runtime: calling a mismatched reply
218
+ method (e.g. `ctx.reply()` when `returns: { kind: 'list' }`) throws a hard
219
+ error. TypeScript also narrows `ctx`'s available reply methods to the ones
220
+ valid for the declared kind, so mismatches are caught at compile time.
166
221
 
167
222
  ## Streaming progress (`ctx.update`)
168
223
 
@@ -175,7 +230,7 @@ const bot = new Bot({
175
230
  streaming: true, // enables ctx.update across all commands
176
231
  });
177
232
 
178
- bot.cmd('analyze', async (ctx) => {
233
+ bot.cmd({ name: 'analyze' }, async (ctx) => {
179
234
  await ctx.update({ progress: 10, text: 'Fetching…' });
180
235
  await ctx.update({ progress: 60, text: 'Crunching…' });
181
236
  await ctx.reply({
@@ -186,11 +241,11 @@ bot.cmd('analyze', async (ctx) => {
186
241
  });
187
242
  ```
188
243
 
189
- Or per-command:
244
+ Or per-command using `streaming` in the definition:
190
245
 
191
246
  ```js
192
- bot.cmd('analyze', { streaming: true }, async (ctx) => { /* … */ });
193
- bot.cmd('ping', async (ctx) => { /* no ctx.update here */ });
247
+ bot.cmd({ name: 'analyze', streaming: true }, async (ctx) => { /* … */ });
248
+ bot.cmd({ name: 'ping' }, async (ctx) => { /* no ctx.update here */ });
194
249
  ```
195
250
 
196
251
  **Progress shapes** (use whichever fits the work):
@@ -218,15 +273,15 @@ Tell the platform what each command's reply looks like so the bot
218
273
  detail page can show consumers what to expect:
219
274
 
220
275
  ```js
221
- bot.cmd('chart', { streaming: true, mimeType: 'image/png' }, async (ctx) => {
276
+ bot.cmd({ name: 'chart', streaming: true, mimeType: 'image/png' }, async (ctx) => {
222
277
  await ctx.reply({ data: chartBytes, mimeType: 'image/png' });
223
278
  });
224
279
 
225
- bot.cmd('summarize', { mimeType: 'text/markdown' }, async (ctx) => {
280
+ bot.cmd({ name: 'summarize', mimeType: 'text/markdown' }, async (ctx) => {
226
281
  await ctx.reply({ text: '# Summary\n…', mimeType: 'text/markdown' });
227
282
  });
228
283
 
229
- bot.cmd('ping', async (ctx) => { // no opts — displays as text/plain
284
+ bot.cmd({ name: 'ping' }, async (ctx) => { // no mimeType — displays as text/plain
230
285
  await ctx.reply({ text: 'pong' });
231
286
  });
232
287
  ```
@@ -267,8 +322,7 @@ v0.4 is additive — nothing breaks for bots calling only `ctx.reply()`.
267
322
  New surface:
268
323
 
269
324
  - `new Bot({ …, streaming: true })` to enable `ctx.update()`
270
- - `bot.cmd(name, { streaming: true }, handler)` to flip the flag per
271
- command without touching bot-init
325
+ - `streaming: true` inside the command definition to flip the flag per command
272
326
  - `ctx.update({ progress?, text?, data?, mimeType? })` — send 1+
273
327
  intermediate frames before `ctx.reply()`
274
328
  - `mimeType` on `ctx.reply()` for the existing final-reply path
@@ -277,11 +331,25 @@ No env-var changes, no protocol breakage, no upstream re-auth.
277
331
 
278
332
  ## v0.4.0 → v0.4.1 (per-command mimeType)
279
333
 
280
- Additive — no breaking changes. The `mimeType?` field in
281
- `CommandHandlerOptions` is optional; bots that ignore it work exactly
282
- the same. The SDK auto-builds an IDENTIFY-time `commands` array from
283
- your registered handlers so the platform can display them on the bot
284
- detail page.
334
+ Additive — no breaking changes. The `mimeType?` field in the command definition
335
+ is optional; bots that ignore it work exactly the same. The SDK auto-builds an
336
+ IDENTIFY-time `commands` array from your registered handlers so the platform can
337
+ display them on the bot detail page.
338
+
339
+ ## v0.14 → v0.15 (object `cmd()`)
340
+
341
+ **Breaking.** See [MIGRATION.md](./MIGRATION.md) for the full guide.
342
+
343
+ `cmd()` now takes a **command definition object** as its first argument. The
344
+ string-form is removed.
345
+
346
+ ```diff
347
+ - bot.cmd("ping", async (ctx) => { … })
348
+ + bot.cmd({ name: "ping" }, async (ctx) => { … })
349
+
350
+ - bot.cmd("analyze", { streaming: true }, async (ctx) => { … })
351
+ + bot.cmd({ name: "analyze", streaming: true }, async (ctx) => { … })
352
+ ```
285
353
 
286
354
  ## License
287
355
 
@@ -0,0 +1,7 @@
1
+ import { type ZodType } from 'zod';
2
+ import type { ArgSpec, ReturnsSpec } from './types.js';
3
+ export declare function argsToJsonSchema(args: Record<string, ArgSpec>): Record<string, unknown>;
4
+ export declare function returnsDataToJsonSchema(data: ReturnsSpec['data']): Record<string, unknown> | undefined;
5
+ export declare function returnsDataToZod(data: ReturnsSpec['data']): ZodType | undefined;
6
+ export declare function argsToZod(args: Record<string, ArgSpec>): ZodType;
7
+ //# sourceMappingURL=argSchema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"argSchema.d.ts","sourceRoot":"","sources":["../src/argSchema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAK,KAAK,OAAO,EAAE,MAAM,KAAK,CAAC;AACtC,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAavD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAUvF;AAED,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,GACxB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAKrC;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,OAAO,GAAG,SAAS,CAO/E;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAkBhE"}
@@ -0,0 +1,63 @@
1
+ import { z } from 'zod';
2
+ function propSchema(s) {
3
+ if (s.type === 'enum') {
4
+ const p = { type: 'string', enum: [...(s.values ?? [])] };
5
+ if (s.description)
6
+ p.description = s.description;
7
+ return p;
8
+ }
9
+ const p = { type: s.type };
10
+ if (s.description)
11
+ p.description = s.description;
12
+ return p;
13
+ }
14
+ export function argsToJsonSchema(args) {
15
+ const properties = {};
16
+ const required = [];
17
+ for (const [name, spec] of Object.entries(args)) {
18
+ properties[name] = propSchema(spec);
19
+ if (spec.required)
20
+ required.push(name);
21
+ }
22
+ const out = { type: 'object', properties };
23
+ if (required.length)
24
+ out.required = required;
25
+ return out;
26
+ }
27
+ export function returnsDataToJsonSchema(data) {
28
+ if (!data)
29
+ return undefined;
30
+ const properties = {};
31
+ for (const [name, t] of Object.entries(data))
32
+ properties[name] = { type: t };
33
+ return { type: 'object', properties };
34
+ }
35
+ export function returnsDataToZod(data) {
36
+ if (!data)
37
+ return undefined;
38
+ const shape = {};
39
+ for (const [name, t] of Object.entries(data)) {
40
+ shape[name] = t === 'number' ? z.number() : t === 'boolean' ? z.boolean() : z.string();
41
+ }
42
+ return z.object(shape);
43
+ }
44
+ export function argsToZod(args) {
45
+ const shape = {};
46
+ for (const [name, spec] of Object.entries(args)) {
47
+ if (spec.type === 'enum' && (!spec.values || spec.values.length === 0)) {
48
+ throw new Error(`enum arg requires a non-empty \`values\` array`);
49
+ }
50
+ let field = spec.type === 'string'
51
+ ? z.string()
52
+ : spec.type === 'number'
53
+ ? z.number()
54
+ : spec.type === 'boolean'
55
+ ? z.boolean()
56
+ : z.enum([...(spec.values ?? [])]);
57
+ if (!spec.required)
58
+ field = field.optional();
59
+ shape[name] = field;
60
+ }
61
+ return z.object(shape);
62
+ }
63
+ //# sourceMappingURL=argSchema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"argSchema.js","sourceRoot":"","sources":["../src/argSchema.ts"],"names":[],"mappings":"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"}
package/dist/bot.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { nanoid } from 'nanoid';
2
- import type { BotOptions, CommandHandler, CommandHandlerOptions } from './types.js';
2
+ import type { ArgsOf, BotOptions, CommandContext, CommandDef, CommandHandler, ReturnsOf } from './types.js';
3
3
  export { SDK_PACKAGE_VERSION } from './version.js';
4
4
  export declare class Bot {
5
5
  private readonly opts;
@@ -18,10 +18,10 @@ export declare class Bot {
18
18
  private reconnectAttempt;
19
19
  private cachedApiKey;
20
20
  constructor(opts: BotOptions);
21
- /** Register a handler with no per-command overrides. */
22
- cmd(name: string, handler: CommandHandler): this;
23
- /** Register a handler with per-command overrides. */
24
- cmd(name: string, opts: CommandHandlerOptions, handler: CommandHandler): this;
21
+ /** Register a command from its definition object. The ONLY public way to add a command. */
22
+ cmd<const D extends CommandDef>(def: D, handler: (ctx: CommandContext<ArgsOf<D>, ReturnsOf<D>>) => void | Promise<void>): this;
23
+ /** Internal: the single registration path. Not part of the public API. */
24
+ private registerHandler;
25
25
  /** Register an internal action handler (Interactive Prompts). Stored in the
26
26
  * same handler map `cmd()` uses — so dispatch routing is unchanged — but
27
27
  * flagged `internal: true` so it is EXCLUDED from the IDENTIFY advertised
package/dist/bot.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../src/bot.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAShC,OAAO,KAAK,EACV,UAAU,EAIV,cAAc,EACd,qBAAqB,EAMtB,MAAM,YAAY,CAAC;AAKpB,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AA8CnD,qBAAa,GAAG;IACd,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAqB;IAC1C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,SAAS,CAA0B;IAC3C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqC;IAC9D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4C;IACpE,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA0B;IAC/C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAgB;IAC1C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAG1B;IACF,OAAO,CAAC,YAAY,CAA6B;IACjD,OAAO,CAAC,YAAY,CAA+C;IACnE,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,YAAY,CAAuB;gBAE/B,IAAI,EAAE,UAAU;IA2C5B,wDAAwD;IACxD,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,IAAI;IAChD,qDAAqD;IACrD,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,qBAAqB,EAAE,OAAO,EAAE,cAAc,GAAG,IAAI;IA0B7E;;;;wEAIoE;IACpE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,IAAI;IAIjD,OAAO,CAAC,cAAc;IAMtB,2FAA2F;IACrF,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAsB9B,iFAAiF;YACnE,aAAa;IAuD3B,OAAO,CAAC,iBAAiB;YAcX,YAAY;IAe1B;;;;;OAKG;YACW,aAAa;IAoB3B,0DAA0D;IACpD,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAoBjC;;yEAEqE;IACrE,OAAO,CAAC,oBAAoB;YAYd,QAAQ;IA6EtB,OAAO,CAAC,oBAAoB;IAQ5B,4EAA4E;IAC5E,OAAO,CAAC,kBAAkB;IAc1B;;iBAEa;IACb,OAAO,CAAC,WAAW;IAcnB,OAAO,CAAC,gBAAgB;IAYxB,OAAO,CAAC,WAAW;IAuUnB,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,UAAU;CAQnB;AAGD,OAAO,EAAE,MAAM,EAAE,CAAC"}
1
+ {"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../src/bot.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAWhC,OAAO,KAAK,EACV,MAAM,EACN,UAAU,EAGV,cAAc,EACd,UAAU,EACV,cAAc,EAMd,SAAS,EAGV,MAAM,YAAY,CAAC;AAKpB,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AA8CnD,qBAAa,GAAG;IACd,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAqB;IAC1C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,SAAS,CAA0B;IAC3C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqC;IAC9D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4C;IACpE,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA0B;IAC/C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAgB;IAC1C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAG1B;IACF,OAAO,CAAC,YAAY,CAA6B;IACjD,OAAO,CAAC,YAAY,CAA+C;IACnE,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,YAAY,CAAuB;gBAE/B,IAAI,EAAE,UAAU;IA2C5B,2FAA2F;IAC3F,GAAG,CAAC,KAAK,CAAC,CAAC,SAAS,UAAU,EAC5B,GAAG,EAAE,CAAC,EACN,OAAO,EAAE,CAAC,GAAG,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAC9E,IAAI;IAiCP,0EAA0E;IAC1E,OAAO,CAAC,eAAe;IASvB;;;;wEAIoE;IACpE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,IAAI;IAIjD,OAAO,CAAC,cAAc;IAMtB,2FAA2F;IACrF,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAsB9B,iFAAiF;YACnE,aAAa;IAmE3B,OAAO,CAAC,iBAAiB;YAcX,YAAY;IAe1B;;;;;OAKG;YACW,aAAa;IAoB3B,0DAA0D;IACpD,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAoBjC;;yEAEqE;IACrE,OAAO,CAAC,oBAAoB;YAYd,QAAQ;IAgGtB,OAAO,CAAC,oBAAoB;IAQ5B,4EAA4E;IAC5E,OAAO,CAAC,kBAAkB;IAc1B;;iBAEa;IACb,OAAO,CAAC,WAAW;IAcnB,OAAO,CAAC,gBAAgB;IAYxB,OAAO,CAAC,WAAW;IA2WnB,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,UAAU;CAQnB;AAGD,OAAO,EAAE,MAAM,EAAE,CAAC"}
package/dist/bot.js CHANGED
@@ -7,6 +7,8 @@ import { readCredentialFor, writeCredentialFor } from './credentials.js';
7
7
  import { runDeviceFlow } from './sso.js';
8
8
  import { CollectStore } from './collectStore.js';
9
9
  import { closeDueByTime, makeSubmitHandler } from './collect.js';
10
+ import { toJsonSchema } from './jsonSchema.js';
11
+ import { argsToJsonSchema, returnsDataToJsonSchema, argsToZod, returnsDataToZod } from './argSchema.js';
10
12
  // Version constant is generated from package.json at build time by
11
13
  // scripts/generate-version.mjs — keeps the IDENTIFY-frame `sdkVersion`
12
14
  // field in sync with the published npm version instead of relying on a
@@ -93,19 +95,57 @@ export class Bot {
93
95
  // touch sqlite.
94
96
  this.action('__collect_submit', (ctx) => makeSubmitHandler(this.ensureCollectStore(), this.collectSink())(ctx));
95
97
  }
96
- cmd(name, handlerOrOpts, maybeHandler) {
97
- if (this.transport) {
98
- throw new Error('cmd() must be called before connect()');
98
+ /** Register a command from its definition object. The ONLY public way to add a command. */
99
+ cmd(def, handler) {
100
+ if (typeof def === 'string' || def === null || typeof def !== 'object') {
101
+ throw new Error("cmd() takes a command object, e.g. cmd({ name: 'x' }, handler)");
99
102
  }
100
- if (this.handlers.has(name)) {
101
- throw new Error(`duplicate handler for command ${name}`);
103
+ if (!def.name || typeof def.name !== 'string') {
104
+ throw new Error('cmd() command object requires a string `name`');
102
105
  }
103
- // Disambiguate overloads
104
- const opts = typeof handlerOrOpts === 'function' ? {} : handlerOrOpts;
105
- const handler = typeof handlerOrOpts === 'function' ? handlerOrOpts : maybeHandler;
106
- if (typeof handler !== 'function') {
107
- throw new Error(`cmd('${name}') missing handler function`);
106
+ const opts = {};
107
+ if (def.streaming !== undefined)
108
+ opts.streaming = def.streaming;
109
+ if (def.mimeType !== undefined)
110
+ opts.mimeType = def.mimeType;
111
+ if (def.description !== undefined)
112
+ opts.description = def.description;
113
+ // Advertise: args descriptor → inputSchema (args wins over legacy input zod).
114
+ if (def.args)
115
+ opts.advertiseInput = argsToJsonSchema(def.args);
116
+ else if (def.input)
117
+ opts.advertiseInput = toJsonSchema(def.input);
118
+ // returns → outputKind + outputSchema (returns wins over legacy output zod).
119
+ if (def.returns) {
120
+ opts.outputKind = def.returns.kind;
121
+ const outputSchema = returnsDataToJsonSchema(def.returns.data);
122
+ if (outputSchema !== undefined)
123
+ opts.advertiseOutput = outputSchema;
124
+ // Wire returns.data into the existing warn-only check path (L567).
125
+ const warnZod = returnsDataToZod(def.returns.data);
126
+ if (warnZod)
127
+ opts.output = warnZod;
108
128
  }
129
+ else if (def.output) {
130
+ opts.advertiseOutput = toJsonSchema(def.output);
131
+ // Keep opts.output for the warn-only output validation in makeContext.
132
+ opts.output = def.output;
133
+ }
134
+ // Validate path: only when explicitly opted in, and only for the args descriptor.
135
+ if (def.validate && def.args)
136
+ opts.argsZod = argsToZod(def.args);
137
+ else if (def.input)
138
+ opts.argsZod = def.input; // legacy zod validates as before
139
+ return this.registerHandler(def.name, opts, handler);
140
+ }
141
+ /** Internal: the single registration path. Not part of the public API. */
142
+ registerHandler(name, opts, handler) {
143
+ if (this.transport)
144
+ throw new Error('cmd()/action() must be called before connect()');
145
+ if (this.handlers.has(name))
146
+ throw new Error(`duplicate handler for command ${name}`);
147
+ if (typeof handler !== 'function')
148
+ throw new Error(`handler for ${name} must be a function`);
109
149
  this.handlers.set(name, handler);
110
150
  this.cmdOpts.set(name, opts);
111
151
  return this;
@@ -116,7 +156,7 @@ export class Bot {
116
156
  * command list (capability advertising / marketplace / `commands`
117
157
  * introspection). An action is simply a non-advertised command. */
118
158
  action(id, handler) {
119
- return this.cmd(id, { internal: true }, handler);
159
+ return this.registerHandler(id, { internal: true }, handler);
120
160
  }
121
161
  isStreamingFor(commandName) {
122
162
  const cmdLevel = this.cmdOpts.get(commandName)?.streaming;
@@ -163,10 +203,17 @@ export class Bot {
163
203
  const entry = {
164
204
  name,
165
205
  streaming,
206
+ description: opts.description ?? '',
166
207
  };
167
208
  if (typeof opts.mimeType === 'string' && opts.mimeType.length > 0) {
168
209
  entry.mimeType = opts.mimeType;
169
210
  }
211
+ if (opts.advertiseInput)
212
+ entry.inputSchema = opts.advertiseInput;
213
+ if (opts.advertiseOutput)
214
+ entry.outputSchema = opts.advertiseOutput;
215
+ if (opts.outputKind)
216
+ entry.outputKind = opts.outputKind;
170
217
  return entry;
171
218
  });
172
219
  const transport = new Transport({
@@ -299,7 +346,7 @@ export class Bot {
299
346
  this.recordSeen(cmd.msgId);
300
347
  const handler = this.handlers.get(cmd.command);
301
348
  const streaming = this.isStreamingFor(cmd.command);
302
- const ctx = this.makeContext(cmd, streaming);
349
+ const { ctx, replyError } = this.makeContext(cmd, streaming);
303
350
  // Built-in `commands` fallback — only used if the bot didn't register
304
351
  // its own. Lets every bot answer "what can you do?" without ceremony.
305
352
  if (!handler && cmd.command === 'commands') {
@@ -335,6 +382,24 @@ export class Bot {
335
382
  }
336
383
  return;
337
384
  }
385
+ // Input validation: a declared zod schema (argsZod from cmd() or legacy input)
386
+ // rejects a malformed dispatch with a structured error and never runs the handler.
387
+ const inputSchema = this.cmdOpts.get(cmd.command)?.argsZod;
388
+ if (inputSchema) {
389
+ const parsed = inputSchema.safeParse(ctx.args);
390
+ if (!parsed.success) {
391
+ try {
392
+ replyError({
393
+ text: `Invalid arguments for ${cmd.command}: ${parsed.error.message}`,
394
+ data: { error: 'invalid_args', issues: parsed.error.issues },
395
+ });
396
+ }
397
+ catch (err) {
398
+ logError('invalid-args reply failed:', err);
399
+ }
400
+ return;
401
+ }
402
+ }
338
403
  try {
339
404
  await handler(ctx);
340
405
  }
@@ -342,10 +407,11 @@ export class Bot {
342
407
  logError('command handler error:', err);
343
408
  // If the handler threw before sending a reply, send a structured error
344
409
  // frame so the consumer doesn't time out waiting for a reply that will
345
- // never arrive. ctx.reply() itself throws if already called swallow
346
- // that case.
410
+ // never arrive. replyError bypasses kind enforcement (this is an SDK
411
+ // diagnostic frame, not a handler reply) but still runs the double-reply
412
+ // guard — so if the handler already replied, we silently skip.
347
413
  try {
348
- await ctx.reply({
414
+ replyError({
349
415
  text: `handler threw: ${err instanceof Error ? err.message : String(err)}`,
350
416
  });
351
417
  }
@@ -405,6 +471,13 @@ export class Bot {
405
471
  makeContext(cmd, streaming) {
406
472
  let replied = false;
407
473
  const transport = this.transport;
474
+ const outputSchema = this.cmdOpts.get(cmd.command)?.output;
475
+ const declaredKind = this.cmdOpts.get(cmd.command)?.outputKind;
476
+ const assertKind = (actual) => {
477
+ if (declaredKind && declaredKind !== actual) {
478
+ throw new Error(`command ${cmd.command} declared returns.kind='${declaredKind}' but sent a '${actual}' reply`);
479
+ }
480
+ };
408
481
  // Interactive Prompts: the server injects the answering user's id as the
409
482
  // reserved `args._user`. Lift it onto ctx.user and strip it from ctx.args
410
483
  // so handlers never see the reserved key. Undefined for normal commands.
@@ -437,7 +510,17 @@ export class Bot {
437
510
  ...(answeringUser ? { user: answeringUser } : {}),
438
511
  msgId: cmd.msgId,
439
512
  reply: async (body) => {
513
+ assertKind((body.kind ?? 'text'));
440
514
  guardReply();
515
+ // Warn-only output validation: a declared output schema that the
516
+ // structured `data` violates logs a warning but NEVER blocks the
517
+ // user-facing reply — schema drift must not break a response.
518
+ if (outputSchema) {
519
+ const res = outputSchema.safeParse(body.data);
520
+ if (!res.success) {
521
+ logWarn(`output for ${cmd.command} does not match its declared schema (sending anyway): ${res.error.message}`);
522
+ }
523
+ }
441
524
  // guardReply throws if transport is null, so the non-null assertion
442
525
  // below is safe — TypeScript can't see through the closure.
443
526
  transport.sendReply(cmd.msgId, body);
@@ -457,6 +540,7 @@ export class Bot {
457
540
  if (header !== undefined && typeof header !== 'string') {
458
541
  throw new Error(`replyList() header must be a string when provided (msgId=${cmd.msgId})`);
459
542
  }
543
+ assertKind('list');
460
544
  guardReply();
461
545
  const body = {
462
546
  kind: 'list',
@@ -485,6 +569,7 @@ export class Bot {
485
569
  throw new Error(`replyTable() row[${r}] has ${Array.isArray(row) ? row.length : 'non-array'} cell(s), expected ${cols} (msgId=${cmd.msgId})`);
486
570
  }
487
571
  }
572
+ assertKind('table');
488
573
  guardReply();
489
574
  const out = {
490
575
  kind: 'table',
@@ -516,6 +601,7 @@ export class Bot {
516
601
  if (!base64) {
517
602
  throw new Error(`replyImage() requires non-empty image data (msgId=${cmd.msgId})`);
518
603
  }
604
+ assertKind('image');
519
605
  guardReply();
520
606
  const out = {
521
607
  kind: 'image',
@@ -542,6 +628,7 @@ export class Bot {
542
628
  throw new Error(`replyButtons() button[${i}] is missing a non-empty action (msgId=${cmd.msgId})`);
543
629
  }
544
630
  }
631
+ assertKind('buttons');
545
632
  guardReply();
546
633
  const out = {
547
634
  kind: 'buttons',
@@ -640,6 +727,7 @@ export class Bot {
640
727
  until,
641
728
  origin: { msgId: cmd.msgId },
642
729
  });
730
+ assertKind('collect');
643
731
  guardReply();
644
732
  transport.sendReply(cmd.msgId, {
645
733
  kind: 'collect',
@@ -675,7 +763,15 @@ export class Bot {
675
763
  transport.sendUpdate(cmd.msgId, progress, body);
676
764
  };
677
765
  }
678
- return ctx;
766
+ // Internal sender for SDK-generated error/diagnostic frames (invalid_args,
767
+ // handler-threw recovery). Bypasses assertKind — kind enforcement is a
768
+ // contract on the HANDLER's reply, not on SDK infrastructure frames — but
769
+ // still runs guardReply so double-reply protection stays intact.
770
+ const replyError = (body) => {
771
+ guardReply();
772
+ transport.sendReply(cmd.msgId, body);
773
+ };
774
+ return { ctx, replyError };
679
775
  }
680
776
  isDuplicate(msgId) {
681
777
  return this.seen.has(msgId);