polygram 0.4.13 → 0.5.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.4.13",
4
+ "version": "0.5.0",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -1,36 +1,275 @@
1
1
  /**
2
- * Convert Claude's CommonMark output into Telegram-safe MarkdownV2.
2
+ * Markdown Telegram HTML.
3
3
  *
4
- * Why: Claude emits standard GitHub-flavoured markdown (headings, bullets,
5
- * `**bold**`, fenced code). Telegram does NOT support headings or bullet
6
- * lists natively; `**bold**` is `*bold*` in its dialect; and MarkdownV2
7
- * requires escaping `_*[]()~\`>#+-=|{}.!` in non-formatted text. Sending
8
- * Claude's raw markdown with no parse_mode shows literal `**` and `#`
9
- * in chat; sending it with parse_mode: MarkdownV2 crashes with "can't
10
- * parse entities" the moment Claude writes a period or exclamation mark.
4
+ * Replaces the 0.4.x telegramify-markdown / MarkdownV2 pipeline with HTML
5
+ * output. Reasons for the switch:
6
+ * - Native syntax highlighting via `<pre><code class="language-X">`
7
+ * - `<blockquote expandable>` for collapsible long output
8
+ * - Only 3 chars need escaping (`<`, `>`, `&`) vs MarkdownV2's 18
9
+ * - Cleaner nesting (HTML tag pairs vs MarkdownV2's brittle ordering)
10
+ * - Tables become `<pre>` blocks with column alignment, no regex hacks
11
11
  *
12
- * telegramify-markdown handles both concerns: downgrades unsupported
13
- * constructs (headings bold, bullets `•`) and escapes reserved chars.
14
- *
15
- * We wrap it here rather than calling it inline so:
16
- * - Swapping libraries later is a one-file change.
17
- * - Fallback-on-throw is centralised (if conversion explodes, we send
18
- * the original text with no parse_mode — worse formatting, but the
19
- * message still arrives).
12
+ * Implementation: `marked` parses CommonMark into tokens, our custom
13
+ * renderer emits Telegram-compatible HTML for each token type. The
14
+ * `wrapFileReferencesInHtml` post-processor (adapted from OpenClaw,
15
+ * MIT) wraps `README.md`-style references in `<code>` so Telegram
16
+ * doesn't auto-linkify them as bogus domains.
20
17
  */
21
18
 
22
- const telegramify = require('telegramify-markdown');
19
+ const { Marked } = require('marked');
23
20
 
24
- function toTelegramMarkdown(text) {
25
- if (typeof text !== 'string' || text.length === 0) {
26
- return { text, parseMode: null };
21
+ // File extensions that are ALSO TLDs and commonly appear in chat (where
22
+ // auto-linking them as `http://README.md` would generate spam preview
23
+ // cards). Adapted from openclaw/openclaw (MIT) src/shared/text/
24
+ // auto-linked-file-ref.ts. Excludes `.ai`, `.io`, `.tv`, `.fm` which
25
+ // are popular real-domain TLDs.
26
+ const FILE_REF_EXTENSIONS = ['md', 'go', 'py', 'pl', 'sh', 'am', 'at', 'be', 'cc'];
27
+
28
+ function escapeHtml(text) {
29
+ if (typeof text !== 'string') return '';
30
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
31
+ }
32
+
33
+ function escapeHtmlAttr(text) {
34
+ return escapeHtml(text).replace(/"/g, '&quot;');
35
+ }
36
+
37
+ // Trigger expandable blockquote when content exceeds either threshold.
38
+ // `<blockquote expandable>` renders as a 3-line preview with "Show more"
39
+ // — useful for long quoted reasoning the user usually skips.
40
+ const EXPANDABLE_BLOCKQUOTE_CHARS = 240;
41
+ const EXPANDABLE_BLOCKQUOTE_LINES = 4;
42
+
43
+ function shouldExpandQuote(innerHtml) {
44
+ const plain = innerHtml.replace(/<[^>]+>/g, '');
45
+ return plain.length > EXPANDABLE_BLOCKQUOTE_CHARS
46
+ || plain.split('\n').length > EXPANDABLE_BLOCKQUOTE_LINES;
47
+ }
48
+
49
+ // Split a list item's tokens into the leading inline run and any
50
+ // block-level tokens that follow (nested list, code block, etc.). Without
51
+ // this split, `parser.parse(item.tokens)` glues inline text directly
52
+ // against the nested list's first marker — "top• nested 1" with no
53
+ // separator — because text() does not emit a trailing newline.
54
+ const BLOCK_TYPES = new Set(['list', 'blockquote', 'code', 'table', 'paragraph', 'space', 'html', 'hr']);
55
+
56
+ function splitItemTokens(tokens) {
57
+ const inline = [];
58
+ const blocks = [];
59
+ let crossedToBlock = false;
60
+ for (const tok of tokens) {
61
+ if (BLOCK_TYPES.has(tok.type)) {
62
+ crossedToBlock = true;
63
+ blocks.push(tok);
64
+ } else if (crossedToBlock) {
65
+ blocks.push({ type: 'paragraph', tokens: [tok] });
66
+ } else {
67
+ inline.push(tok);
68
+ }
27
69
  }
70
+ return { inline, blocks };
71
+ }
72
+
73
+ function buildRenderer() {
74
+ // Different bullet glyphs per nesting depth: visual hierarchy without
75
+ // relying on Telegram preserving leading spaces (it does for HTML
76
+ // mode but the cue is clearer with distinct bullets).
77
+ const NESTED_BULLETS = ['•', '◦', '▪', '▫'];
78
+ let listDepth = 0;
79
+ function bulletFor(d) { return NESTED_BULLETS[Math.min(d, NESTED_BULLETS.length - 1)]; }
80
+
81
+ return {
82
+ heading({ tokens }) {
83
+ return `<b>${this.parser.parseInline(tokens)}</b>\n\n`;
84
+ },
85
+ paragraph({ tokens }) {
86
+ return this.parser.parseInline(tokens) + '\n\n';
87
+ },
88
+ text({ tokens, text }) {
89
+ if (Array.isArray(tokens) && tokens.length) return this.parser.parseInline(tokens);
90
+ return escapeHtml(text);
91
+ },
92
+ strong({ tokens }) { return `<b>${this.parser.parseInline(tokens)}</b>`; },
93
+ em({ tokens }) { return `<i>${this.parser.parseInline(tokens)}</i>`; },
94
+ del({ tokens }) { return `<s>${this.parser.parseInline(tokens)}</s>`; },
95
+ codespan({ text }) { return `<code>${escapeHtml(text)}</code>`; },
96
+ code({ text, lang }) {
97
+ const langClass = lang ? ` class="language-${escapeHtmlAttr(lang)}"` : '';
98
+ return `<pre><code${langClass}>${escapeHtml(text)}</code></pre>\n\n`;
99
+ },
100
+ blockquote({ tokens }) {
101
+ const inner = this.parser.parse(tokens).trim();
102
+ const expandable = shouldExpandQuote(inner) ? ' expandable' : '';
103
+ return `<blockquote${expandable}>${inner}</blockquote>\n\n`;
104
+ },
105
+ link({ href, tokens }) {
106
+ return `<a href="${escapeHtmlAttr(href)}">${this.parser.parseInline(tokens)}</a>`;
107
+ },
108
+ image({ href, text }) {
109
+ return `<a href="${escapeHtmlAttr(href)}">${escapeHtml(text || href)}</a>`;
110
+ },
111
+ list({ items, ordered, start }) {
112
+ const depth = listDepth;
113
+ listDepth += 1;
114
+ const indent = ' '.repeat(depth);
115
+ const bullet = bulletFor(depth);
116
+ try {
117
+ const lines = items.map((item, i) => {
118
+ const marker = ordered ? `${(start || 1) + i}. ` : `${bullet} `;
119
+ const { inline, blocks } = splitItemTokens(item.tokens);
120
+ const leader = this.parser.parseInline(inline).trim();
121
+ let line = `${indent}${marker}${leader}`;
122
+ for (const block of blocks) {
123
+ const rendered = this.parser.parse([block]).replace(/\n+$/, '');
124
+ if (rendered) line += '\n' + rendered;
125
+ }
126
+ return line;
127
+ });
128
+ return lines.join('\n') + (depth === 0 ? '\n\n' : '');
129
+ } finally {
130
+ listDepth -= 1;
131
+ }
132
+ },
133
+ listitem({ tokens }) { return this.parser.parse(tokens); },
134
+ hr() { return '\n──────\n\n'; },
135
+ br() { return '\n'; },
136
+ table({ header, rows, align }) {
137
+ const headerCells = header.map((cell) => this.parser.parseInline(cell.tokens));
138
+ const rowCells = rows.map((row) => row.map((cell) => this.parser.parseInline(cell.tokens)));
139
+ const stripTags = (s) => s.replace(/<[^>]+>/g, '');
140
+ const widths = headerCells.map((h, col) => {
141
+ let maxLen = stripTags(h).length;
142
+ for (const r of rowCells) {
143
+ const cellLen = stripTags(r[col] || '').length;
144
+ if (cellLen > maxLen) maxLen = cellLen;
145
+ }
146
+ return maxLen;
147
+ });
148
+ const padCell = (s, col) => {
149
+ const len = stripTags(s).length;
150
+ const padding = ' '.repeat(Math.max(0, widths[col] - len));
151
+ if (align[col] === 'right') return padding + s;
152
+ return s + padding;
153
+ };
154
+ const renderRow = (cells) => '| ' + cells.map((c, i) => padCell(c, i)).join(' | ') + ' |';
155
+ const sep = '|' + widths.map((w) => '-'.repeat(w + 2)).join('|') + '|';
156
+ const lines = [renderRow(headerCells), sep, ...rowCells.map(renderRow)];
157
+ return `<pre>${lines.join('\n')}</pre>\n\n`;
158
+ },
159
+ html({ text }) { return escapeHtml(text); },
160
+ };
161
+ }
162
+
163
+ // Spoiler extension for marked. Recognises `||hidden||` inline. Telegram-
164
+ // specific (matches OpenClaw's enableSpoilers behaviour).
165
+ const spoilerExtension = {
166
+ name: 'spoiler',
167
+ level: 'inline',
168
+ start(src) { return src.indexOf('||'); },
169
+ tokenizer(src) {
170
+ const m = /^\|\|([\s\S]+?)\|\|/.exec(src);
171
+ if (!m) return undefined;
172
+ return {
173
+ type: 'spoiler',
174
+ raw: m[0],
175
+ text: m[1],
176
+ tokens: this.lexer.inlineTokens(m[1]),
177
+ };
178
+ },
179
+ renderer({ tokens }) {
180
+ return `<tg-spoiler>${this.parser.parseInline(tokens)}</tg-spoiler>`;
181
+ },
182
+ };
183
+
184
+ const HTML_TAG_RE = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi;
185
+ const AUTO_LINKED_ANCHOR_RE = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi;
186
+
187
+ function escapeRegex(str) {
188
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
189
+ }
190
+
191
+ const _extPattern = FILE_REF_EXTENSIONS.map(escapeRegex).join('|');
192
+ const FILE_REF_PATTERN = new RegExp(
193
+ `(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${_extPattern}))(?=$|[^a-zA-Z0-9_\\-/])`,
194
+ 'gi',
195
+ );
196
+ const ORPHANED_TLD_PATTERN = new RegExp(
197
+ `([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${_extPattern}))(?=[^a-zA-Z0-9/]|$)`,
198
+ 'g',
199
+ );
200
+
201
+ function isAutoLinkedFileRef(label) {
202
+ const dotIdx = label.lastIndexOf('.');
203
+ if (dotIdx < 1) return false;
204
+ const ext = label.slice(dotIdx + 1).toLowerCase();
205
+ if (!FILE_REF_EXTENSIONS.includes(ext)) return false;
206
+ const segments = label.split('/');
207
+ if (segments.length > 1) {
208
+ for (let i = 0; i < segments.length - 1; i += 1) {
209
+ if (segments[i].includes('.')) return false;
210
+ }
211
+ }
212
+ return true;
213
+ }
214
+
215
+ function wrapStandaloneFileRef(_match, prefix, filename) {
216
+ if (filename.startsWith('//')) return _match;
217
+ if (/https?:\/\/$/i.test(prefix)) return _match;
218
+ return `${prefix}<code>${escapeHtml(filename)}</code>`;
219
+ }
220
+
221
+ function wrapSegment(text, codeDepth, preDepth, anchorDepth) {
222
+ if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) return text;
223
+ const w = text.replace(FILE_REF_PATTERN, wrapStandaloneFileRef);
224
+ return w.replace(ORPHANED_TLD_PATTERN, (m, prefix, tld) => {
225
+ if (prefix === '>') return m;
226
+ return `${prefix}<code>${escapeHtml(tld)}</code>`;
227
+ });
228
+ }
229
+
230
+ function wrapFileReferencesInHtml(html) {
231
+ AUTO_LINKED_ANCHOR_RE.lastIndex = 0;
232
+ const deLinkified = html.replace(AUTO_LINKED_ANCHOR_RE, (m, label) => {
233
+ if (!isAutoLinkedFileRef(label)) return m;
234
+ return `<code>${escapeHtml(label)}</code>`;
235
+ });
236
+ let codeDepth = 0, preDepth = 0, anchorDepth = 0;
237
+ let result = '';
238
+ let lastIndex = 0;
239
+ HTML_TAG_RE.lastIndex = 0;
240
+ let match;
241
+ while ((match = HTML_TAG_RE.exec(deLinkified)) !== null) {
242
+ const tagStart = match.index;
243
+ const tagEnd = HTML_TAG_RE.lastIndex;
244
+ const isClosing = match[1] === '</';
245
+ const tagName = match[2].toLowerCase();
246
+ result += wrapSegment(deLinkified.slice(lastIndex, tagStart), codeDepth, preDepth, anchorDepth);
247
+ if (tagName === 'code') codeDepth = isClosing ? Math.max(0, codeDepth - 1) : codeDepth + 1;
248
+ else if (tagName === 'pre') preDepth = isClosing ? Math.max(0, preDepth - 1) : preDepth + 1;
249
+ else if (tagName === 'a') anchorDepth = isClosing ? Math.max(0, anchorDepth - 1) : anchorDepth + 1;
250
+ result += deLinkified.slice(tagStart, tagEnd);
251
+ lastIndex = tagEnd;
252
+ }
253
+ result += wrapSegment(deLinkified.slice(lastIndex), codeDepth, preDepth, anchorDepth);
254
+ return result;
255
+ }
256
+
257
+ const _markedInstance = new Marked(
258
+ { gfm: true, breaks: false, renderer: buildRenderer() },
259
+ { extensions: [spoilerExtension] },
260
+ );
261
+
262
+ function toTelegramHtml(text) {
263
+ if (typeof text !== 'string' || text.length === 0) return { text, parseMode: null };
28
264
  try {
29
- const converted = telegramify(text, 'escape');
30
- return { text: converted, parseMode: 'MarkdownV2' };
265
+ const html = _markedInstance.parse(text).trimEnd();
266
+ const wrapped = wrapFileReferencesInHtml(html);
267
+ return { text: wrapped, parseMode: 'HTML' };
31
268
  } catch {
32
269
  return { text, parseMode: null };
33
270
  }
34
271
  }
35
272
 
36
- module.exports = { toTelegramMarkdown };
273
+ function toTelegramMarkdown(text) { return toTelegramHtml(text); }
274
+
275
+ module.exports = { toTelegramMarkdown, toTelegramHtml, wrapFileReferencesInHtml, escapeHtml };
package/lib/telegram.js CHANGED
@@ -43,12 +43,12 @@ function sleep(ms) {
43
43
  return new Promise((r) => setTimeout(r, ms));
44
44
  }
45
45
 
46
- // Methods whose `text` / `caption` fields we auto-format into MarkdownV2.
46
+ // Methods whose `text` / `caption` fields we auto-format into Telegram HTML.
47
47
  // Anything else passes through untouched (setMessageReaction, sendSticker,
48
48
  // deleteMessage, etc. have no text to format).
49
49
  const FORMATTABLE_METHODS = new Set(['sendMessage', 'editMessageText']);
50
50
 
51
- // Apply Claude-markdown → Telegram-MarkdownV2 conversion in-place on the
51
+ // Apply Claude-markdown → Telegram HTML conversion in-place on the
52
52
  // params object. Skipped if:
53
53
  // - Method doesn't carry formattable text.
54
54
  // - Caller already set a parse_mode (respect explicit choice).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.4.13",
3
+ "version": "0.5.0",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc-client.js",
6
6
  "bin": {
@@ -62,6 +62,7 @@
62
62
  "dependencies": {
63
63
  "better-sqlite3": "^12.9.0",
64
64
  "grammy": "^1.42.0",
65
- "telegramify-markdown": "^1.3.3"
65
+ "markdown-it": "^14.1.1",
66
+ "marked": "^18.0.2"
66
67
  }
67
68
  }