@moxxy/plugin-telegram 0.27.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/LICENSE +21 -0
- package/dist/approval.d.ts +37 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +75 -0
- package/dist/approval.js.map +1 -0
- package/dist/channel/approval-prompt.d.ts +20 -0
- package/dist/channel/approval-prompt.d.ts.map +1 -0
- package/dist/channel/approval-prompt.js +34 -0
- package/dist/channel/approval-prompt.js.map +1 -0
- package/dist/channel/callback-handler.d.ts +32 -0
- package/dist/channel/callback-handler.d.ts.map +1 -0
- package/dist/channel/callback-handler.js +212 -0
- package/dist/channel/callback-handler.js.map +1 -0
- package/dist/channel/frame-pump.d.ts +58 -0
- package/dist/channel/frame-pump.d.ts.map +1 -0
- package/dist/channel/frame-pump.js +159 -0
- package/dist/channel/frame-pump.js.map +1 -0
- package/dist/channel/html.d.ts +24 -0
- package/dist/channel/html.d.ts.map +1 -0
- package/dist/channel/html.js +62 -0
- package/dist/channel/html.js.map +1 -0
- package/dist/channel/pairing-handler.d.ts +66 -0
- package/dist/channel/pairing-handler.d.ts.map +1 -0
- package/dist/channel/pairing-handler.js +147 -0
- package/dist/channel/pairing-handler.js.map +1 -0
- package/dist/channel/permission-prompt.d.ts +21 -0
- package/dist/channel/permission-prompt.d.ts.map +1 -0
- package/dist/channel/permission-prompt.js +36 -0
- package/dist/channel/permission-prompt.js.map +1 -0
- package/dist/channel/slash-handler.d.ts +47 -0
- package/dist/channel/slash-handler.d.ts.map +1 -0
- package/dist/channel/slash-handler.js +214 -0
- package/dist/channel/slash-handler.js.map +1 -0
- package/dist/channel/text-handler.d.ts +43 -0
- package/dist/channel/text-handler.d.ts.map +1 -0
- package/dist/channel/text-handler.js +116 -0
- package/dist/channel/text-handler.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +40 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +67 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel/typing-indicator.d.ts +14 -0
- package/dist/channel/typing-indicator.d.ts.map +1 -0
- package/dist/channel/typing-indicator.js +29 -0
- package/dist/channel/typing-indicator.js.map +1 -0
- package/dist/channel/voice-handler.d.ts +42 -0
- package/dist/channel/voice-handler.d.ts.map +1 -0
- package/dist/channel/voice-handler.js +138 -0
- package/dist/channel/voice-handler.js.map +1 -0
- package/dist/channel.d.ts +118 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +478 -0
- package/dist/channel.js.map +1 -0
- package/dist/channel.test-d.d.ts +2 -0
- package/dist/channel.test-d.d.ts.map +1 -0
- package/dist/channel.test-d.js +5 -0
- package/dist/channel.test-d.js.map +1 -0
- package/dist/format.d.ts +38 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +237 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +304 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +37 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +42 -0
- package/dist/keys.js.map +1 -0
- package/dist/pair-flow.d.ts +18 -0
- package/dist/pair-flow.d.ts.map +1 -0
- package/dist/pair-flow.js +119 -0
- package/dist/pair-flow.js.map +1 -0
- package/dist/pairing.d.ts +90 -0
- package/dist/pairing.d.ts.map +1 -0
- package/dist/pairing.js +97 -0
- package/dist/pairing.js.map +1 -0
- package/dist/permission.d.ts +25 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +49 -0
- package/dist/permission.js.map +1 -0
- package/dist/render.d.ts +68 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +439 -0
- package/dist/render.js.map +1 -0
- package/dist/setup-wizard.d.ts +21 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +140 -0
- package/dist/setup-wizard.js.map +1 -0
- package/package.json +91 -0
- package/src/approval.test.ts +103 -0
- package/src/approval.ts +86 -0
- package/src/channel/approval-prompt.ts +46 -0
- package/src/channel/callback-handler.test.ts +188 -0
- package/src/channel/callback-handler.ts +256 -0
- package/src/channel/frame-pump.ts +176 -0
- package/src/channel/html.test.ts +40 -0
- package/src/channel/html.ts +60 -0
- package/src/channel/pairing-handler.ts +181 -0
- package/src/channel/permission-prompt.ts +51 -0
- package/src/channel/slash-handler.ts +259 -0
- package/src/channel/text-handler.test.ts +222 -0
- package/src/channel/text-handler.ts +174 -0
- package/src/channel/turn-runner.ts +102 -0
- package/src/channel/typing-indicator.ts +31 -0
- package/src/channel/voice-handler.test.ts +328 -0
- package/src/channel/voice-handler.ts +198 -0
- package/src/channel/voice-reply.test.ts +223 -0
- package/src/channel.test-d.ts +7 -0
- package/src/channel.ts +564 -0
- package/src/format.test.ts +159 -0
- package/src/format.ts +257 -0
- package/src/index.ts +347 -0
- package/src/keys.ts +52 -0
- package/src/pair-flow.ts +131 -0
- package/src/pairing.test.ts +113 -0
- package/src/pairing.ts +184 -0
- package/src/permission.test.ts +56 -0
- package/src/permission.ts +61 -0
- package/src/render.test.ts +350 -0
- package/src/render.ts +522 -0
- package/src/setup-wizard.ts +178 -0
- package/src/subcommands.test.ts +153 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { markdownToTelegramHtml } from './format.js';
|
|
3
|
+
|
|
4
|
+
describe('markdownToTelegramHtml', () => {
|
|
5
|
+
it('escapes raw HTML special chars in plain text', () => {
|
|
6
|
+
expect(markdownToTelegramHtml('< & > "')).toBe('< & > "');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('renders headings as bold lines', () => {
|
|
10
|
+
expect(markdownToTelegramHtml('# Title\nbody')).toContain('<b>Title</b>');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('converts bold and italic correctly', () => {
|
|
14
|
+
const out = markdownToTelegramHtml('Hello **world** and *italic*.');
|
|
15
|
+
expect(out).toContain('<b>world</b>');
|
|
16
|
+
expect(out).toContain('<i>italic</i>');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('renders inline code in <code>', () => {
|
|
20
|
+
const out = markdownToTelegramHtml('use `npm install` to set up.');
|
|
21
|
+
expect(out).toContain('<code>npm install</code>');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('renders fenced code blocks with language class', () => {
|
|
25
|
+
const out = markdownToTelegramHtml('```ts\nconst x = 1;\n```');
|
|
26
|
+
expect(out).toContain('<pre><code class="language-ts">');
|
|
27
|
+
expect(out).toContain('const x = 1;');
|
|
28
|
+
expect(out).toContain('</code></pre>');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('does NOT process markdown inside code blocks', () => {
|
|
32
|
+
const out = markdownToTelegramHtml('```\n**not bold**\n```');
|
|
33
|
+
expect(out).not.toContain('<b>not bold</b>');
|
|
34
|
+
expect(out).toContain('**not bold**');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('converts links to <a href="...">', () => {
|
|
38
|
+
const out = markdownToTelegramHtml('See [docs](https://example.com).');
|
|
39
|
+
expect(out).toContain('<a href="https://example.com">docs</a>');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('allows http, mailto, and tel scheme links', () => {
|
|
43
|
+
expect(markdownToTelegramHtml('[a](http://x.com)')).toContain('<a href="http://x.com">');
|
|
44
|
+
expect(markdownToTelegramHtml('[m](mailto:a@b.com)')).toContain('<a href="mailto:a@b.com">');
|
|
45
|
+
expect(markdownToTelegramHtml('[t](tel:+15551234)')).toContain('<a href="tel:+15551234">');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('allows scheme-less / relative / anchor links', () => {
|
|
49
|
+
expect(markdownToTelegramHtml('[r](/path/to/thing)')).toContain('<a href="/path/to/thing">');
|
|
50
|
+
expect(markdownToTelegramHtml('[a](#section)')).toContain('<a href="#section">');
|
|
51
|
+
expect(markdownToTelegramHtml('[h](example.com/page)')).toContain('<a href="example.com/page">');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does NOT emit an anchor for dangerous URL schemes', () => {
|
|
55
|
+
// javascript:, data:, file:, and tg:// deep links must never become a
|
|
56
|
+
// clickable <a href> — they fall back to the (escaped) link text.
|
|
57
|
+
for (const url of [
|
|
58
|
+
'javascript:doThing',
|
|
59
|
+
'JavaScript:doThing',
|
|
60
|
+
'data:text/html,<script>x</script>',
|
|
61
|
+
'file:///etc/passwd',
|
|
62
|
+
'tg://resolve?domain=evil',
|
|
63
|
+
]) {
|
|
64
|
+
const out = markdownToTelegramHtml(`[click](${url})`);
|
|
65
|
+
expect(out).not.toContain('<a href');
|
|
66
|
+
expect(out).toContain('[click]');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects an uppercase / mixed-case dangerous scheme', () => {
|
|
71
|
+
expect(markdownToTelegramHtml('[x](TG://resolve?domain=evil)')).not.toContain('<a href');
|
|
72
|
+
expect(markdownToTelegramHtml('[x](Data:text/plain,hi)')).not.toContain('<a href');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects a control char used to hide a dangerous scheme', () => {
|
|
76
|
+
// A leading SOH (\x01) must not let "javascript:" slip past the scheme check.
|
|
77
|
+
const out = markdownToTelegramHtml('[x](' + String.fromCharCode(1) + 'javascript:doThing)');
|
|
78
|
+
expect(out).not.toContain('<a href');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('converts bullet markers to • glyph', () => {
|
|
82
|
+
const out = markdownToTelegramHtml('- one\n- two');
|
|
83
|
+
expect(out).toContain('• one');
|
|
84
|
+
expect(out).toContain('• two');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("doesn't italicize mid-word underscores", () => {
|
|
88
|
+
const out = markdownToTelegramHtml('var_name_here');
|
|
89
|
+
expect(out).not.toContain('<i>');
|
|
90
|
+
expect(out).toContain('var_name_here');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('renders ~~strikethrough~~ as <s>', () => {
|
|
94
|
+
const out = markdownToTelegramHtml('this is ~~gone~~ now');
|
|
95
|
+
expect(out).toContain('<s>gone</s>');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('leaves a single ~ tilde literal', () => {
|
|
99
|
+
const out = markdownToTelegramHtml('about ~5 minutes');
|
|
100
|
+
expect(out).not.toContain('<s>');
|
|
101
|
+
expect(out).toContain('~5 minutes');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('renders ||spoiler|| as <tg-spoiler>', () => {
|
|
105
|
+
const out = markdownToTelegramHtml('the answer is ||42||');
|
|
106
|
+
expect(out).toContain('<tg-spoiler>42</tg-spoiler>');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("doesn't turn a single | into a spoiler", () => {
|
|
110
|
+
const out = markdownToTelegramHtml('a | b | c');
|
|
111
|
+
expect(out).not.toContain('tg-spoiler');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('renders a [!note] callout as a titled, always-open blockquote', () => {
|
|
115
|
+
const out = markdownToTelegramHtml('> [!note] Heads up\n> read this carefully');
|
|
116
|
+
expect(out).toContain('<blockquote><b>ℹ️ Heads up</b>');
|
|
117
|
+
expect(out).toContain('read this carefully');
|
|
118
|
+
expect(out).not.toContain('expandable');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('uses the type name as the title when none is given', () => {
|
|
122
|
+
const out = markdownToTelegramHtml('> [!warning]\n> careful');
|
|
123
|
+
expect(out).toContain('<b>⚠️ Warning</b>');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('collapses a [!details]- callout into an expandable box', () => {
|
|
127
|
+
const out = markdownToTelegramHtml('> [!details]- Internals\n> gory detail one\n> gory detail two');
|
|
128
|
+
expect(out).toContain('<blockquote expandable><b>📋 Internals</b>');
|
|
129
|
+
expect(out).toContain('gory detail one');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('collapses fold-by-default callout types (details) even without a marker', () => {
|
|
133
|
+
expect(markdownToTelegramHtml('> [!details] X\n> y')).toContain('<blockquote expandable>');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('honours a trailing + to force a fold-by-default callout open', () => {
|
|
137
|
+
const out = markdownToTelegramHtml('> [!faq]+ Q?\n> A.');
|
|
138
|
+
expect(out).toContain('<blockquote><b>❓ Q?</b>');
|
|
139
|
+
expect(out).not.toContain('expandable');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('auto-collapses a long plain quote into an expandable box', () => {
|
|
143
|
+
const out = markdownToTelegramHtml('> one\n> two\n> three\n> four');
|
|
144
|
+
expect(out).toContain('<blockquote expandable>');
|
|
145
|
+
expect(out).toContain('four');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('keeps a short plain quote as a normal blockquote', () => {
|
|
149
|
+
const out = markdownToTelegramHtml('> just one line');
|
|
150
|
+
expect(out).toContain('<blockquote>just one line</blockquote>');
|
|
151
|
+
expect(out).not.toContain('expandable');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('leaves an unrecognised [!type] as literal quote text', () => {
|
|
155
|
+
const out = markdownToTelegramHtml('> [!bogus] hello');
|
|
156
|
+
expect(out).toContain('<blockquote>[!bogus] hello</blockquote>');
|
|
157
|
+
expect(out).not.toContain('expandable');
|
|
158
|
+
});
|
|
159
|
+
});
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert the model's Markdown output into Telegram-flavoured HTML so
|
|
3
|
+
* the bot renders **bold**, `code`, ```code blocks```, [links](…),
|
|
4
|
+
* lists, and headings instead of dumping raw `**` and `#` characters
|
|
5
|
+
* into the chat.
|
|
6
|
+
*
|
|
7
|
+
* Why HTML and not MarkdownV2: MarkdownV2 requires escaping
|
|
8
|
+
* `_ * [ ] ( ) ~ \` > # + - = | { } . !` literally everywhere they
|
|
9
|
+
* AREN'T part of formatting — one stray `.` in a sentence breaks the
|
|
10
|
+
* whole message. HTML only needs three escapes (`< > &`) in text
|
|
11
|
+
* segments, and Telegram's HTML mode supports every tag we need
|
|
12
|
+
* (`<b>`, `<i>`, `<s>`, `<tg-spoiler>`, `<code>`, `<pre>`, `<a>`,
|
|
13
|
+
* `<blockquote>` / `<blockquote expandable>`).
|
|
14
|
+
*
|
|
15
|
+
* Telegram has no native headings or list elements, so:
|
|
16
|
+
* - `#`, `##`, `###` headings → bold text on their own line.
|
|
17
|
+
* - `- item` / `* item` bullets → `• item` (bullet glyph).
|
|
18
|
+
* - `1. item` numbered → kept as-is (the digit is fine).
|
|
19
|
+
*
|
|
20
|
+
* Beyond CommonMark we also map a few extensions so the model can lean
|
|
21
|
+
* on Telegram's richer surface without learning a new syntax:
|
|
22
|
+
* - `~~strike~~` → `<s>` strikethrough.
|
|
23
|
+
* - `||spoiler||` → `<tg-spoiler>` (tap-to-reveal hidden text).
|
|
24
|
+
* - `> [!note] Heading` → a titled, emoji-tagged callout blockquote.
|
|
25
|
+
* - `> [!details]- Title` → a COLLAPSED (expandable) callout — the
|
|
26
|
+
* load-bearing "hide the details" box. The
|
|
27
|
+
* trailing `-` forces collapsed, `+` forces
|
|
28
|
+
* open; some types (details/example/faq)
|
|
29
|
+
* collapse by default.
|
|
30
|
+
* - a plain `>` quote that runs long auto-collapses into an
|
|
31
|
+
* `<blockquote expandable>` so a wall of quoted text stays tidy.
|
|
32
|
+
*
|
|
33
|
+
* Code blocks are emitted as `<pre><code class="language-xxx">…</code></pre>`,
|
|
34
|
+
* inline code as `<code>…</code>`. Everything inside code is escaped
|
|
35
|
+
* but no formatting markers run.
|
|
36
|
+
*/
|
|
37
|
+
export function markdownToTelegramHtml(md: string): string {
|
|
38
|
+
// Pull fenced code blocks out FIRST so their contents skip inline
|
|
39
|
+
// markdown processing. Replace each with a placeholder, render the
|
|
40
|
+
// rest, then splice them back.
|
|
41
|
+
const fences: string[] = [];
|
|
42
|
+
let working = md.replace(/```([a-zA-Z0-9_+-]*)\n?([\s\S]*?)```/g, (_, lang, body) => {
|
|
43
|
+
const html =
|
|
44
|
+
`<pre><code${lang ? ` class="language-${escapeHtml(String(lang))}"` : ''}>` +
|
|
45
|
+
escapeHtml(String(body)).replace(/\n+$/, '') +
|
|
46
|
+
'</code></pre>';
|
|
47
|
+
fences.push(html);
|
|
48
|
+
return ` FENCE${fences.length - 1} `;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Pull inline code spans out next, same reason.
|
|
52
|
+
const inlines: string[] = [];
|
|
53
|
+
working = working.replace(/`([^`\n]+)`/g, (_, body) => {
|
|
54
|
+
inlines.push(`<code>${escapeHtml(String(body))}</code>`);
|
|
55
|
+
return ` INLINE${inlines.length - 1} `;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Now safe to escape HTML special chars in everything else.
|
|
59
|
+
working = escapeHtml(working);
|
|
60
|
+
|
|
61
|
+
// Headings → bold. Telegram has no heading element; bold + line
|
|
62
|
+
// break is the conventional substitution.
|
|
63
|
+
working = working.replace(/^(#{1,6})\s+(.*)$/gm, (_, _hashes, text) => `<b>${String(text)}</b>`);
|
|
64
|
+
|
|
65
|
+
// Block quotes (>) — Telegram supports <blockquote> and, since Bot API
|
|
66
|
+
// 7.0, <blockquote expandable> (a collapsible "show more" box). Wrap
|
|
67
|
+
// consecutive quote lines into one block and let `renderBlockquote`
|
|
68
|
+
// decide collapsed-vs-open from an optional `[!callout]` marker or the
|
|
69
|
+
// quote's length.
|
|
70
|
+
working = working.replace(/((?:^>[^\n]*\n?)+)/gm, (m: string) => {
|
|
71
|
+
const stripped = m
|
|
72
|
+
.replace(/> ?/g, '')
|
|
73
|
+
.replace(/\n$/, '');
|
|
74
|
+
return `${renderBlockquote(stripped)}\n`;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Bullet list items → bullet glyph. Match leading whitespace +
|
|
78
|
+
// -/*/+ + space. Don't touch indented sub-bullets' indent.
|
|
79
|
+
working = working.replace(/^(\s*)([-*+])\s+/gm, (_m, indent: string) => `${indent}• `);
|
|
80
|
+
|
|
81
|
+
// Bold **text** — process before italic so `**text**` doesn't get
|
|
82
|
+
// captured by the single-asterisk italic rule first.
|
|
83
|
+
working = working.replace(/\*\*([^*\n]+)\*\*/g, (_, body) => `<b>${String(body)}</b>`);
|
|
84
|
+
// Bold __text__
|
|
85
|
+
working = working.replace(/__([^_\n]+)__/g, (_, body) => `<b>${String(body)}</b>`);
|
|
86
|
+
|
|
87
|
+
// Strikethrough ~~text~~ → <s>. (GitHub flavour; a single `~` stays
|
|
88
|
+
// literal so it never eats a stray tilde in prose.)
|
|
89
|
+
working = working.replace(/~~([^~\n]+)~~/g, (_, body) => `<s>${String(body)}</s>`);
|
|
90
|
+
|
|
91
|
+
// Spoiler ||text|| → <tg-spoiler> (Telegram's tap-to-reveal). Content
|
|
92
|
+
// can't span a `|` so a single bar / table pipe is left untouched.
|
|
93
|
+
working = working.replace(/\|\|([^|\n]+)\|\|/g, (_, body) => `<tg-spoiler>${String(body)}</tg-spoiler>`);
|
|
94
|
+
|
|
95
|
+
// Italic *text* and _text_. Use lookarounds to avoid matching
|
|
96
|
+
// mid-word underscores (`some_var_name`) and stray bullets.
|
|
97
|
+
working = working.replace(/(^|[\s(,.!?:;])\*([^*\n]+)\*(?=$|[\s),.!?:;])/g, (_m, pre, body) => `${pre}<i>${String(body)}</i>`);
|
|
98
|
+
working = working.replace(/(^|[\s(,.!?:;])_([^_\n]+)_(?=$|[\s),.!?:;])/g, (_m, pre, body) => `${pre}<i>${String(body)}</i>`);
|
|
99
|
+
|
|
100
|
+
// Links [text](url) — strip url tracking params if needed later;
|
|
101
|
+
// for now just emit. URLs may contain `&` which is already escaped
|
|
102
|
+
// to `&` by escapeHtml above; that's valid inside href.
|
|
103
|
+
working = working.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, text, url) => {
|
|
104
|
+
// The url got HTML-escaped earlier; restore enough that the link
|
|
105
|
+
// actually points where it should. Only `&` → `&` is needed
|
|
106
|
+
// for the typical case; `<>` shouldn't appear in a URL but unescape
|
|
107
|
+
// them defensively.
|
|
108
|
+
const cleanUrl = String(url).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
109
|
+
// The anchor target comes from arbitrary model output (and, via prompt
|
|
110
|
+
// injection, untrusted data the model echoes). Don't rely on Telegram's
|
|
111
|
+
// server-side parser as the sole sanitizer: emit a clickable <a> only for
|
|
112
|
+
// an explicit allow-list of schemes (and scheme-relative URLs). Anything
|
|
113
|
+
// else — `javascript:`, `data:`, `file:`, `tg://` deep links — is left as
|
|
114
|
+
// the original `[text](url)` text (already HTML-escaped upstream) so it
|
|
115
|
+
// can't navigate the client.
|
|
116
|
+
if (!isAllowedUrl(cleanUrl)) return String(m);
|
|
117
|
+
return `<a href="${escapeAttr(cleanUrl)}">${String(text)}</a>`;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Splice fenced + inline code back in.
|
|
121
|
+
working = working.replace(/ INLINE(\d+) /g, (_, i) => inlines[Number(i)] ?? '');
|
|
122
|
+
working = working.replace(/ FENCE(\d+) /g, (_, i) => fences[Number(i)] ?? '');
|
|
123
|
+
|
|
124
|
+
return working;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* GitHub / Obsidian-style callout types we recognise as the first line of a
|
|
129
|
+
* blockquote (`[!note]`, `[!warning]`, …). Each maps to a leading emoji, a
|
|
130
|
+
* default title, and whether the box collapses by default. `fold: true` types
|
|
131
|
+
* (details / example / faq) start COLLAPSED — they're meant for "extra info you
|
|
132
|
+
* can open if you want", which is exactly the detail-hiding the channel is for.
|
|
133
|
+
*/
|
|
134
|
+
const CALLOUTS: Record<string, { emoji: string; label: string; fold: boolean }> = {
|
|
135
|
+
note: { emoji: 'ℹ️', label: 'Note', fold: false },
|
|
136
|
+
info: { emoji: 'ℹ️', label: 'Info', fold: false },
|
|
137
|
+
tip: { emoji: '💡', label: 'Tip', fold: false },
|
|
138
|
+
hint: { emoji: '💡', label: 'Hint', fold: false },
|
|
139
|
+
important: { emoji: '❗', label: 'Important', fold: false },
|
|
140
|
+
warning: { emoji: '⚠️', label: 'Warning', fold: false },
|
|
141
|
+
caution: { emoji: '⚠️', label: 'Caution', fold: false },
|
|
142
|
+
danger: { emoji: '🚨', label: 'Danger', fold: false },
|
|
143
|
+
error: { emoji: '🚨', label: 'Error', fold: false },
|
|
144
|
+
success: { emoji: '✅', label: 'Success', fold: false },
|
|
145
|
+
done: { emoji: '✅', label: 'Done', fold: false },
|
|
146
|
+
question: { emoji: '❓', label: 'Question', fold: false },
|
|
147
|
+
faq: { emoji: '❓', label: 'FAQ', fold: true },
|
|
148
|
+
quote: { emoji: '💬', label: 'Quote', fold: false },
|
|
149
|
+
details: { emoji: '📋', label: 'Details', fold: true },
|
|
150
|
+
example: { emoji: '📋', label: 'Example', fold: true },
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** A quote this big collapses into an expandable box even without a marker. */
|
|
154
|
+
const LONG_QUOTE_LINES = 4;
|
|
155
|
+
const LONG_QUOTE_CHARS = 280;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Render the (already HTML-escaped) inner text of a `>` blockquote into a
|
|
159
|
+
* Telegram `<blockquote>` / `<blockquote expandable>`.
|
|
160
|
+
*
|
|
161
|
+
* Three shapes, in order of precedence:
|
|
162
|
+
* 1. Recognised callout — first line is `[!type]`, optionally `+`/`-` then a
|
|
163
|
+
* title. Emits a bold `emoji Title` header line, then the body; collapses
|
|
164
|
+
* when the marker is `-` or the type folds by default.
|
|
165
|
+
* 2. Long plain quote — no marker but ≥4 lines / ≥280 chars → expandable so a
|
|
166
|
+
* wall of quoted text doesn't dominate the chat.
|
|
167
|
+
* 3. Short plain quote — a normal, always-open `<blockquote>`.
|
|
168
|
+
*
|
|
169
|
+
* The close tag is always `</blockquote>` (the `expandable` lives only on the
|
|
170
|
+
* open tag — Telegram rejects it on the close).
|
|
171
|
+
*/
|
|
172
|
+
function renderBlockquote(stripped: string): string {
|
|
173
|
+
const callout = parseCallout(stripped);
|
|
174
|
+
let inner: string;
|
|
175
|
+
let expandable: boolean;
|
|
176
|
+
if (callout) {
|
|
177
|
+
inner = callout.body ? `${callout.header}\n${callout.body}` : callout.header;
|
|
178
|
+
expandable = callout.expandable;
|
|
179
|
+
} else {
|
|
180
|
+
inner = stripped;
|
|
181
|
+
expandable = isLongQuote(stripped);
|
|
182
|
+
}
|
|
183
|
+
const open = expandable ? '<blockquote expandable>' : '<blockquote>';
|
|
184
|
+
return `${open}${inner}</blockquote>`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface ParsedCallout {
|
|
188
|
+
readonly header: string;
|
|
189
|
+
readonly body: string;
|
|
190
|
+
readonly expandable: boolean;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Parse a `[!type]`/`[!type]-`/`[!type]+ Title` first line. Returns null when
|
|
195
|
+
* the first line isn't a RECOGNISED callout, so an unknown `[!whatever]` is left
|
|
196
|
+
* as ordinary quote text rather than guessing a rendering for it.
|
|
197
|
+
*/
|
|
198
|
+
function parseCallout(stripped: string): ParsedCallout | null {
|
|
199
|
+
const nl = stripped.indexOf('\n');
|
|
200
|
+
const firstLine = nl === -1 ? stripped : stripped.slice(0, nl);
|
|
201
|
+
const rest = nl === -1 ? '' : stripped.slice(nl + 1);
|
|
202
|
+
const m = /^\[!(\w+)\]([+-]?)[ \t]*(.*)$/.exec(firstLine.trim());
|
|
203
|
+
if (!m) return null;
|
|
204
|
+
const meta = CALLOUTS[m[1]!.toLowerCase()];
|
|
205
|
+
if (!meta) return null;
|
|
206
|
+
const fold = m[2];
|
|
207
|
+
const title = m[3]!.trim() || meta.label;
|
|
208
|
+
return {
|
|
209
|
+
header: `<b>${meta.emoji} ${title}</b>`,
|
|
210
|
+
body: rest,
|
|
211
|
+
expandable: fold === '-' ? true : fold === '+' ? false : meta.fold,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** True when a plain quote is long enough to be worth collapsing. */
|
|
216
|
+
function isLongQuote(stripped: string): boolean {
|
|
217
|
+
if (stripped.length >= LONG_QUOTE_CHARS) return true;
|
|
218
|
+
let lines = 1;
|
|
219
|
+
for (let i = 0; i < stripped.length; i++) if (stripped[i] === '\n') lines++;
|
|
220
|
+
return lines >= LONG_QUOTE_LINES;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Schemes we let through as a clickable `<a href>`. */
|
|
224
|
+
const ALLOWED_URL_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']);
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* True when `url` is safe to emit as an anchor target. A URL with an explicit
|
|
228
|
+
* `scheme:` prefix must use an allow-listed scheme; URLs without a scheme
|
|
229
|
+
* (relative paths, `#anchors`, `//host`, bare `host.com/...`) carry no
|
|
230
|
+
* navigation scheme and are allowed. This rejects `javascript:`, `data:`,
|
|
231
|
+
* `file:`, `tg://`, and any other unexpected scheme.
|
|
232
|
+
*/
|
|
233
|
+
function isAllowedUrl(url: string): boolean {
|
|
234
|
+
// Control chars (incl. an embedded NUL/SOH) have no legitimate place in a URL
|
|
235
|
+
// and are a classic way to slip a dangerous scheme past a naive check — reject
|
|
236
|
+
// outright rather than try to normalize them away.
|
|
237
|
+
for (let i = 0; i < url.length; i++) {
|
|
238
|
+
if (url.charCodeAt(i) <= 0x1f || url.charCodeAt(i) === 0x7f) return false;
|
|
239
|
+
}
|
|
240
|
+
// Strip leading whitespace/hyphen an attacker might use to shift the apparent
|
|
241
|
+
// scheme boundary, then detect the scheme.
|
|
242
|
+
const trimmed = url.replace(/^[\s-]+/, '');
|
|
243
|
+
// RFC3986 scheme: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":".
|
|
244
|
+
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed);
|
|
245
|
+
if (!m) return true; // no explicit scheme — relative/anchor/host-only link
|
|
246
|
+
return ALLOWED_URL_SCHEMES.has(m[1]!.toLowerCase());
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function escapeHtml(s: string): string {
|
|
250
|
+
return s.replace(/[&<>]/g, (c) => (c === '&' ? '&' : c === '<' ? '<' : '>'));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function escapeAttr(s: string): string {
|
|
254
|
+
return s.replace(/[&<>"]/g, (c) =>
|
|
255
|
+
c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : '"',
|
|
256
|
+
);
|
|
257
|
+
}
|