@alexnodeland/claude-telegram 0.3.0 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexnodeland/claude-telegram",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Claude Code Channel plugin + standalone orchestrator bridging Telegram to Claude Code sessions",
5
5
  "module": "src/index.ts",
6
6
  "main": "src/index.ts",
package/src/html.ts CHANGED
@@ -23,3 +23,125 @@ export const fmt = {
23
23
  link: (text: string, url: string) => `<a href="${escapeHtml(url)}">${escapeHtml(text)}</a>`,
24
24
  strikethrough: (text: string) => `<s>${escapeHtml(text)}</s>`,
25
25
  };
26
+
27
+ /**
28
+ * Convert Markdown (Claude's output format) to Telegram-compatible HTML.
29
+ *
30
+ * Handles: fenced code blocks, inline code, bold, italic, strikethrough,
31
+ * links, and headers. Content is HTML-escaped before conversion so the
32
+ * resulting string is safe to send with parse_mode: "HTML".
33
+ */
34
+ export function markdownToTelegramHtml(md: string): string {
35
+ const PLACEHOLDER_PREFIX = "\u2060CBLK";
36
+ const PLACEHOLDER_SUFFIX = "CBLK\u2060";
37
+
38
+ // 1. Extract fenced code blocks and tables into placeholders
39
+ const codeBlocks: string[] = [];
40
+ let withPlaceholders = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang: string, code: string) => {
41
+ const escaped = escapeHtml(code.replace(/\n$/, ""));
42
+ const html = lang
43
+ ? `<pre><code class="language-${escapeHtml(lang)}">${escaped}</code></pre>`
44
+ : `<pre>${escaped}</pre>`;
45
+ codeBlocks.push(html);
46
+ return `${PLACEHOLDER_PREFIX}${codeBlocks.length - 1}${PLACEHOLDER_SUFFIX}`;
47
+ });
48
+
49
+ // 1b. Extract Markdown tables into <pre> placeholders
50
+ withPlaceholders = withPlaceholders.replace(/(?:^|\n)(\|.+\|(?:\r?\n\|.+\|)*)/g, (_match, tableBlock: string) => {
51
+ const html = convertMarkdownTable(tableBlock);
52
+ codeBlocks.push(html);
53
+ return `\n${PLACEHOLDER_PREFIX}${codeBlocks.length - 1}${PLACEHOLDER_SUFFIX}`;
54
+ });
55
+
56
+ // 2. Process non-code-block text
57
+ const placeholderRe = new RegExp(`(${PLACEHOLDER_PREFIX}\\d+${PLACEHOLDER_SUFFIX})`, "g");
58
+ const matchRe = new RegExp(`^${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}$`);
59
+
60
+ const processed = withPlaceholders
61
+ .split(placeholderRe)
62
+ .map((segment) => {
63
+ const cbMatch = segment.match(matchRe);
64
+ if (cbMatch) return codeBlocks[Number(cbMatch[1])];
65
+ return convertInlineFormatting(segment);
66
+ })
67
+ .join("");
68
+
69
+ return processed;
70
+ }
71
+
72
+ /** Convert inline Markdown formatting in a text segment (no fenced code blocks). */
73
+ function convertInlineFormatting(text: string): string {
74
+ const parts = text.split(/(`[^`]+`)/g);
75
+
76
+ return parts
77
+ .map((part) => {
78
+ if (part.startsWith("`") && part.endsWith("`")) {
79
+ return `<code>${escapeHtml(part.slice(1, -1))}</code>`;
80
+ }
81
+
82
+ let s = escapeHtml(part);
83
+
84
+ // Headers → bold
85
+ s = s.replace(/^(#{1,6})\s+(.+)$/gm, (_m, _hashes: string, content: string) => `<b>${content}</b>`);
86
+
87
+ // Bold: **text** or __text__
88
+ s = s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
89
+ s = s.replace(/__(.+?)__/g, "<b>$1</b>");
90
+
91
+ // Italic: *text* or _text_
92
+ s = s.replace(/(?<!\w)\*(?!\s)(.+?)(?<!\s)\*(?!\w)/g, "<i>$1</i>");
93
+ s = s.replace(/(?<!\w)_(?!\s)(.+?)(?<!\s)_(?!\w)/g, "<i>$1</i>");
94
+
95
+ // Strikethrough: ~~text~~
96
+ s = s.replace(/~~(.+?)~~/g, "<s>$1</s>");
97
+
98
+ // Links: [text](url)
99
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
100
+
101
+ // Bullet lists: leading "- " or "* " → "• "
102
+ s = s.replace(/^[\t ]*[-*]\s+/gm, "• ");
103
+
104
+ return s;
105
+ })
106
+ .join("");
107
+ }
108
+
109
+ /** Convert a Markdown table to an aligned monospace <pre> block. */
110
+ function convertMarkdownTable(tableText: string): string {
111
+ const rows = tableText.split(/\r?\n/).filter((r) => r.includes("|"));
112
+
113
+ // Parse cells from each row
114
+ const parsed = rows.map((row) =>
115
+ row
116
+ .replace(/^\|/, "")
117
+ .replace(/\|$/, "")
118
+ .split("|")
119
+ .map((c) => c.trim()),
120
+ );
121
+
122
+ // Filter out separator rows (--- or :---: etc.)
123
+ const dataRows = parsed.filter((cells) => !cells.every((c) => /^[:\-\s]+$/.test(c)));
124
+ if (dataRows.length === 0) return `<pre>${escapeHtml(tableText)}</pre>`;
125
+
126
+ // Calculate max width per column
127
+ const colCount = Math.max(...dataRows.map((r) => r.length));
128
+ const widths: number[] = Array.from({ length: colCount }, () => 0);
129
+ for (const row of dataRows) {
130
+ for (let i = 0; i < colCount; i++) {
131
+ widths[i] = Math.max(widths[i] ?? 0, (row[i] ?? "").length);
132
+ }
133
+ }
134
+
135
+ // Build aligned rows
136
+ const lines = dataRows.map((row, rowIdx) => {
137
+ const padded = widths.map((w, i) => (row[i] ?? "").padEnd(w)).join(" ");
138
+ // Add a separator line after the header
139
+ if (rowIdx === 0 && dataRows.length > 1) {
140
+ const sep = widths.map((w) => "─".repeat(w)).join("──");
141
+ return `${padded}\n${sep}`;
142
+ }
143
+ return padded;
144
+ });
145
+
146
+ return `<pre>${escapeHtml(lines.join("\n"))}</pre>`;
147
+ }
package/src/streaming.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { escapeHtml, fmt } from "./html.js";
1
+ import { escapeHtml, fmt, markdownToTelegramHtml } from "./html.js";
2
2
  import type { TelegramClient } from "./telegram.js";
3
3
 
4
4
  const MAX_TG_LENGTH = 4096;
@@ -33,7 +33,7 @@ export class StreamingRenderer {
33
33
  const trimmed = text.trim();
34
34
  if (!trimmed) return;
35
35
  this.lastToolMessageId = null; // new text block clears tool context
36
- await sendLongMessage(this.tg, this.chatId, escapeHtml(trimmed));
36
+ await sendLongMessage(this.tg, this.chatId, markdownToTelegramHtml(trimmed));
37
37
  }
38
38
 
39
39
  async showToolCall(toolName: string, input: unknown): Promise<void> {