clawgram 2.14.0 → 2.17.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.
- package/README.md +82 -0
- package/dist/channel.js +117 -2
- package/dist/chat-info.js +4 -1
- package/dist/dialogs.js +88 -0
- package/dist/gramjs-client.js +94 -34
- package/dist/group-tool-policy.js +36 -0
- package/dist/helpers.js +54 -5
- package/dist/history.js +62 -2
- package/dist/html-render.js +461 -0
- package/dist/normalize.js +5 -5
- package/dist/topics.js +84 -0
- package/openclaw.plugin.json +154 -3
- package/package.json +1 -1
package/dist/helpers.js
CHANGED
|
@@ -10,6 +10,7 @@ exports.inferOutboundTargetKind = inferOutboundTargetKind;
|
|
|
10
10
|
exports.routeKindFromChatType = routeKindFromChatType;
|
|
11
11
|
exports.buildConversationTarget = buildConversationTarget;
|
|
12
12
|
exports.buildScopedGroupPeerId = buildScopedGroupPeerId;
|
|
13
|
+
exports.stripAccountScopedGroupId = stripAccountScopedGroupId;
|
|
13
14
|
exports.stripReplyDirectiveTags = stripReplyDirectiveTags;
|
|
14
15
|
exports.readLatestAssistantFallbackFromTranscript = readLatestAssistantFallbackFromTranscript;
|
|
15
16
|
exports.resolveActionTarget = resolveActionTarget;
|
|
@@ -21,6 +22,7 @@ exports.resolveAllowFrom = resolveAllowFrom;
|
|
|
21
22
|
exports.resolveGroupPolicy = resolveGroupPolicy;
|
|
22
23
|
exports.resolveGroups = resolveGroups;
|
|
23
24
|
exports.resolveGroupConfig = resolveGroupConfig;
|
|
25
|
+
exports.resolveGroupPromptSettings = resolveGroupPromptSettings;
|
|
24
26
|
exports.resolveActiveUsername = resolveActiveUsername;
|
|
25
27
|
exports.normalizeAllowEntry = normalizeAllowEntry;
|
|
26
28
|
exports.isSenderAllowed = isSenderAllowed;
|
|
@@ -89,6 +91,20 @@ function buildScopedGroupPeerId(accountId, chatId) {
|
|
|
89
91
|
const scopedAccountId = (accountId ?? "default").trim() || "default";
|
|
90
92
|
return `${scopedAccountId}:${chatId}`;
|
|
91
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Inverse of `buildScopedGroupPeerId`. Core derives group ids from the session
|
|
96
|
+
* key, so a channel hook receives `<accountId>:<chatId>` while `groups` in the
|
|
97
|
+
* config is keyed by the bare chat id. Only this account's prefix is stripped;
|
|
98
|
+
* anything else passes through untouched.
|
|
99
|
+
*/
|
|
100
|
+
function stripAccountScopedGroupId(groupId, accountId) {
|
|
101
|
+
const raw = typeof groupId === "string" ? groupId.trim() : "";
|
|
102
|
+
if (!raw) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const prefix = `${(accountId ?? "default").trim() || "default"}:`;
|
|
106
|
+
return raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
|
|
107
|
+
}
|
|
92
108
|
function stripReplyDirectiveTags(text) {
|
|
93
109
|
return text
|
|
94
110
|
.replace(/\[\[\s*reply_to_current\s*\]\]/gi, " ")
|
|
@@ -258,6 +274,28 @@ function resolveAllowFrom(value) {
|
|
|
258
274
|
function resolveGroupPolicy(value) {
|
|
259
275
|
return value === "open" ? "open" : "mention";
|
|
260
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* Per-group `skills` → core `replyOptions.skillFilter`, `systemPrompt` →
|
|
279
|
+
* `GroupSystemPrompt`. An empty `skills` array is kept as `[]` — "no skills
|
|
280
|
+
* in this chat" is an answer, the same one core gives `agents.list[].skills: []`
|
|
281
|
+
* — while a blank `systemPrompt` is unset rather than an empty trusted block.
|
|
282
|
+
*/
|
|
283
|
+
function resolveGroupPromptSettings(groupConfig) {
|
|
284
|
+
const settings = {};
|
|
285
|
+
if (!groupConfig) {
|
|
286
|
+
return settings;
|
|
287
|
+
}
|
|
288
|
+
if (Array.isArray(groupConfig.skills)) {
|
|
289
|
+
settings.skillFilter = groupConfig.skills
|
|
290
|
+
.filter((entry) => typeof entry === "string")
|
|
291
|
+
.map((entry) => entry.trim())
|
|
292
|
+
.filter(Boolean);
|
|
293
|
+
}
|
|
294
|
+
if (typeof groupConfig.systemPrompt === "string" && groupConfig.systemPrompt.trim()) {
|
|
295
|
+
settings.systemPrompt = groupConfig.systemPrompt.trim();
|
|
296
|
+
}
|
|
297
|
+
return settings;
|
|
298
|
+
}
|
|
261
299
|
function resolveGroups(value) {
|
|
262
300
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
263
301
|
return {};
|
|
@@ -273,6 +311,7 @@ function resolveGroups(value) {
|
|
|
273
311
|
enabled: groupConfig.enabled !== false,
|
|
274
312
|
groupPolicy: resolveGroupPolicy(groupConfig.groupPolicy),
|
|
275
313
|
allowFrom: resolveAllowFrom(groupConfig.allowFrom),
|
|
314
|
+
...resolveGroupPromptSettings(groupConfig),
|
|
276
315
|
},
|
|
277
316
|
];
|
|
278
317
|
}).filter(([groupId]) => Boolean(groupId)));
|
|
@@ -564,15 +603,22 @@ function normalizeParseMode(raw) {
|
|
|
564
603
|
return "markdown";
|
|
565
604
|
if (value === "html")
|
|
566
605
|
return "html";
|
|
567
|
-
|
|
606
|
+
// "none" switches GramJS parsing off entirely. It exists because absent is
|
|
607
|
+
// NOT plain text: GramJS falls back to its own markdown parser by default,
|
|
608
|
+
// which quietly ate `**` from "plain" sends since the fork began.
|
|
609
|
+
if (value === "none")
|
|
610
|
+
return "none";
|
|
611
|
+
throw new Error(`clawgram: invalid parseMode "${String(raw)}" — use "markdown", "html" or "none"`);
|
|
568
612
|
}
|
|
569
613
|
/**
|
|
570
614
|
* Reply parse mode for the inbound reply path (2.3.1). The action `send`
|
|
571
615
|
* takes parseMode per-call, but replies to a mention run through the reply
|
|
572
616
|
* pipeline, which has no per-call slot — so the format is a channel setting:
|
|
573
|
-
* `channels.clawgram.accounts.<id>.replyParseMode: "markdown" | "html"
|
|
574
|
-
* Absent
|
|
575
|
-
*
|
|
617
|
+
* `channels.clawgram.accounts.<id>.replyParseMode: "markdown" | "html" |
|
|
618
|
+
* "none"`. Absent keeps GramJS's historical default — its markdown parser,
|
|
619
|
+
* not plain text, which 2.3.1 believed and 2.15.0 disproved. An invalid
|
|
620
|
+
* value throws at config-read time, loud and early, rather than shipping
|
|
621
|
+
* raw markup.
|
|
576
622
|
*/
|
|
577
623
|
function resolveReplyParseMode(cfg, accountId) {
|
|
578
624
|
const channel = cfg?.channels?.["clawgram"];
|
|
@@ -617,8 +663,11 @@ function resolveDryRun(dryRun, params) {
|
|
|
617
663
|
function resolveOutboundParseMode(params, cfg, accountId) {
|
|
618
664
|
const raw = params?.parseMode;
|
|
619
665
|
// An explicitly empty value is a decision, not an omission: send it raw.
|
|
666
|
+
// "none" (not undefined) is what actually delivers on that: an absent
|
|
667
|
+
// parseMode at the GramJS boundary means GramJS's own default markdown
|
|
668
|
+
// parser, which would still eat `**` out of a message about markup.
|
|
620
669
|
if (raw === "" || raw === null) {
|
|
621
|
-
return
|
|
670
|
+
return "none";
|
|
622
671
|
}
|
|
623
672
|
return raw === undefined
|
|
624
673
|
? resolveReplyParseMode(cfg, accountId)
|
package/dist/history.js
CHANGED
|
@@ -16,6 +16,7 @@ exports.parseTimeBoundary = parseTimeBoundary;
|
|
|
16
16
|
exports.parseLimit = parseLimit;
|
|
17
17
|
exports.parseMessageId = parseMessageId;
|
|
18
18
|
exports.parseListMessagesParams = parseListMessagesParams;
|
|
19
|
+
exports.normalizeParticipants = normalizeParticipants;
|
|
19
20
|
exports.parseListParticipantsParams = parseListParticipantsParams;
|
|
20
21
|
exports.buildHistoryQuery = buildHistoryQuery;
|
|
21
22
|
exports.normalizeChatKey = normalizeChatKey;
|
|
@@ -26,6 +27,7 @@ exports.collectHistoryWindow = collectHistoryWindow;
|
|
|
26
27
|
exports.HISTORY_DEFAULT_LIMIT = 100;
|
|
27
28
|
exports.HISTORY_MAX_LIMIT = 500;
|
|
28
29
|
const media_1 = require("./media");
|
|
30
|
+
const helpers_1 = require("./helpers");
|
|
29
31
|
/**
|
|
30
32
|
* Matches `normalize.ts` and `gramjs-client.ts` deliberately.
|
|
31
33
|
*
|
|
@@ -119,6 +121,7 @@ function parseListMessagesParams(params) {
|
|
|
119
121
|
if (since !== undefined && until !== undefined && since > until) {
|
|
120
122
|
throw new Error("clawgram: since must not be later than until");
|
|
121
123
|
}
|
|
124
|
+
const rawThreadId = params.threadId ?? params.topicId ?? params.messageThreadId ?? params.topic;
|
|
122
125
|
return {
|
|
123
126
|
target,
|
|
124
127
|
limit: parseLimit(params.limit),
|
|
@@ -126,10 +129,61 @@ function parseListMessagesParams(params) {
|
|
|
126
129
|
until,
|
|
127
130
|
minId: parseMessageId(params.after, "after"),
|
|
128
131
|
maxId: parseMessageId(params.before, "before"),
|
|
132
|
+
messageThreadId: parseMessageId(rawThreadId, "threadId"),
|
|
129
133
|
};
|
|
130
134
|
}
|
|
131
135
|
exports.PARTICIPANTS_DEFAULT_LIMIT = 200;
|
|
132
136
|
exports.PARTICIPANTS_MAX_LIMIT = 1000;
|
|
137
|
+
/**
|
|
138
|
+
* Membership as the caller sees it.
|
|
139
|
+
*
|
|
140
|
+
* The handle comes through `resolveActiveUsername`, not off the raw field:
|
|
141
|
+
* once an account holds more than one username — several handles, or a
|
|
142
|
+
* collectible one — Telegram moves them into `usernames[]` and leaves the
|
|
143
|
+
* legacy `username` EMPTY. Reading the raw field is why the owner of this
|
|
144
|
+
* deployment appeared in every generated table as "(без тэга)" beside a bare
|
|
145
|
+
* numeric id, the only person without a handle in chats of 23, 9, 7 and 3.
|
|
146
|
+
*/
|
|
147
|
+
function normalizeParticipants(raw, options) {
|
|
148
|
+
if (!Array.isArray(raw))
|
|
149
|
+
return [];
|
|
150
|
+
const participants = [];
|
|
151
|
+
for (const entry of raw) {
|
|
152
|
+
if (!entry || typeof entry !== "object")
|
|
153
|
+
continue;
|
|
154
|
+
const candidate = entry;
|
|
155
|
+
const userId = toStringId(candidate.id);
|
|
156
|
+
if (!userId)
|
|
157
|
+
continue;
|
|
158
|
+
const member = {
|
|
159
|
+
userId,
|
|
160
|
+
username: (0, helpers_1.resolveActiveUsername)(candidate),
|
|
161
|
+
isBot: candidate.bot === true,
|
|
162
|
+
};
|
|
163
|
+
// Display names are personal data, so they are opt-in: only the identity
|
|
164
|
+
// linking flow asks for them, and it discards them once a link is made.
|
|
165
|
+
if (options.includeNames) {
|
|
166
|
+
if (typeof candidate.firstName === "string" && candidate.firstName.length > 0) {
|
|
167
|
+
member.firstName = candidate.firstName;
|
|
168
|
+
}
|
|
169
|
+
if (typeof candidate.lastName === "string" && candidate.lastName.length > 0) {
|
|
170
|
+
member.lastName = candidate.lastName;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
participants.push(member);
|
|
174
|
+
}
|
|
175
|
+
return participants;
|
|
176
|
+
}
|
|
177
|
+
function parseParticipantsFilter(params) {
|
|
178
|
+
if (params.admins === true)
|
|
179
|
+
return "admins";
|
|
180
|
+
const raw = params.filter;
|
|
181
|
+
if (raw === undefined || raw === null || raw === "")
|
|
182
|
+
return "all";
|
|
183
|
+
if (raw === "all" || raw === "admins")
|
|
184
|
+
return raw;
|
|
185
|
+
throw new Error('clawgram: participants filter must be "all" or "admins"');
|
|
186
|
+
}
|
|
133
187
|
/**
|
|
134
188
|
* Membership is asked for by chat, so a target is required. `limit` is clamped
|
|
135
189
|
* for the same reason it is clamped when reading history: a large group must
|
|
@@ -142,15 +196,16 @@ function parseListParticipantsParams(params) {
|
|
|
142
196
|
throw new Error("clawgram: participants requires a chatId");
|
|
143
197
|
}
|
|
144
198
|
const includeNames = params.includeNames === true || params.includeNames === "true";
|
|
199
|
+
const filter = parseParticipantsFilter(params);
|
|
145
200
|
const rawLimit = params.limit;
|
|
146
201
|
if (rawLimit === undefined || rawLimit === null || rawLimit === "") {
|
|
147
|
-
return { target, limit: exports.PARTICIPANTS_DEFAULT_LIMIT, includeNames };
|
|
202
|
+
return { target, limit: exports.PARTICIPANTS_DEFAULT_LIMIT, includeNames, filter };
|
|
148
203
|
}
|
|
149
204
|
const parsed = Number(rawLimit);
|
|
150
205
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
151
206
|
throw new Error("clawgram: participants limit must be a positive number");
|
|
152
207
|
}
|
|
153
|
-
return { target, limit: Math.min(Math.floor(parsed), exports.PARTICIPANTS_MAX_LIMIT), includeNames };
|
|
208
|
+
return { target, limit: Math.min(Math.floor(parsed), exports.PARTICIPANTS_MAX_LIMIT), includeNames, filter };
|
|
154
209
|
}
|
|
155
210
|
/**
|
|
156
211
|
* Builds the GramJS query for a window.
|
|
@@ -164,6 +219,11 @@ function parseListParticipantsParams(params) {
|
|
|
164
219
|
*/
|
|
165
220
|
function buildHistoryQuery(args) {
|
|
166
221
|
const query = { limit: args.limit };
|
|
222
|
+
// GramJS turns `replyTo` into messages.GetReplies, which is Telegram's way of
|
|
223
|
+
// asking for one forum topic rather than the whole chat.
|
|
224
|
+
if (args.messageThreadId !== undefined) {
|
|
225
|
+
query.replyTo = args.messageThreadId;
|
|
226
|
+
}
|
|
167
227
|
if (args.until !== undefined) {
|
|
168
228
|
query.offsetDate = args.until + 1;
|
|
169
229
|
}
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Markdown → Telegram HTML rendering for outbound text (2.15.0).
|
|
4
|
+
*
|
|
5
|
+
* The account's reply format is `html`, and the agent writes what language
|
|
6
|
+
* models write: markdown. GramJS's HTML parser does not touch markdown, so
|
|
7
|
+
* every `**bold**` reached a live work chat with its asterisks showing —
|
|
8
|
+
* 2026-08-12 00:13 UTC, a 2167-character monthly report worth of them. The
|
|
9
|
+
* converse configuration is no better: GramJS's markdown mode knows five
|
|
10
|
+
* delimiters and no links, so the HTML links the agent is told to use would
|
|
11
|
+
* arrive as tag soup. Neither mode alone can carry what the agent produces,
|
|
12
|
+
* which is markdown, Telegram HTML, or both in one message.
|
|
13
|
+
*
|
|
14
|
+
* This renderer runs in front of GramJS's HTML parser and emits HTML that
|
|
15
|
+
* parser maps onto Telegram entities:
|
|
16
|
+
*
|
|
17
|
+
* - Markdown becomes tags: `**b**`, `*i*`, `_i_`, `__b__`, `~~s~~`,
|
|
18
|
+
* `||spoiler||`, `` `code` ``, ``` fences (with language), `[text](url)`,
|
|
19
|
+
* `# heading` (a bold line), `> quote` (a blockquote).
|
|
20
|
+
* - HTML the parser understands passes through, its attributes reduced to
|
|
21
|
+
* the meaningful set. Bot-API-only names are mapped to what GramJS knows
|
|
22
|
+
* (`tg-spoiler` → `spoiler`, `ins` → `u`, `strike` → `s`, `h1..h6` → `b`);
|
|
23
|
+
* `<br>` becomes a newline.
|
|
24
|
+
* - Structural HTML Telegram cannot render (`<p>`, `<ul>`, `<li>`…) is
|
|
25
|
+
* dropped exactly as the parser silently drops it today, keeping the text.
|
|
26
|
+
* - Everything else — a stray `<`, a bare `&`, a `<плейсхолдер>` — is
|
|
27
|
+
* escaped, so it survives the parser as literal text instead of being
|
|
28
|
+
* half-eaten as a failed tag.
|
|
29
|
+
*
|
|
30
|
+
* Markdown inside code — spans, fences, `<code>`/`<pre>` bodies — is never
|
|
31
|
+
* converted; code arrives verbatim. Already-valid Telegram HTML passes
|
|
32
|
+
* through unchanged, which is what keeps the links that are authored as
|
|
33
|
+
* `<a href="…">` working.
|
|
34
|
+
*/
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.renderTelegramHtml = renderTelegramHtml;
|
|
37
|
+
/** Tags GramJS's HTML parser turns into entities, plus aliases it does not
|
|
38
|
+
* know mapped onto ones it does. `canonical` is what gets emitted; `attrs`
|
|
39
|
+
* is the full set of attributes worth keeping for that tag. */
|
|
40
|
+
const TELEGRAM_TAGS = {
|
|
41
|
+
b: { canonical: "b" },
|
|
42
|
+
strong: { canonical: "strong" },
|
|
43
|
+
i: { canonical: "i" },
|
|
44
|
+
em: { canonical: "em" },
|
|
45
|
+
u: { canonical: "u" },
|
|
46
|
+
ins: { canonical: "u" },
|
|
47
|
+
s: { canonical: "s" },
|
|
48
|
+
del: { canonical: "del" },
|
|
49
|
+
strike: { canonical: "s" },
|
|
50
|
+
spoiler: { canonical: "spoiler" },
|
|
51
|
+
"tg-spoiler": { canonical: "spoiler" },
|
|
52
|
+
a: { canonical: "a", attrs: ["href"] },
|
|
53
|
+
code: { canonical: "code", attrs: ["class"] },
|
|
54
|
+
pre: { canonical: "pre" },
|
|
55
|
+
blockquote: { canonical: "blockquote", attrs: ["expandable"] },
|
|
56
|
+
"tg-emoji": { canonical: "tg-emoji", attrs: ["emoji-id"] },
|
|
57
|
+
h1: { canonical: "b" },
|
|
58
|
+
h2: { canonical: "b" },
|
|
59
|
+
h3: { canonical: "b" },
|
|
60
|
+
h4: { canonical: "b" },
|
|
61
|
+
h5: { canonical: "b" },
|
|
62
|
+
h6: { canonical: "b" },
|
|
63
|
+
};
|
|
64
|
+
/** Structural HTML the parser swallows today; keep swallowing it rather than
|
|
65
|
+
* turning a `<ul>` the agent wrote into visible angle brackets. */
|
|
66
|
+
const DROP_TAGS = new Set([
|
|
67
|
+
"p", "div", "span", "ul", "ol", "li", "hr", "table", "thead", "tbody",
|
|
68
|
+
"tr", "td", "th", "details", "summary", "img", "small", "sup", "sub",
|
|
69
|
+
"font", "center", "section", "article", "header", "footer", "main", "nav",
|
|
70
|
+
]);
|
|
71
|
+
const TAG_RE = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/;
|
|
72
|
+
const ATTR_RE = /([a-zA-Z-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
73
|
+
/** A character reference the parser will decode; everything else is a bare `&`. */
|
|
74
|
+
const ENTITY_RE = /^&(?:#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{1,31});/;
|
|
75
|
+
const WORD_RE = /[\p{L}\p{N}_]/u;
|
|
76
|
+
/** GFM backslash-escapable punctuation, so `\*` means a literal asterisk. */
|
|
77
|
+
const ESCAPABLE = new Set([..."\\`*_{}[]()#+-.!|~<>"]);
|
|
78
|
+
const EMPHASIS = {
|
|
79
|
+
"*": [
|
|
80
|
+
{ len: 3, open: "<b><i>", close: "</i></b>" },
|
|
81
|
+
{ len: 2, open: "<b>", close: "</b>" },
|
|
82
|
+
{ len: 1, open: "<i>", close: "</i>" },
|
|
83
|
+
],
|
|
84
|
+
"_": [
|
|
85
|
+
{ len: 3, open: "<b><i>", close: "</i></b>" },
|
|
86
|
+
{ len: 2, open: "<b>", close: "</b>" },
|
|
87
|
+
{ len: 1, open: "<i>", close: "</i>" },
|
|
88
|
+
],
|
|
89
|
+
"~": [{ len: 2, open: "<s>", close: "</s>" }],
|
|
90
|
+
"|": [{ len: 2, open: "<spoiler>", close: "</spoiler>" }],
|
|
91
|
+
};
|
|
92
|
+
/** `&`/`<`/`>` escaped unconditionally — for code bodies generated from
|
|
93
|
+
* markdown, where a `&` the author typed must arrive as `&`. */
|
|
94
|
+
function escapeAll(text) {
|
|
95
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
96
|
+
}
|
|
97
|
+
/** Like {@link escapeAll}, but an already-written character reference is kept,
|
|
98
|
+
* so hand-authored HTML (`a < b`) is not double-escaped into `&lt;`. */
|
|
99
|
+
function escapeKeepEntities(text) {
|
|
100
|
+
let out = "";
|
|
101
|
+
for (let i = 0; i < text.length; i++) {
|
|
102
|
+
const c = text[i];
|
|
103
|
+
if (c === "&") {
|
|
104
|
+
const m = ENTITY_RE.exec(text.slice(i));
|
|
105
|
+
if (m) {
|
|
106
|
+
out += m[0];
|
|
107
|
+
i += m[0].length - 1;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
out += "&";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (c === "<") {
|
|
114
|
+
out += "<";
|
|
115
|
+
}
|
|
116
|
+
else if (c === ">") {
|
|
117
|
+
out += ">";
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
out += c;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/** Attribute values may arrive raw (`?a=1&b=2`) or escaped (`&`); decode
|
|
126
|
+
* the few references that matter, then encode once. Idempotent either way. */
|
|
127
|
+
function escapeAttr(value) {
|
|
128
|
+
const decoded = value
|
|
129
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
130
|
+
.replace(/"/g, "\"").replace(/�?39;/g, "'");
|
|
131
|
+
return decoded
|
|
132
|
+
.replace(/&/g, "&").replace(/</g, "<")
|
|
133
|
+
.replace(/>/g, ">").replace(/"/g, """);
|
|
134
|
+
}
|
|
135
|
+
/** URL schemes worth turning into a link entity. Anything else stays text:
|
|
136
|
+
* a relative path or a bare word in `[x](y)` position is not a Telegram link. */
|
|
137
|
+
const URL_RE = /^(https?:\/\/|tg:\/\/|mailto:)/i;
|
|
138
|
+
function parseAttrs(raw) {
|
|
139
|
+
const attrs = new Map();
|
|
140
|
+
for (const m of raw.matchAll(ATTR_RE)) {
|
|
141
|
+
attrs.set(m[1].toLowerCase(), m[2] ?? m[3] ?? m[4] ?? "");
|
|
142
|
+
}
|
|
143
|
+
return attrs;
|
|
144
|
+
}
|
|
145
|
+
function buildOpenTag(canonical, allowed, attrs) {
|
|
146
|
+
let out = `<${canonical}`;
|
|
147
|
+
for (const name of allowed ?? []) {
|
|
148
|
+
if (!attrs.has(name))
|
|
149
|
+
continue;
|
|
150
|
+
const value = attrs.get(name) ?? "";
|
|
151
|
+
if (name === "href" && !URL_RE.test(value.trim()))
|
|
152
|
+
continue;
|
|
153
|
+
if (name === "class" && !/^language-[\w+#.-]+$/.test(value))
|
|
154
|
+
continue;
|
|
155
|
+
if (name === "emoji-id" && !/^\d+$/.test(value))
|
|
156
|
+
continue;
|
|
157
|
+
out += value === "" && name === "expandable" ? ` ${name}` : ` ${name}="${escapeAttr(value)}"`;
|
|
158
|
+
}
|
|
159
|
+
return out + ">";
|
|
160
|
+
}
|
|
161
|
+
function tryCodeSpan(s, i) {
|
|
162
|
+
let run = 0;
|
|
163
|
+
while (s[i + run] === "`")
|
|
164
|
+
run++;
|
|
165
|
+
let k = i + run;
|
|
166
|
+
while (k < s.length) {
|
|
167
|
+
if (s[k] !== "`") {
|
|
168
|
+
k++;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
let end = k;
|
|
172
|
+
while (s[end] === "`")
|
|
173
|
+
end++;
|
|
174
|
+
if (end - k === run) {
|
|
175
|
+
let inner = s.slice(i + run, k);
|
|
176
|
+
if (inner.includes("\n\n"))
|
|
177
|
+
return null;
|
|
178
|
+
// GFM: one space is stripped from each side when both are present,
|
|
179
|
+
// so `` ` code ` `` can hold a leading backtick.
|
|
180
|
+
if (inner.length >= 2 && inner.startsWith(" ") && inner.endsWith(" ") && inner.trim() !== "") {
|
|
181
|
+
inner = inner.slice(1, -1);
|
|
182
|
+
}
|
|
183
|
+
return { html: `<code>${escapeAll(inner)}</code>`, next: end };
|
|
184
|
+
}
|
|
185
|
+
k = end;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
function tryLink(s, i) {
|
|
190
|
+
let depth = 1;
|
|
191
|
+
let j = i + 1;
|
|
192
|
+
for (; j < s.length; j++) {
|
|
193
|
+
const c = s[j];
|
|
194
|
+
if (c === "\n")
|
|
195
|
+
return null;
|
|
196
|
+
if (c === "\\")
|
|
197
|
+
j++;
|
|
198
|
+
else if (c === "[")
|
|
199
|
+
depth++;
|
|
200
|
+
else if (c === "]" && --depth === 0)
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
if (depth !== 0 || s[j + 1] !== "(")
|
|
204
|
+
return null;
|
|
205
|
+
let pdepth = 1;
|
|
206
|
+
let k = j + 2;
|
|
207
|
+
for (; k < s.length; k++) {
|
|
208
|
+
const c = s[k];
|
|
209
|
+
if (c === "\n")
|
|
210
|
+
return null;
|
|
211
|
+
if (c === "\\")
|
|
212
|
+
k++;
|
|
213
|
+
else if (c === "(")
|
|
214
|
+
pdepth++;
|
|
215
|
+
else if (c === ")" && --pdepth === 0)
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
if (pdepth !== 0)
|
|
219
|
+
return null;
|
|
220
|
+
const label = s.slice(i + 1, j);
|
|
221
|
+
let dest = s.slice(j + 2, k).trim();
|
|
222
|
+
if (dest.startsWith("<") && dest.endsWith(">"))
|
|
223
|
+
dest = dest.slice(1, -1);
|
|
224
|
+
const ws = dest.search(/\s/);
|
|
225
|
+
if (ws !== -1)
|
|
226
|
+
dest = dest.slice(0, ws); // an optional "title" is dropped
|
|
227
|
+
if (label.length === 0 || !URL_RE.test(dest))
|
|
228
|
+
return null;
|
|
229
|
+
return { html: `<a href="${escapeAttr(dest)}">${renderInline(label)}</a>`, next: k + 1 };
|
|
230
|
+
}
|
|
231
|
+
function tryEmphasis(s, i) {
|
|
232
|
+
const ch = s[i];
|
|
233
|
+
const variants = EMPHASIS[ch];
|
|
234
|
+
if (!variants)
|
|
235
|
+
return null;
|
|
236
|
+
let run = 0;
|
|
237
|
+
while (s[i + run] === ch)
|
|
238
|
+
run++;
|
|
239
|
+
for (const v of variants) {
|
|
240
|
+
if (run < v.len)
|
|
241
|
+
continue;
|
|
242
|
+
const after = s[i + v.len];
|
|
243
|
+
// Left flank: the run must hug its content. `2 * 3` stays arithmetic.
|
|
244
|
+
if (after === undefined || /\s/.test(after))
|
|
245
|
+
continue;
|
|
246
|
+
// `_` must not open inside a word, or snake_case_names grow italics.
|
|
247
|
+
if (ch === "_" && i > 0 && WORD_RE.test(s[i - 1]))
|
|
248
|
+
continue;
|
|
249
|
+
const delim = ch.repeat(v.len);
|
|
250
|
+
let j = s.indexOf(delim, i + v.len);
|
|
251
|
+
while (j !== -1) {
|
|
252
|
+
const between = s.slice(i + v.len, j);
|
|
253
|
+
if (between.includes("\n\n"))
|
|
254
|
+
break; // an emphasis does not cross a blank line
|
|
255
|
+
const before = s[j - 1];
|
|
256
|
+
const afterClose = s[j + v.len];
|
|
257
|
+
const closes = before !== undefined && !/\s/.test(before)
|
|
258
|
+
&& (ch !== "_" || afterClose === undefined || !WORD_RE.test(afterClose));
|
|
259
|
+
if (closes && between.length > 0) {
|
|
260
|
+
return { html: v.open + renderInline(between) + v.close, next: j + v.len };
|
|
261
|
+
}
|
|
262
|
+
j = s.indexOf(delim, j + 1);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
/** Inline pass: markdown spans, allowed HTML, and escaping, over text that
|
|
268
|
+
* the block pass already cleared of fences, headings and quotes. */
|
|
269
|
+
function renderInline(s) {
|
|
270
|
+
let out = "";
|
|
271
|
+
let i = 0;
|
|
272
|
+
while (i < s.length) {
|
|
273
|
+
const c = s[i];
|
|
274
|
+
if (c === "\\" && i + 1 < s.length && ESCAPABLE.has(s[i + 1])) {
|
|
275
|
+
const next = s[i + 1];
|
|
276
|
+
out += next === "<" ? "<" : next === ">" ? ">" : next;
|
|
277
|
+
i += 2;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (c === "<") {
|
|
281
|
+
const m = TAG_RE.exec(s.slice(i));
|
|
282
|
+
if (m) {
|
|
283
|
+
const closing = m[1] === "/";
|
|
284
|
+
const name = m[2].toLowerCase();
|
|
285
|
+
if (name === "br") {
|
|
286
|
+
if (!closing)
|
|
287
|
+
out += "\n";
|
|
288
|
+
i += m[0].length;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const spec = TELEGRAM_TAGS[name];
|
|
292
|
+
if (spec) {
|
|
293
|
+
if (closing) {
|
|
294
|
+
out += `</${spec.canonical}>`;
|
|
295
|
+
i += m[0].length;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (spec.canonical === "code" || spec.canonical === "pre") {
|
|
299
|
+
// Code bodies pass through untouched by markdown: `**` inside
|
|
300
|
+
// <code> is content, not emphasis.
|
|
301
|
+
const close = s.toLowerCase().indexOf(`</${name}>`, i + m[0].length);
|
|
302
|
+
if (close !== -1) {
|
|
303
|
+
out += buildOpenTag(spec.canonical, spec.attrs, parseAttrs(m[3]))
|
|
304
|
+
+ escapeKeepEntities(s.slice(i + m[0].length, close))
|
|
305
|
+
+ `</${spec.canonical}>`;
|
|
306
|
+
i = close + name.length + 3;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
// An unclosed <code> is not markup; fall through to a literal `<`.
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
out += buildOpenTag(spec.canonical, spec.attrs, parseAttrs(m[3]));
|
|
313
|
+
i += m[0].length;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
else if (DROP_TAGS.has(name)) {
|
|
318
|
+
i += m[0].length;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
out += "<";
|
|
323
|
+
i++;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (c === "&") {
|
|
327
|
+
const m = ENTITY_RE.exec(s.slice(i));
|
|
328
|
+
if (m) {
|
|
329
|
+
out += m[0];
|
|
330
|
+
i += m[0].length;
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
out += "&";
|
|
334
|
+
i++;
|
|
335
|
+
}
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (c === ">") {
|
|
339
|
+
out += ">";
|
|
340
|
+
i++;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (c === "`") {
|
|
344
|
+
const span = tryCodeSpan(s, i);
|
|
345
|
+
if (span) {
|
|
346
|
+
out += span.html;
|
|
347
|
+
i = span.next;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
let run = 0;
|
|
351
|
+
while (s[i + run] === "`")
|
|
352
|
+
run++;
|
|
353
|
+
out += s.slice(i, i + run);
|
|
354
|
+
i += run;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (c === "[") {
|
|
358
|
+
const link = tryLink(s, i);
|
|
359
|
+
if (link) {
|
|
360
|
+
out += link.html;
|
|
361
|
+
i = link.next;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
out += c;
|
|
365
|
+
i++;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (c === "*" || c === "_" || c === "~" || c === "|") {
|
|
369
|
+
const em = tryEmphasis(s, i);
|
|
370
|
+
if (em) {
|
|
371
|
+
out += em.html;
|
|
372
|
+
i = em.next;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
let run = 0;
|
|
376
|
+
while (s[i + run] === c)
|
|
377
|
+
run++;
|
|
378
|
+
out += s.slice(i, i + run);
|
|
379
|
+
i += run;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
out += c;
|
|
383
|
+
i++;
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
const FENCE_OPEN_RE = /^\s{0,3}(`{3,}|~{3,})\s*(.*)$/;
|
|
388
|
+
const FENCE_CLOSE_RE = /^\s{0,3}(`{3,}|~{3,})\s*$/;
|
|
389
|
+
const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*)$/;
|
|
390
|
+
const QUOTE_RE = /^\s{0,3}>\s?(.*)$/;
|
|
391
|
+
/**
|
|
392
|
+
* Render agent-authored text — markdown, Telegram HTML, or a mix — into
|
|
393
|
+
* HTML for GramJS's `html` parse mode. Block constructs are handled here;
|
|
394
|
+
* everything between them goes through {@link renderInline} as one chunk,
|
|
395
|
+
* so an emphasis may span a soft line break but never a blank line.
|
|
396
|
+
*/
|
|
397
|
+
function renderTelegramHtml(input) {
|
|
398
|
+
const lines = input.split("\n");
|
|
399
|
+
const out = [];
|
|
400
|
+
let plain = [];
|
|
401
|
+
const flush = () => {
|
|
402
|
+
if (plain.length > 0) {
|
|
403
|
+
out.push(renderInline(plain.join("\n")));
|
|
404
|
+
plain = [];
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
let i = 0;
|
|
408
|
+
while (i < lines.length) {
|
|
409
|
+
const line = lines[i];
|
|
410
|
+
const fence = FENCE_OPEN_RE.exec(line);
|
|
411
|
+
if (fence) {
|
|
412
|
+
const marker = fence[1][0];
|
|
413
|
+
const minLen = fence[1].length;
|
|
414
|
+
const info = (fence[2].trim().split(/\s+/)[0] ?? "");
|
|
415
|
+
const lang = /^[\w+#.-]+$/.test(info) ? info : "";
|
|
416
|
+
const body = [];
|
|
417
|
+
let j = i + 1;
|
|
418
|
+
let closed = false;
|
|
419
|
+
for (; j < lines.length; j++) {
|
|
420
|
+
const close = FENCE_CLOSE_RE.exec(lines[j]);
|
|
421
|
+
if (close && close[1][0] === marker && close[1].length >= minLen) {
|
|
422
|
+
closed = true;
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
body.push(lines[j]);
|
|
426
|
+
}
|
|
427
|
+
flush();
|
|
428
|
+
const code = escapeAll(body.join("\n"));
|
|
429
|
+
out.push(lang
|
|
430
|
+
? `<pre><code class="language-${lang}">${code}</code></pre>`
|
|
431
|
+
: `<pre>${code}</pre>`);
|
|
432
|
+
i = closed ? j + 1 : lines.length;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const heading = HEADING_RE.exec(line);
|
|
436
|
+
if (heading) {
|
|
437
|
+
flush();
|
|
438
|
+
out.push(`<b>${renderInline(heading[2].trim())}</b>`);
|
|
439
|
+
i++;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (QUOTE_RE.test(line)) {
|
|
443
|
+
flush();
|
|
444
|
+
const quoted = [];
|
|
445
|
+
let j = i;
|
|
446
|
+
for (; j < lines.length; j++) {
|
|
447
|
+
const q = QUOTE_RE.exec(lines[j]);
|
|
448
|
+
if (!q)
|
|
449
|
+
break;
|
|
450
|
+
quoted.push(q[1]);
|
|
451
|
+
}
|
|
452
|
+
out.push(`<blockquote>${renderInline(quoted.join("\n"))}</blockquote>`);
|
|
453
|
+
i = j;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
plain.push(line);
|
|
457
|
+
i++;
|
|
458
|
+
}
|
|
459
|
+
flush();
|
|
460
|
+
return out.join("\n");
|
|
461
|
+
}
|