@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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/dist/approval.d.ts +37 -0
  3. package/dist/approval.d.ts.map +1 -0
  4. package/dist/approval.js +75 -0
  5. package/dist/approval.js.map +1 -0
  6. package/dist/channel/approval-prompt.d.ts +20 -0
  7. package/dist/channel/approval-prompt.d.ts.map +1 -0
  8. package/dist/channel/approval-prompt.js +34 -0
  9. package/dist/channel/approval-prompt.js.map +1 -0
  10. package/dist/channel/callback-handler.d.ts +32 -0
  11. package/dist/channel/callback-handler.d.ts.map +1 -0
  12. package/dist/channel/callback-handler.js +212 -0
  13. package/dist/channel/callback-handler.js.map +1 -0
  14. package/dist/channel/frame-pump.d.ts +58 -0
  15. package/dist/channel/frame-pump.d.ts.map +1 -0
  16. package/dist/channel/frame-pump.js +159 -0
  17. package/dist/channel/frame-pump.js.map +1 -0
  18. package/dist/channel/html.d.ts +24 -0
  19. package/dist/channel/html.d.ts.map +1 -0
  20. package/dist/channel/html.js +62 -0
  21. package/dist/channel/html.js.map +1 -0
  22. package/dist/channel/pairing-handler.d.ts +66 -0
  23. package/dist/channel/pairing-handler.d.ts.map +1 -0
  24. package/dist/channel/pairing-handler.js +147 -0
  25. package/dist/channel/pairing-handler.js.map +1 -0
  26. package/dist/channel/permission-prompt.d.ts +21 -0
  27. package/dist/channel/permission-prompt.d.ts.map +1 -0
  28. package/dist/channel/permission-prompt.js +36 -0
  29. package/dist/channel/permission-prompt.js.map +1 -0
  30. package/dist/channel/slash-handler.d.ts +47 -0
  31. package/dist/channel/slash-handler.d.ts.map +1 -0
  32. package/dist/channel/slash-handler.js +214 -0
  33. package/dist/channel/slash-handler.js.map +1 -0
  34. package/dist/channel/text-handler.d.ts +43 -0
  35. package/dist/channel/text-handler.d.ts.map +1 -0
  36. package/dist/channel/text-handler.js +116 -0
  37. package/dist/channel/text-handler.js.map +1 -0
  38. package/dist/channel/turn-runner.d.ts +40 -0
  39. package/dist/channel/turn-runner.d.ts.map +1 -0
  40. package/dist/channel/turn-runner.js +67 -0
  41. package/dist/channel/turn-runner.js.map +1 -0
  42. package/dist/channel/typing-indicator.d.ts +14 -0
  43. package/dist/channel/typing-indicator.d.ts.map +1 -0
  44. package/dist/channel/typing-indicator.js +29 -0
  45. package/dist/channel/typing-indicator.js.map +1 -0
  46. package/dist/channel/voice-handler.d.ts +42 -0
  47. package/dist/channel/voice-handler.d.ts.map +1 -0
  48. package/dist/channel/voice-handler.js +138 -0
  49. package/dist/channel/voice-handler.js.map +1 -0
  50. package/dist/channel.d.ts +118 -0
  51. package/dist/channel.d.ts.map +1 -0
  52. package/dist/channel.js +478 -0
  53. package/dist/channel.js.map +1 -0
  54. package/dist/channel.test-d.d.ts +2 -0
  55. package/dist/channel.test-d.d.ts.map +1 -0
  56. package/dist/channel.test-d.js +5 -0
  57. package/dist/channel.test-d.js.map +1 -0
  58. package/dist/format.d.ts +38 -0
  59. package/dist/format.d.ts.map +1 -0
  60. package/dist/format.js +237 -0
  61. package/dist/format.js.map +1 -0
  62. package/dist/index.d.ts +24 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +304 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/keys.d.ts +37 -0
  67. package/dist/keys.d.ts.map +1 -0
  68. package/dist/keys.js +42 -0
  69. package/dist/keys.js.map +1 -0
  70. package/dist/pair-flow.d.ts +18 -0
  71. package/dist/pair-flow.d.ts.map +1 -0
  72. package/dist/pair-flow.js +119 -0
  73. package/dist/pair-flow.js.map +1 -0
  74. package/dist/pairing.d.ts +90 -0
  75. package/dist/pairing.d.ts.map +1 -0
  76. package/dist/pairing.js +97 -0
  77. package/dist/pairing.js.map +1 -0
  78. package/dist/permission.d.ts +25 -0
  79. package/dist/permission.d.ts.map +1 -0
  80. package/dist/permission.js +49 -0
  81. package/dist/permission.js.map +1 -0
  82. package/dist/render.d.ts +68 -0
  83. package/dist/render.d.ts.map +1 -0
  84. package/dist/render.js +439 -0
  85. package/dist/render.js.map +1 -0
  86. package/dist/setup-wizard.d.ts +21 -0
  87. package/dist/setup-wizard.d.ts.map +1 -0
  88. package/dist/setup-wizard.js +140 -0
  89. package/dist/setup-wizard.js.map +1 -0
  90. package/package.json +91 -0
  91. package/src/approval.test.ts +103 -0
  92. package/src/approval.ts +86 -0
  93. package/src/channel/approval-prompt.ts +46 -0
  94. package/src/channel/callback-handler.test.ts +188 -0
  95. package/src/channel/callback-handler.ts +256 -0
  96. package/src/channel/frame-pump.ts +176 -0
  97. package/src/channel/html.test.ts +40 -0
  98. package/src/channel/html.ts +60 -0
  99. package/src/channel/pairing-handler.ts +181 -0
  100. package/src/channel/permission-prompt.ts +51 -0
  101. package/src/channel/slash-handler.ts +259 -0
  102. package/src/channel/text-handler.test.ts +222 -0
  103. package/src/channel/text-handler.ts +174 -0
  104. package/src/channel/turn-runner.ts +102 -0
  105. package/src/channel/typing-indicator.ts +31 -0
  106. package/src/channel/voice-handler.test.ts +328 -0
  107. package/src/channel/voice-handler.ts +198 -0
  108. package/src/channel/voice-reply.test.ts +223 -0
  109. package/src/channel.test-d.ts +7 -0
  110. package/src/channel.ts +564 -0
  111. package/src/format.test.ts +159 -0
  112. package/src/format.ts +257 -0
  113. package/src/index.ts +347 -0
  114. package/src/keys.ts +52 -0
  115. package/src/pair-flow.ts +131 -0
  116. package/src/pairing.test.ts +113 -0
  117. package/src/pairing.ts +184 -0
  118. package/src/permission.test.ts +56 -0
  119. package/src/permission.ts +61 -0
  120. package/src/render.test.ts +350 -0
  121. package/src/render.ts +522 -0
  122. package/src/setup-wizard.ts +178 -0
  123. 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('&lt; &amp; &gt; "');
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(/((?:^&gt;[^\n]*\n?)+)/gm, (m: string) => {
71
+ const stripped = m
72
+ .replace(/&gt; ?/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 `&amp;` 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 `&amp;` → `&` is needed
106
+ // for the typical case; `<>` shouldn't appear in a URL but unescape
107
+ // them defensively.
108
+ const cleanUrl = String(url).replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/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 === '&' ? '&amp;' : c === '<' ? '&lt;' : '&gt;'));
251
+ }
252
+
253
+ function escapeAttr(s: string): string {
254
+ return s.replace(/[&<>"]/g, (c) =>
255
+ c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&quot;',
256
+ );
257
+ }