@yaebal/conversation 0.0.1
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/LICENSE +21 -0
- package/README.md +32 -0
- package/lib/index.d.ts +48 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +74 -0
- package/lib/index.js.map +1 -0
- package/lib/index.test.d.ts +2 -0
- package/lib/index.test.d.ts.map +1 -0
- package/lib/index.test.js +97 -0
- package/lib/index.test.js.map +1 -0
- package/package.json +48 -0
- package/src/index.test.ts +123 -0
- package/src/index.ts +143 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 neverlane
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# @yaebal/conversation
|
|
2
|
+
|
|
3
|
+
write multi-step dialogs as straight async functions — a coroutine approach where `cv.wait()` resolves with the next update for that chat.
|
|
4
|
+
|
|
5
|
+
## install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @yaebal/conversation
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { conversation, createConversation } from "@yaebal/conversation";
|
|
15
|
+
|
|
16
|
+
const greet = createConversation("greet", async (cv, ctx) => {
|
|
17
|
+
await ctx.send("what's your name?");
|
|
18
|
+
|
|
19
|
+
const answer = await cv.wait();
|
|
20
|
+
await answer.send(`hi ${answer.text}! nice to meet you.`);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const bot = new Bot(token).install(conversation([greet]));
|
|
24
|
+
|
|
25
|
+
bot.command("greet", (ctx) => ctx.conversation.enter("greet"));
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
while a conversation is active it owns the chat's updates — they don't reach other handlers. state is in-memory (lost on restart), similar to `@yaebal/scenes`.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
part of [**yaebal**](https://github.com/neverlane/yaebal) — a type-safe, runtime-agnostic Telegram Bot API framework. MIT.
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Context, Plugin } from "@yaebal/core";
|
|
2
|
+
/**
|
|
3
|
+
* @yaebal/conversation — write multi-step dialogs as a straight line:
|
|
4
|
+
*
|
|
5
|
+
* const greet = createConversation("greet", async (cv, ctx) => {
|
|
6
|
+
* await ctx.send("what's your name?");
|
|
7
|
+
*
|
|
8
|
+
* const answer = await cv.wait();
|
|
9
|
+
* await answer.send(`hi ${answer.text}`);
|
|
10
|
+
* });
|
|
11
|
+
* bot.install(conversation([greet]));
|
|
12
|
+
* bot.command("greet", (ctx) => ctx.conversation.enter("greet"));
|
|
13
|
+
*
|
|
14
|
+
* it's a COROUTINE, not a replay engine (grammY-style): the builder runs once,
|
|
15
|
+
* detached, and `cv.wait()` resolves with the next update for that chat. while a
|
|
16
|
+
* conversation is active it OWNS the chat's updates (they don't reach other
|
|
17
|
+
* handlers). state is in-memory, lost on restart — like `prompt`/`scenes`.
|
|
18
|
+
*/
|
|
19
|
+
export interface Conversation {
|
|
20
|
+
/** resolve with the next update's context for this chat. */
|
|
21
|
+
wait(): Promise<Context>;
|
|
22
|
+
/** the most recent context (the entering update, then each waited one). */
|
|
23
|
+
readonly ctx: Context;
|
|
24
|
+
}
|
|
25
|
+
export type ConversationBuilder = (cv: Conversation, ctx: Context) => void | Promise<void>;
|
|
26
|
+
export interface ConversationDef {
|
|
27
|
+
name: string;
|
|
28
|
+
builder: ConversationBuilder;
|
|
29
|
+
}
|
|
30
|
+
export declare function createConversation(name: string, builder: ConversationBuilder): ConversationDef;
|
|
31
|
+
export interface ConversationControl {
|
|
32
|
+
/** start a registered conversation for this chat. */
|
|
33
|
+
enter(name: string): void;
|
|
34
|
+
/** whether a conversation is currently running for this chat. */
|
|
35
|
+
active(): boolean;
|
|
36
|
+
/** abandon the active conversation (its coroutine is left parked). */
|
|
37
|
+
leave(): void;
|
|
38
|
+
}
|
|
39
|
+
export interface ConversationOptions {
|
|
40
|
+
/** conversation key for an update. default: per-chat (`ctx.chat.id`). */
|
|
41
|
+
getKey?: (ctx: Context) => string | undefined;
|
|
42
|
+
/** called if a conversation builder throws. */
|
|
43
|
+
onError?: (error: unknown, ctx: Context) => void;
|
|
44
|
+
}
|
|
45
|
+
export declare function conversation(defs: ConversationDef[], options?: ConversationOptions): Plugin<Context, {
|
|
46
|
+
conversation: ConversationControl;
|
|
47
|
+
}>;
|
|
48
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAEpD;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,WAAW,YAAY;IAC5B,4DAA4D;IAC5D,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IACzB,2EAA2E;IAC3E,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,MAAM,mBAAmB,GAAG,CAAC,EAAE,EAAE,YAAY,EAAE,GAAG,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAE3F,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,mBAAmB,CAAC;CAC7B;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,mBAAmB,GAAG,eAAe,CAE9F;AAED,MAAM,WAAW,mBAAmB;IACnC,qDAAqD;IACrD,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,iEAAiE;IACjE,MAAM,IAAI,OAAO,CAAC;IAClB,sEAAsE;IACtE,KAAK,IAAI,IAAI,CAAC;CACd;AAED,MAAM,WAAW,mBAAmB;IACnC,yEAAyE;IACzE,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC;IAC9C,+CAA+C;IAC/C,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;CACjD;AAQD,wBAAgB,YAAY,CAC3B,IAAI,EAAE,eAAe,EAAE,EACvB,OAAO,GAAE,mBAAwB,GAC/B,MAAM,CAAC,OAAO,EAAE;IAAE,YAAY,EAAE,mBAAmB,CAAA;CAAE,CAAC,CA+ExD"}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export function createConversation(name, builder) {
|
|
2
|
+
return { name, builder };
|
|
3
|
+
}
|
|
4
|
+
export function conversation(defs, options = {}) {
|
|
5
|
+
const registry = new Map(defs.map((d) => [d.name, d]));
|
|
6
|
+
const sessions = new Map();
|
|
7
|
+
const getKey = options.getKey ?? ((ctx) => ctx.chat?.id?.toString());
|
|
8
|
+
const start = (key, def, enterCtx) => {
|
|
9
|
+
const live = { queue: [], current: enterCtx };
|
|
10
|
+
sessions.set(key, live);
|
|
11
|
+
const cv = {
|
|
12
|
+
wait: () => new Promise((resolve) => {
|
|
13
|
+
const queued = live.queue.shift();
|
|
14
|
+
if (queued) {
|
|
15
|
+
live.current = queued;
|
|
16
|
+
resolve(queued);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
live.waiter = resolve;
|
|
20
|
+
}
|
|
21
|
+
}),
|
|
22
|
+
get ctx() {
|
|
23
|
+
return live.current;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
Promise.resolve(def.builder(cv, enterCtx))
|
|
27
|
+
.catch((error) => options.onError?.(error, live.current))
|
|
28
|
+
.finally(() => {
|
|
29
|
+
if (sessions.get(key) === live)
|
|
30
|
+
sessions.delete(key);
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
const plugin = (composer) => composer
|
|
34
|
+
.derive((ctx) => {
|
|
35
|
+
const key = getKey(ctx);
|
|
36
|
+
const control = {
|
|
37
|
+
enter: (name) => {
|
|
38
|
+
if (key === undefined)
|
|
39
|
+
return;
|
|
40
|
+
const def = registry.get(name);
|
|
41
|
+
if (!def)
|
|
42
|
+
throw new Error(`conversation "${name}" is not registered`);
|
|
43
|
+
start(key, def, ctx);
|
|
44
|
+
},
|
|
45
|
+
active: () => key !== undefined && sessions.has(key),
|
|
46
|
+
leave: () => {
|
|
47
|
+
if (key !== undefined)
|
|
48
|
+
sessions.delete(key);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
return { conversation: control };
|
|
52
|
+
})
|
|
53
|
+
.use(async (ctx, next) => {
|
|
54
|
+
const key = getKey(ctx);
|
|
55
|
+
if (key !== undefined) {
|
|
56
|
+
const live = sessions.get(key);
|
|
57
|
+
if (live) {
|
|
58
|
+
if (live.waiter) {
|
|
59
|
+
const resolve = live.waiter;
|
|
60
|
+
live.waiter = undefined;
|
|
61
|
+
live.current = ctx;
|
|
62
|
+
resolve(ctx);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
live.queue.push(ctx);
|
|
66
|
+
}
|
|
67
|
+
return; // owned by the active conversation
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await next();
|
|
71
|
+
});
|
|
72
|
+
return plugin;
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Composer, Context } from "@yaebal/core";
|
|
4
|
+
import { conversation, createConversation } from "./index.js";
|
|
5
|
+
const noop = async () => { };
|
|
6
|
+
const flush = () => new Promise((r) => setTimeout(r, 0));
|
|
7
|
+
const entry = (c) => c.toMiddleware();
|
|
8
|
+
function fakeApi() {
|
|
9
|
+
const sent = [];
|
|
10
|
+
const api = {
|
|
11
|
+
sendMessage(p) {
|
|
12
|
+
sent.push(String(p.text));
|
|
13
|
+
return Promise.resolve({ message_id: 1 });
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
return { api, sent };
|
|
17
|
+
}
|
|
18
|
+
const msgCtx = (api, text, chatId) => new Context({
|
|
19
|
+
api,
|
|
20
|
+
update: {
|
|
21
|
+
update_id: 1,
|
|
22
|
+
message: {
|
|
23
|
+
message_id: 1,
|
|
24
|
+
date: 0,
|
|
25
|
+
chat: { id: chatId, type: "private" },
|
|
26
|
+
from: { id: chatId, is_bot: false, first_name: "u" },
|
|
27
|
+
text,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
updateType: "message",
|
|
31
|
+
});
|
|
32
|
+
test("conversation walks through steps and consumes the chat's updates", async () => {
|
|
33
|
+
const { api, sent } = fakeApi();
|
|
34
|
+
let fellThrough = 0;
|
|
35
|
+
const greet = createConversation("greet", async (cv, ctx) => {
|
|
36
|
+
await ctx.send("name?");
|
|
37
|
+
const a = await cv.wait();
|
|
38
|
+
await a.send("age?");
|
|
39
|
+
const b = await cv.wait();
|
|
40
|
+
await b.send(`${a.text} is ${b.text}`);
|
|
41
|
+
});
|
|
42
|
+
const mw = entry(new Composer()
|
|
43
|
+
.install(conversation([greet]))
|
|
44
|
+
.command("greet", (ctx) => ctx.conversation.enter("greet"))
|
|
45
|
+
.on("message:text", () => {
|
|
46
|
+
fellThrough++;
|
|
47
|
+
}));
|
|
48
|
+
await mw(msgCtx(api, "/greet", 1), noop);
|
|
49
|
+
await flush();
|
|
50
|
+
await mw(msgCtx(api, "Bob", 1), noop);
|
|
51
|
+
await flush();
|
|
52
|
+
await mw(msgCtx(api, "30", 1), noop);
|
|
53
|
+
await flush();
|
|
54
|
+
assert.deepEqual(sent, ["name?", "age?", "Bob is 30"]);
|
|
55
|
+
assert.equal(fellThrough, 0); // every step was consumed by the conversation
|
|
56
|
+
});
|
|
57
|
+
test("a different chat is not captured by another chat's conversation", async () => {
|
|
58
|
+
const { api } = fakeApi();
|
|
59
|
+
let seen = "";
|
|
60
|
+
const wait1 = createConversation("wait", async (cv) => {
|
|
61
|
+
await cv.wait();
|
|
62
|
+
});
|
|
63
|
+
const mw = entry(new Composer()
|
|
64
|
+
.install(conversation([wait1]))
|
|
65
|
+
.command("go", (ctx) => ctx.conversation.enter("wait"))
|
|
66
|
+
.on("message:text", (ctx) => {
|
|
67
|
+
seen = ctx.text;
|
|
68
|
+
}));
|
|
69
|
+
await mw(msgCtx(api, "/go", 1), noop); // chat 1 enters a conversation
|
|
70
|
+
await flush();
|
|
71
|
+
await mw(msgCtx(api, "hello", 2), noop); // chat 2 is independent
|
|
72
|
+
assert.equal(seen, "hello");
|
|
73
|
+
});
|
|
74
|
+
test("active() reflects conversation state, leave() ends it", async () => {
|
|
75
|
+
const { api } = fakeApi();
|
|
76
|
+
const states = [];
|
|
77
|
+
const wait1 = createConversation("wait", async (cv) => {
|
|
78
|
+
await cv.wait();
|
|
79
|
+
await cv.wait(); // would need a third update, but we leave() before then
|
|
80
|
+
});
|
|
81
|
+
const mw = entry(new Composer()
|
|
82
|
+
.install(conversation([wait1]))
|
|
83
|
+
.command("go", (ctx) => ctx.conversation.enter("wait"))
|
|
84
|
+
.on("message:text", (ctx) => {
|
|
85
|
+
states.push(ctx.conversation.active());
|
|
86
|
+
}));
|
|
87
|
+
await mw(msgCtx(api, "/go", 1), noop);
|
|
88
|
+
await flush();
|
|
89
|
+
// chat 1 now has an active conversation: the next text is consumed (no fall-through)
|
|
90
|
+
await mw(msgCtx(api, "x", 1), noop);
|
|
91
|
+
await flush();
|
|
92
|
+
const probe = msgCtx(api, "probe", 3);
|
|
93
|
+
await mw(probe, noop);
|
|
94
|
+
assert.equal(states.includes(true), false); // chat 3 never had one
|
|
95
|
+
assert.equal(probe.conversation.active(), false);
|
|
96
|
+
});
|
|
97
|
+
//# sourceMappingURL=index.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.js","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yaebal/conversation",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "yaebal conversation — await-style multi-step dialogs (coroutine, no replay).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.js",
|
|
7
|
+
"types": "./lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"import": "./lib/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"lib",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@yaebal/core": "0.0.1"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "latest"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"telegram",
|
|
29
|
+
"telegram-bot",
|
|
30
|
+
"yaebal",
|
|
31
|
+
"conversation",
|
|
32
|
+
"dialog"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/neverlane/yaebal",
|
|
38
|
+
"directory": "packages/conversation"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
46
|
+
"test": "node --test lib"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Composer, Context, type Middleware } from "@yaebal/core";
|
|
4
|
+
import { type ConversationControl, conversation, createConversation } from "./index.js";
|
|
5
|
+
|
|
6
|
+
const noop = async () => {};
|
|
7
|
+
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
|
|
8
|
+
const entry = <C extends Context>(c: Composer<C>) =>
|
|
9
|
+
c.toMiddleware() as unknown as Middleware<Context>;
|
|
10
|
+
|
|
11
|
+
function fakeApi() {
|
|
12
|
+
const sent: string[] = [];
|
|
13
|
+
const api = {
|
|
14
|
+
sendMessage(p: Record<string, unknown>) {
|
|
15
|
+
sent.push(String(p.text));
|
|
16
|
+
return Promise.resolve({ message_id: 1 });
|
|
17
|
+
},
|
|
18
|
+
} as never;
|
|
19
|
+
return { api, sent };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const msgCtx = (api: never, text: string, chatId: number) =>
|
|
23
|
+
new Context({
|
|
24
|
+
api,
|
|
25
|
+
update: {
|
|
26
|
+
update_id: 1,
|
|
27
|
+
message: {
|
|
28
|
+
message_id: 1,
|
|
29
|
+
date: 0,
|
|
30
|
+
chat: { id: chatId, type: "private" },
|
|
31
|
+
from: { id: chatId, is_bot: false, first_name: "u" },
|
|
32
|
+
text,
|
|
33
|
+
},
|
|
34
|
+
} as never,
|
|
35
|
+
updateType: "message",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
type Ctx = Context & { conversation: ConversationControl };
|
|
39
|
+
|
|
40
|
+
test("conversation walks through steps and consumes the chat's updates", async () => {
|
|
41
|
+
const { api, sent } = fakeApi();
|
|
42
|
+
let fellThrough = 0;
|
|
43
|
+
|
|
44
|
+
const greet = createConversation("greet", async (cv, ctx) => {
|
|
45
|
+
await ctx.send("name?");
|
|
46
|
+
const a = await cv.wait();
|
|
47
|
+
await a.send("age?");
|
|
48
|
+
const b = await cv.wait();
|
|
49
|
+
await b.send(`${a.text} is ${b.text}`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const mw = entry(
|
|
53
|
+
new Composer<Context>()
|
|
54
|
+
.install(conversation([greet]))
|
|
55
|
+
.command("greet", (ctx) => (ctx as Ctx).conversation.enter("greet"))
|
|
56
|
+
.on("message:text", () => {
|
|
57
|
+
fellThrough++;
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
await mw(msgCtx(api, "/greet", 1), noop);
|
|
62
|
+
await flush();
|
|
63
|
+
await mw(msgCtx(api, "Bob", 1), noop);
|
|
64
|
+
await flush();
|
|
65
|
+
await mw(msgCtx(api, "30", 1), noop);
|
|
66
|
+
await flush();
|
|
67
|
+
|
|
68
|
+
assert.deepEqual(sent, ["name?", "age?", "Bob is 30"]);
|
|
69
|
+
assert.equal(fellThrough, 0); // every step was consumed by the conversation
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a different chat is not captured by another chat's conversation", async () => {
|
|
73
|
+
const { api } = fakeApi();
|
|
74
|
+
let seen = "";
|
|
75
|
+
|
|
76
|
+
const wait1 = createConversation("wait", async (cv) => {
|
|
77
|
+
await cv.wait();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const mw = entry(
|
|
81
|
+
new Composer<Context>()
|
|
82
|
+
.install(conversation([wait1]))
|
|
83
|
+
.command("go", (ctx) => (ctx as Ctx).conversation.enter("wait"))
|
|
84
|
+
.on("message:text", (ctx) => {
|
|
85
|
+
seen = ctx.text;
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
await mw(msgCtx(api, "/go", 1), noop); // chat 1 enters a conversation
|
|
90
|
+
await flush();
|
|
91
|
+
await mw(msgCtx(api, "hello", 2), noop); // chat 2 is independent
|
|
92
|
+
assert.equal(seen, "hello");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("active() reflects conversation state, leave() ends it", async () => {
|
|
96
|
+
const { api } = fakeApi();
|
|
97
|
+
const states: boolean[] = [];
|
|
98
|
+
|
|
99
|
+
const wait1 = createConversation("wait", async (cv) => {
|
|
100
|
+
await cv.wait();
|
|
101
|
+
await cv.wait(); // would need a third update, but we leave() before then
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const mw = entry(
|
|
105
|
+
new Composer<Context>()
|
|
106
|
+
.install(conversation([wait1]))
|
|
107
|
+
.command("go", (ctx) => (ctx as Ctx).conversation.enter("wait"))
|
|
108
|
+
.on("message:text", (ctx) => {
|
|
109
|
+
states.push((ctx as Ctx).conversation.active());
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
await mw(msgCtx(api, "/go", 1), noop);
|
|
114
|
+
await flush();
|
|
115
|
+
// chat 1 now has an active conversation: the next text is consumed (no fall-through)
|
|
116
|
+
await mw(msgCtx(api, "x", 1), noop);
|
|
117
|
+
await flush();
|
|
118
|
+
|
|
119
|
+
const probe = msgCtx(api, "probe", 3);
|
|
120
|
+
await mw(probe, noop);
|
|
121
|
+
assert.equal(states.includes(true), false); // chat 3 never had one
|
|
122
|
+
assert.equal((probe as Ctx).conversation.active(), false);
|
|
123
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { Context, Plugin } from "@yaebal/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @yaebal/conversation — write multi-step dialogs as a straight line:
|
|
5
|
+
*
|
|
6
|
+
* const greet = createConversation("greet", async (cv, ctx) => {
|
|
7
|
+
* await ctx.send("what's your name?");
|
|
8
|
+
*
|
|
9
|
+
* const answer = await cv.wait();
|
|
10
|
+
* await answer.send(`hi ${answer.text}`);
|
|
11
|
+
* });
|
|
12
|
+
* bot.install(conversation([greet]));
|
|
13
|
+
* bot.command("greet", (ctx) => ctx.conversation.enter("greet"));
|
|
14
|
+
*
|
|
15
|
+
* it's a COROUTINE, not a replay engine (grammY-style): the builder runs once,
|
|
16
|
+
* detached, and `cv.wait()` resolves with the next update for that chat. while a
|
|
17
|
+
* conversation is active it OWNS the chat's updates (they don't reach other
|
|
18
|
+
* handlers). state is in-memory, lost on restart — like `prompt`/`scenes`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface Conversation {
|
|
22
|
+
/** resolve with the next update's context for this chat. */
|
|
23
|
+
wait(): Promise<Context>;
|
|
24
|
+
/** the most recent context (the entering update, then each waited one). */
|
|
25
|
+
readonly ctx: Context;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ConversationBuilder = (cv: Conversation, ctx: Context) => void | Promise<void>;
|
|
29
|
+
|
|
30
|
+
export interface ConversationDef {
|
|
31
|
+
name: string;
|
|
32
|
+
builder: ConversationBuilder;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createConversation(name: string, builder: ConversationBuilder): ConversationDef {
|
|
36
|
+
return { name, builder };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ConversationControl {
|
|
40
|
+
/** start a registered conversation for this chat. */
|
|
41
|
+
enter(name: string): void;
|
|
42
|
+
/** whether a conversation is currently running for this chat. */
|
|
43
|
+
active(): boolean;
|
|
44
|
+
/** abandon the active conversation (its coroutine is left parked). */
|
|
45
|
+
leave(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ConversationOptions {
|
|
49
|
+
/** conversation key for an update. default: per-chat (`ctx.chat.id`). */
|
|
50
|
+
getKey?: (ctx: Context) => string | undefined;
|
|
51
|
+
/** called if a conversation builder throws. */
|
|
52
|
+
onError?: (error: unknown, ctx: Context) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Live {
|
|
56
|
+
queue: Context[];
|
|
57
|
+
waiter?: (ctx: Context) => void;
|
|
58
|
+
current: Context;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function conversation(
|
|
62
|
+
defs: ConversationDef[],
|
|
63
|
+
options: ConversationOptions = {},
|
|
64
|
+
): Plugin<Context, { conversation: ConversationControl }> {
|
|
65
|
+
const registry = new Map(defs.map((d) => [d.name, d]));
|
|
66
|
+
const sessions = new Map<string, Live>();
|
|
67
|
+
const getKey = options.getKey ?? ((ctx: Context) => ctx.chat?.id?.toString());
|
|
68
|
+
|
|
69
|
+
const start = (key: string, def: ConversationDef, enterCtx: Context) => {
|
|
70
|
+
const live: Live = { queue: [], current: enterCtx };
|
|
71
|
+
sessions.set(key, live);
|
|
72
|
+
|
|
73
|
+
const cv: Conversation = {
|
|
74
|
+
wait: () =>
|
|
75
|
+
new Promise<Context>((resolve) => {
|
|
76
|
+
const queued = live.queue.shift();
|
|
77
|
+
|
|
78
|
+
if (queued) {
|
|
79
|
+
live.current = queued;
|
|
80
|
+
resolve(queued);
|
|
81
|
+
} else {
|
|
82
|
+
live.waiter = resolve;
|
|
83
|
+
}
|
|
84
|
+
}),
|
|
85
|
+
get ctx() {
|
|
86
|
+
return live.current;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
Promise.resolve(def.builder(cv, enterCtx))
|
|
91
|
+
.catch((error) => options.onError?.(error, live.current))
|
|
92
|
+
.finally(() => {
|
|
93
|
+
if (sessions.get(key) === live) sessions.delete(key);
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const plugin: Plugin<Context, { conversation: ConversationControl }> = (composer) =>
|
|
98
|
+
composer
|
|
99
|
+
.derive((ctx) => {
|
|
100
|
+
const key = getKey(ctx);
|
|
101
|
+
const control: ConversationControl = {
|
|
102
|
+
enter: (name) => {
|
|
103
|
+
if (key === undefined) return;
|
|
104
|
+
|
|
105
|
+
const def = registry.get(name);
|
|
106
|
+
if (!def) throw new Error(`conversation "${name}" is not registered`);
|
|
107
|
+
|
|
108
|
+
start(key, def, ctx);
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
active: () => key !== undefined && sessions.has(key),
|
|
112
|
+
leave: () => {
|
|
113
|
+
if (key !== undefined) sessions.delete(key);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return { conversation: control };
|
|
118
|
+
})
|
|
119
|
+
.use(async (ctx, next) => {
|
|
120
|
+
const key = getKey(ctx);
|
|
121
|
+
if (key !== undefined) {
|
|
122
|
+
const live = sessions.get(key);
|
|
123
|
+
|
|
124
|
+
if (live) {
|
|
125
|
+
if (live.waiter) {
|
|
126
|
+
const resolve = live.waiter;
|
|
127
|
+
|
|
128
|
+
live.waiter = undefined;
|
|
129
|
+
live.current = ctx;
|
|
130
|
+
|
|
131
|
+
resolve(ctx);
|
|
132
|
+
} else {
|
|
133
|
+
live.queue.push(ctx);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return; // owned by the active conversation
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
await next();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return plugin;
|
|
143
|
+
}
|