@yaebal/rich 0.0.1 → 0.0.3
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 +109 -35
- package/lib/blocks.d.ts +93 -32
- package/lib/blocks.d.ts.map +1 -1
- package/lib/blocks.js +227 -71
- package/lib/blocks.js.map +1 -1
- package/lib/blocks.test.d.ts +2 -0
- package/lib/blocks.test.d.ts.map +1 -0
- package/lib/blocks.test.js +142 -0
- package/lib/blocks.test.js.map +1 -0
- package/lib/document.d.ts +28 -0
- package/lib/document.d.ts.map +1 -0
- package/lib/document.js +46 -0
- package/lib/document.js.map +1 -0
- package/lib/draft.d.ts +46 -9
- package/lib/draft.d.ts.map +1 -1
- package/lib/draft.js +115 -19
- package/lib/draft.js.map +1 -1
- package/lib/draft.test.d.ts +2 -0
- package/lib/draft.test.d.ts.map +1 -0
- package/lib/draft.test.js +122 -0
- package/lib/draft.test.js.map +1 -0
- package/lib/escape.d.ts +8 -0
- package/lib/escape.d.ts.map +1 -1
- package/lib/escape.js +17 -0
- package/lib/escape.js.map +1 -1
- package/lib/escape.test.d.ts +2 -0
- package/lib/escape.test.d.ts.map +1 -0
- package/lib/escape.test.js +28 -0
- package/lib/escape.test.js.map +1 -0
- package/lib/guards.test.d.ts +2 -0
- package/lib/guards.test.d.ts.map +1 -0
- package/lib/guards.test.js +85 -0
- package/lib/guards.test.js.map +1 -0
- package/lib/index.d.ts +18 -9
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +17 -8
- package/lib/index.js.map +1 -1
- package/lib/index.test.js +73 -103
- package/lib/index.test.js.map +1 -1
- package/lib/inline.d.ts +48 -59
- package/lib/inline.d.ts.map +1 -1
- package/lib/inline.js +87 -82
- package/lib/inline.js.map +1 -1
- package/lib/inline.test.d.ts +2 -0
- package/lib/inline.test.d.ts.map +1 -0
- package/lib/inline.test.js +84 -0
- package/lib/inline.test.js.map +1 -0
- package/lib/node.d.ts +25 -0
- package/lib/node.d.ts.map +1 -0
- package/lib/node.js +21 -0
- package/lib/node.js.map +1 -0
- package/lib/plaintext.test.d.ts +2 -0
- package/lib/plaintext.test.d.ts.map +1 -0
- package/lib/plaintext.test.js +100 -0
- package/lib/plaintext.test.js.map +1 -0
- package/lib/render.d.ts +17 -0
- package/lib/render.d.ts.map +1 -0
- package/lib/render.js +30 -0
- package/lib/render.js.map +1 -0
- package/lib/send.d.ts +6 -3
- package/lib/send.d.ts.map +1 -1
- package/lib/send.js +12 -3
- package/lib/send.js.map +1 -1
- package/lib/template.d.ts +46 -0
- package/lib/template.d.ts.map +1 -0
- package/lib/template.js +82 -0
- package/lib/template.js.map +1 -0
- package/lib/template.test.d.ts +2 -0
- package/lib/template.test.d.ts.map +1 -0
- package/lib/template.test.js +78 -0
- package/lib/template.test.js.map +1 -0
- package/package.json +5 -4
- package/src/blocks.test.ts +233 -0
- package/src/blocks.ts +304 -86
- package/src/document.ts +51 -0
- package/src/draft.test.ts +167 -0
- package/src/draft.ts +148 -21
- package/src/escape.test.ts +38 -0
- package/src/escape.ts +20 -0
- package/src/guards.test.ts +138 -0
- package/src/index.test.ts +79 -140
- package/src/index.ts +26 -11
- package/src/inline.test.ts +125 -0
- package/src/inline.ts +131 -97
- package/src/node.ts +43 -0
- package/src/plaintext.test.ts +141 -0
- package/src/render.ts +54 -0
- package/src/send.ts +17 -10
- package/src/template.test.ts +100 -0
- package/src/template.ts +115 -0
- package/lib/message.d.ts +0 -26
- package/lib/message.d.ts.map +0 -1
- package/lib/message.js +0 -31
- package/lib/message.js.map +0 -1
- package/src/message.ts +0 -45
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mockApi } from "@yaebal/test";
|
|
4
|
+
import { RichMessageDraft } from "./draft.js";
|
|
5
|
+
|
|
6
|
+
test("rewrite() pushes a full sendRichMessageDraft snapshot and re-arms the keep-alive timer", async () => {
|
|
7
|
+
const { api, calls } = mockApi();
|
|
8
|
+
const draft = new RichMessageDraft(api, 1, 7, { keepAliveMs: 60_000 });
|
|
9
|
+
|
|
10
|
+
await draft.rewrite("<tg-thinking>…</tg-thinking>");
|
|
11
|
+
|
|
12
|
+
assert.deepEqual(
|
|
13
|
+
calls.map((c) => ({ method: c.method, params: c.params })),
|
|
14
|
+
[
|
|
15
|
+
{
|
|
16
|
+
method: "sendRichMessageDraft",
|
|
17
|
+
params: { chat_id: 1, draft_id: 7, rich_message: { html: "<tg-thinking>…</tg-thinking>" } },
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("rewrite() replaces the whole draft — a second call drops the first's content", async () => {
|
|
24
|
+
const { api, calls } = mockApi();
|
|
25
|
+
const draft = new RichMessageDraft(api, 1, 1, { keepAliveMs: 60_000 });
|
|
26
|
+
|
|
27
|
+
await draft.rewrite("<p>one</p>");
|
|
28
|
+
await draft.rewrite("<p>two</p>");
|
|
29
|
+
|
|
30
|
+
assert.equal(calls.length, 2);
|
|
31
|
+
assert.deepEqual(calls[1]?.params?.rich_message, { html: "<p>two</p>" });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("write() appends via string concatenation onto the current draft", async () => {
|
|
35
|
+
const { api, calls } = mockApi();
|
|
36
|
+
const draft = new RichMessageDraft(api, 1, 1, { keepAliveMs: 60_000 });
|
|
37
|
+
|
|
38
|
+
await draft.rewrite({ html: "<p>hello</p>" });
|
|
39
|
+
await draft.write({ html: "<hr/>" });
|
|
40
|
+
|
|
41
|
+
assert.deepEqual(calls[1]?.params?.rich_message, { html: "<p>hello</p><hr/>" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("write() before the first rewrite() throws", async () => {
|
|
45
|
+
const { api } = mockApi();
|
|
46
|
+
const draft = new RichMessageDraft(api, 1, 1);
|
|
47
|
+
|
|
48
|
+
await assert.rejects(() => draft.write("x"), /write\(\) before the first rewrite\(\)/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("write() with a dialect that doesn't match the draft's throws", async () => {
|
|
52
|
+
const { api } = mockApi();
|
|
53
|
+
const draft = new RichMessageDraft(api, 1, 1, { keepAliveMs: 60_000 });
|
|
54
|
+
|
|
55
|
+
await draft.rewrite({ html: "<p>hi</p>" });
|
|
56
|
+
|
|
57
|
+
await assert.rejects(
|
|
58
|
+
() => draft.write({ markdown: "hi" }),
|
|
59
|
+
/dialect "markdown" doesn't match the draft's "html"/,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("send() with no override auto-assembles from the accumulated rewrite()/write() calls", async () => {
|
|
64
|
+
const { api, calls } = mockApi();
|
|
65
|
+
const draft = new RichMessageDraft(api, 1, 1, { keepAliveMs: 60_000 });
|
|
66
|
+
|
|
67
|
+
await draft.rewrite({ html: "<p>hello</p>" });
|
|
68
|
+
await draft.write({ html: "<hr/>" });
|
|
69
|
+
await draft.send();
|
|
70
|
+
|
|
71
|
+
assert.equal(calls[2]?.method, "sendRichMessage");
|
|
72
|
+
assert.deepEqual(calls[2]?.params, { chat_id: 1, rich_message: { html: "<p>hello</p><hr/>" } });
|
|
73
|
+
assert.equal(draft.closed, true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("send(override) persists the override instead of the accumulated draft", async () => {
|
|
77
|
+
const { api, calls } = mockApi();
|
|
78
|
+
const draft = new RichMessageDraft(api, 1, 1, { keepAliveMs: 60_000 });
|
|
79
|
+
|
|
80
|
+
await draft.rewrite({ html: "<p>draft text</p>" });
|
|
81
|
+
await draft.send({ html: "<p>final text</p>" }, { reply_markup: { x: 1 } });
|
|
82
|
+
|
|
83
|
+
assert.equal(calls[1]?.method, "sendRichMessage");
|
|
84
|
+
assert.deepEqual(calls[1]?.params, {
|
|
85
|
+
chat_id: 1,
|
|
86
|
+
rich_message: { html: "<p>final text</p>" },
|
|
87
|
+
reply_markup: { x: 1 },
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("send() with nothing written and no override throws", async () => {
|
|
92
|
+
const { api } = mockApi();
|
|
93
|
+
const draft = new RichMessageDraft(api, 1, 1);
|
|
94
|
+
|
|
95
|
+
await assert.rejects(() => draft.send(), /send\(\) with nothing written/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("cancel() closes without persisting anything", async () => {
|
|
99
|
+
const { api, calls } = mockApi();
|
|
100
|
+
const draft = new RichMessageDraft(api, 1, 2, { keepAliveMs: 60_000 });
|
|
101
|
+
|
|
102
|
+
await draft.rewrite("draft text");
|
|
103
|
+
draft.cancel();
|
|
104
|
+
|
|
105
|
+
assert.equal(draft.closed, true);
|
|
106
|
+
assert.equal(
|
|
107
|
+
calls.some((c) => c.method === "sendRichMessage"),
|
|
108
|
+
false,
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("rewrite()/write() after send()/cancel() throws", async () => {
|
|
113
|
+
const { api } = mockApi();
|
|
114
|
+
const draft = new RichMessageDraft(api, 1, 1, { keepAliveMs: 60_000 });
|
|
115
|
+
|
|
116
|
+
await draft.rewrite("x");
|
|
117
|
+
await draft.send();
|
|
118
|
+
|
|
119
|
+
await assert.rejects(() => draft.rewrite("late"), /after send\(\)\/cancel\(\)/);
|
|
120
|
+
await assert.rejects(() => draft.write("late"), /after send\(\)\/cancel\(\)/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("messageThreadId routes both the keep-alive pushes and the final send", async () => {
|
|
124
|
+
const { api, calls } = mockApi();
|
|
125
|
+
const draft = new RichMessageDraft(api, 1, 1, { keepAliveMs: 60_000, messageThreadId: 42 });
|
|
126
|
+
|
|
127
|
+
await draft.rewrite("<p>hi</p>");
|
|
128
|
+
await draft.send();
|
|
129
|
+
|
|
130
|
+
assert.equal(calls[0]?.method, "sendRichMessageDraft");
|
|
131
|
+
assert.deepEqual(calls[0]?.params, {
|
|
132
|
+
chat_id: 1,
|
|
133
|
+
draft_id: 1,
|
|
134
|
+
rich_message: { html: "<p>hi</p>" },
|
|
135
|
+
message_thread_id: 42,
|
|
136
|
+
});
|
|
137
|
+
assert.equal(calls[1]?.method, "sendRichMessage");
|
|
138
|
+
assert.deepEqual(calls[1]?.params, {
|
|
139
|
+
chat_id: 1,
|
|
140
|
+
rich_message: { html: "<p>hi</p>" },
|
|
141
|
+
message_thread_id: 42,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("businessConnectionId routes only the final send (sendRichMessageDraft has no such param)", async () => {
|
|
146
|
+
const { api, calls } = mockApi();
|
|
147
|
+
const draft = new RichMessageDraft(api, 1, 1, {
|
|
148
|
+
keepAliveMs: 60_000,
|
|
149
|
+
businessConnectionId: "bc1",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await draft.rewrite("<p>hi</p>");
|
|
153
|
+
await draft.send();
|
|
154
|
+
|
|
155
|
+
assert.equal(calls[0]?.params?.business_connection_id, undefined);
|
|
156
|
+
assert.equal(calls[1]?.params?.business_connection_id, "bc1");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("preserves is_rtl/skip_entity_detection across write() unless the new push overrides them", async () => {
|
|
160
|
+
const { api, calls } = mockApi();
|
|
161
|
+
const draft = new RichMessageDraft(api, 1, 1, { keepAliveMs: 60_000 });
|
|
162
|
+
|
|
163
|
+
await draft.rewrite({ html: "<p>a</p>", is_rtl: true });
|
|
164
|
+
await draft.write({ html: "<p>b</p>" });
|
|
165
|
+
|
|
166
|
+
assert.deepEqual(calls[1]?.params?.rich_message, { html: "<p>a</p><p>b</p>", is_rtl: true });
|
|
167
|
+
});
|
package/src/draft.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Api, Message } from "@yaebal/core";
|
|
2
2
|
import type { InputRichMessage } from "@yaebal/types";
|
|
3
|
+
import { RichDocument } from "./document.js";
|
|
4
|
+
import type { Dialect } from "./node.js";
|
|
3
5
|
|
|
4
6
|
export interface RichMessageDraftOptions {
|
|
5
7
|
/**
|
|
@@ -11,6 +13,59 @@ export interface RichMessageDraftOptions {
|
|
|
11
13
|
keepAliveMs?: number;
|
|
12
14
|
/** called when a background keep-alive push fails (e.g. network blip). */
|
|
13
15
|
onError?: (error: unknown) => void;
|
|
16
|
+
/**
|
|
17
|
+
* the forum topic to stream/send into. sendRichMessageDraft has no
|
|
18
|
+
* business_connection_id param (drafts aren't supported in business chats), so only
|
|
19
|
+
* the thread id carries through the keep-alive pushes; `send()` additionally accepts
|
|
20
|
+
* `businessConnectionId` for the final, non-ephemeral sendRichMessage call.
|
|
21
|
+
*/
|
|
22
|
+
messageThreadId?: number;
|
|
23
|
+
/** routed into the final `send()` only — see {@link messageThreadId}. */
|
|
24
|
+
businessConnectionId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface DraftState {
|
|
28
|
+
dialect: Dialect;
|
|
29
|
+
text: string;
|
|
30
|
+
isRtl?: boolean;
|
|
31
|
+
skipEntityDetection?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type DraftSource = RichDocument | InputRichMessage | string;
|
|
35
|
+
|
|
36
|
+
function resolve(input: DraftSource, fallbackDialect: Dialect): DraftState {
|
|
37
|
+
if (typeof input === "string") return { dialect: fallbackDialect, text: input };
|
|
38
|
+
if (input instanceof RichDocument) return resolve(input.toInputRichMessage(), fallbackDialect);
|
|
39
|
+
|
|
40
|
+
if (input.html !== undefined) {
|
|
41
|
+
return {
|
|
42
|
+
dialect: "html",
|
|
43
|
+
text: input.html,
|
|
44
|
+
isRtl: input.is_rtl,
|
|
45
|
+
skipEntityDetection: input.skip_entity_detection,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (input.markdown !== undefined) {
|
|
50
|
+
return {
|
|
51
|
+
dialect: "markdown",
|
|
52
|
+
text: input.markdown,
|
|
53
|
+
isRtl: input.is_rtl,
|
|
54
|
+
skipEntityDetection: input.skip_entity_detection,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error("RichMessageDraft: input must have an `html` or `markdown` field");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toInputRichMessage(state: DraftState): InputRichMessage {
|
|
62
|
+
return {
|
|
63
|
+
[state.dialect]: state.text,
|
|
64
|
+
...(state.isRtl !== undefined ? { is_rtl: state.isRtl } : {}),
|
|
65
|
+
...(state.skipEntityDetection !== undefined
|
|
66
|
+
? { skip_entity_detection: state.skipEntityDetection }
|
|
67
|
+
: {}),
|
|
68
|
+
};
|
|
14
69
|
}
|
|
15
70
|
|
|
16
71
|
/**
|
|
@@ -22,15 +77,30 @@ export interface RichMessageDraftOptions {
|
|
|
22
77
|
*
|
|
23
78
|
* - re-pushes the latest draft on a timer so a slow generator (e.g. an LLM
|
|
24
79
|
* stream) doesn't lose the draft between tokens;
|
|
25
|
-
* - refuses to push after `
|
|
80
|
+
* - refuses to push after `send()`/`cancel()`, so a stray late call can't
|
|
26
81
|
* resurrect a closed draft;
|
|
27
|
-
* - requires an explicit `
|
|
82
|
+
* - requires an explicit `send()` — there is no implicit "last push wins".
|
|
83
|
+
*
|
|
84
|
+
* two ways to grow the draft: `rewrite()` replaces the whole thing (what you want
|
|
85
|
+
* for a token stream, where each chunk is a longer version of the *same* paragraph),
|
|
86
|
+
* `write()` appends to it via plain string concatenation (what you want to tack on
|
|
87
|
+
* a block — a footer, a divider — after content that's already there, without
|
|
88
|
+
* re-supplying it). a push boundary that lands mid-tag with `write()` is on the
|
|
89
|
+
* caller, same tradeoff raw concatenation always has.
|
|
90
|
+
*
|
|
91
|
+
* `send()` finalizes: with no argument it auto-assembles from the accumulated
|
|
92
|
+
* `rewrite()`/`write()` calls, so you don't have to re-render the final content —
|
|
93
|
+
* pass an explicit override when you want the persisted message to differ from the
|
|
94
|
+
* last draft snapshot.
|
|
28
95
|
*
|
|
29
96
|
* @example
|
|
30
97
|
* const draft = ctx.richMessageDraft(1);
|
|
31
|
-
* await draft.
|
|
32
|
-
* for await (const chunk of stream)
|
|
33
|
-
*
|
|
98
|
+
* await draft.rewrite(document([thinking("…")]));
|
|
99
|
+
* for await (const chunk of stream) {
|
|
100
|
+
* soFar += chunk;
|
|
101
|
+
* await draft.rewrite(document([paragraph(soFar)]));
|
|
102
|
+
* }
|
|
103
|
+
* await draft.send();
|
|
34
104
|
*/
|
|
35
105
|
export class RichMessageDraft {
|
|
36
106
|
readonly #api: Api;
|
|
@@ -38,9 +108,11 @@ export class RichMessageDraft {
|
|
|
38
108
|
readonly #draftId: number;
|
|
39
109
|
readonly #keepAliveMs: number;
|
|
40
110
|
readonly #onError?: (error: unknown) => void;
|
|
111
|
+
readonly #messageThreadId?: number;
|
|
112
|
+
readonly #businessConnectionId?: string;
|
|
41
113
|
|
|
42
114
|
#timer?: ReturnType<typeof setInterval>;
|
|
43
|
-
#
|
|
115
|
+
#state: DraftState | undefined;
|
|
44
116
|
#closed = false;
|
|
45
117
|
|
|
46
118
|
constructor(api: Api, chatId: number, draftId: number, options: RichMessageDraftOptions = {}) {
|
|
@@ -49,28 +121,65 @@ export class RichMessageDraft {
|
|
|
49
121
|
this.#draftId = draftId;
|
|
50
122
|
this.#keepAliveMs = options.keepAliveMs ?? 20_000;
|
|
51
123
|
this.#onError = options.onError;
|
|
124
|
+
this.#messageThreadId = options.messageThreadId;
|
|
125
|
+
this.#businessConnectionId = options.businessConnectionId;
|
|
52
126
|
}
|
|
53
127
|
|
|
54
128
|
get closed(): boolean {
|
|
55
129
|
return this.#closed;
|
|
56
130
|
}
|
|
57
131
|
|
|
58
|
-
/**
|
|
59
|
-
async
|
|
60
|
-
|
|
132
|
+
/** replace the whole draft with new content. */
|
|
133
|
+
async rewrite(input: DraftSource): Promise<void> {
|
|
134
|
+
this.#assertOpen();
|
|
61
135
|
|
|
62
|
-
|
|
136
|
+
this.#state = resolve(input, this.#state?.dialect ?? "html");
|
|
137
|
+
await this.#pushCurrent();
|
|
138
|
+
this.#arm();
|
|
139
|
+
}
|
|
63
140
|
|
|
64
|
-
|
|
65
|
-
|
|
141
|
+
/**
|
|
142
|
+
* append to the current draft (plain string concatenation of the resolved text).
|
|
143
|
+
* throws if `input`'s dialect doesn't match the draft's, or if nothing has been
|
|
144
|
+
* written yet — call `rewrite()` first.
|
|
145
|
+
*/
|
|
146
|
+
async write(input: DraftSource): Promise<void> {
|
|
147
|
+
this.#assertOpen();
|
|
148
|
+
|
|
149
|
+
if (!this.#state) {
|
|
150
|
+
throw new Error("RichMessageDraft: write() before the first rewrite()");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const next = resolve(input, this.#state.dialect);
|
|
154
|
+
if (next.dialect !== this.#state.dialect) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`RichMessageDraft: write() dialect "${next.dialect}" doesn't match the draft's "${this.#state.dialect}"`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this.#state = {
|
|
161
|
+
dialect: this.#state.dialect,
|
|
162
|
+
text: this.#state.text + next.text,
|
|
163
|
+
isRtl: next.isRtl ?? this.#state.isRtl,
|
|
164
|
+
skipEntityDetection: next.skipEntityDetection ?? this.#state.skipEntityDetection,
|
|
165
|
+
};
|
|
166
|
+
await this.#pushCurrent();
|
|
66
167
|
this.#arm();
|
|
67
168
|
}
|
|
68
169
|
|
|
170
|
+
#pushCurrent(): Promise<boolean> {
|
|
171
|
+
const state = this.#state;
|
|
172
|
+
if (!state) throw new Error("unreachable: #pushCurrent() with no state");
|
|
173
|
+
|
|
174
|
+
return this.#push(toInputRichMessage(state));
|
|
175
|
+
}
|
|
176
|
+
|
|
69
177
|
#push(input: InputRichMessage): Promise<boolean> {
|
|
70
178
|
return this.#api.call<boolean>("sendRichMessageDraft", {
|
|
71
179
|
chat_id: this.#chatId,
|
|
72
180
|
draft_id: this.#draftId,
|
|
73
181
|
rich_message: input,
|
|
182
|
+
...(this.#messageThreadId === undefined ? {} : { message_thread_id: this.#messageThreadId }),
|
|
74
183
|
});
|
|
75
184
|
}
|
|
76
185
|
|
|
@@ -78,26 +187,38 @@ export class RichMessageDraft {
|
|
|
78
187
|
clearInterval(this.#timer);
|
|
79
188
|
|
|
80
189
|
this.#timer = setInterval(() => {
|
|
81
|
-
if (!this.#
|
|
190
|
+
if (!this.#state) return;
|
|
82
191
|
|
|
83
|
-
this.#push(this.#
|
|
192
|
+
this.#push(toInputRichMessage(this.#state)).catch((error: unknown) => this.#onError?.(error));
|
|
84
193
|
}, this.#keepAliveMs);
|
|
85
194
|
|
|
86
195
|
this.#timer.unref?.();
|
|
87
196
|
}
|
|
88
197
|
|
|
89
|
-
/**
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
198
|
+
/**
|
|
199
|
+
* finalize: persist the real message and stop the keep-alive. with no `override`,
|
|
200
|
+
* auto-assembles from the accumulated `rewrite()`/`write()` calls. always call this
|
|
201
|
+
* or `cancel()`.
|
|
202
|
+
*/
|
|
203
|
+
async send(override?: DraftSource, extra: Record<string, unknown> = {}): Promise<Message> {
|
|
94
204
|
this.#stop();
|
|
95
205
|
|
|
96
|
-
const
|
|
206
|
+
const state =
|
|
207
|
+
override !== undefined ? resolve(override, this.#state?.dialect ?? "html") : this.#state;
|
|
208
|
+
|
|
209
|
+
if (!state) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
"RichMessageDraft: send() with nothing written — call rewrite()/write() first or pass an override",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
97
214
|
|
|
98
215
|
return this.#api.call<Message>("sendRichMessage", {
|
|
99
216
|
chat_id: this.#chatId,
|
|
100
|
-
rich_message:
|
|
217
|
+
rich_message: toInputRichMessage(state),
|
|
218
|
+
...(this.#messageThreadId === undefined ? {} : { message_thread_id: this.#messageThreadId }),
|
|
219
|
+
...(this.#businessConnectionId === undefined
|
|
220
|
+
? {}
|
|
221
|
+
: { business_connection_id: this.#businessConnectionId }),
|
|
101
222
|
...extra,
|
|
102
223
|
});
|
|
103
224
|
}
|
|
@@ -113,4 +234,10 @@ export class RichMessageDraft {
|
|
|
113
234
|
this.#closed = true;
|
|
114
235
|
clearInterval(this.#timer);
|
|
115
236
|
}
|
|
237
|
+
|
|
238
|
+
#assertOpen(): void {
|
|
239
|
+
if (this.#closed) {
|
|
240
|
+
throw new Error("RichMessageDraft: rewrite()/write() after send()/cancel()");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
116
243
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { escapeAttr, escapeMarkdown, escapeMarkdownUrl, escapeText } from "./escape.js";
|
|
4
|
+
|
|
5
|
+
test("escapeText escapes &, <, > with named entities", () => {
|
|
6
|
+
assert.equal(escapeText("a & b < c > d"), "a & b < c > d");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("escapeText leaves other characters (including quotes) untouched", () => {
|
|
10
|
+
assert.equal(escapeText(`"hi" 'there'`), `"hi" 'there'`);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("escapeAttr additionally escapes double quotes", () => {
|
|
14
|
+
assert.equal(escapeAttr(`say "hi" & bye`), "say "hi" & bye");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("escapeMarkdown backslash-escapes rich-markdown specials", () => {
|
|
18
|
+
assert.equal(
|
|
19
|
+
escapeMarkdown("a*b_c~d=e|f[g]h(i)j#k!l+m-n`o"),
|
|
20
|
+
"a\\*b\\_c\\~d\\=e\\|f\\[g\\]h\\(i\\)j\\#k\\!l\\+m\\-n\\`o",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("escapeMarkdown numeric-entity-escapes &, <, >", () => {
|
|
25
|
+
assert.equal(escapeMarkdown("a & b < c > d"), "a & b < c > d");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("escapeMarkdown leaves plain text untouched", () => {
|
|
29
|
+
assert.equal(escapeMarkdown("hello world 123"), "hello world 123");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("escapeMarkdownUrl escapes parens/backslash so a url can't break out of [text](url)", () => {
|
|
33
|
+
assert.equal(escapeMarkdownUrl("https://x.test/a(b)c\\d"), "https://x.test/a\\(b\\)c\\\\d");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("escapeMarkdownUrl percent-encodes whitespace", () => {
|
|
37
|
+
assert.equal(escapeMarkdownUrl("https://x.test/a b"), "https://x.test/a%20b");
|
|
38
|
+
});
|
package/src/escape.ts
CHANGED
|
@@ -7,3 +7,23 @@ export function escapeText(value: string): string {
|
|
|
7
7
|
export function escapeAttr(value: string): string {
|
|
8
8
|
return escapeText(value).replace(/"/g, """);
|
|
9
9
|
}
|
|
10
|
+
|
|
11
|
+
// rich-markdown specials. `&`/`<`/`>` become numeric entities (rich-markdown renders
|
|
12
|
+
// `\<` with the backslash showing, but does accept entities — the same trick telegram's
|
|
13
|
+
// classic parse_mode markdown uses); everything else backslash-escapes cleanly.
|
|
14
|
+
const MARKDOWN_SPECIALS = /[\\`*_~=|[\]()#!+\-&<>]/g;
|
|
15
|
+
const MARKDOWN_ENTITY: Record<string, string> = { "&": "&", "<": "<", ">": ">" };
|
|
16
|
+
|
|
17
|
+
/** escape text so it can never be re-parsed as rich-markdown syntax. */
|
|
18
|
+
export function escapeMarkdown(value: string): string {
|
|
19
|
+
return value.replace(MARKDOWN_SPECIALS, (ch) => MARKDOWN_ENTITY[ch] ?? `\\${ch}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* escape a url used as a markdown link destination. `[text](url)` is terminated by `)`
|
|
24
|
+
* or whitespace, so an attacker-controlled url can't break out of the link — escape the
|
|
25
|
+
* parens/backslash and percent-encode whitespace.
|
|
26
|
+
*/
|
|
27
|
+
export function escapeMarkdownUrl(value: string): string {
|
|
28
|
+
return value.replace(/[\\()]/g, "\\$&").replace(/\s/g, (ch) => encodeURIComponent(ch));
|
|
29
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import type { RichBlock, RichText } from "@yaebal/types";
|
|
4
|
+
import {
|
|
5
|
+
isAnchorBlock,
|
|
6
|
+
isAnchorLink,
|
|
7
|
+
isAnchorText,
|
|
8
|
+
isAnimation,
|
|
9
|
+
isAudio,
|
|
10
|
+
isBankCardNumber,
|
|
11
|
+
isBlockquote,
|
|
12
|
+
isBold,
|
|
13
|
+
isBotCommand,
|
|
14
|
+
isCashtag,
|
|
15
|
+
isCodeText,
|
|
16
|
+
isCollage,
|
|
17
|
+
isCustomEmoji,
|
|
18
|
+
isDateTime,
|
|
19
|
+
isDetails,
|
|
20
|
+
isDivider,
|
|
21
|
+
isEmailAddress,
|
|
22
|
+
isFooter,
|
|
23
|
+
isHashtag,
|
|
24
|
+
isHeading,
|
|
25
|
+
isItalic,
|
|
26
|
+
isList,
|
|
27
|
+
isMap,
|
|
28
|
+
isMarked,
|
|
29
|
+
isMathBlock,
|
|
30
|
+
isMathText,
|
|
31
|
+
isMentionText,
|
|
32
|
+
isParagraph,
|
|
33
|
+
isPhoneNumber,
|
|
34
|
+
isPhoto,
|
|
35
|
+
isPreformatted,
|
|
36
|
+
isPullquote,
|
|
37
|
+
isReference,
|
|
38
|
+
isReferenceLink,
|
|
39
|
+
isSlideshow,
|
|
40
|
+
isSpoilerText,
|
|
41
|
+
isStrikethrough,
|
|
42
|
+
isSubscript,
|
|
43
|
+
isSuperscript,
|
|
44
|
+
isTable,
|
|
45
|
+
isTextMention,
|
|
46
|
+
isThinking,
|
|
47
|
+
isUnderline,
|
|
48
|
+
isUrlText,
|
|
49
|
+
isVideo,
|
|
50
|
+
isVoiceNote,
|
|
51
|
+
} from "./guards.js";
|
|
52
|
+
|
|
53
|
+
const BLOCK_GUARDS: [string, (b: RichBlock) => boolean][] = [
|
|
54
|
+
["paragraph", isParagraph],
|
|
55
|
+
["heading", isHeading],
|
|
56
|
+
["pre", isPreformatted],
|
|
57
|
+
["footer", isFooter],
|
|
58
|
+
["divider", isDivider],
|
|
59
|
+
["mathematical_expression", isMathBlock],
|
|
60
|
+
["anchor", isAnchorBlock],
|
|
61
|
+
["list", isList],
|
|
62
|
+
["blockquote", isBlockquote],
|
|
63
|
+
["pullquote", isPullquote],
|
|
64
|
+
["collage", isCollage],
|
|
65
|
+
["slideshow", isSlideshow],
|
|
66
|
+
["table", isTable],
|
|
67
|
+
["details", isDetails],
|
|
68
|
+
["map", isMap],
|
|
69
|
+
["animation", isAnimation],
|
|
70
|
+
["audio", isAudio],
|
|
71
|
+
["photo", isPhoto],
|
|
72
|
+
["video", isVideo],
|
|
73
|
+
["voice_note", isVoiceNote],
|
|
74
|
+
["thinking", isThinking],
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const TEXT_GUARDS: [string, (t: RichText) => boolean][] = [
|
|
78
|
+
["bold", isBold],
|
|
79
|
+
["italic", isItalic],
|
|
80
|
+
["underline", isUnderline],
|
|
81
|
+
["strikethrough", isStrikethrough],
|
|
82
|
+
["spoiler", isSpoilerText],
|
|
83
|
+
["date_time", isDateTime],
|
|
84
|
+
["text_mention", isTextMention],
|
|
85
|
+
["subscript", isSubscript],
|
|
86
|
+
["superscript", isSuperscript],
|
|
87
|
+
["marked", isMarked],
|
|
88
|
+
["code", isCodeText],
|
|
89
|
+
["custom_emoji", isCustomEmoji],
|
|
90
|
+
["mathematical_expression", isMathText],
|
|
91
|
+
["url", isUrlText],
|
|
92
|
+
["email_address", isEmailAddress],
|
|
93
|
+
["phone_number", isPhoneNumber],
|
|
94
|
+
["bank_card_number", isBankCardNumber],
|
|
95
|
+
["mention", isMentionText],
|
|
96
|
+
["hashtag", isHashtag],
|
|
97
|
+
["cashtag", isCashtag],
|
|
98
|
+
["bot_command", isBotCommand],
|
|
99
|
+
["anchor", isAnchorText],
|
|
100
|
+
["anchor_link", isAnchorLink],
|
|
101
|
+
["reference", isReference],
|
|
102
|
+
["reference_link", isReferenceLink],
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
test("every RichBlock guard matches its own .type and rejects every other guard's", () => {
|
|
106
|
+
for (const [type, guard] of BLOCK_GUARDS) {
|
|
107
|
+
const block = { type } as unknown as RichBlock;
|
|
108
|
+
assert.equal(guard(block), true, `${type} guard should match its own type`);
|
|
109
|
+
|
|
110
|
+
for (const [otherType, otherGuard] of BLOCK_GUARDS) {
|
|
111
|
+
if (otherType === type) continue;
|
|
112
|
+
assert.equal(otherGuard(block), false, `${otherType} guard should reject a "${type}" block`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("every RichText guard matches its own .type and rejects every other guard's", () => {
|
|
118
|
+
for (const [type, guard] of TEXT_GUARDS) {
|
|
119
|
+
const text = { type } as unknown as RichText;
|
|
120
|
+
assert.equal(guard(text), true, `${type} guard should match its own type`);
|
|
121
|
+
|
|
122
|
+
for (const [otherType, otherGuard] of TEXT_GUARDS) {
|
|
123
|
+
if (otherType === type) continue;
|
|
124
|
+
assert.equal(otherGuard(text), false, `${otherType} guard should reject a "${type}" text`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("guards narrow RichBlock by .type despite the generated field being `string`", () => {
|
|
130
|
+
const blocks = [
|
|
131
|
+
{ type: "table", cells: [[{ text: "x", align: "left", valign: "top" }]] },
|
|
132
|
+
{ type: "photo", photo: [] },
|
|
133
|
+
] as unknown as RichBlock[];
|
|
134
|
+
|
|
135
|
+
assert.equal(isTable(blocks[0] as RichBlock), true);
|
|
136
|
+
assert.equal(isPhoto(blocks[1] as RichBlock), true);
|
|
137
|
+
assert.equal(isTable(blocks[1] as RichBlock), false);
|
|
138
|
+
});
|