clawgram 2.13.1 → 2.15.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 +17 -0
- package/dist/channel.js +48 -2
- package/dist/gramjs-client.js +27 -5
- package/dist/group-reply-address.js +17 -19
- package/dist/group-visible-reply-guard.js +65 -2
- package/dist/helpers.js +15 -5
- package/dist/html-render.js +461 -0
- package/openclaw.plugin.json +4 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -228,6 +228,7 @@ openclaw gateway restart
|
|
|
228
228
|
| `groups` | object | `{}` | Allowed groups map keyed by explicit group id or `*` |
|
|
229
229
|
| `proxy` | object | unset | Optional SOCKS4/SOCKS5 proxy for this account — see [Proxy (SOCKS4/SOCKS5)](#proxy-socks4socks5) |
|
|
230
230
|
| `manageChats` | string[] | unset | Chats the assistant may **manage** — see [Chat management](#chat-management). Absent or empty = management off; `["*"]` = every chat |
|
|
231
|
+
| `replyParseMode` | `"html"` \| `"markdown"` \| `"none"` | unset | Outbound format for replies, core-delivered text, captions and `send` calls that omit `parseMode` — see [Message formatting](#message-formatting) |
|
|
231
232
|
| `twoFaPassword` | string \| SecretRef | unset | The account's Telegram 2FA password; read only by `transferOwnership` |
|
|
232
233
|
|
|
233
234
|
Group config fields:
|
|
@@ -238,6 +239,22 @@ Group config fields:
|
|
|
238
239
|
| `groupPolicy` | `"open"` \| `"mention"` | `"mention"` | `open` replies to any group message, `mention` only on @mention or reply-to-self |
|
|
239
240
|
| `allowFrom` | string[] | `["*"]` | Allowed sender IDs/usernames inside that group |
|
|
240
241
|
|
|
242
|
+
### Message formatting
|
|
243
|
+
|
|
244
|
+
`replyParseMode` sets the outbound format for every path that does not name
|
|
245
|
+
one explicitly: replies, core-delivered text, media captions, and `send`
|
|
246
|
+
actions without a `parseMode` parameter. A per-call `parseMode` still wins.
|
|
247
|
+
|
|
248
|
+
| Mode | Behavior |
|
|
249
|
+
|---|---|
|
|
250
|
+
| `"html"` | **Recommended for agents.** The text is rendered before sending (2.15.0): markdown (`**bold**`, `*italic*`, `` `code` ``, ``` fences, `[text](url)`, `# headings`, `> quotes`, `~~strike~~`, `\|\|spoiler\|\|`) becomes Telegram entities, hand-written Telegram HTML (`<b>`, `<a href>`, `<code>`, …) passes through, structural HTML (`<ul>`, `<p>`, …) is dropped, and stray `<`, `>`, `&` arrive as literal text instead of vanishing into a failed tag. Markdown inside code is never converted. |
|
|
251
|
+
| `"markdown"` | GramJS's own markdown parser: `**`, `__`, `~~`, `` ` ``, ``` ``` ``` only — no links, no single-asterisk emphasis. |
|
|
252
|
+
| `"none"` | No parsing at all: the text is delivered exactly as typed. |
|
|
253
|
+
| unset | GramJS's historical default, which is its markdown parser — **not** plain text. Set `"none"` if you want plain. |
|
|
254
|
+
|
|
255
|
+
Agent-authored messages mix markdown and HTML freely, so `"html"` is the mode
|
|
256
|
+
that renders both. There is no reliable way to prompt a model out of writing
|
|
257
|
+
markdown; rendering it is the deterministic fix.
|
|
241
258
|
|
|
242
259
|
### Configuration variant for example
|
|
243
260
|
|
package/dist/channel.js
CHANGED
|
@@ -596,7 +596,8 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
596
596
|
const sendTextToConversation = async (args) => {
|
|
597
597
|
const targets = [conversationTarget, ...conversationFallbackTargets];
|
|
598
598
|
// Replies have no per-call parseMode slot — the format is an
|
|
599
|
-
// account setting (2.3.1); absent keeps
|
|
599
|
+
// account setting (2.3.1); absent keeps the GramJS default
|
|
600
|
+
// (its markdown parser — not plain text, see 2.15.0 notes).
|
|
600
601
|
const replyParseMode = gram.replyParseMode;
|
|
601
602
|
let lastError;
|
|
602
603
|
for (const target of targets) {
|
|
@@ -1762,6 +1763,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1762
1763
|
target: uploadTo,
|
|
1763
1764
|
file,
|
|
1764
1765
|
caption: caption || undefined,
|
|
1766
|
+
// Same resolution as the text `send`: per-call value wins, an
|
|
1767
|
+
// omitted one inherits the account format (2.15.0). A caption is
|
|
1768
|
+
// the same prose as a message and renders identically.
|
|
1769
|
+
parseMode: (0, helpers_1.resolveOutboundParseMode)(params, cfg, uploadAccountId),
|
|
1765
1770
|
replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(rawUploadTo, uploadReplyToId),
|
|
1766
1771
|
messageThreadId: parseOptionalThreadId(uploadThreadId),
|
|
1767
1772
|
asVoice,
|
|
@@ -1834,10 +1839,15 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1834
1839
|
accountId: resolvedAccountId,
|
|
1835
1840
|
});
|
|
1836
1841
|
}
|
|
1842
|
+
// Whom to greet is decided by the message this turn is answering, not
|
|
1843
|
+
// by whoever spoke last. An agent replying to a request rarely passes
|
|
1844
|
+
// `replyToId`, and until 2026-08-10 that fell through to the most
|
|
1845
|
+
// recent sender: in an interleaved chat the owner's report went out
|
|
1846
|
+
// addressed to a colleague who had asked something else entirely.
|
|
1837
1847
|
const groupReplyAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
|
|
1838
1848
|
accountId: resolvedAccountId,
|
|
1839
1849
|
chatId: to,
|
|
1840
|
-
replyToId,
|
|
1850
|
+
replyToId: replyToId ?? currentMessageId,
|
|
1841
1851
|
});
|
|
1842
1852
|
const requestedText = (0, helpers_1.readMessageText)(params).replaceAll("\\n", "\n");
|
|
1843
1853
|
// `NO_REPLY` is OpenClaw's "say nothing" sentinel. The inbound pipeline
|
|
@@ -1896,6 +1906,17 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1896
1906
|
currentMessageId,
|
|
1897
1907
|
});
|
|
1898
1908
|
}
|
|
1909
|
+
// The turn has now spoken for itself. Recorded for every send into the
|
|
1910
|
+
// chat this turn came from — with or without an explicit replyToId —
|
|
1911
|
+
// so that core delivering the turn's final text a few seconds later
|
|
1912
|
+
// can be recognised as an echo of this same answer.
|
|
1913
|
+
if (currentMessageId !== null && currentMessageId !== undefined) {
|
|
1914
|
+
(0, group_visible_reply_guard_1.rememberTurnSend)({
|
|
1915
|
+
accountId: resolvedAccountId,
|
|
1916
|
+
chatId: to,
|
|
1917
|
+
currentMessageId,
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1899
1920
|
actionLog.info("clawgram handleAction send completed", {
|
|
1900
1921
|
accountId: resolvedAccountId,
|
|
1901
1922
|
to,
|
|
@@ -1969,6 +1990,28 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1969
1990
|
if (!gram) {
|
|
1970
1991
|
throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
|
|
1971
1992
|
}
|
|
1993
|
+
// The agent already answered this message with its own `send`, and this
|
|
1994
|
+
// is core delivering the same turn's final text. Two messages for one
|
|
1995
|
+
// answer is how 2026-08-10 read in a work chat: every request reported
|
|
1996
|
+
// twice, in slightly different words, seconds apart.
|
|
1997
|
+
//
|
|
1998
|
+
// Core's own convention is that an agent which has sent a message
|
|
1999
|
+
// returns NO_REPLY; this catches the turns that forget. The window is
|
|
2000
|
+
// seconds wide, so a result the assistant comes back with later is
|
|
2001
|
+
// still delivered.
|
|
2002
|
+
if (ctx.replyToId !== null && ctx.replyToId !== undefined && (0, group_visible_reply_guard_1.hadTurnSendJustNow)({
|
|
2003
|
+
accountId: ctx.accountId,
|
|
2004
|
+
chatId: (0, helpers_1.normalizeOutboundTarget)(ctx.to),
|
|
2005
|
+
currentMessageId: ctx.replyToId,
|
|
2006
|
+
})) {
|
|
2007
|
+
actionLog.warn("clawgram suppressing echo of a turn that already sent", {
|
|
2008
|
+
accountId: ctx.accountId,
|
|
2009
|
+
rawTo: ctx.to,
|
|
2010
|
+
replyToId: ctx.replyToId,
|
|
2011
|
+
textLength: ctx.text.length,
|
|
2012
|
+
});
|
|
2013
|
+
return { skipped: "duplicate" };
|
|
2014
|
+
}
|
|
1972
2015
|
const groupReplyAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
|
|
1973
2016
|
accountId: ctx.accountId,
|
|
1974
2017
|
chatId: ctx.to,
|
|
@@ -2027,6 +2070,9 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2027
2070
|
target,
|
|
2028
2071
|
file,
|
|
2029
2072
|
caption: ctx.caption ?? ctx.text,
|
|
2073
|
+
// Captions follow the account reply format like every other reply:
|
|
2074
|
+
// they are the same agent prose, just attached to a file (2.15.0).
|
|
2075
|
+
parseMode: gram.replyParseMode,
|
|
2030
2076
|
replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(ctx.to, ctx.replyToId),
|
|
2031
2077
|
messageThreadId,
|
|
2032
2078
|
asVoice: ctx.audioAsVoice === true,
|
package/dist/gramjs-client.js
CHANGED
|
@@ -8,6 +8,7 @@ const sessions_1 = require("telegram/sessions");
|
|
|
8
8
|
// the package has no `exports` field to forbid it.
|
|
9
9
|
const Password_1 = require("telegram/Password");
|
|
10
10
|
const helpers_1 = require("./helpers");
|
|
11
|
+
const html_render_1 = require("./html-render");
|
|
11
12
|
const proxy_config_1 = require("./proxy-config");
|
|
12
13
|
const secret_refs_1 = require("./secret-refs");
|
|
13
14
|
const history_1 = require("./history");
|
|
@@ -323,10 +324,20 @@ class GramJsClientManager {
|
|
|
323
324
|
const messageThreadId = args.messageThreadId ?? resolved.messageThreadId;
|
|
324
325
|
const replyParams = buildForumReplyParams(messageThreadId, args.replyToMessageId);
|
|
325
326
|
return this.client.sendMessage(resolved.peer, {
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
327
|
+
// In html mode the text is rendered first: the agent writes markdown,
|
|
328
|
+
// Telegram HTML, or both, and GramJS's HTML parser alone would ship
|
|
329
|
+
// the markdown as literal asterisks (2026-08-12 00:13 UTC, a whole
|
|
330
|
+
// monthly report of them).
|
|
331
|
+
message: args.parseMode === "html" ? (0, html_render_1.renderTelegramHtml)(args.text) : args.text,
|
|
332
|
+
// GramJS accepts "md" | "html". `false` switches parsing off — needed
|
|
333
|
+
// because an *absent* mode is not plain text: GramJS then applies its
|
|
334
|
+
// own default markdown parser, and always has. "none" is the honest
|
|
335
|
+
// spelling of "exactly as typed".
|
|
336
|
+
...(args.parseMode === "none"
|
|
337
|
+
? { parseMode: false }
|
|
338
|
+
: args.parseMode
|
|
339
|
+
? { parseMode: args.parseMode === "markdown" ? "md" : "html" }
|
|
340
|
+
: {}),
|
|
330
341
|
...replyParams,
|
|
331
342
|
});
|
|
332
343
|
}
|
|
@@ -561,7 +572,18 @@ class GramJsClientManager {
|
|
|
561
572
|
const replyParams = buildForumReplyParams(messageThreadId, args.replyToMessageId);
|
|
562
573
|
return this.client.sendFile(resolved.peer, {
|
|
563
574
|
file: args.file,
|
|
564
|
-
caption
|
|
575
|
+
// Captions are agent prose too — the outbound path sends `caption ??
|
|
576
|
+
// text` — so they render exactly like sendText does. Before 2.15.0
|
|
577
|
+
// captions carried no mode at all, which meant GramJS's default
|
|
578
|
+
// markdown pass, a third rendering behavior nobody chose.
|
|
579
|
+
caption: args.parseMode === "html" && args.caption
|
|
580
|
+
? (0, html_render_1.renderTelegramHtml)(args.caption)
|
|
581
|
+
: args.caption,
|
|
582
|
+
...(args.parseMode === "none"
|
|
583
|
+
? { parseMode: false }
|
|
584
|
+
: args.parseMode
|
|
585
|
+
? { parseMode: args.parseMode === "markdown" ? "md" : "html" }
|
|
586
|
+
: {}),
|
|
565
587
|
...replyParams,
|
|
566
588
|
...buildVoiceNoteParams(args.asVoice),
|
|
567
589
|
});
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.rememberGroupReplyAddress = rememberGroupReplyAddress;
|
|
4
4
|
exports.consumeGroupReplyAddress = consumeGroupReplyAddress;
|
|
5
|
+
exports.resetGroupReplyAddresses = resetGroupReplyAddresses;
|
|
5
6
|
exports.buildGroupReplyAddress = buildGroupReplyAddress;
|
|
6
7
|
const helpers_1 = require("./helpers");
|
|
7
8
|
const groupReplyAddresses = new Map();
|
|
8
9
|
const GROUP_REPLY_ADDRESS_TTL_MS = 10 * 60 * 1000;
|
|
9
|
-
const GROUP_REPLY_LATEST_ID = "__latest__";
|
|
10
10
|
function normalizeGroupReplyTarget(rawTarget) {
|
|
11
11
|
if (typeof rawTarget !== "string") {
|
|
12
12
|
return String(rawTarget ?? "").trim();
|
|
@@ -33,23 +33,20 @@ function rememberGroupReplyAddress(input) {
|
|
|
33
33
|
address: input.address,
|
|
34
34
|
expiresAt: Date.now() + GROUP_REPLY_ADDRESS_TTL_MS,
|
|
35
35
|
});
|
|
36
|
-
const latestKey = buildGroupReplyAddressKey({
|
|
37
|
-
...input,
|
|
38
|
-
replyToId: GROUP_REPLY_LATEST_ID,
|
|
39
|
-
});
|
|
40
|
-
if (latestKey) {
|
|
41
|
-
groupReplyAddresses.set(latestKey, {
|
|
42
|
-
address: input.address,
|
|
43
|
-
expiresAt: Date.now() + GROUP_REPLY_ADDRESS_TTL_MS,
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* The address remembered for one specific incoming message.
|
|
39
|
+
*
|
|
40
|
+
* There used to be a `__latest__` entry as well, so that a send carrying no
|
|
41
|
+
* `replyToId` still greeted somebody. In a chat where requests interleave that
|
|
42
|
+
* "somebody" is whoever spoke last, which on 2026-08-10 put the owner's report
|
|
43
|
+
* out as "@colleague, готово": the colleague read a result they had not asked
|
|
44
|
+
* for, and the owner's own request looked unanswered. Recency is not an answer
|
|
45
|
+
* to "who am I replying to", so the fallback is gone — callers pass the message
|
|
46
|
+
* the turn is actually answering, and no message means no greeting.
|
|
47
|
+
*/
|
|
47
48
|
function consumeGroupReplyAddress(input) {
|
|
48
|
-
const
|
|
49
|
-
...input,
|
|
50
|
-
replyToId: GROUP_REPLY_LATEST_ID,
|
|
51
|
-
});
|
|
52
|
-
const key = buildGroupReplyAddressKey(input) ?? latestKey;
|
|
49
|
+
const key = buildGroupReplyAddressKey(input);
|
|
53
50
|
if (!key) {
|
|
54
51
|
return undefined;
|
|
55
52
|
}
|
|
@@ -58,14 +55,15 @@ function consumeGroupReplyAddress(input) {
|
|
|
58
55
|
return undefined;
|
|
59
56
|
}
|
|
60
57
|
groupReplyAddresses.delete(key);
|
|
61
|
-
if (latestKey) {
|
|
62
|
-
groupReplyAddresses.delete(latestKey);
|
|
63
|
-
}
|
|
64
58
|
if (stored.expiresAt < Date.now()) {
|
|
65
59
|
return undefined;
|
|
66
60
|
}
|
|
67
61
|
return stored.address;
|
|
68
62
|
}
|
|
63
|
+
/** Test seam: the map is module state, and suites must not leak into each other. */
|
|
64
|
+
function resetGroupReplyAddresses() {
|
|
65
|
+
groupReplyAddresses.clear();
|
|
66
|
+
}
|
|
69
67
|
function buildGroupReplyAddress(input) {
|
|
70
68
|
const username = input.senderUsername?.replace(/^@/, "").trim();
|
|
71
69
|
if (username) {
|
|
@@ -2,9 +2,23 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.hasRecentVisibleGroupReply = hasRecentVisibleGroupReply;
|
|
4
4
|
exports.rememberVisibleGroupReply = rememberVisibleGroupReply;
|
|
5
|
+
exports.rememberTurnSend = rememberTurnSend;
|
|
6
|
+
exports.hadTurnSendJustNow = hadTurnSendJustNow;
|
|
7
|
+
exports.resetVisibleGroupReplies = resetVisibleGroupReplies;
|
|
5
8
|
const helpers_1 = require("./helpers");
|
|
9
|
+
/** When each visible reply went out, keyed by account + chat + incoming message. */
|
|
6
10
|
const recentVisibleGroupReplies = new Map();
|
|
7
11
|
const GROUP_VISIBLE_REPLY_TTL_MS = 10 * 60 * 1000;
|
|
12
|
+
/**
|
|
13
|
+
* How long after the agent's own send core's delivery of the same turn's final
|
|
14
|
+
* text still counts as an echo rather than as something new.
|
|
15
|
+
*
|
|
16
|
+
* Measured on the live incident: `handleAction send` at 12:39:02, core's
|
|
17
|
+
* delivery at 12:39:09 — seven seconds, twice in a row. Work that produces
|
|
18
|
+
* genuinely new information takes far longer, and the window has to stay well
|
|
19
|
+
* under that: dropping a real result is worse than letting a duplicate through.
|
|
20
|
+
*/
|
|
21
|
+
const GROUP_TURN_ECHO_WINDOW_MS = 20 * 1000;
|
|
8
22
|
function buildVisibleGroupReplyKey(input) {
|
|
9
23
|
const chatId = (0, helpers_1.normalizeOutboundTarget)(String(input.chatId ?? "").trim());
|
|
10
24
|
const currentMessageId = input.currentMessageId === null || input.currentMessageId === undefined
|
|
@@ -39,10 +53,59 @@ function hasRecentVisibleGroupReply(input) {
|
|
|
39
53
|
}
|
|
40
54
|
return true;
|
|
41
55
|
}
|
|
42
|
-
function rememberVisibleGroupReply(input) {
|
|
56
|
+
function rememberVisibleGroupReply(input, sentAt = Date.now()) {
|
|
43
57
|
const key = buildVisibleGroupReplyKey(input);
|
|
44
58
|
if (!key) {
|
|
45
59
|
return;
|
|
46
60
|
}
|
|
47
|
-
recentVisibleGroupReplies.set(key,
|
|
61
|
+
recentVisibleGroupReplies.set(key, sentAt + GROUP_VISIBLE_REPLY_TTL_MS);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* When a turn last spoke for itself, keyed the same way.
|
|
65
|
+
*
|
|
66
|
+
* Kept apart from `recentVisibleGroupReplies` on purpose: that map drives a
|
|
67
|
+
* ten-minute suppression of repeated sends, and widening what feeds it would
|
|
68
|
+
* quietly make that rule stricter. This one answers a different question —
|
|
69
|
+
* did core just echo the turn that has only now finished.
|
|
70
|
+
*/
|
|
71
|
+
const lastTurnSends = new Map();
|
|
72
|
+
/** Records that the agent itself put a message in the chat during this turn. */
|
|
73
|
+
function rememberTurnSend(input, sentAt = Date.now()) {
|
|
74
|
+
const key = buildVisibleGroupReplyKey(input);
|
|
75
|
+
if (!key) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
lastTurnSends.set(key, sentAt);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* True when this turn already put a visible message in this chat moments ago.
|
|
82
|
+
*
|
|
83
|
+
* The case it exists for: the agent answers by calling `send`, then returns
|
|
84
|
+
* text as well, and core delivers that text as a second message. Both are the
|
|
85
|
+
* same answer — on 2026-08-10 every request in a work chat was reported twice,
|
|
86
|
+
* and the reader had to work out that the two messages were one event.
|
|
87
|
+
*
|
|
88
|
+
* Bounded by `GROUP_TURN_ECHO_WINDOW_MS` rather than by the ten-minute TTL:
|
|
89
|
+
* beyond a few seconds the assistant is coming back with something new, and
|
|
90
|
+
* dropping that would lose a real result.
|
|
91
|
+
*/
|
|
92
|
+
function hadTurnSendJustNow(input, now = Date.now()) {
|
|
93
|
+
const key = buildVisibleGroupReplyKey(input);
|
|
94
|
+
if (!key) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
const sentAt = lastTurnSends.get(key);
|
|
98
|
+
if (sentAt === undefined) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
if (now - sentAt > GROUP_TURN_ECHO_WINDOW_MS) {
|
|
102
|
+
lastTurnSends.delete(key);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
/** Test seam: module state must not leak between suites. */
|
|
108
|
+
function resetVisibleGroupReplies() {
|
|
109
|
+
recentVisibleGroupReplies.clear();
|
|
110
|
+
lastTurnSends.clear();
|
|
48
111
|
}
|
package/dist/helpers.js
CHANGED
|
@@ -564,15 +564,22 @@ function normalizeParseMode(raw) {
|
|
|
564
564
|
return "markdown";
|
|
565
565
|
if (value === "html")
|
|
566
566
|
return "html";
|
|
567
|
-
|
|
567
|
+
// "none" switches GramJS parsing off entirely. It exists because absent is
|
|
568
|
+
// NOT plain text: GramJS falls back to its own markdown parser by default,
|
|
569
|
+
// which quietly ate `**` from "plain" sends since the fork began.
|
|
570
|
+
if (value === "none")
|
|
571
|
+
return "none";
|
|
572
|
+
throw new Error(`clawgram: invalid parseMode "${String(raw)}" — use "markdown", "html" or "none"`);
|
|
568
573
|
}
|
|
569
574
|
/**
|
|
570
575
|
* Reply parse mode for the inbound reply path (2.3.1). The action `send`
|
|
571
576
|
* takes parseMode per-call, but replies to a mention run through the reply
|
|
572
577
|
* 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
|
-
*
|
|
578
|
+
* `channels.clawgram.accounts.<id>.replyParseMode: "markdown" | "html" |
|
|
579
|
+
* "none"`. Absent keeps GramJS's historical default — its markdown parser,
|
|
580
|
+
* not plain text, which 2.3.1 believed and 2.15.0 disproved. An invalid
|
|
581
|
+
* value throws at config-read time, loud and early, rather than shipping
|
|
582
|
+
* raw markup.
|
|
576
583
|
*/
|
|
577
584
|
function resolveReplyParseMode(cfg, accountId) {
|
|
578
585
|
const channel = cfg?.channels?.["clawgram"];
|
|
@@ -617,8 +624,11 @@ function resolveDryRun(dryRun, params) {
|
|
|
617
624
|
function resolveOutboundParseMode(params, cfg, accountId) {
|
|
618
625
|
const raw = params?.parseMode;
|
|
619
626
|
// An explicitly empty value is a decision, not an omission: send it raw.
|
|
627
|
+
// "none" (not undefined) is what actually delivers on that: an absent
|
|
628
|
+
// parseMode at the GramJS boundary means GramJS's own default markdown
|
|
629
|
+
// parser, which would still eat `**` out of a message about markup.
|
|
620
630
|
if (raw === "" || raw === null) {
|
|
621
|
-
return
|
|
631
|
+
return "none";
|
|
622
632
|
}
|
|
623
633
|
return raw === undefined
|
|
624
634
|
? resolveReplyParseMode(cfg, accountId)
|
|
@@ -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
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "clawgram",
|
|
3
3
|
"name": "Clawgram",
|
|
4
4
|
"description": "Clawgram — personal Telegram (MTProto userbot) channel for OpenClaw. Your AI assistant reads and responds as you.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.15.0",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
|
@@ -382,9 +382,10 @@
|
|
|
382
382
|
"enum": [
|
|
383
383
|
"markdown",
|
|
384
384
|
"md",
|
|
385
|
-
"html"
|
|
385
|
+
"html",
|
|
386
|
+
"none"
|
|
386
387
|
],
|
|
387
|
-
"description": "
|
|
388
|
+
"description": "Outbound format for replies, core-delivered text, captions and send calls that omit parseMode. html (2.15.0) renders agent markdown and Telegram HTML into entities; markdown is GramJS's five-delimiter parser; none disables parsing entirely. Absent keeps GramJS's historical default, which is its markdown parser — not plain text."
|
|
388
389
|
}
|
|
389
390
|
},
|
|
390
391
|
"required": [
|
package/package.json
CHANGED