@yaebal/rich 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 +109 -0
- package/lib/blocks.d.ts +115 -0
- package/lib/blocks.d.ts.map +1 -0
- package/lib/blocks.js +149 -0
- package/lib/blocks.js.map +1 -0
- package/lib/draft.d.ts +44 -0
- package/lib/draft.d.ts.map +1 -0
- package/lib/draft.js +85 -0
- package/lib/draft.js.map +1 -0
- package/lib/escape.d.ts +5 -0
- package/lib/escape.d.ts.map +1 -0
- package/lib/escape.js +9 -0
- package/lib/escape.js.map +1 -0
- package/lib/guards.d.ts +106 -0
- package/lib/guards.d.ts.map +1 -0
- package/lib/guards.js +55 -0
- package/lib/guards.js.map +1 -0
- package/lib/index.d.ts +33 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +32 -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 +130 -0
- package/lib/index.test.js.map +1 -0
- package/lib/inline.d.ts +79 -0
- package/lib/inline.d.ts.map +1 -0
- package/lib/inline.js +120 -0
- package/lib/inline.js.map +1 -0
- package/lib/message.d.ts +26 -0
- package/lib/message.d.ts.map +1 -0
- package/lib/message.js +31 -0
- package/lib/message.js.map +1 -0
- package/lib/plaintext.d.ts +16 -0
- package/lib/plaintext.d.ts.map +1 -0
- package/lib/plaintext.js +104 -0
- package/lib/plaintext.js.map +1 -0
- package/lib/send.d.ts +27 -0
- package/lib/send.d.ts.map +1 -0
- package/lib/send.js +52 -0
- package/lib/send.js.map +1 -0
- package/package.json +51 -0
- package/src/blocks.ts +250 -0
- package/src/draft.ts +116 -0
- package/src/escape.ts +9 -0
- package/src/guards.ts +169 -0
- package/src/index.test.ts +201 -0
- package/src/index.ts +199 -0
- package/src/inline.ts +152 -0
- package/src/message.ts +45 -0
- package/src/plaintext.ts +143 -0
- package/src/send.ts +81 -0
package/src/draft.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Api, Message } from "@yaebal/core";
|
|
2
|
+
import type { InputRichMessage } from "@yaebal/types";
|
|
3
|
+
|
|
4
|
+
export interface RichMessageDraftOptions {
|
|
5
|
+
/**
|
|
6
|
+
* how often to re-push the last draft to keep it alive, in ms. telegram drops
|
|
7
|
+
* a draft 30s after the last `sendRichMessageDraft` call for its `draft_id`
|
|
8
|
+
* ("ephemeral … temporary 30-second preview"); this must stay comfortably
|
|
9
|
+
* under that. defaults to 20_000.
|
|
10
|
+
*/
|
|
11
|
+
keepAliveMs?: number;
|
|
12
|
+
/** called when a background keep-alive push fails (e.g. network blip). */
|
|
13
|
+
onError?: (error: unknown) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* a `sendRichMessageDraft` streaming session — the operationally hard part of the
|
|
18
|
+
* rich-message api. telegram's draft is ephemeral: it vanishes 30s after the last
|
|
19
|
+
* push, and it never turns into a real message on its own (per the schema:
|
|
20
|
+
* "once the output is finalized, you must call sendRichMessage with the complete
|
|
21
|
+
* message to persist it"). this class:
|
|
22
|
+
*
|
|
23
|
+
* - re-pushes the latest draft on a timer so a slow generator (e.g. an LLM
|
|
24
|
+
* stream) doesn't lose the draft between tokens;
|
|
25
|
+
* - refuses to push after `commit()`/`cancel()`, so a stray late token can't
|
|
26
|
+
* resurrect a closed draft;
|
|
27
|
+
* - requires an explicit `commit()` — there is no implicit "last push wins".
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* 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)]));
|
|
34
|
+
*/
|
|
35
|
+
export class RichMessageDraft {
|
|
36
|
+
readonly #api: Api;
|
|
37
|
+
readonly #chatId: number;
|
|
38
|
+
readonly #draftId: number;
|
|
39
|
+
readonly #keepAliveMs: number;
|
|
40
|
+
readonly #onError?: (error: unknown) => void;
|
|
41
|
+
|
|
42
|
+
#timer?: ReturnType<typeof setInterval>;
|
|
43
|
+
#latest: InputRichMessage | undefined;
|
|
44
|
+
#closed = false;
|
|
45
|
+
|
|
46
|
+
constructor(api: Api, chatId: number, draftId: number, options: RichMessageDraftOptions = {}) {
|
|
47
|
+
this.#api = api;
|
|
48
|
+
this.#chatId = chatId;
|
|
49
|
+
this.#draftId = draftId;
|
|
50
|
+
this.#keepAliveMs = options.keepAliveMs ?? 20_000;
|
|
51
|
+
this.#onError = options.onError;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get closed(): boolean {
|
|
55
|
+
return this.#closed;
|
|
56
|
+
}
|
|
57
|
+
|
|
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()");
|
|
61
|
+
|
|
62
|
+
const resolved = typeof input === "string" ? { html: input } : input;
|
|
63
|
+
|
|
64
|
+
this.#latest = resolved;
|
|
65
|
+
await this.#push(resolved);
|
|
66
|
+
this.#arm();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#push(input: InputRichMessage): Promise<boolean> {
|
|
70
|
+
return this.#api.call<boolean>("sendRichMessageDraft", {
|
|
71
|
+
chat_id: this.#chatId,
|
|
72
|
+
draft_id: this.#draftId,
|
|
73
|
+
rich_message: input,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#arm(): void {
|
|
78
|
+
clearInterval(this.#timer);
|
|
79
|
+
|
|
80
|
+
this.#timer = setInterval(() => {
|
|
81
|
+
if (!this.#latest) return;
|
|
82
|
+
|
|
83
|
+
this.#push(this.#latest).catch((error: unknown) => this.#onError?.(error));
|
|
84
|
+
}, this.#keepAliveMs);
|
|
85
|
+
|
|
86
|
+
this.#timer.unref?.();
|
|
87
|
+
}
|
|
88
|
+
|
|
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> {
|
|
94
|
+
this.#stop();
|
|
95
|
+
|
|
96
|
+
const resolved = typeof input === "string" ? { html: input } : input;
|
|
97
|
+
|
|
98
|
+
return this.#api.call<Message>("sendRichMessage", {
|
|
99
|
+
chat_id: this.#chatId,
|
|
100
|
+
rich_message: resolved,
|
|
101
|
+
...extra,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** abandon the draft without persisting anything — it expires within 30s on its own. */
|
|
106
|
+
cancel(): void {
|
|
107
|
+
this.#stop();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#stop(): void {
|
|
111
|
+
if (this.#closed) return;
|
|
112
|
+
|
|
113
|
+
this.#closed = true;
|
|
114
|
+
clearInterval(this.#timer);
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/escape.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** escape text so it can never be re-parsed as a tag or entity. */
|
|
2
|
+
export function escapeText(value: string): string {
|
|
3
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** escape a double-quoted attribute value. */
|
|
7
|
+
export function escapeAttr(value: string): string {
|
|
8
|
+
return escapeText(value).replace(/"/g, """);
|
|
9
|
+
}
|
package/src/guards.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RichBlock,
|
|
3
|
+
RichBlockAnchor,
|
|
4
|
+
RichBlockAnimation,
|
|
5
|
+
RichBlockAudio,
|
|
6
|
+
RichBlockBlockQuotation,
|
|
7
|
+
RichBlockCollage,
|
|
8
|
+
RichBlockDetails,
|
|
9
|
+
RichBlockDivider,
|
|
10
|
+
RichBlockFooter,
|
|
11
|
+
RichBlockList,
|
|
12
|
+
RichBlockMap,
|
|
13
|
+
RichBlockMathematicalExpression,
|
|
14
|
+
RichBlockParagraph,
|
|
15
|
+
RichBlockPhoto,
|
|
16
|
+
RichBlockPreformatted,
|
|
17
|
+
RichBlockPullQuotation,
|
|
18
|
+
RichBlockSectionHeading,
|
|
19
|
+
RichBlockSlideshow,
|
|
20
|
+
RichBlockTable,
|
|
21
|
+
RichBlockThinking,
|
|
22
|
+
RichBlockVideo,
|
|
23
|
+
RichBlockVoiceNote,
|
|
24
|
+
RichText,
|
|
25
|
+
RichTextAnchor,
|
|
26
|
+
RichTextAnchorLink,
|
|
27
|
+
RichTextBankCardNumber,
|
|
28
|
+
RichTextBold,
|
|
29
|
+
RichTextBotCommand,
|
|
30
|
+
RichTextCashtag,
|
|
31
|
+
RichTextCode,
|
|
32
|
+
RichTextCustomEmoji,
|
|
33
|
+
RichTextDateTime,
|
|
34
|
+
RichTextEmailAddress,
|
|
35
|
+
RichTextHashtag,
|
|
36
|
+
RichTextItalic,
|
|
37
|
+
RichTextMarked,
|
|
38
|
+
RichTextMathematicalExpression,
|
|
39
|
+
RichTextMention,
|
|
40
|
+
RichTextPhoneNumber,
|
|
41
|
+
RichTextReference,
|
|
42
|
+
RichTextReferenceLink,
|
|
43
|
+
RichTextSpoiler,
|
|
44
|
+
RichTextStrikethrough,
|
|
45
|
+
RichTextSubscript,
|
|
46
|
+
RichTextSuperscript,
|
|
47
|
+
RichTextTextMention,
|
|
48
|
+
RichTextUnderline,
|
|
49
|
+
RichTextUrl,
|
|
50
|
+
} from "@yaebal/types";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* the generated `.type` field is typed as plain `string` (the codegen doesn't turn
|
|
54
|
+
* telegram's "always X" doc convention into a literal type), so `RichBlock["type"]`
|
|
55
|
+
* can't discriminate the union on its own — `Extract<RichBlock, { type: "table" }>`
|
|
56
|
+
* would just be `never`. this lookup map is the narrowing telescope instead: one
|
|
57
|
+
* entry per `.type` string, hand-matched to its interface.
|
|
58
|
+
*/
|
|
59
|
+
interface RichBlockByType {
|
|
60
|
+
paragraph: RichBlockParagraph;
|
|
61
|
+
heading: RichBlockSectionHeading;
|
|
62
|
+
pre: RichBlockPreformatted;
|
|
63
|
+
footer: RichBlockFooter;
|
|
64
|
+
divider: RichBlockDivider;
|
|
65
|
+
mathematical_expression: RichBlockMathematicalExpression;
|
|
66
|
+
anchor: RichBlockAnchor;
|
|
67
|
+
list: RichBlockList;
|
|
68
|
+
blockquote: RichBlockBlockQuotation;
|
|
69
|
+
pullquote: RichBlockPullQuotation;
|
|
70
|
+
collage: RichBlockCollage;
|
|
71
|
+
slideshow: RichBlockSlideshow;
|
|
72
|
+
table: RichBlockTable;
|
|
73
|
+
details: RichBlockDetails;
|
|
74
|
+
map: RichBlockMap;
|
|
75
|
+
animation: RichBlockAnimation;
|
|
76
|
+
audio: RichBlockAudio;
|
|
77
|
+
photo: RichBlockPhoto;
|
|
78
|
+
video: RichBlockVideo;
|
|
79
|
+
voice_note: RichBlockVoiceNote;
|
|
80
|
+
thinking: RichBlockThinking;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface RichTextByType {
|
|
84
|
+
bold: RichTextBold;
|
|
85
|
+
italic: RichTextItalic;
|
|
86
|
+
underline: RichTextUnderline;
|
|
87
|
+
strikethrough: RichTextStrikethrough;
|
|
88
|
+
spoiler: RichTextSpoiler;
|
|
89
|
+
date_time: RichTextDateTime;
|
|
90
|
+
text_mention: RichTextTextMention;
|
|
91
|
+
subscript: RichTextSubscript;
|
|
92
|
+
superscript: RichTextSuperscript;
|
|
93
|
+
marked: RichTextMarked;
|
|
94
|
+
code: RichTextCode;
|
|
95
|
+
custom_emoji: RichTextCustomEmoji;
|
|
96
|
+
mathematical_expression: RichTextMathematicalExpression;
|
|
97
|
+
url: RichTextUrl;
|
|
98
|
+
email_address: RichTextEmailAddress;
|
|
99
|
+
phone_number: RichTextPhoneNumber;
|
|
100
|
+
bank_card_number: RichTextBankCardNumber;
|
|
101
|
+
mention: RichTextMention;
|
|
102
|
+
hashtag: RichTextHashtag;
|
|
103
|
+
cashtag: RichTextCashtag;
|
|
104
|
+
bot_command: RichTextBotCommand;
|
|
105
|
+
anchor: RichTextAnchor;
|
|
106
|
+
anchor_link: RichTextAnchorLink;
|
|
107
|
+
reference: RichTextReference;
|
|
108
|
+
reference_link: RichTextReferenceLink;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function blockGuard<K extends keyof RichBlockByType>(type: K) {
|
|
112
|
+
return (block: RichBlock): block is RichBlockByType[K] => block.type === type;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function textGuard<K extends keyof RichTextByType>(type: K) {
|
|
116
|
+
return (text: RichText): text is RichTextByType[K] => text.type === type;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- RichBlock (21 variants) — one guard per `.type` discriminant ---
|
|
120
|
+
|
|
121
|
+
export const isParagraph = blockGuard("paragraph");
|
|
122
|
+
export const isHeading = blockGuard("heading");
|
|
123
|
+
export const isPreformatted = blockGuard("pre");
|
|
124
|
+
export const isFooter = blockGuard("footer");
|
|
125
|
+
export const isDivider = blockGuard("divider");
|
|
126
|
+
export const isMathBlock = blockGuard("mathematical_expression");
|
|
127
|
+
export const isAnchorBlock = blockGuard("anchor");
|
|
128
|
+
export const isList = blockGuard("list");
|
|
129
|
+
export const isBlockquote = blockGuard("blockquote");
|
|
130
|
+
export const isPullquote = blockGuard("pullquote");
|
|
131
|
+
export const isCollage = blockGuard("collage");
|
|
132
|
+
export const isSlideshow = blockGuard("slideshow");
|
|
133
|
+
export const isTable = blockGuard("table");
|
|
134
|
+
export const isDetails = blockGuard("details");
|
|
135
|
+
export const isMap = blockGuard("map");
|
|
136
|
+
export const isAnimation = blockGuard("animation");
|
|
137
|
+
export const isAudio = blockGuard("audio");
|
|
138
|
+
export const isPhoto = blockGuard("photo");
|
|
139
|
+
export const isVideo = blockGuard("video");
|
|
140
|
+
export const isVoiceNote = blockGuard("voice_note");
|
|
141
|
+
export const isThinking = blockGuard("thinking");
|
|
142
|
+
|
|
143
|
+
// --- RichText (25 variants) ---
|
|
144
|
+
|
|
145
|
+
export const isBold = textGuard("bold");
|
|
146
|
+
export const isItalic = textGuard("italic");
|
|
147
|
+
export const isUnderline = textGuard("underline");
|
|
148
|
+
export const isStrikethrough = textGuard("strikethrough");
|
|
149
|
+
export const isSpoilerText = textGuard("spoiler");
|
|
150
|
+
export const isDateTime = textGuard("date_time");
|
|
151
|
+
export const isTextMention = textGuard("text_mention");
|
|
152
|
+
export const isSubscript = textGuard("subscript");
|
|
153
|
+
export const isSuperscript = textGuard("superscript");
|
|
154
|
+
export const isMarked = textGuard("marked");
|
|
155
|
+
export const isCodeText = textGuard("code");
|
|
156
|
+
export const isCustomEmoji = textGuard("custom_emoji");
|
|
157
|
+
export const isMathText = textGuard("mathematical_expression");
|
|
158
|
+
export const isUrlText = textGuard("url");
|
|
159
|
+
export const isEmailAddress = textGuard("email_address");
|
|
160
|
+
export const isPhoneNumber = textGuard("phone_number");
|
|
161
|
+
export const isBankCardNumber = textGuard("bank_card_number");
|
|
162
|
+
export const isMentionText = textGuard("mention");
|
|
163
|
+
export const isHashtag = textGuard("hashtag");
|
|
164
|
+
export const isCashtag = textGuard("cashtag");
|
|
165
|
+
export const isBotCommand = textGuard("bot_command");
|
|
166
|
+
export const isAnchorText = textGuard("anchor");
|
|
167
|
+
export const isAnchorLink = textGuard("anchor_link");
|
|
168
|
+
export const isReference = textGuard("reference");
|
|
169
|
+
export const isReferenceLink = textGuard("reference_link");
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { type Api, Composer, Context } from "@yaebal/core";
|
|
4
|
+
import type { RichBlock, RichMessage } from "@yaebal/types";
|
|
5
|
+
import {
|
|
6
|
+
bold,
|
|
7
|
+
cell,
|
|
8
|
+
details,
|
|
9
|
+
document,
|
|
10
|
+
heading,
|
|
11
|
+
html,
|
|
12
|
+
image,
|
|
13
|
+
isPhoto,
|
|
14
|
+
isTable,
|
|
15
|
+
item,
|
|
16
|
+
link,
|
|
17
|
+
list,
|
|
18
|
+
paragraph,
|
|
19
|
+
RichMessageDraft,
|
|
20
|
+
rich,
|
|
21
|
+
richMessageToPlainText,
|
|
22
|
+
sendRichMessage,
|
|
23
|
+
sendRichMessageDraft,
|
|
24
|
+
table,
|
|
25
|
+
thinking,
|
|
26
|
+
} from "./index.js";
|
|
27
|
+
|
|
28
|
+
function mockApi(): Api & { calls: [string, Record<string, unknown> | undefined][] } {
|
|
29
|
+
const calls: [string, Record<string, unknown> | undefined][] = [];
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
calls,
|
|
33
|
+
call: async (method: string, params?: Record<string, unknown>) => {
|
|
34
|
+
calls.push([method, params]);
|
|
35
|
+
return {} as never;
|
|
36
|
+
},
|
|
37
|
+
} as unknown as Api & { calls: [string, Record<string, unknown> | undefined][] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test("html template escapes interpolations and splices RichNode subs", () => {
|
|
41
|
+
const node = html`<p>hi ${"<b>hax</b>"} — ${bold("safe")}</p>`;
|
|
42
|
+
|
|
43
|
+
assert.equal(node.html, "<p>hi <b>hax</b> — <b>safe</b></p>");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("inline builders emit the documented tags", () => {
|
|
47
|
+
assert.equal(bold("x").html, "<b>x</b>");
|
|
48
|
+
assert.equal(link("https://yaeb.al", "docs").html, '<a href="https://yaeb.al">docs</a>');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("block builders compose into a document", () => {
|
|
52
|
+
const input = document([
|
|
53
|
+
heading(1, "title"),
|
|
54
|
+
paragraph("hello ", bold("world")),
|
|
55
|
+
list([item(["a"]), item(["b"], { checkbox: true, checked: true })]),
|
|
56
|
+
details("more", [paragraph("hidden")], { open: false }),
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
assert.equal(
|
|
60
|
+
input.html,
|
|
61
|
+
"<h1>title</h1>" +
|
|
62
|
+
"<p>hello <b>world</b></p>" +
|
|
63
|
+
'<ul><li>a</li><li><input type="checkbox" checked/> b</li></ul>' +
|
|
64
|
+
"<details><summary>more</summary><p>hidden</p></details>",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("table() and cell() render alignment attributes telegram's schema documents", () => {
|
|
69
|
+
const node = table([
|
|
70
|
+
[cell("a", { header: true, align: "center" }), cell("b", { valign: "middle" })],
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
assert.equal(
|
|
74
|
+
node.html,
|
|
75
|
+
'<table><tr><th align="center">a</th><td valign="middle">b</td></tr></table>',
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("image() wraps caption/credit in figure/figcaption/cite", () => {
|
|
80
|
+
const node = image("https://example.com/x.jpg", { caption: "a cat", credit: "photographer" });
|
|
81
|
+
|
|
82
|
+
assert.equal(
|
|
83
|
+
node.html,
|
|
84
|
+
'<figure><img src="https://example.com/x.jpg"></img><figcaption>a cat<cite>photographer</cite></figcaption></figure>',
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("thinking() is exposed for draft-only use", () => {
|
|
89
|
+
assert.equal(thinking("…").html, "<tg-thinking>…</tg-thinking>");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("sendRichMessage posts chat_id + rich_message to the raw api", async () => {
|
|
93
|
+
const api = mockApi();
|
|
94
|
+
|
|
95
|
+
await sendRichMessage(api, 42, document([paragraph("hi")]), { reply_markup: { x: 1 } });
|
|
96
|
+
|
|
97
|
+
assert.deepEqual(api.calls, [
|
|
98
|
+
[
|
|
99
|
+
"sendRichMessage",
|
|
100
|
+
{ chat_id: 42, rich_message: { html: "<p>hi</p>" }, reply_markup: { x: 1 } },
|
|
101
|
+
],
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("sendRichMessageDraft posts draft_id alongside chat_id + rich_message", async () => {
|
|
106
|
+
const api = mockApi();
|
|
107
|
+
|
|
108
|
+
await sendRichMessageDraft(api, 42, 7, "<tg-thinking>…</tg-thinking>");
|
|
109
|
+
|
|
110
|
+
assert.deepEqual(api.calls, [
|
|
111
|
+
[
|
|
112
|
+
"sendRichMessageDraft",
|
|
113
|
+
{ chat_id: 42, draft_id: 7, rich_message: { html: "<tg-thinking>…</tg-thinking>" } },
|
|
114
|
+
],
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("rich() plugin decorates ctx.sendRichMessage bound to the current chat", async () => {
|
|
119
|
+
const api = mockApi();
|
|
120
|
+
const composer = new Composer().install(rich());
|
|
121
|
+
|
|
122
|
+
const ctx = new Context({
|
|
123
|
+
api,
|
|
124
|
+
update: {
|
|
125
|
+
update_id: 1,
|
|
126
|
+
message: { message_id: 1, date: 0, chat: { id: 5, type: "private" } },
|
|
127
|
+
} as never,
|
|
128
|
+
updateType: "message",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await composer.toMiddleware()(ctx as never, async () => {});
|
|
132
|
+
|
|
133
|
+
const decorated = ctx as unknown as {
|
|
134
|
+
sendRichMessage: (input: unknown) => Promise<unknown>;
|
|
135
|
+
richMessageDraft: (draftId: number) => RichMessageDraft;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
await decorated.sendRichMessage(document([paragraph("hi")]));
|
|
139
|
+
assert.deepEqual(api.calls[0], [
|
|
140
|
+
"sendRichMessage",
|
|
141
|
+
{ chat_id: 5, rich_message: { html: "<p>hi</p>" } },
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
const draft = decorated.richMessageDraft(1);
|
|
145
|
+
assert.ok(draft instanceof RichMessageDraft);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("RichMessageDraft.push() re-arms a keep-alive timer and commit() stops it", async () => {
|
|
149
|
+
const api = mockApi();
|
|
150
|
+
const draft = new RichMessageDraft(api, 1, 1, { keepAliveMs: 60_000 });
|
|
151
|
+
|
|
152
|
+
await draft.push(thinking("…"));
|
|
153
|
+
assert.equal(api.calls[0]?.[0], "sendRichMessageDraft");
|
|
154
|
+
|
|
155
|
+
await draft.commit(document([paragraph("done")]));
|
|
156
|
+
assert.equal(api.calls[1]?.[0], "sendRichMessage");
|
|
157
|
+
assert.equal(draft.closed, true);
|
|
158
|
+
|
|
159
|
+
await assert.rejects(() => draft.push("late"), /after commit\(\)\/cancel\(\)/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("RichMessageDraft.cancel() closes without persisting", async () => {
|
|
163
|
+
const api = mockApi();
|
|
164
|
+
const draft = new RichMessageDraft(api, 1, 2);
|
|
165
|
+
|
|
166
|
+
await draft.push("draft text");
|
|
167
|
+
draft.cancel();
|
|
168
|
+
|
|
169
|
+
assert.equal(draft.closed, true);
|
|
170
|
+
assert.equal(
|
|
171
|
+
api.calls.some(([method]) => method === "sendRichMessage"),
|
|
172
|
+
false,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("guards narrow RichBlock by .type despite the generated field being `string`", () => {
|
|
177
|
+
const blocks = [
|
|
178
|
+
{ type: "table", cells: [[{ text: "x", align: "left", valign: "top" }]] },
|
|
179
|
+
{ type: "photo", photo: [] },
|
|
180
|
+
] as unknown as RichBlock[];
|
|
181
|
+
|
|
182
|
+
assert.equal(isTable(blocks[0] as RichBlock), true);
|
|
183
|
+
assert.equal(isPhoto(blocks[1] as RichBlock), true);
|
|
184
|
+
assert.equal(isTable(blocks[1] as RichBlock), false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("richMessageToPlainText flattens a full block tree", () => {
|
|
188
|
+
const message = {
|
|
189
|
+
blocks: [
|
|
190
|
+
{ type: "heading", text: "title", size: 1 },
|
|
191
|
+
{ type: "paragraph", text: [{ type: "bold", text: "hi" }, " there"] },
|
|
192
|
+
{ type: "divider" },
|
|
193
|
+
{
|
|
194
|
+
type: "list",
|
|
195
|
+
items: [{ label: "1", blocks: [{ type: "paragraph", text: "one" }] }],
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
} as unknown as RichMessage;
|
|
199
|
+
|
|
200
|
+
assert.equal(richMessageToPlainText(message), "title\nhi there\n---\n- one");
|
|
201
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @yaebal/rich — telegram's `sendRichMessage` / `sendRichMessageDraft`.
|
|
3
|
+
*
|
|
4
|
+
* unlike classic `parse_mode`/entities (see `@yaebal/fmt`), a rich message is a
|
|
5
|
+
* block-tree document: you write extended html (or markdown) once, telegram
|
|
6
|
+
* parses it server-side, and you get the same tree back on `message.rich_message`.
|
|
7
|
+
* this package covers all three parts of that surface:
|
|
8
|
+
*
|
|
9
|
+
* - **write**: `document()` + block builders (blocks.ts) and inline mark builders
|
|
10
|
+
* (inline.ts) assemble an `InputRichMessage` without hand-written angle brackets.
|
|
11
|
+
* - **stream**: `RichMessageDraft` (draft.ts) owns the fiddly part of
|
|
12
|
+
* `sendRichMessageDraft` — the draft is ephemeral (telegram drops it 30s after
|
|
13
|
+
* the last push) and must be closed with a real `sendRichMessage` or explicitly
|
|
14
|
+
* cancelled.
|
|
15
|
+
* - **read**: type guards (guards.ts) and plain-text flattening (plaintext.ts)
|
|
16
|
+
* cover every `RichBlock`/`RichText` variant telegram can hand back.
|
|
17
|
+
*
|
|
18
|
+
* a handful of inline/block tags have no "corresponding to the html tag …" note in
|
|
19
|
+
* the schema (marked, subscript, superscript, date_time, inline math, map,
|
|
20
|
+
* reference/reference_link, table borders) — those are flagged best-effort in
|
|
21
|
+
* their doc comments in inline.ts/blocks.ts. everything else is either a
|
|
22
|
+
* documented tag or (for url/email/phone/bank-card/@mention/#hashtag/$cashtag/
|
|
23
|
+
* /bot_command) auto-detected from plain text by telegram itself.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// the full generated rich-message type surface, re-exported so consumers never
|
|
27
|
+
// need to reach into `@yaebal/types` directly for annotations.
|
|
28
|
+
export type {
|
|
29
|
+
InputRichMessage,
|
|
30
|
+
InputRichMessageContent,
|
|
31
|
+
RichBlock,
|
|
32
|
+
RichBlockAnchor,
|
|
33
|
+
RichBlockAnimation,
|
|
34
|
+
RichBlockAudio,
|
|
35
|
+
RichBlockBlockQuotation,
|
|
36
|
+
RichBlockCaption,
|
|
37
|
+
RichBlockCollage,
|
|
38
|
+
RichBlockDetails,
|
|
39
|
+
RichBlockDivider,
|
|
40
|
+
RichBlockFooter,
|
|
41
|
+
RichBlockList,
|
|
42
|
+
RichBlockListItem,
|
|
43
|
+
RichBlockMap,
|
|
44
|
+
RichBlockMathematicalExpression,
|
|
45
|
+
RichBlockParagraph,
|
|
46
|
+
RichBlockPhoto,
|
|
47
|
+
RichBlockPreformatted,
|
|
48
|
+
RichBlockPullQuotation,
|
|
49
|
+
RichBlockSectionHeading,
|
|
50
|
+
RichBlockSlideshow,
|
|
51
|
+
RichBlockTable,
|
|
52
|
+
RichBlockTableCell,
|
|
53
|
+
RichBlockThinking,
|
|
54
|
+
RichBlockVideo,
|
|
55
|
+
RichBlockVoiceNote,
|
|
56
|
+
RichMessage,
|
|
57
|
+
RichText,
|
|
58
|
+
RichTextAnchor,
|
|
59
|
+
RichTextAnchorLink,
|
|
60
|
+
RichTextBankCardNumber,
|
|
61
|
+
RichTextBold,
|
|
62
|
+
RichTextBotCommand,
|
|
63
|
+
RichTextCashtag,
|
|
64
|
+
RichTextCode,
|
|
65
|
+
RichTextCustomEmoji,
|
|
66
|
+
RichTextDateTime,
|
|
67
|
+
RichTextEmailAddress,
|
|
68
|
+
RichTextHashtag,
|
|
69
|
+
RichTextItalic,
|
|
70
|
+
RichTextMarked,
|
|
71
|
+
RichTextMathematicalExpression,
|
|
72
|
+
RichTextMention,
|
|
73
|
+
RichTextPhoneNumber,
|
|
74
|
+
RichTextReference,
|
|
75
|
+
RichTextReferenceLink,
|
|
76
|
+
RichTextSpoiler,
|
|
77
|
+
RichTextStrikethrough,
|
|
78
|
+
RichTextSubscript,
|
|
79
|
+
RichTextSuperscript,
|
|
80
|
+
RichTextTextMention,
|
|
81
|
+
RichTextUnderline,
|
|
82
|
+
RichTextUrl,
|
|
83
|
+
SendRichMessageDraftParams,
|
|
84
|
+
SendRichMessageParams,
|
|
85
|
+
} from "@yaebal/types";
|
|
86
|
+
|
|
87
|
+
export {
|
|
88
|
+
anchorBlock,
|
|
89
|
+
audio,
|
|
90
|
+
blockquote,
|
|
91
|
+
type Caption,
|
|
92
|
+
cell,
|
|
93
|
+
collage,
|
|
94
|
+
type DetailsOptions,
|
|
95
|
+
details,
|
|
96
|
+
divider,
|
|
97
|
+
footer,
|
|
98
|
+
heading,
|
|
99
|
+
image,
|
|
100
|
+
item,
|
|
101
|
+
type ListItemOptions,
|
|
102
|
+
type ListOptions,
|
|
103
|
+
list,
|
|
104
|
+
type MapOptions,
|
|
105
|
+
type MediaOptions,
|
|
106
|
+
map,
|
|
107
|
+
mathBlock,
|
|
108
|
+
paragraph,
|
|
109
|
+
preformatted,
|
|
110
|
+
pullquote,
|
|
111
|
+
slideshow,
|
|
112
|
+
type TableCellOptions,
|
|
113
|
+
type TableOptions,
|
|
114
|
+
table,
|
|
115
|
+
thinking,
|
|
116
|
+
video,
|
|
117
|
+
} from "./blocks.js";
|
|
118
|
+
export { RichMessageDraft, type RichMessageDraftOptions } from "./draft.js";
|
|
119
|
+
export {
|
|
120
|
+
isAnchorBlock,
|
|
121
|
+
isAnchorLink,
|
|
122
|
+
isAnchorText,
|
|
123
|
+
isAnimation,
|
|
124
|
+
isAudio,
|
|
125
|
+
isBankCardNumber,
|
|
126
|
+
isBlockquote,
|
|
127
|
+
isBold,
|
|
128
|
+
isBotCommand,
|
|
129
|
+
isCashtag,
|
|
130
|
+
isCodeText,
|
|
131
|
+
isCollage,
|
|
132
|
+
isCustomEmoji,
|
|
133
|
+
isDateTime,
|
|
134
|
+
isDetails,
|
|
135
|
+
isDivider,
|
|
136
|
+
isEmailAddress,
|
|
137
|
+
isFooter,
|
|
138
|
+
isHashtag,
|
|
139
|
+
isHeading,
|
|
140
|
+
isList,
|
|
141
|
+
isMap,
|
|
142
|
+
isMarked,
|
|
143
|
+
isMathBlock,
|
|
144
|
+
isMathText,
|
|
145
|
+
isMentionText,
|
|
146
|
+
isParagraph,
|
|
147
|
+
isPhoneNumber,
|
|
148
|
+
isPhoto,
|
|
149
|
+
isPreformatted,
|
|
150
|
+
isPullquote,
|
|
151
|
+
isReference,
|
|
152
|
+
isReferenceLink,
|
|
153
|
+
isSlideshow,
|
|
154
|
+
isSpoilerText,
|
|
155
|
+
isStrikethrough,
|
|
156
|
+
isSubscript,
|
|
157
|
+
isSuperscript,
|
|
158
|
+
isTable,
|
|
159
|
+
isTextMention,
|
|
160
|
+
isThinking,
|
|
161
|
+
isUnderline,
|
|
162
|
+
isUrlText,
|
|
163
|
+
isVideo,
|
|
164
|
+
isVoiceNote,
|
|
165
|
+
} from "./guards.js";
|
|
166
|
+
export {
|
|
167
|
+
anchor,
|
|
168
|
+
anchorLink,
|
|
169
|
+
bold,
|
|
170
|
+
code,
|
|
171
|
+
customEmoji,
|
|
172
|
+
dateTime,
|
|
173
|
+
html,
|
|
174
|
+
type Insertable,
|
|
175
|
+
isRichNode,
|
|
176
|
+
italic,
|
|
177
|
+
link,
|
|
178
|
+
marked,
|
|
179
|
+
math,
|
|
180
|
+
type RichNode,
|
|
181
|
+
reference,
|
|
182
|
+
referenceLink,
|
|
183
|
+
spoiler,
|
|
184
|
+
strikethrough,
|
|
185
|
+
subscript,
|
|
186
|
+
superscript,
|
|
187
|
+
textMention,
|
|
188
|
+
toHtml,
|
|
189
|
+
underline,
|
|
190
|
+
} from "./inline.js";
|
|
191
|
+
export { type DocumentOptions, document, markdown } from "./message.js";
|
|
192
|
+
|
|
193
|
+
export { richBlockToPlainText, richMessageToPlainText, richTextToPlainText } from "./plaintext.js";
|
|
194
|
+
export {
|
|
195
|
+
type RichContext,
|
|
196
|
+
rich,
|
|
197
|
+
sendRichMessage,
|
|
198
|
+
sendRichMessageDraft,
|
|
199
|
+
} from "./send.js";
|