@localhostdevs/sdk 0.14.0 → 0.15.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 +97 -29
- package/dist/argSchema.d.ts +7 -0
- package/dist/argSchema.d.ts.map +1 -0
- package/dist/argSchema.js +63 -0
- package/dist/argSchema.js.map +1 -0
- package/dist/bot.d.ts +5 -5
- package/dist/bot.d.ts.map +1 -1
- package/dist/bot.js +109 -16
- package/dist/bot.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/jsonSchema.d.ts +5 -0
- package/dist/jsonSchema.d.ts.map +1 -0
- package/dist/jsonSchema.js +7 -0
- package/dist/jsonSchema.js.map +1 -0
- package/dist/transport.d.ts +3 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +100 -33
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +64 -61
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(
|
|
56
|
+
### `bot.cmd(def, handler)`
|
|
57
57
|
|
|
58
|
-
Register a handler for a command. Must be called before `connect()`.
|
|
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:
|
|
109
|
+
bot.cmd({ name: 'greet' }, async (ctx) => {
|
|
110
|
+
// ctx.args: parsed args from the consumer
|
|
63
111
|
// ctx.command: 'greet'
|
|
64
|
-
// ctx.msgId:
|
|
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
|
|
158
|
+
**kinded** body via the helpers:
|
|
111
159
|
|
|
112
160
|
```js
|
|
113
161
|
// List output
|
|
114
|
-
bot.cmd(
|
|
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(
|
|
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
|
-
**
|
|
163
|
-
|
|
164
|
-
(`
|
|
165
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
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
|
|
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
|
-
- `
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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,
|
|
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
|
|
22
|
-
cmd(
|
|
23
|
-
/**
|
|
24
|
-
|
|
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;
|
|
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;IAgCP,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;IAiE3B,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,55 @@ export class Bot {
|
|
|
93
95
|
// touch sqlite.
|
|
94
96
|
this.action('__collect_submit', (ctx) => makeSubmitHandler(this.ensureCollectStore(), this.collectSink())(ctx));
|
|
95
97
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 (
|
|
101
|
-
throw new Error(
|
|
103
|
+
if (!def.name || typeof def.name !== 'string') {
|
|
104
|
+
throw new Error('cmd() command object requires a string `name`');
|
|
102
105
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
|
|
106
|
+
const opts = {};
|
|
107
|
+
if (def.streaming !== undefined)
|
|
108
|
+
opts.streaming = def.streaming;
|
|
109
|
+
if (def.mimeType !== undefined)
|
|
110
|
+
opts.mimeType = def.mimeType;
|
|
111
|
+
// Advertise: args descriptor → inputSchema (args wins over legacy input zod).
|
|
112
|
+
if (def.args)
|
|
113
|
+
opts.advertiseInput = argsToJsonSchema(def.args);
|
|
114
|
+
else if (def.input)
|
|
115
|
+
opts.advertiseInput = toJsonSchema(def.input);
|
|
116
|
+
// returns → outputKind + outputSchema (returns wins over legacy output zod).
|
|
117
|
+
if (def.returns) {
|
|
118
|
+
opts.outputKind = def.returns.kind;
|
|
119
|
+
const outputSchema = returnsDataToJsonSchema(def.returns.data);
|
|
120
|
+
if (outputSchema !== undefined)
|
|
121
|
+
opts.advertiseOutput = outputSchema;
|
|
122
|
+
// Wire returns.data into the existing warn-only check path (L567).
|
|
123
|
+
const warnZod = returnsDataToZod(def.returns.data);
|
|
124
|
+
if (warnZod)
|
|
125
|
+
opts.output = warnZod;
|
|
108
126
|
}
|
|
127
|
+
else if (def.output) {
|
|
128
|
+
opts.advertiseOutput = toJsonSchema(def.output);
|
|
129
|
+
// Keep opts.output for the warn-only output validation in makeContext.
|
|
130
|
+
opts.output = def.output;
|
|
131
|
+
}
|
|
132
|
+
// Validate path: only when explicitly opted in, and only for the args descriptor.
|
|
133
|
+
if (def.validate && def.args)
|
|
134
|
+
opts.argsZod = argsToZod(def.args);
|
|
135
|
+
else if (def.input)
|
|
136
|
+
opts.argsZod = def.input; // legacy zod validates as before
|
|
137
|
+
return this.registerHandler(def.name, opts, handler);
|
|
138
|
+
}
|
|
139
|
+
/** Internal: the single registration path. Not part of the public API. */
|
|
140
|
+
registerHandler(name, opts, handler) {
|
|
141
|
+
if (this.transport)
|
|
142
|
+
throw new Error('cmd()/action() must be called before connect()');
|
|
143
|
+
if (this.handlers.has(name))
|
|
144
|
+
throw new Error(`duplicate handler for command ${name}`);
|
|
145
|
+
if (typeof handler !== 'function')
|
|
146
|
+
throw new Error(`handler for ${name} must be a function`);
|
|
109
147
|
this.handlers.set(name, handler);
|
|
110
148
|
this.cmdOpts.set(name, opts);
|
|
111
149
|
return this;
|
|
@@ -116,7 +154,7 @@ export class Bot {
|
|
|
116
154
|
* command list (capability advertising / marketplace / `commands`
|
|
117
155
|
* introspection). An action is simply a non-advertised command. */
|
|
118
156
|
action(id, handler) {
|
|
119
|
-
return this.
|
|
157
|
+
return this.registerHandler(id, { internal: true }, handler);
|
|
120
158
|
}
|
|
121
159
|
isStreamingFor(commandName) {
|
|
122
160
|
const cmdLevel = this.cmdOpts.get(commandName)?.streaming;
|
|
@@ -167,6 +205,12 @@ export class Bot {
|
|
|
167
205
|
if (typeof opts.mimeType === 'string' && opts.mimeType.length > 0) {
|
|
168
206
|
entry.mimeType = opts.mimeType;
|
|
169
207
|
}
|
|
208
|
+
if (opts.advertiseInput)
|
|
209
|
+
entry.inputSchema = opts.advertiseInput;
|
|
210
|
+
if (opts.advertiseOutput)
|
|
211
|
+
entry.outputSchema = opts.advertiseOutput;
|
|
212
|
+
if (opts.outputKind)
|
|
213
|
+
entry.outputKind = opts.outputKind;
|
|
170
214
|
return entry;
|
|
171
215
|
});
|
|
172
216
|
const transport = new Transport({
|
|
@@ -299,7 +343,7 @@ export class Bot {
|
|
|
299
343
|
this.recordSeen(cmd.msgId);
|
|
300
344
|
const handler = this.handlers.get(cmd.command);
|
|
301
345
|
const streaming = this.isStreamingFor(cmd.command);
|
|
302
|
-
const ctx = this.makeContext(cmd, streaming);
|
|
346
|
+
const { ctx, replyError } = this.makeContext(cmd, streaming);
|
|
303
347
|
// Built-in `commands` fallback — only used if the bot didn't register
|
|
304
348
|
// its own. Lets every bot answer "what can you do?" without ceremony.
|
|
305
349
|
if (!handler && cmd.command === 'commands') {
|
|
@@ -335,6 +379,24 @@ export class Bot {
|
|
|
335
379
|
}
|
|
336
380
|
return;
|
|
337
381
|
}
|
|
382
|
+
// Input validation: a declared zod schema (argsZod from cmd() or legacy input)
|
|
383
|
+
// rejects a malformed dispatch with a structured error and never runs the handler.
|
|
384
|
+
const inputSchema = this.cmdOpts.get(cmd.command)?.argsZod;
|
|
385
|
+
if (inputSchema) {
|
|
386
|
+
const parsed = inputSchema.safeParse(ctx.args);
|
|
387
|
+
if (!parsed.success) {
|
|
388
|
+
try {
|
|
389
|
+
replyError({
|
|
390
|
+
text: `Invalid arguments for ${cmd.command}: ${parsed.error.message}`,
|
|
391
|
+
data: { error: 'invalid_args', issues: parsed.error.issues },
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
logError('invalid-args reply failed:', err);
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
338
400
|
try {
|
|
339
401
|
await handler(ctx);
|
|
340
402
|
}
|
|
@@ -342,10 +404,11 @@ export class Bot {
|
|
|
342
404
|
logError('command handler error:', err);
|
|
343
405
|
// If the handler threw before sending a reply, send a structured error
|
|
344
406
|
// frame so the consumer doesn't time out waiting for a reply that will
|
|
345
|
-
// never arrive.
|
|
346
|
-
//
|
|
407
|
+
// never arrive. replyError bypasses kind enforcement (this is an SDK
|
|
408
|
+
// diagnostic frame, not a handler reply) but still runs the double-reply
|
|
409
|
+
// guard — so if the handler already replied, we silently skip.
|
|
347
410
|
try {
|
|
348
|
-
|
|
411
|
+
replyError({
|
|
349
412
|
text: `handler threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
350
413
|
});
|
|
351
414
|
}
|
|
@@ -405,6 +468,13 @@ export class Bot {
|
|
|
405
468
|
makeContext(cmd, streaming) {
|
|
406
469
|
let replied = false;
|
|
407
470
|
const transport = this.transport;
|
|
471
|
+
const outputSchema = this.cmdOpts.get(cmd.command)?.output;
|
|
472
|
+
const declaredKind = this.cmdOpts.get(cmd.command)?.outputKind;
|
|
473
|
+
const assertKind = (actual) => {
|
|
474
|
+
if (declaredKind && declaredKind !== actual) {
|
|
475
|
+
throw new Error(`command ${cmd.command} declared returns.kind='${declaredKind}' but sent a '${actual}' reply`);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
408
478
|
// Interactive Prompts: the server injects the answering user's id as the
|
|
409
479
|
// reserved `args._user`. Lift it onto ctx.user and strip it from ctx.args
|
|
410
480
|
// so handlers never see the reserved key. Undefined for normal commands.
|
|
@@ -437,7 +507,17 @@ export class Bot {
|
|
|
437
507
|
...(answeringUser ? { user: answeringUser } : {}),
|
|
438
508
|
msgId: cmd.msgId,
|
|
439
509
|
reply: async (body) => {
|
|
510
|
+
assertKind((body.kind ?? 'text'));
|
|
440
511
|
guardReply();
|
|
512
|
+
// Warn-only output validation: a declared output schema that the
|
|
513
|
+
// structured `data` violates logs a warning but NEVER blocks the
|
|
514
|
+
// user-facing reply — schema drift must not break a response.
|
|
515
|
+
if (outputSchema) {
|
|
516
|
+
const res = outputSchema.safeParse(body.data);
|
|
517
|
+
if (!res.success) {
|
|
518
|
+
logWarn(`output for ${cmd.command} does not match its declared schema (sending anyway): ${res.error.message}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
441
521
|
// guardReply throws if transport is null, so the non-null assertion
|
|
442
522
|
// below is safe — TypeScript can't see through the closure.
|
|
443
523
|
transport.sendReply(cmd.msgId, body);
|
|
@@ -457,6 +537,7 @@ export class Bot {
|
|
|
457
537
|
if (header !== undefined && typeof header !== 'string') {
|
|
458
538
|
throw new Error(`replyList() header must be a string when provided (msgId=${cmd.msgId})`);
|
|
459
539
|
}
|
|
540
|
+
assertKind('list');
|
|
460
541
|
guardReply();
|
|
461
542
|
const body = {
|
|
462
543
|
kind: 'list',
|
|
@@ -485,6 +566,7 @@ export class Bot {
|
|
|
485
566
|
throw new Error(`replyTable() row[${r}] has ${Array.isArray(row) ? row.length : 'non-array'} cell(s), expected ${cols} (msgId=${cmd.msgId})`);
|
|
486
567
|
}
|
|
487
568
|
}
|
|
569
|
+
assertKind('table');
|
|
488
570
|
guardReply();
|
|
489
571
|
const out = {
|
|
490
572
|
kind: 'table',
|
|
@@ -516,6 +598,7 @@ export class Bot {
|
|
|
516
598
|
if (!base64) {
|
|
517
599
|
throw new Error(`replyImage() requires non-empty image data (msgId=${cmd.msgId})`);
|
|
518
600
|
}
|
|
601
|
+
assertKind('image');
|
|
519
602
|
guardReply();
|
|
520
603
|
const out = {
|
|
521
604
|
kind: 'image',
|
|
@@ -542,6 +625,7 @@ export class Bot {
|
|
|
542
625
|
throw new Error(`replyButtons() button[${i}] is missing a non-empty action (msgId=${cmd.msgId})`);
|
|
543
626
|
}
|
|
544
627
|
}
|
|
628
|
+
assertKind('buttons');
|
|
545
629
|
guardReply();
|
|
546
630
|
const out = {
|
|
547
631
|
kind: 'buttons',
|
|
@@ -640,6 +724,7 @@ export class Bot {
|
|
|
640
724
|
until,
|
|
641
725
|
origin: { msgId: cmd.msgId },
|
|
642
726
|
});
|
|
727
|
+
assertKind('collect');
|
|
643
728
|
guardReply();
|
|
644
729
|
transport.sendReply(cmd.msgId, {
|
|
645
730
|
kind: 'collect',
|
|
@@ -675,7 +760,15 @@ export class Bot {
|
|
|
675
760
|
transport.sendUpdate(cmd.msgId, progress, body);
|
|
676
761
|
};
|
|
677
762
|
}
|
|
678
|
-
|
|
763
|
+
// Internal sender for SDK-generated error/diagnostic frames (invalid_args,
|
|
764
|
+
// handler-threw recovery). Bypasses assertKind — kind enforcement is a
|
|
765
|
+
// contract on the HANDLER's reply, not on SDK infrastructure frames — but
|
|
766
|
+
// still runs guardReply so double-reply protection stays intact.
|
|
767
|
+
const replyError = (body) => {
|
|
768
|
+
guardReply();
|
|
769
|
+
transport.sendReply(cmd.msgId, body);
|
|
770
|
+
};
|
|
771
|
+
return { ctx, replyError };
|
|
679
772
|
}
|
|
680
773
|
isDuplicate(msgId) {
|
|
681
774
|
return this.seen.has(msgId);
|