@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.
Files changed (95) hide show
  1. package/README.md +109 -35
  2. package/lib/blocks.d.ts +93 -32
  3. package/lib/blocks.d.ts.map +1 -1
  4. package/lib/blocks.js +227 -71
  5. package/lib/blocks.js.map +1 -1
  6. package/lib/blocks.test.d.ts +2 -0
  7. package/lib/blocks.test.d.ts.map +1 -0
  8. package/lib/blocks.test.js +142 -0
  9. package/lib/blocks.test.js.map +1 -0
  10. package/lib/document.d.ts +28 -0
  11. package/lib/document.d.ts.map +1 -0
  12. package/lib/document.js +46 -0
  13. package/lib/document.js.map +1 -0
  14. package/lib/draft.d.ts +46 -9
  15. package/lib/draft.d.ts.map +1 -1
  16. package/lib/draft.js +115 -19
  17. package/lib/draft.js.map +1 -1
  18. package/lib/draft.test.d.ts +2 -0
  19. package/lib/draft.test.d.ts.map +1 -0
  20. package/lib/draft.test.js +122 -0
  21. package/lib/draft.test.js.map +1 -0
  22. package/lib/escape.d.ts +8 -0
  23. package/lib/escape.d.ts.map +1 -1
  24. package/lib/escape.js +17 -0
  25. package/lib/escape.js.map +1 -1
  26. package/lib/escape.test.d.ts +2 -0
  27. package/lib/escape.test.d.ts.map +1 -0
  28. package/lib/escape.test.js +28 -0
  29. package/lib/escape.test.js.map +1 -0
  30. package/lib/guards.test.d.ts +2 -0
  31. package/lib/guards.test.d.ts.map +1 -0
  32. package/lib/guards.test.js +85 -0
  33. package/lib/guards.test.js.map +1 -0
  34. package/lib/index.d.ts +18 -9
  35. package/lib/index.d.ts.map +1 -1
  36. package/lib/index.js +17 -8
  37. package/lib/index.js.map +1 -1
  38. package/lib/index.test.js +73 -103
  39. package/lib/index.test.js.map +1 -1
  40. package/lib/inline.d.ts +48 -59
  41. package/lib/inline.d.ts.map +1 -1
  42. package/lib/inline.js +87 -82
  43. package/lib/inline.js.map +1 -1
  44. package/lib/inline.test.d.ts +2 -0
  45. package/lib/inline.test.d.ts.map +1 -0
  46. package/lib/inline.test.js +84 -0
  47. package/lib/inline.test.js.map +1 -0
  48. package/lib/node.d.ts +25 -0
  49. package/lib/node.d.ts.map +1 -0
  50. package/lib/node.js +21 -0
  51. package/lib/node.js.map +1 -0
  52. package/lib/plaintext.test.d.ts +2 -0
  53. package/lib/plaintext.test.d.ts.map +1 -0
  54. package/lib/plaintext.test.js +100 -0
  55. package/lib/plaintext.test.js.map +1 -0
  56. package/lib/render.d.ts +17 -0
  57. package/lib/render.d.ts.map +1 -0
  58. package/lib/render.js +30 -0
  59. package/lib/render.js.map +1 -0
  60. package/lib/send.d.ts +6 -3
  61. package/lib/send.d.ts.map +1 -1
  62. package/lib/send.js +12 -3
  63. package/lib/send.js.map +1 -1
  64. package/lib/template.d.ts +46 -0
  65. package/lib/template.d.ts.map +1 -0
  66. package/lib/template.js +82 -0
  67. package/lib/template.js.map +1 -0
  68. package/lib/template.test.d.ts +2 -0
  69. package/lib/template.test.d.ts.map +1 -0
  70. package/lib/template.test.js +78 -0
  71. package/lib/template.test.js.map +1 -0
  72. package/package.json +5 -4
  73. package/src/blocks.test.ts +233 -0
  74. package/src/blocks.ts +304 -86
  75. package/src/document.ts +51 -0
  76. package/src/draft.test.ts +167 -0
  77. package/src/draft.ts +148 -21
  78. package/src/escape.test.ts +38 -0
  79. package/src/escape.ts +20 -0
  80. package/src/guards.test.ts +138 -0
  81. package/src/index.test.ts +79 -140
  82. package/src/index.ts +26 -11
  83. package/src/inline.test.ts +125 -0
  84. package/src/inline.ts +131 -97
  85. package/src/node.ts +43 -0
  86. package/src/plaintext.test.ts +141 -0
  87. package/src/render.ts +54 -0
  88. package/src/send.ts +17 -10
  89. package/src/template.test.ts +100 -0
  90. package/src/template.ts +115 -0
  91. package/lib/message.d.ts +0 -26
  92. package/lib/message.d.ts.map +0 -1
  93. package/lib/message.js +0 -31
  94. package/lib/message.js.map +0 -1
  95. 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 `commit()`/`cancel()`, so a stray late token can't
80
+ * - refuses to push after `send()`/`cancel()`, so a stray late call can't
26
81
  * resurrect a closed draft;
27
- * - requires an explicit `commit()` — there is no implicit "last push wins".
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.push(thinking("…"));
32
- * for await (const chunk of stream) await draft.push(document([paragraph(soFar)]));
33
- * await draft.commit(document([paragraph(finalAnswer)]));
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
- #latest: InputRichMessage | undefined;
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
- /** push a new partial draft; telegram animates the transition for a shared `draft_id`. */
59
- async push(input: InputRichMessage | string): Promise<void> {
60
- if (this.#closed) throw new Error("RichMessageDraft: push() after commit()/cancel()");
132
+ /** replace the whole draft with new content. */
133
+ async rewrite(input: DraftSource): Promise<void> {
134
+ this.#assertOpen();
61
135
 
62
- const resolved = typeof input === "string" ? { html: input } : input;
136
+ this.#state = resolve(input, this.#state?.dialect ?? "html");
137
+ await this.#pushCurrent();
138
+ this.#arm();
139
+ }
63
140
 
64
- this.#latest = resolved;
65
- await this.#push(resolved);
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.#latest) return;
190
+ if (!this.#state) return;
82
191
 
83
- this.#push(this.#latest).catch((error: unknown) => this.#onError?.(error));
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
- /** finalize: persist the real message and stop the keep-alive. always call this or `cancel()`. */
90
- async commit(
91
- input: InputRichMessage | string,
92
- extra: Record<string, unknown> = {},
93
- ): Promise<Message> {
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 resolved = typeof input === "string" ? { html: input } : input;
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: resolved,
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 &amp; b &lt; c &gt; 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 &quot;hi&quot; &amp; 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 &#38; b &#60; c &#62; 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, "&quot;");
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> = { "&": "&#38;", "<": "&#60;", ">": "&#62;" };
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
+ });