@fraqjs/plugin-conversation 0.1.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.
@@ -0,0 +1,42 @@
1
+ import { Context, Router, Session, milky } from "@fraqjs/fraq";
2
+
3
+ //#region src/service.d.ts
4
+ interface ConversationServiceOptions {
5
+ defaultTimeout?: number;
6
+ onCollision?: 'reject-incoming' | 'abort-existing';
7
+ }
8
+ interface ConversationContext<R> {
9
+ session: Session;
10
+ router: Router;
11
+ done(result: R): void;
12
+ abort(reason?: string): void;
13
+ }
14
+ interface ConversationOptions {
15
+ timeout?: number;
16
+ onUnmatched?: (raw: milky.IncomingMessage) => void | Promise<void>;
17
+ }
18
+ declare class ConversationService {
19
+ readonly defaultTimeout: number;
20
+ readonly onCollision: 'reject-incoming' | 'abort-existing';
21
+ private readonly activeConversations;
22
+ constructor(ctx: Context, options?: ConversationServiceOptions);
23
+ open<R>(session: Session, handler: (conversationContext: ConversationContext<R>) => void | Promise<void>, options?: ConversationOptions): Promise<R | null>;
24
+ private accept;
25
+ private resolve;
26
+ private reject;
27
+ private abort;
28
+ private settle;
29
+ }
30
+ //#endregion
31
+ //#region src/error.d.ts
32
+ declare class ConversationRejectionError extends Error {
33
+ constructor(reason: string);
34
+ }
35
+ declare class ConversationAbortionError extends Error {
36
+ constructor(reason: string);
37
+ }
38
+ //#endregion
39
+ //#region src/index.d.ts
40
+ declare const ConversationPlugin: import("@fraqjs/fraq").Plugin<[options: ConversationServiceOptions], import("@fraqjs/fraq").Injection | undefined, import("@fraqjs/fraq").Injection | undefined>;
41
+ //#endregion
42
+ export { ConversationAbortionError, ConversationContext, ConversationOptions, ConversationPlugin, ConversationRejectionError, ConversationService, ConversationServiceOptions };
package/dist/index.mjs ADDED
@@ -0,0 +1,124 @@
1
+ import { Router, definePlugin } from "@fraqjs/fraq";
2
+ //#region src/error.ts
3
+ var ConversationRejectionError = class extends Error {
4
+ constructor(reason) {
5
+ super(`Conversation rejected: ${reason}`);
6
+ this.name = "ConversationRejectionError";
7
+ }
8
+ };
9
+ var ConversationAbortionError = class extends Error {
10
+ constructor(reason) {
11
+ super(`Conversation aborted: ${reason}`);
12
+ this.name = "ConversationAbortionError";
13
+ }
14
+ };
15
+ //#endregion
16
+ //#region src/service.ts
17
+ function conversationKey(raw) {
18
+ return `${raw.sender_id}:${raw.message_scene}:${raw.peer_id}`;
19
+ }
20
+ function assertPositiveTimeout(timeout, name) {
21
+ if (!Number.isFinite(timeout) || timeout <= 0) throw new RangeError(`${name} must be a positive finite number.`);
22
+ }
23
+ function isSame(a, b) {
24
+ return a.message_scene === b.message_scene && a.peer_id === b.peer_id && a.sender_id === b.sender_id && a.message_seq === b.message_seq;
25
+ }
26
+ var ConversationService = class {
27
+ defaultTimeout;
28
+ onCollision;
29
+ activeConversations = /* @__PURE__ */ new Map();
30
+ constructor(ctx, options) {
31
+ this.defaultTimeout = options?.defaultTimeout ?? 3e4;
32
+ assertPositiveTimeout(this.defaultTimeout, "defaultTimeout");
33
+ this.onCollision = options?.onCollision ?? "reject-incoming";
34
+ ctx.on("message_receive", ({ data }) => {
35
+ this.accept(data);
36
+ });
37
+ }
38
+ async open(session, handler, options) {
39
+ const timeout = options?.timeout ?? this.defaultTimeout;
40
+ assertPositiveTimeout(timeout, "timeout");
41
+ const key = conversationKey(session.raw);
42
+ const existing = this.activeConversations.get(key);
43
+ if (existing) {
44
+ if (this.onCollision === "reject-incoming") return Promise.reject(new ConversationRejectionError("conversation already active"));
45
+ this.abort(existing, "aborted due to new conversation");
46
+ }
47
+ return new Promise((resolve, reject) => {
48
+ const active = {
49
+ key,
50
+ session,
51
+ router: new Router(),
52
+ options,
53
+ resolve,
54
+ reject,
55
+ ready: Promise.resolve().then(() => handler({
56
+ session,
57
+ router: active.router,
58
+ done: (result) => this.resolve(active, result),
59
+ abort: (reason) => this.abort(active, reason)
60
+ })).catch((error) => {
61
+ this.reject(active, error);
62
+ }),
63
+ settled: false
64
+ };
65
+ active.timeoutId = setTimeout(() => {
66
+ this.resolve(active, null);
67
+ }, timeout);
68
+ this.activeConversations.set(key, active);
69
+ });
70
+ }
71
+ async accept(raw) {
72
+ const active = this.activeConversations.get(conversationKey(raw));
73
+ if (!active || active.settled) return;
74
+ if (isSame(active.session.raw, raw)) return;
75
+ await active.ready;
76
+ if (this.activeConversations.get(active.key) !== active || active.settled) return;
77
+ const session = {
78
+ ...active.session,
79
+ raw
80
+ };
81
+ let matched;
82
+ try {
83
+ matched = await active.router.dispatch(session, raw);
84
+ } catch (error) {
85
+ this.reject(active, error);
86
+ return;
87
+ }
88
+ if (!matched && this.activeConversations.get(active.key) === active && !active.settled) try {
89
+ await active.options?.onUnmatched?.(raw);
90
+ } catch (error) {
91
+ this.reject(active, error);
92
+ }
93
+ }
94
+ resolve(active, result) {
95
+ this.settle(active, () => active.resolve(result));
96
+ }
97
+ reject(active, error) {
98
+ this.settle(active, () => active.reject(error));
99
+ }
100
+ abort(active, reason) {
101
+ this.reject(active, new ConversationAbortionError(reason ?? "conversation aborted"));
102
+ }
103
+ settle(active, onSettled) {
104
+ if (active.settled) return;
105
+ active.settled = true;
106
+ if (active.timeoutId) {
107
+ clearTimeout(active.timeoutId);
108
+ active.timeoutId = void 0;
109
+ }
110
+ if (this.activeConversations.get(active.key) === active) this.activeConversations.delete(active.key);
111
+ onSettled();
112
+ }
113
+ };
114
+ //#endregion
115
+ //#region src/index.ts
116
+ const ConversationPlugin = definePlugin({
117
+ name: "conversation",
118
+ provides: [ConversationService],
119
+ apply(ctx, options) {
120
+ ctx.provide(ConversationService, new ConversationService(ctx, options));
121
+ }
122
+ });
123
+ //#endregion
124
+ export { ConversationAbortionError, ConversationPlugin, ConversationRejectionError, ConversationService };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@fraqjs/plugin-conversation",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "description": "Conversation utility plugin for Fraq",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "main": "dist/index.mjs",
10
+ "typings": "dist/index.d.mts",
11
+ "keywords": [],
12
+ "author": "fraqjs",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/fraqjs/fraq.git"
16
+ },
17
+ "homepage": "https://fraq.ntqqrev.org/",
18
+ "license": "MIT",
19
+ "peerDependencies": {
20
+ "@fraqjs/fraq": "^0.4.0"
21
+ },
22
+ "devDependencies": {
23
+ "@fraqjs/mock": "^0.1.0"
24
+ },
25
+ "scripts": {
26
+ "build": "tsdown",
27
+ "test": "tsx --test test/*.test.ts"
28
+ }
29
+ }