@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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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, '"')
|
|
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 (& < >)
|
|
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 '
|
|
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 ?? []
|