@a1hvdy/cc-openclaw 0.27.2 → 0.27.6

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.
Files changed (45) hide show
  1. package/dist/src/channels/telegram-mirror/card-renderer.d.ts +8 -22
  2. package/dist/src/channels/telegram-mirror/card-renderer.js +227 -22
  3. package/dist/src/channels/telegram-mirror/commands.d.ts +16 -0
  4. package/dist/src/channels/telegram-mirror/commands.js +34 -0
  5. package/dist/src/channels/telegram-mirror/inbound-handler.js +1 -1
  6. package/dist/src/channels/telegram-mirror/turn-bridge.d.ts +12 -1
  7. package/dist/src/channels/telegram-mirror/turn-bridge.js +41 -2
  8. package/dist/src/constants.d.ts +27 -0
  9. package/dist/src/constants.js +28 -0
  10. package/dist/src/engines/persistent-session.d.ts +5 -0
  11. package/dist/src/engines/persistent-session.js +27 -0
  12. package/dist/src/lib/error-formatter.d.ts +14 -2
  13. package/dist/src/lib/error-formatter.js +23 -11
  14. package/dist/src/lib/error-renderer.js +3 -1
  15. package/dist/src/lib/html-render.d.ts +23 -16
  16. package/dist/src/lib/html-render.js +127 -1
  17. package/dist/src/lib/markdown-to-mdv2.js +2 -1
  18. package/dist/src/lib/telegram-bot-api.d.ts +22 -6
  19. package/dist/src/lib/telegram-bot-api.js +94 -14
  20. package/dist/src/openai-compat/non-streaming-handler.js +18 -1
  21. package/dist/src/openai-compat/openai-compat.js +61 -2
  22. package/dist/src/openai-compat/request-coalescer.d.ts +77 -0
  23. package/dist/src/openai-compat/request-coalescer.js +157 -0
  24. package/dist/src/openai-compat/streaming-handler.d.ts +9 -1
  25. package/dist/src/openai-compat/streaming-handler.js +40 -5
  26. package/dist/src/session/persisted-sessions.d.ts +11 -0
  27. package/dist/src/session/persisted-sessions.js +17 -0
  28. package/dist/src/session/session-manager.js +22 -6
  29. package/dist/src/session/watchdogs.d.ts +3 -0
  30. package/dist/src/session/watchdogs.js +6 -0
  31. package/dist/src/session-bootstrap/cwd-patch.js +1 -2
  32. package/dist/src/types.d.ts +11 -0
  33. package/package.json +1 -1
  34. package/dist/src/config/drift-detector.d.ts +0 -28
  35. package/dist/src/config/drift-detector.js +0 -74
  36. package/dist/src/lib/stale-pid-files.d.ts +0 -17
  37. package/dist/src/lib/stale-pid-files.js +0 -39
  38. package/dist/src/persistence/snapshot.d.ts +0 -18
  39. package/dist/src/persistence/snapshot.js +0 -31
  40. package/dist/src/persistence/wal.d.ts +0 -17
  41. package/dist/src/persistence/wal.js +0 -31
  42. package/dist/src/types/index.d.ts +0 -15
  43. package/dist/src/types/index.js +0 -15
  44. package/dist/src/types/session.d.ts +0 -48
  45. package/dist/src/types/session.js +0 -19
@@ -59,6 +59,7 @@ export class PersistentClaudeSession extends EventEmitter {
59
59
  startTime: null,
60
60
  lastActivity: null,
61
61
  lastProgressAt: null,
62
+ inFlightTools: 0,
62
63
  history: [],
63
64
  retries: 0,
64
65
  lastRetryError: undefined,
@@ -291,6 +292,9 @@ export class PersistentClaudeSession extends EventEmitter {
291
292
  });
292
293
  this.proc.on('close', (code) => {
293
294
  this._isReady = false;
295
+ // v0.27.6 — liveness watchdog (Killer #1): subprocess gone, nothing can be
296
+ // in flight; clear so a stale count never outlives the process.
297
+ this.stats.inFlightTools = 0;
294
298
  this.emit(SESSION_EVENT.CLOSE, code);
295
299
  });
296
300
  this.proc.on('error', (err) => {
@@ -385,6 +389,10 @@ export class PersistentClaudeSession extends EventEmitter {
385
389
  const block = inner.content_block;
386
390
  if (block?.type === 'tool_use') {
387
391
  this.stats.toolCalls++;
392
+ // v0.27.6 — liveness watchdog (Killer #1): a tool is now in flight.
393
+ // Decremented on its tool_result; reset to 0 at turn boundaries so a
394
+ // missed result can't pin the count open forever.
395
+ this.stats.inFlightTools++;
388
396
  // v0.26.1: carry the tool_use block id so the Telegram mirror can
389
397
  // match the later tool_result event and flip the glyph … → ✓/✗.
390
398
  const toolEvent = { tool: { name: block.name, input: {}, id: block.id } };
@@ -470,6 +478,10 @@ export class PersistentClaudeSession extends EventEmitter {
470
478
  };
471
479
  if (b.is_error)
472
480
  this.stats.toolErrors++;
481
+ // v0.27.6 — liveness watchdog (Killer #1): this tool's result
482
+ // arrived, so it's no longer in flight. Clamp at 0 (a duplicate or
483
+ // unmatched result must never push the count negative).
484
+ this.stats.inFlightTools = Math.max(0, this.stats.inFlightTools - 1);
473
485
  try {
474
486
  this._streamCallbacks?.onToolResult?.(resultEvent);
475
487
  }
@@ -511,6 +523,8 @@ export class PersistentClaudeSession extends EventEmitter {
511
523
  }
512
524
  else if (block.type === 'tool_use') {
513
525
  this.stats.toolCalls++;
526
+ // v0.27.6 — liveness watchdog (Killer #1): tool in flight (see above).
527
+ this.stats.inFlightTools++;
514
528
  const toolEvent = {
515
529
  tool: {
516
530
  name: block.name,
@@ -559,6 +573,8 @@ export class PersistentClaudeSession extends EventEmitter {
559
573
  break;
560
574
  case 'tool_use':
561
575
  this.stats.toolCalls++;
576
+ // v0.27.6 — liveness watchdog (Killer #1): tool in flight (see above).
577
+ this.stats.inFlightTools++;
562
578
  try {
563
579
  this._streamCallbacks?.onToolUse?.(event);
564
580
  }
@@ -568,6 +584,8 @@ export class PersistentClaudeSession extends EventEmitter {
568
584
  this.emit(SESSION_EVENT.TOOL_USE, event);
569
585
  break;
570
586
  case 'tool_result':
587
+ // v0.27.6 — liveness watchdog (Killer #1): tool result arrived (see above).
588
+ this.stats.inFlightTools = Math.max(0, this.stats.inFlightTools - 1);
571
589
  try {
572
590
  this._streamCallbacks?.onToolResult?.(event);
573
591
  }
@@ -601,6 +619,10 @@ export class PersistentClaudeSession extends EventEmitter {
601
619
  }
602
620
  this.emit(SESSION_EVENT.RESULT, event);
603
621
  this.emit(SESSION_EVENT.TURN_COMPLETE, event);
622
+ // v0.27.6 — liveness watchdog (Killer #1): turn is over; clear any
623
+ // in-flight tool count so a missed tool_result can't pin the session
624
+ // "alive" across turns and permanently disable the stalled backstop.
625
+ this.stats.inFlightTools = 0;
604
626
  this._fireHook('onTurnComplete', {
605
627
  text: event.result,
606
628
  usage,
@@ -668,6 +690,10 @@ export class PersistentClaudeSession extends EventEmitter {
668
690
  this._streamCallbacks = options.callbacks;
669
691
  if (options.waitForComplete) {
670
692
  this._isBusy = true;
693
+ // v0.27.6 — liveness watchdog (Killer #1): each turn starts with zero
694
+ // tools in flight; guarantees no count leaks across turns regardless of
695
+ // how the prior turn ended (result / error / partial).
696
+ this.stats.inFlightTools = 0;
671
697
  try {
672
698
  return await this._waitForTurnComplete(options.timeout || resolveTurnTimeoutMs());
673
699
  }
@@ -781,6 +807,7 @@ export class PersistentClaudeSession extends EventEmitter {
781
807
  startTime: this.stats.startTime,
782
808
  lastActivity: this.stats.lastActivity,
783
809
  lastProgressAt: this.stats.lastProgressAt,
810
+ inFlightTools: this.stats.inFlightTools,
784
811
  // v0.6.0: contextPercent now reflects ACTUAL per-turn context occupancy
785
812
  // (input + cache-read tokens from the last `result` event), not lifetime
786
813
  // cumulative tokens. Pre-fix it saturated at 100% by turn 3 of any
@@ -49,7 +49,12 @@ export interface ErrorContext {
49
49
  export interface FormattedError {
50
50
  /** NDJSON-ready row */
51
51
  jsonlRow: ErrorJsonlRow;
52
- /** Telegram message text (MarkdownV2-safe) */
52
+ /**
53
+ * Telegram message text (HTML parse_mode). v0.27.3 — converted from
54
+ * MarkdownV2 to HTML so the entire Telegram surface (live card + error
55
+ * alerts) renders through ONE parse mode. error-renderer sends this with
56
+ * parse_mode: 'HTML'.
57
+ */
53
58
  telegramText: string;
54
59
  }
55
60
  export interface ErrorJsonlRow {
@@ -64,7 +69,14 @@ export interface ErrorJsonlRow {
64
69
  }
65
70
  /** Extract a usable message from unknown thrown values. */
66
71
  export declare function extractMessage(error: unknown): string;
67
- /** Escape characters special to Telegram MarkdownV2. */
72
+ /**
73
+ * Escape characters special to Telegram MarkdownV2.
74
+ *
75
+ * v0.27.3 — RETAINED for back-compat (and its own unit tests) but no longer
76
+ * used by formatError, which now emits HTML (see the telegramText block) so the
77
+ * whole Telegram surface renders through one parse mode. Kept exported in case a
78
+ * caller still needs MarkdownV2 escaping.
79
+ */
68
80
  export declare function escapeMdV2(text: string): string;
69
81
  /**
70
82
  * Pure formatter — no side effects on the return value, but DOES emit
@@ -80,7 +80,14 @@ function extractStack(error) {
80
80
  }
81
81
  return undefined;
82
82
  }
83
- /** Escape characters special to Telegram MarkdownV2. */
83
+ /**
84
+ * Escape characters special to Telegram MarkdownV2.
85
+ *
86
+ * v0.27.3 — RETAINED for back-compat (and its own unit tests) but no longer
87
+ * used by formatError, which now emits HTML (see the telegramText block) so the
88
+ * whole Telegram surface renders through one parse mode. Kept exported in case a
89
+ * caller still needs MarkdownV2 escaping.
90
+ */
84
91
  export function escapeMdV2(text) {
85
92
  // Per Telegram docs, these must be escaped: _ * [ ] ( ) ~ ` > # + - = | { } . !
86
93
  return text.replace(/[_*[\]()~`>#+=|{}.!\\-]/g, (c) => `\\${c}`);
@@ -97,6 +104,7 @@ const SEVERITY_EMOJI = {
97
104
  // are unset, so the import itself has zero runtime cost.
98
105
  import { emit as emitTrajectory } from './trajectory.js';
99
106
  import { metricsRegistry } from '../health/metrics.js';
107
+ import { escapeHtml } from './html-render.js';
100
108
  /**
101
109
  * Pure formatter — no side effects on the return value, but DOES emit
102
110
  * trajectory + metrics events for centralized observability per Pillars A+B.
@@ -128,27 +136,31 @@ export function formatError(error, context) {
128
136
  ...(stack !== undefined ? { stack } : {}),
129
137
  ...(context.details !== undefined ? { details: context.details } : {}),
130
138
  };
139
+ // v0.27.3 — emit Telegram HTML (sent with parse_mode: 'HTML' by
140
+ // error-renderer) so error alerts share the live card's single render path.
141
+ // <b> code, <code> message, <i> timestamp; all interpolated text is
142
+ // HTML-escaped (& < >) so a stray < in a message can't break the parser.
131
143
  const emoji = SEVERITY_EMOJI[severity];
132
- const safeCode = escapeMdV2(context.code);
133
- const safeMsg = escapeMdV2(message.length > 300 ? message.slice(0, 300) + '...' : message);
134
- const safeTs = escapeMdV2(ts.slice(0, 19).replace('T', ' '));
144
+ const safeCode = escapeHtml(context.code);
145
+ const safeMsg = escapeHtml(message.length > 300 ? message.slice(0, 300) + '...' : message);
146
+ const safeTs = escapeHtml(ts.slice(0, 19).replace('T', ' '));
135
147
  const lines = [
136
- `${emoji} *${safeCode}*`,
137
- `\`${safeMsg}\``,
138
- `_${safeTs}_`,
148
+ `${emoji} <b>${safeCode}</b>`,
149
+ `<code>${safeMsg}</code>`,
150
+ `<i>${safeTs}</i>`,
139
151
  ];
140
152
  if (context.sessionId) {
141
- lines.push(`session: \`${escapeMdV2(context.sessionId)}\``);
153
+ lines.push(`session: <code>${escapeHtml(context.sessionId)}</code>`);
142
154
  }
143
155
  if (context.laptopId) {
144
- lines.push(`laptop: \`${escapeMdV2(context.laptopId)}\``);
156
+ lines.push(`laptop: <code>${escapeHtml(context.laptopId)}</code>`);
145
157
  }
146
158
  if (context.details && Object.keys(context.details).length > 0) {
147
159
  const detailParts = Object.entries(context.details)
148
160
  .slice(0, 5)
149
- .map(([k, v]) => `${escapeMdV2(k)}: ${escapeMdV2(String(v))}`)
161
+ .map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}`)
150
162
  .join(', ');
151
- lines.push(`_${detailParts}_`);
163
+ lines.push(`<i>${detailParts}</i>`);
152
164
  }
153
165
  const telegramText = lines.join('\n');
154
166
  return { jsonlRow, telegramText };
@@ -67,7 +67,9 @@ export async function renderError(formatted, opts = {}) {
67
67
  const params = {
68
68
  chat_id: chatId,
69
69
  text: telegramText,
70
- parse_mode: 'MarkdownV2',
70
+ // v0.27.3 — HTML (formatter now emits HTML) so error alerts share the live
71
+ // card's single render path. The 400-fallback below sends plain text.
72
+ parse_mode: 'HTML',
71
73
  };
72
74
  if (opts.threadId)
73
75
  params.message_thread_id = opts.threadId;
@@ -18,26 +18,33 @@
18
18
  export declare function escapeHtml(text: string | null | undefined): string;
19
19
  /** Inline monospace span: `<code>escaped</code>`. */
20
20
  export declare function code(s: string): string;
21
+ /**
22
+ * Strip Telegram HTML markup back to readable plain text (v0.27.3). Used
23
+ * by editTg's plain-text fallback: when an HTML edit is rejected, re-sending the
24
+ * SAME string without parse_mode would dump literal `<b>`/`<pre>` tags into the
25
+ * chat. This removes the tags and decodes the basic entities so the fallback
26
+ * stays legible. Not a general sanitizer — scoped to the tags this module emits.
27
+ */
28
+ export declare function stripHtml(input: string | null | undefined): string;
29
+ /**
30
+ * v0.27.5 M4 — close any unclosed Telegram-HTML tags this module emits, in LIFO
31
+ * order. A hard truncation of a rendered card (renderTurn's 4096-char backstop)
32
+ * can slice through an open `<pre>`/`<b>`/… ; left dangling, Telegram rejects the
33
+ * edit and editTg falls back to plain text — dumping literal `<pre>`/`**`/`- `
34
+ * markup into the chat. Closing the danglers keeps the truncated HTML well-formed
35
+ * so the edit is accepted.
36
+ *
37
+ * Conservative by design: only the tags this module emits (pre, code, blockquote,
38
+ * b, i, s, a) are tracked, balanced input passes through byte-identical, and
39
+ * stray/mismatched closers are tolerated (pop nearest match, never go negative).
40
+ * Attribute values here never contain a literal `>` (escapeHtml runs on class /
41
+ * href), so `[^>]*` safely consumes the whole opening tag.
42
+ */
43
+ export declare function closeDanglingTags(html: string): string;
21
44
  /**
22
45
  * Fenced code block: `<pre><code class="language-LANG">escaped</code></pre>`.
23
46
  * Omits the class when no language is given. Body is HTML-escaped (the only
24
47
  * escaping Telegram requires inside pre/code).
25
48
  */
26
49
  export declare function pre(body: string, lang?: string): string;
27
- /**
28
- * Convert a subset of CommonMark Markdown to Telegram HTML with structure
29
- * preserved (`**bold**`→`<b>`, fenced blocks→`<pre><code>`, etc.). Returns ''
30
- * for null/undefined.
31
- *
32
- * Algorithm (placeholder substitution, NUL-sentinel keyed — mirrors
33
- * markdown-to-mdv2.ts so behaviour is auditable side-by-side):
34
- * 1. code fences → <pre><code [class]>…</code></pre>
35
- * 2. inline code → <code>…</code>
36
- * 3. bold (**…**) → <b>…</b>
37
- * 4. italic (*…* / _…_) → <i>…</i>
38
- * 5. ATX headers (#…) at line start → <b>…</b>
39
- * 6. links [label](url) → <a href="url">label</a>
40
- * 7. escape remaining text (& < >)
41
- * 8. restore placeholders verbatim
42
- */
43
50
  export declare function markdownToHtml(input: string | null | undefined): string;
@@ -14,6 +14,7 @@
14
14
  * pre+code[class=language-X], blockquote, tg-spoiler. Everything else in text
15
15
  * content must have &, <, > escaped.
16
16
  */
17
+ /* eslint-disable no-control-regex -- NUL-byte sentinel delimiters intentionally guard placeholders through the bulk-escape step, then are restored */
17
18
  /** Escape the three HTML-significant chars for Telegram HTML text content. */
18
19
  export function escapeHtml(text) {
19
20
  if (text == null)
@@ -27,6 +28,59 @@ export function escapeHtml(text) {
27
28
  export function code(s) {
28
29
  return `<code>${escapeHtml(s)}</code>`;
29
30
  }
31
+ /**
32
+ * Strip Telegram HTML markup back to readable plain text (v0.27.3). Used
33
+ * by editTg's plain-text fallback: when an HTML edit is rejected, re-sending the
34
+ * SAME string without parse_mode would dump literal `<b>`/`<pre>` tags into the
35
+ * chat. This removes the tags and decodes the basic entities so the fallback
36
+ * stays legible. Not a general sanitizer — scoped to the tags this module emits.
37
+ */
38
+ export function stripHtml(input) {
39
+ if (input == null)
40
+ return '';
41
+ return String(input)
42
+ .replace(/<\/?(?:b|strong|i|em|u|s|a|code|pre|blockquote|tg-spoiler)\b[^>]*>/gi, '')
43
+ .replace(/&lt;/g, '<')
44
+ .replace(/&gt;/g, '>')
45
+ .replace(/&quot;/g, '"')
46
+ .replace(/&amp;/g, '&');
47
+ }
48
+ /**
49
+ * v0.27.5 M4 — close any unclosed Telegram-HTML tags this module emits, in LIFO
50
+ * order. A hard truncation of a rendered card (renderTurn's 4096-char backstop)
51
+ * can slice through an open `<pre>`/`<b>`/… ; left dangling, Telegram rejects the
52
+ * edit and editTg falls back to plain text — dumping literal `<pre>`/`**`/`- `
53
+ * markup into the chat. Closing the danglers keeps the truncated HTML well-formed
54
+ * so the edit is accepted.
55
+ *
56
+ * Conservative by design: only the tags this module emits (pre, code, blockquote,
57
+ * b, i, s, a) are tracked, balanced input passes through byte-identical, and
58
+ * stray/mismatched closers are tolerated (pop nearest match, never go negative).
59
+ * Attribute values here never contain a literal `>` (escapeHtml runs on class /
60
+ * href), so `[^>]*` safely consumes the whole opening tag.
61
+ */
62
+ export function closeDanglingTags(html) {
63
+ const stack = [];
64
+ const re = /<(\/?)(pre|code|blockquote|b|i|s|a)\b[^>]*>/gi;
65
+ let m;
66
+ while ((m = re.exec(html)) !== null) {
67
+ const tag = m[2].toLowerCase();
68
+ if (m[1] === '/') {
69
+ const idx = stack.lastIndexOf(tag);
70
+ if (idx !== -1)
71
+ stack.splice(idx, 1);
72
+ }
73
+ else {
74
+ stack.push(tag);
75
+ }
76
+ }
77
+ if (stack.length === 0)
78
+ return html;
79
+ let out = html;
80
+ for (let i = stack.length - 1; i >= 0; i--)
81
+ out += `</${stack[i]}>`;
82
+ return out;
83
+ }
30
84
  /**
31
85
  * Fenced code block: `<pre><code class="language-LANG">escaped</code></pre>`.
32
86
  * Omits the class when no language is given. Body is HTML-escaped (the only
@@ -52,16 +106,73 @@ export function pre(body, lang) {
52
106
  * 7. escape remaining text (& < >)
53
107
  * 8. restore placeholders verbatim
54
108
  */
109
+ /** Split a GFM table row into trimmed cells, dropping the empty cells produced
110
+ * by leading/trailing pipes (`| a | b |` → ['a','b']). */
111
+ function splitTableRow(row) {
112
+ const cells = row.split('|').map((c) => c.trim());
113
+ if (cells.length && cells[0] === '')
114
+ cells.shift();
115
+ if (cells.length && cells[cells.length - 1] === '')
116
+ cells.pop();
117
+ return cells;
118
+ }
119
+ /** Render parsed table rows as a padded monospace block. Telegram HTML has no
120
+ * <table>, so column-aligned text inside <pre> is the faithful terminal-style
121
+ * rendering. Returns a ready <pre><code> string (content HTML-escaped by pre()). */
122
+ function renderPaddedTable(rows) {
123
+ const cols = Math.max(...rows.map((r) => r.length));
124
+ const widths = [];
125
+ for (let c = 0; c < cols; c++) {
126
+ widths[c] = Math.max(...rows.map((r) => (r[c] ?? '').length));
127
+ }
128
+ const body = rows
129
+ .map((r) => Array.from({ length: cols }, (_v, c) => (r[c] ?? '').padEnd(widths[c]))
130
+ .join(' | ')
131
+ .replace(/\s+$/, ''))
132
+ .join('\n');
133
+ return pre(body);
134
+ }
55
135
  export function markdownToHtml(input) {
56
136
  if (input == null)
57
137
  return '';
58
138
  let text = String(input);
59
139
  const codeBlocks = [];
60
- text = text.replace(/```([a-zA-Z0-9_+\-]*)\n?([\s\S]*?)```/g, (_m, lang, body) => {
140
+ text = text.replace(/```([a-zA-Z0-9_+-]*)\n?([\s\S]*?)```/g, (_m, lang, body) => {
61
141
  const idx = codeBlocks.length;
62
142
  codeBlocks.push(pre(body.replace(/\n$/, ''), lang || undefined));
63
143
  return `\x00CODEBLOCK${idx}\x00`;
64
144
  });
145
+ // v0.27.4 M4 — GFM pipe tables → padded monospace <pre> (gap #5). Telegram HTML
146
+ // has no <table>; column-aligned text is the terminal-faithful rendering. A
147
+ // table is a row containing `|` immediately followed by a `---` separator row.
148
+ const tables = [];
149
+ {
150
+ const lines = text.split('\n');
151
+ const out = [];
152
+ let i = 0;
153
+ while (i < lines.length) {
154
+ const header = lines[i];
155
+ const sep = lines[i + 1];
156
+ const isSep = sep != null && sep.includes('-') && /^\s*\|?[\s:|-]+\|?\s*$/.test(sep);
157
+ if (header != null && header.includes('|') && isSep) {
158
+ const block = [splitTableRow(header)];
159
+ let j = i + 2;
160
+ while (j < lines.length && lines[j].includes('|') && lines[j].trim() !== '') {
161
+ block.push(splitTableRow(lines[j]));
162
+ j++;
163
+ }
164
+ const idx = tables.length;
165
+ tables.push(renderPaddedTable(block));
166
+ out.push(`\x00TABLE${idx}\x00`);
167
+ i = j;
168
+ }
169
+ else {
170
+ out.push(header);
171
+ i++;
172
+ }
173
+ }
174
+ text = out.join('\n');
175
+ }
65
176
  const inlineCodes = [];
66
177
  text = text.replace(/`([^`\n]+)`/g, (_m, body) => {
67
178
  const idx = inlineCodes.length;
@@ -74,6 +185,14 @@ export function markdownToHtml(input) {
74
185
  bolds.push(`<b>${escapeHtml(body)}</b>`);
75
186
  return `\x00BOLD${idx}\x00`;
76
187
  });
188
+ // v0.27.4 M4 — strikethrough ~~text~~ → <s> (gap #5). After bold so the `~~`
189
+ // pass never sees `**`-delimited spans.
190
+ const strikes = [];
191
+ text = text.replace(/~~([^~\n]+)~~/g, (_m, body) => {
192
+ const idx = strikes.length;
193
+ strikes.push(`<s>${escapeHtml(body)}</s>`);
194
+ return `\x00STRIKE${idx}\x00`;
195
+ });
77
196
  const italics = [];
78
197
  text = text.replace(/(?<![\w*])\*([^*\n]+)\*(?!\w)/g, (_m, body) => {
79
198
  const idx = italics.length;
@@ -99,10 +218,17 @@ export function markdownToHtml(input) {
99
218
  links.push(`<a href="${safeUrl}">${escapeHtml(label)}</a>`);
100
219
  return `\x00LINK${idx}\x00`;
101
220
  });
221
+ // v0.27.4 M4 — unordered list markers (-, *, +) at line start → "• " bullet
222
+ // (gap #5). Ordered lists (1. 2.) already read fine as plain text. The bullet
223
+ // glyph isn't HTML-significant, so it survives the escape below. Uses [ \t]
224
+ // (not \s) so it never consumes the line break.
225
+ text = text.replace(/^([ \t]*)[-*+][ \t]+/gm, (_m, indent) => `${indent}• `);
102
226
  text = escapeHtml(text);
103
227
  text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m, idx) => codeBlocks[Number(idx)]);
228
+ text = text.replace(/\x00TABLE(\d+)\x00/g, (_m, idx) => tables[Number(idx)]);
104
229
  text = text.replace(/\x00INLINECODE(\d+)\x00/g, (_m, idx) => inlineCodes[Number(idx)]);
105
230
  text = text.replace(/\x00BOLD(\d+)\x00/g, (_m, idx) => bolds[Number(idx)]);
231
+ text = text.replace(/\x00STRIKE(\d+)\x00/g, (_m, idx) => strikes[Number(idx)]);
106
232
  text = text.replace(/\x00ITALIC(\d+)\x00/g, (_m, idx) => italics[Number(idx)]);
107
233
  text = text.replace(/\x00HEADER(\d+)\x00/g, (_m, idx) => headers[Number(idx)]);
108
234
  text = text.replace(/\x00LINK(\d+)\x00/g, (_m, idx) => links[Number(idx)]);
@@ -46,6 +46,7 @@
46
46
  * - "CODE"/"BOLD"/etc letters are not in the special-char set
47
47
  * So placeholders survive the bulk-escape step intact.
48
48
  */
49
+ /* eslint-disable no-control-regex -- NUL-byte sentinel delimiters intentionally guard placeholders through the bulk-escape step, then are restored */
49
50
  /** MarkdownV2 special characters (Telegram Bot API spec). Outside code spans
50
51
  * / pre blocks, these MUST be backslash-escaped when intended as content. */
51
52
  const MDV2_TEXT_SPECIAL = /[_*[\]()~`>#+\-=|{}.!\\]/g;
@@ -73,7 +74,7 @@ export function markdownToMdv2(input) {
73
74
  // Step 1 — extract code fences (triple-backtick blocks). Body verbatim
74
75
  // except backticks and backslashes are escaped per Telegram spec.
75
76
  const codeBlocks = [];
76
- text = text.replace(/```([a-zA-Z0-9_+\-]*)\n?([\s\S]*?)```/g, (_m, lang, body) => {
77
+ text = text.replace(/```([a-zA-Z0-9_+-]*)\n?([\s\S]*?)```/g, (_m, lang, body) => {
77
78
  const idx = codeBlocks.length;
78
79
  const langStr = lang || '';
79
80
  const escapedBody = escapeCodeContent(body);
@@ -87,15 +87,31 @@ export interface TelegramApiResponse {
87
87
  */
88
88
  export declare function telegramApi(method: string, params: Record<string, unknown>): Promise<TelegramApiResponse>;
89
89
  /**
90
- * sendMessage with MarkdownV2 first + plain-text fallback. The fallback
91
- * is the v0.20.1 fix: prior implementation stripped punctuation on
92
- * MarkdownV2 parse errors; current behaviour retries with parse_mode
93
- * omitted so all content survives.
90
+ * v0.27.4 M6 CLI-parity gap #8: split a message that exceeds Telegram's 4096
91
+ * char cap into ≤max-char chunks so long cc-openclaw-originated content sends as
92
+ * sequential messages instead of being rejected outright. Splits on newline
93
+ * boundaries (never mid-line) so HTML constructs mostly stay intact; a single
94
+ * line longer than max is hard-split. Returns [text] unchanged when ≤max (the
95
+ * common path — no behavior change for normal messages).
96
+ *
97
+ * Scope note: this covers cc-openclaw's OWN sends (slash/error responses). The
98
+ * live card is one edited message (truncated by design) and the model's final
99
+ * answer is delivered by the OpenClaw gateway — neither flows through here.
100
+ */
101
+ export declare function splitForTelegram(text: string, max?: number): string[];
102
+ /**
103
+ * sendMessage with HTML parse_mode first + plain-text fallback. The fallback
104
+ * is the v0.20.1 fix: prior implementation stripped punctuation on parse
105
+ * errors; current behaviour retries with parse_mode omitted so all content
106
+ * survives. (v0.27.0 switched the live mirror MarkdownV2 → HTML; v0.27.3
107
+ * converted the last MarkdownV2 emitter, error-formatter, so the whole
108
+ * Telegram surface is now one HTML render path.) v0.27.4 M6 — auto-chunks
109
+ * over-cap text and the plain fallback now strips HTML (was: re-sent raw tags).
94
110
  */
95
111
  export declare function sendTg(chatId: string | number, text: string, threadId?: string | number, replyMarkup?: unknown, replyToMessageId?: number | null): Promise<TelegramApiResponse>;
96
112
  /**
97
- * editMessageText with MarkdownV2-first + 429 retry-after handling +
98
- * plain-text fallback.
113
+ * editMessageText with HTML parse_mode + 429 retry-after handling +
114
+ * plain-text fallback (v0.27.3 stripHtml fallback on rejection).
99
115
  */
100
116
  export declare function editTg(chatId: string | number, messageId: number, text: string, replyMarkup?: unknown): Promise<TelegramApiResponse>;
101
117
  export interface SendDocumentOptions {
@@ -28,6 +28,9 @@ import { readFileSync } from 'node:fs';
28
28
  import { homedir } from 'node:os';
29
29
  import { join } from 'node:path';
30
30
  import { randomBytes } from 'node:crypto';
31
+ import { stripHtml } from './html-render.js';
32
+ /** Telegram's hard per-message character cap. */
33
+ const TG_MAX_CHARS = 4096;
31
34
  export const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
32
35
  const PLUGIN_TAG = '[cc-openclaw/telegram-bot-api]';
33
36
  // ─── Bot token state ───────────────────────────────────────────────────────
@@ -139,32 +142,96 @@ export function telegramApi(method, params) {
139
142
  });
140
143
  }
141
144
  /**
142
- * sendMessage with MarkdownV2 first + plain-text fallback. The fallback
143
- * is the v0.20.1 fix: prior implementation stripped punctuation on
144
- * MarkdownV2 parse errors; current behaviour retries with parse_mode
145
- * omitted so all content survives.
145
+ * v0.27.4 M6 CLI-parity gap #8: split a message that exceeds Telegram's 4096
146
+ * char cap into ≤max-char chunks so long cc-openclaw-originated content sends as
147
+ * sequential messages instead of being rejected outright. Splits on newline
148
+ * boundaries (never mid-line) so HTML constructs mostly stay intact; a single
149
+ * line longer than max is hard-split. Returns [text] unchanged when ≤max (the
150
+ * common path — no behavior change for normal messages).
151
+ *
152
+ * Scope note: this covers cc-openclaw's OWN sends (slash/error responses). The
153
+ * live card is one edited message (truncated by design) and the model's final
154
+ * answer is delivered by the OpenClaw gateway — neither flows through here.
155
+ */
156
+ export function splitForTelegram(text, max = TG_MAX_CHARS) {
157
+ if (text.length <= max)
158
+ return [text];
159
+ const chunks = [];
160
+ let current = '';
161
+ for (const line of text.split('\n')) {
162
+ if (line.length > max) {
163
+ if (current) {
164
+ chunks.push(current);
165
+ current = '';
166
+ }
167
+ for (let i = 0; i < line.length; i += max)
168
+ chunks.push(line.slice(i, i + max));
169
+ continue;
170
+ }
171
+ const candidate = current ? `${current}\n${line}` : line;
172
+ if (candidate.length > max) {
173
+ if (current)
174
+ chunks.push(current);
175
+ current = line;
176
+ }
177
+ else {
178
+ current = candidate;
179
+ }
180
+ }
181
+ if (current)
182
+ chunks.push(current);
183
+ return chunks;
184
+ }
185
+ /**
186
+ * sendMessage with HTML parse_mode first + plain-text fallback. The fallback
187
+ * is the v0.20.1 fix: prior implementation stripped punctuation on parse
188
+ * errors; current behaviour retries with parse_mode omitted so all content
189
+ * survives. (v0.27.0 switched the live mirror MarkdownV2 → HTML; v0.27.3
190
+ * converted the last MarkdownV2 emitter, error-formatter, so the whole
191
+ * Telegram surface is now one HTML render path.) v0.27.4 M6 — auto-chunks
192
+ * over-cap text and the plain fallback now strips HTML (was: re-sent raw tags).
146
193
  */
147
194
  export async function sendTg(chatId, text, threadId, replyMarkup, replyToMessageId) {
148
195
  try {
149
196
  const base = { chat_id: chatId, disable_web_page_preview: true };
150
197
  if (threadId)
151
198
  base.message_thread_id = Number(threadId);
152
- if (replyMarkup)
153
- base.reply_markup = replyMarkup;
154
199
  if (replyToMessageId)
155
200
  base.reply_to_message_id = Number(replyToMessageId);
156
- const res = await telegramApi('sendMessage', { ...base, text, parse_mode: 'HTML' });
157
- if (res.ok)
158
- return res;
159
- return telegramApi('sendMessage', { ...base, text: text || 'Session update' });
201
+ // Send one body with HTML first, then a stripped plain-text fallback so a
202
+ // chunk that split an HTML tag still lands legibly (mirrors editTg v0.27.3).
203
+ const sendOne = async (body, markup) => {
204
+ const params = { ...base, text: body, parse_mode: 'HTML' };
205
+ if (markup)
206
+ params.reply_markup = markup;
207
+ const res = await telegramApi('sendMessage', params);
208
+ if (res.ok)
209
+ return res;
210
+ const fb = { ...base, text: stripHtml(body) || 'Session update' };
211
+ if (markup)
212
+ fb.reply_markup = markup;
213
+ return telegramApi('sendMessage', fb);
214
+ };
215
+ if (text.length > TG_MAX_CHARS) {
216
+ const chunks = splitForTelegram(text, TG_MAX_CHARS);
217
+ let first = { ok: false };
218
+ for (let i = 0; i < chunks.length; i++) {
219
+ // Keyboard rides only the LAST chunk (actions belong with the tail).
220
+ const res = await sendOne(chunks[i], i === chunks.length - 1 ? replyMarkup : undefined);
221
+ if (i === 0)
222
+ first = res;
223
+ }
224
+ return first;
225
+ }
226
+ return await sendOne(text, replyMarkup);
160
227
  }
161
228
  catch {
162
229
  return { ok: false };
163
230
  }
164
231
  }
165
232
  /**
166
- * editMessageText with MarkdownV2-first + 429 retry-after handling +
167
- * plain-text fallback.
233
+ * editMessageText with HTML parse_mode + 429 retry-after handling +
234
+ * plain-text fallback (v0.27.3 stripHtml fallback on rejection).
168
235
  */
169
236
  export async function editTg(chatId, messageId, text, replyMarkup) {
170
237
  try {
@@ -188,17 +255,30 @@ export async function editTg(chatId, messageId, text, replyMarkup) {
188
255
  }
189
256
  if (res.ok)
190
257
  return res;
258
+ // v0.27.3 — the HTML edit was rejected (commonly "can't parse
259
+ // entities" or "message is too long"). Re-sending the SAME string without
260
+ // parse_mode would dump literal <b>/<pre> tags into the chat AND, if the
261
+ // reason was length, fail identically. Strip the markup back to plain text
262
+ // and hard-truncate to Telegram's cap so the fallback can actually land.
263
+ // Log the original failure so a broken live card is never silent again
264
+ // (the prior swallow is exactly why the 4096-overflow regression hid).
265
+ process.stderr.write(`[cc-openclaw/telegram-bot-api] editTg HTML edit rejected (len=${text.length}) ` +
266
+ `code=${res.error_code ?? '?'} desc=${JSON.stringify(res.description ?? '')} — plain-text fallback\n`);
267
+ let plain = stripHtml(text) || 'Session update';
268
+ if (plain.length > TG_MAX_CHARS)
269
+ plain = plain.slice(0, TG_MAX_CHARS - 1) + '…';
191
270
  const fallback = {
192
271
  chat_id: chatId,
193
272
  message_id: messageId,
194
- text: text || 'Session update',
273
+ text: plain,
195
274
  disable_web_page_preview: true,
196
275
  };
197
276
  if (replyMarkup)
198
277
  fallback.reply_markup = replyMarkup;
199
278
  return telegramApi('editMessageText', fallback);
200
279
  }
201
- catch {
280
+ catch (err) {
281
+ process.stderr.write(`[cc-openclaw/telegram-bot-api] editTg threw: ${err.message}\n`);
202
282
  return { ok: false };
203
283
  }
204
284
  }