@0xmaxma/claude-gateway 1.3.21 → 1.3.23

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.
@@ -15,28 +15,113 @@
15
15
 
16
16
  export const TELEGRAM_MAX_CHARS = 4096
17
17
 
18
+ /**
19
+ * Telegram rejects a message whose HTML entities are unbalanced, so a chunk cut
20
+ * that falls inside a <pre><code>…</code></pre> block must close the open tags
21
+ * at the end of the chunk and reopen them at the start of the next one.
22
+ * Reserve room for that worst-case suffix (</a></code></pre></b></i>) plus the
23
+ * mirrored reopening prefix so balancing never pushes a chunk past the limit.
24
+ */
25
+ const HTML_BALANCE_HEADROOM = 64
26
+
27
+ /** Tags toTelegramHtml() emits — the only ones balancing needs to understand. */
28
+ const BALANCED_TAGS = ['b', 'i', 'code', 'pre', 'a'] as const
29
+
30
+ /**
31
+ * Scan an HTML fragment (as produced by toTelegramHtml — no attributes except
32
+ * <a href>, no self-closing forms) and return the stack of tags still open at
33
+ * the end, as full opening-tag strings in opening order.
34
+ */
35
+ export function openTagStack(html: string): string[] {
36
+ const stack: string[] = []
37
+ // Attribute part tolerates '>' inside quoted values (<a href="a>b">).
38
+ const re = /<(\/?)([a-z]+)((?:\s(?:"[^"]*"|[^>])*)?)>/g
39
+ let m: RegExpExecArray | null
40
+ while ((m = re.exec(html)) !== null) {
41
+ const closing = m[1] === '/'
42
+ const name = m[2]
43
+ if (!(BALANCED_TAGS as readonly string[]).includes(name)) continue
44
+ if (closing) {
45
+ // toTelegramHtml emits well-nested pairs, so the match is always the top.
46
+ const top = stack.length - 1
47
+ if (top >= 0 && /^<([a-z]+)/.exec(stack[top])?.[1] === name) stack.pop()
48
+ } else {
49
+ stack.push(`<${name}${m[3] ?? ''}>`)
50
+ }
51
+ }
52
+ return stack
53
+ }
54
+
55
+ /** Strip Telegram-HTML tags and unescape entities → plain-text equivalent. */
56
+ export function htmlToPlain(html: string): string {
57
+ return html
58
+ .replace(/<[^>]+>/g, '')
59
+ .replace(/&lt;/g, '<')
60
+ .replace(/&gt;/g, '>')
61
+ .replace(/&quot;/g, '"')
62
+ .replace(/&amp;/g, '&')
63
+ }
64
+
18
65
  /**
19
66
  * Split text into chunks that fit within Telegram's message size limit.
20
67
  * Prefers paragraph → line → space boundaries over hard cuts.
21
- * When htmlSafe=true, avoids cutting inside an HTML tag (e.g. <code>, <b>).
68
+ * When htmlSafe=true, avoids cutting inside an HTML tag (e.g. <code>, <b>) AND
69
+ * keeps every chunk entity-balanced: tags left open at a cut are closed at the
70
+ * chunk's end and reopened at the next chunk's start, so no chunk is ever
71
+ * rejected by Telegram's HTML parser for an unclosed <pre>/<code>/<b>.
22
72
  */
23
73
  export function chunkText(text: string, limit = TELEGRAM_MAX_CHARS, htmlSafe = false): string[] {
24
74
  if (text.length <= limit) return [text]
25
75
  const out: string[] = []
26
76
  let rest = text
27
- while (rest.length > limit) {
28
- const para = rest.lastIndexOf('\n\n', limit)
29
- const line = rest.lastIndexOf('\n', limit)
30
- const space = rest.lastIndexOf(' ', limit)
31
- let cut = para > limit / 2 ? para : line > limit / 2 ? line : space > 0 ? space : limit
77
+ const effLimit = htmlSafe ? limit - HTML_BALANCE_HEADROOM : limit
78
+ // If cut lands inside an open tag (<...>), move cut to before the '<'
79
+ const avoidMidTag = (cut: number): number => {
80
+ const tagStart = rest.lastIndexOf('<', cut)
81
+ const tagEnd = rest.lastIndexOf('>', cut)
82
+ return tagStart > tagEnd ? tagStart : cut
83
+ }
84
+ const closersFor = (open: string[]): string =>
85
+ open.map((t) => `</${/^<([a-z]+)/.exec(t)![1]}>`).reverse().join('')
86
+ // Cut at `cut`, balancing tags across the boundary when htmlSafe.
87
+ const splitAt = (cut: number): { head: string; tail: string } => {
88
+ let head = rest.slice(0, cut)
89
+ let tail = rest.slice(cut).replace(/^\n+/, '')
32
90
  if (htmlSafe) {
33
- // If cut lands inside an open tag (<...>), move cut to before the '<'
34
- const tagStart = rest.lastIndexOf('<', cut)
35
- const tagEnd = rest.lastIndexOf('>', cut)
36
- if (tagStart > tagEnd) cut = tagStart
91
+ const open = openTagStack(head)
92
+ if (open.length) {
93
+ head += closersFor(open)
94
+ tail = open.join('') + tail
95
+ }
96
+ }
97
+ return { head, tail }
98
+ }
99
+ while (rest.length > effLimit) {
100
+ const para = rest.lastIndexOf('\n\n', effLimit)
101
+ const line = rest.lastIndexOf('\n', effLimit)
102
+ const space = rest.lastIndexOf(' ', effLimit)
103
+ let cut = para > effLimit / 2 ? para : line > effLimit / 2 ? line : space > 0 ? space : effLimit
104
+ if (htmlSafe) cut = avoidMidTag(cut)
105
+ let { head, tail } = splitAt(cut)
106
+ // Forward-progress guard: when the chosen boundary sits right after an
107
+ // opening tag (e.g. "<b> " + one unbroken >limit token), the reopened tag
108
+ // prefix can re-add as much as the cut removed and `rest` never shrinks —
109
+ // an infinite loop that would hang the whole receiver. Retry with a hard
110
+ // cut at effLimit; if even that cannot shrink (degenerate tag-heavy input,
111
+ // e.g. a single huge <a href>), emit the remainder as one oversized chunk
112
+ // and stop — Telegram rejects it and the plain-text retry rescues the
113
+ // content, which beats hanging the process.
114
+ if (tail.length >= rest.length) {
115
+ cut = htmlSafe ? avoidMidTag(effLimit) : effLimit
116
+ ;({ head, tail } = splitAt(cut))
117
+ if (tail.length >= rest.length) {
118
+ out.push(rest)
119
+ rest = ''
120
+ break
121
+ }
37
122
  }
38
- out.push(rest.slice(0, cut))
39
- rest = rest.slice(cut).replace(/^\n+/, '')
123
+ out.push(head)
124
+ rest = tail
40
125
  }
41
126
  if (rest) out.push(rest)
42
127
  return out
@@ -111,6 +196,101 @@ export interface FsApi {
111
196
  statSync(path: string): { mtimeMs: number }
112
197
  }
113
198
 
199
+ /**
200
+ * Deliver auto-forwarded turn text to a chat, never silently dropping content.
201
+ * Each chunk that fails as HTML (e.g. Telegram rejects an entity the balancer
202
+ * didn't anticipate) is retried as plain text — the user always gets the words,
203
+ * worst case without formatting. The generic "could not be delivered" notice is
204
+ * a last resort reserved for chunks that fail even as plain text (network/API
205
+ * outage), and is sent at most once.
206
+ */
207
+ export async function deliverForwardText(
208
+ botApi: Pick<BotApi, 'sendMessage'>,
209
+ chatId: string,
210
+ forwardText: string,
211
+ parseMode: 'HTML' | undefined,
212
+ ): Promise<void> {
213
+ const msgOpts = parseMode ? { parse_mode: parseMode } : {}
214
+ const chunks = chunkText(forwardText, TELEGRAM_MAX_CHARS, parseMode === 'HTML')
215
+ let deliveryFailed = false
216
+ for (const part of chunks) {
217
+ try {
218
+ await botApi.sendMessage(chatId, part, msgOpts)
219
+ } catch {
220
+ const plain = parseMode === 'HTML' ? htmlToPlain(part) : part
221
+ try {
222
+ await botApi.sendMessage(chatId, plain)
223
+ } catch {
224
+ deliveryFailed = true
225
+ break
226
+ }
227
+ }
228
+ }
229
+ if (deliveryFailed) {
230
+ await botApi.sendMessage(
231
+ chatId,
232
+ '⚠️ Claude responded but the message could not be delivered. Please try asking again.',
233
+ ).catch(() => {})
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Orphan auto-forward delivery (one poll pass). The typing-loop teardown
239
+ * (stop()) normally drains `<chatId>.forward`, but a forward can be written
240
+ * with NO typing loop running at all: an autonomous wake (a background Task
241
+ * finished and Claude continued on its own — e.g. writing up a plan) has no
242
+ * inbound message, so nothing ever started typing and stop() never runs.
243
+ * Without this drain that text sits on disk forever and the user sees a
244
+ * silent chat. Chats with a live typing state are skipped — stop() owns
245
+ * their delivery (including the .replied dedup) and draining them here
246
+ * would double-send.
247
+ */
248
+ export function drainOrphanForwards(
249
+ typingDir: string,
250
+ activeChatIds: { has(chatId: string): boolean },
251
+ botApi: Pick<BotApi, 'sendMessage'>,
252
+ fsApi: Pick<FsApi, 'existsSync' | 'rmSync' | 'readFileSync'> & { readdirSync(path: string): string[] },
253
+ ): void {
254
+ let files: string[]
255
+ try {
256
+ files = fsApi.readdirSync(typingDir)
257
+ } catch {
258
+ return
259
+ }
260
+ for (const name of files) {
261
+ if (!name.endsWith('.forward')) continue
262
+ const chatId = name.slice(0, -'.forward'.length)
263
+ if (activeChatIds.has(chatId)) continue
264
+ const forwardPath = `${typingDir}/${name}`
265
+ let raw: string
266
+ try {
267
+ raw = fsApi.readFileSync(forwardPath, 'utf8').trim()
268
+ } catch {
269
+ fsApi.rmSync(forwardPath, { force: true })
270
+ continue
271
+ }
272
+ // Remove BEFORE sending so a slow/failing send can't double-deliver.
273
+ fsApi.rmSync(forwardPath, { force: true })
274
+ // Mirror stop()'s dedup: a lingering .replied with no typing state means
275
+ // the agent already sent this turn's text via the reply tool.
276
+ const repliedPath = `${typingDir}/${chatId}.replied`
277
+ if (fsApi.existsSync(repliedPath)) {
278
+ fsApi.rmSync(repliedPath, { force: true })
279
+ continue
280
+ }
281
+ let text = raw
282
+ let parseMode: 'HTML' | undefined
283
+ try {
284
+ const parsed = JSON.parse(raw) as { text: string; format: string }
285
+ text = parsed.text
286
+ parseMode = parsed.format === 'html' ? 'HTML' : undefined
287
+ } catch {
288
+ // Old format: plain text
289
+ }
290
+ if (text) void deliverForwardText(botApi, chatId, text, parseMode)
291
+ }
292
+ }
293
+
114
294
  export function createWorkingStateManager(
115
295
  typingDir: string,
116
296
  botApi: BotApi,
@@ -191,23 +371,7 @@ export function createWorkingStateManager(
191
371
  // Skip if the reply tool already sent a message (agent already replied)
192
372
  const alreadyReplied = fsApi.existsSync(repliedPath)
193
373
  if (!alreadyReplied && forwardText) {
194
- const msgOpts = parseMode ? { parse_mode: parseMode } : {}
195
- const chunks = chunkText(forwardText, TELEGRAM_MAX_CHARS, parseMode === 'HTML')
196
- let deliveryFailed = false
197
- for (const part of chunks) {
198
- try {
199
- await botApi.sendMessage(chatId, part, msgOpts)
200
- } catch {
201
- deliveryFailed = true
202
- break
203
- }
204
- }
205
- if (deliveryFailed) {
206
- await botApi.sendMessage(
207
- chatId,
208
- '⚠️ Claude responded but the message could not be delivered. Please try asking again.',
209
- ).catch(() => {})
210
- }
374
+ await deliverForwardText(botApi, chatId, forwardText, parseMode)
211
375
  }
212
376
  } catch {}
213
377
  fsApi.rmSync(forwardPath, { force: true })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmaxma/claude-gateway",
3
- "version": "1.3.21",
3
+ "version": "1.3.23",
4
4
  "description": "Multi-agent gateway for Claude",
5
5
  "repository": {
6
6
  "type": "git",