@0xmaxma/claude-gateway 1.0.1 → 1.0.3

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.
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Markdown detection and conversion utilities for Telegram HTML mode.
3
+ * Used by AgentRunner to auto-format agent output before forwarding.
4
+ */
5
+
6
+ /**
7
+ * Detects whether text contains markdown formatting patterns
8
+ * that warrant HTML rendering in Telegram.
9
+ */
10
+ export function hasMarkdown(text: string): boolean {
11
+ return (
12
+ /\*\*[^*\n]+\*\*/m.test(text) || // **bold**
13
+ /\*[^\s*\n][^*\n]*\*/m.test(text) || // *italic*
14
+ /_[^\s_\n][^_\n]*_/m.test(text) || // _italic_
15
+ /`[^`\n]+`/m.test(text) || // `inline code`
16
+ /^```/m.test(text) || // ```code block
17
+ /^#{1,6}\s/m.test(text) || // # header
18
+ /^\|.+\|/m.test(text) || // | table row |
19
+ /^- /m.test(text) || // - bullet list
20
+ /\[.+?\]\(https?:\/\/.+?\)/m.test(text) // [link](url)
21
+ )
22
+ }
23
+
24
+ /**
25
+ * Escapes HTML special characters in plain text.
26
+ */
27
+ function escapeHtml(text: string): string {
28
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
29
+ }
30
+
31
+ /**
32
+ * Escapes HTML special characters in a URL attribute value.
33
+ */
34
+ function escapeHtmlAttr(url: string): string {
35
+ return escapeHtml(url).replace(/"/g, '&quot;')
36
+ }
37
+
38
+ /**
39
+ * Measures display width of a string, treating Thai/Unicode combining chars as 0-width.
40
+ * Each base character counts as 1 column.
41
+ */
42
+ function displayWidth(s: string): number {
43
+ // Strip Unicode combining characters (Mn category) — these include Thai tone marks,
44
+ // vowel signs above/below (U+0E31, U+0E34–U+0E3A, U+0E47–U+0E4E), and other combining marks
45
+ const stripped = s.replace(/[\u0300-\u036F\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0E4F]/g, '')
46
+ return stripped.length
47
+ }
48
+
49
+ /**
50
+ * Pads a string on the right to reach the target display width.
51
+ */
52
+ function padEnd(s: string, width: number): string {
53
+ const pad = width - displayWidth(s)
54
+ return pad > 0 ? s + ' '.repeat(pad) : s
55
+ }
56
+
57
+ /**
58
+ * Splits a table row like `| a | b | c |` into trimmed cell strings.
59
+ */
60
+ function splitCells(line: string): string[] {
61
+ return line
62
+ .replace(/^\s*\|\s*/, '')
63
+ .replace(/\s*\|\s*$/, '')
64
+ .split(/\s*\|\s*/)
65
+ }
66
+
67
+ /**
68
+ * Returns true if the line is a separator row (e.g. |---|:---:|---:|)
69
+ */
70
+ function isSeparator(line: string): boolean {
71
+ return /^\s*\|[\s\-:|]+\|\s*$/.test(line)
72
+ }
73
+
74
+ /**
75
+ * Aligns table columns by padding each cell to the widest value in its column.
76
+ */
77
+ function formatTable(lines: string[]): string {
78
+ const dataLines = lines.filter(l => !isSeparator(l))
79
+ const rows = dataLines.map(splitCells)
80
+
81
+ // Find column count
82
+ const colCount = rows.reduce((m, r) => Math.max(m, r.length), 0)
83
+
84
+ // Compute max display width per column
85
+ const colWidths: number[] = Array(colCount).fill(0)
86
+ for (const row of rows) {
87
+ for (let c = 0; c < row.length; c++) {
88
+ colWidths[c] = Math.max(colWidths[c], displayWidth(row[c]))
89
+ }
90
+ }
91
+
92
+ // Render rows
93
+ const rendered = rows.map(row => {
94
+ const cells = Array.from({ length: colCount }, (_, c) => padEnd(row[c] ?? '', colWidths[c]))
95
+ return '| ' + cells.join(' | ') + ' |'
96
+ })
97
+
98
+ // Rebuild separator line from computed widths
99
+ const sep = '| ' + colWidths.map(w => '-'.repeat(w)).join(' | ') + ' |'
100
+
101
+ // Insert separator after header (first row)
102
+ if (rendered.length > 1) {
103
+ rendered.splice(1, 0, sep)
104
+ }
105
+
106
+ return rendered.join('\n')
107
+ }
108
+
109
+ /**
110
+ * Converts standard Markdown to Telegram HTML format.
111
+ *
112
+ * Conversions:
113
+ * - **bold** → <b>bold</b>
114
+ * - *italic* / _italic_ → <i>italic</i>
115
+ * - `code` → <code>code</code>
116
+ * - ```block``` → <pre><code>block</code></pre>
117
+ * - [text](url) → <a href="url">text</a>
118
+ * - # Header → <b>Header</b>
119
+ * - | table | → wrapped in <pre> (pipes preserved, monospace)
120
+ * - - bullet → • bullet
121
+ * - plain text → HTML-escaped (&amp; &lt; &gt;)
122
+ */
123
+ export function toTelegramHtml(text: string): string {
124
+ // Convert bullet lists first (line-level transform, safe as pre-pass)
125
+ text = text.replace(/^- /gm, '• ')
126
+
127
+ const out: string[] = []
128
+ let i = 0
129
+ const len = text.length
130
+
131
+ while (i < len) {
132
+ // Triple backtick code block: ```[lang]\n...\n```
133
+ if (text.startsWith('```', i)) {
134
+ const closeIdx = text.indexOf('\n```', i + 3)
135
+ if (closeIdx !== -1) {
136
+ const inner = text.slice(i + 3, closeIdx)
137
+ const nlIdx = inner.indexOf('\n')
138
+ const code = nlIdx > 0 ? inner.slice(nlIdx + 1) : inner.replace(/^\n/, '')
139
+ out.push('<pre><code>' + escapeHtml(code) + '</code></pre>')
140
+ i = closeIdx + 4
141
+ continue
142
+ }
143
+ }
144
+
145
+ // Inline code `...` (not ```)
146
+ if (text[i] === '`' && text[i + 1] !== '`') {
147
+ const closeIdx = text.indexOf('`', i + 1)
148
+ if (closeIdx !== -1) {
149
+ out.push('<code>' + escapeHtml(text.slice(i + 1, closeIdx)) + '</code>')
150
+ i = closeIdx + 1
151
+ continue
152
+ }
153
+ }
154
+
155
+ // Bold **...**
156
+ if (text.startsWith('**', i) && text[i + 2] !== '*' && text[i + 2] !== ' ') {
157
+ const closeIdx = text.indexOf('**', i + 2)
158
+ if (closeIdx !== -1 && !text.slice(i + 2, closeIdx).includes('\n')) {
159
+ out.push('<b>' + escapeHtml(text.slice(i + 2, closeIdx)) + '</b>')
160
+ i = closeIdx + 2
161
+ continue
162
+ }
163
+ }
164
+
165
+ // Italic *...* (single asterisk, not bold)
166
+ if (text[i] === '*' && text[i + 1] !== '*' && text[i + 1] !== ' ' && text[i + 1] !== undefined) {
167
+ const closeIdx = text.indexOf('*', i + 1)
168
+ if (closeIdx !== -1 && !text.slice(i + 1, closeIdx).includes('\n')) {
169
+ out.push('<i>' + escapeHtml(text.slice(i + 1, closeIdx)) + '</i>')
170
+ i = closeIdx + 1
171
+ continue
172
+ }
173
+ }
174
+
175
+ // Italic _..._ (underscore style)
176
+ if (text[i] === '_' && text[i + 1] !== '_' && text[i + 1] !== ' ' && text[i + 1] !== undefined) {
177
+ const closeIdx = text.indexOf('_', i + 1)
178
+ if (closeIdx !== -1 && !text.slice(i + 1, closeIdx).includes('\n')) {
179
+ out.push('<i>' + escapeHtml(text.slice(i + 1, closeIdx)) + '</i>')
180
+ i = closeIdx + 1
181
+ continue
182
+ }
183
+ }
184
+
185
+ // Link [text](url)
186
+ if (text[i] === '[') {
187
+ const closeBracket = text.indexOf(']', i + 1)
188
+ if (closeBracket !== -1 && text[closeBracket + 1] === '(') {
189
+ const closeParen = text.indexOf(')', closeBracket + 2)
190
+ if (closeParen !== -1) {
191
+ const linkText = text.slice(i + 1, closeBracket)
192
+ const url = text.slice(closeBracket + 2, closeParen)
193
+ out.push('<a href="' + escapeHtmlAttr(url) + '">' + escapeHtml(linkText) + '</a>')
194
+ i = closeParen + 1
195
+ continue
196
+ }
197
+ }
198
+ }
199
+
200
+ // Header # at start of line → bold
201
+ if ((i === 0 || text[i - 1] === '\n') && text[i] === '#') {
202
+ let level = 0
203
+ while (i + level < len && text[i + level] === '#') level++
204
+ if (level <= 6 && text[i + level] === ' ') {
205
+ const lineEnd = text.indexOf('\n', i + level + 1)
206
+ const end = lineEnd === -1 ? len : lineEnd
207
+ out.push('<b>' + escapeHtml(text.slice(i + level + 1, end)) + '</b>')
208
+ i = end
209
+ continue
210
+ }
211
+ }
212
+
213
+ // Table lines starting with | at line start → collect, align columns, wrap in <pre>
214
+ if ((i === 0 || text[i - 1] === '\n') && text[i] === '|') {
215
+ const tableLines: string[] = []
216
+ let j = i
217
+ while (j < len) {
218
+ const lineEnd = text.indexOf('\n', j)
219
+ const end = lineEnd === -1 ? len : lineEnd
220
+ const line = text.slice(j, end)
221
+ if (/^\s*\|.*\|\s*$/.test(line)) {
222
+ tableLines.push(line)
223
+ j = lineEnd === -1 ? len : lineEnd + 1
224
+ if (lineEnd === -1) break
225
+ } else {
226
+ break
227
+ }
228
+ }
229
+ if (tableLines.length > 0) {
230
+ out.push('<pre>' + escapeHtml(formatTable(tableLines)) + '</pre>')
231
+ i = j
232
+ continue
233
+ }
234
+ }
235
+
236
+ // Accumulate plain text until next markdown token
237
+ let j = i + 1
238
+ while (j < len) {
239
+ const c = text[j]
240
+ if (
241
+ text.startsWith('```', j) ||
242
+ (c === '`' && text[j + 1] !== '`') ||
243
+ text.startsWith('**', j) ||
244
+ (c === '*' && text[j + 1] !== '*' && text[j + 1] !== ' ') ||
245
+ (c === '_' && text[j + 1] !== '_' && text[j + 1] !== ' ') ||
246
+ c === '[' ||
247
+ (c === '#' && (j === 0 || text[j - 1] === '\n')) ||
248
+ (c === '|' && (j === 0 || text[j - 1] === '\n'))
249
+ ) break
250
+ j++
251
+ }
252
+ out.push(escapeHtml(text.slice(i, j)))
253
+ i = j
254
+ }
255
+
256
+ return out.join('')
257
+ }
@@ -194,7 +194,7 @@ export function gateLogic(
194
194
  return { action: 'drop' }
195
195
  }
196
196
 
197
- export { hasMarkdown, toTelegramHtml } from '../../../src/telegram/markdown'
197
+ export { hasMarkdown, toTelegramHtml } from './lib/markdown'
198
198
 
199
199
  export function isMentionedPure(input: GateInput, extraPatterns?: string[]): boolean {
200
200
  const entities = input.messageEntities ?? input.captionEntities ?? []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmaxma/claude-gateway",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Multi-agent gateway for Claude",
5
5
  "repository": {
6
6
  "type": "git",