@a1hvdy/cc-openclaw 0.26.6 → 0.27.0

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.
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { CallbackMap } from './callback-mapping.js';
24
24
  import { sendTg, editTg, telegramApi } from '../../lib/telegram-bot-api.js';
25
+ import { escapeHtml } from '../../lib/html-render.js';
25
26
  /** Namespace prefix for callback_data so api.registerInteractiveHandler routes
26
27
  * taps here. Matched at the first ':' by the gateway (must be [A-Za-z0-9._-]+). */
27
28
  export const ASKUSER_NS = 'ccmirror';
@@ -74,21 +75,34 @@ export function parseFirstQuestion(input) {
74
75
  function shortQid() {
75
76
  return Math.random().toString(36).slice(2, 8);
76
77
  }
78
+ /**
79
+ * v0.27.0 M3 — render the question body as organized Telegram HTML (bold header,
80
+ * numbered options with italic descriptions). All dynamic text is HTML-escaped
81
+ * (the send path uses parse_mode HTML since M1, so a stray < in a question would
82
+ * otherwise break the message). Mirrors the CLI's clean option layout.
83
+ */
77
84
  function bodyText(s) {
78
- const lines = [`❓ ${s.header}`, s.question];
79
- // v0.26.4 polish — list options with their descriptions in the body (buttons
80
- // only show the label, so descriptions would otherwise be lost).
85
+ const lines = [`❓ <b>${escapeHtml(s.header)}</b>`, escapeHtml(s.question)];
86
+ // List options with their descriptions in the body (buttons only show the
87
+ // label, so descriptions would otherwise be lost).
81
88
  const described = s.options.filter((o) => o.description);
82
89
  if (described.length > 0) {
83
90
  lines.push('');
84
91
  s.options.forEach((o, i) => {
85
- lines.push(o.description ? `${i + 1}. ${o.label} — ${o.description}` : `${i + 1}. ${o.label}`);
92
+ const n = `<b>${i + 1}.</b>`;
93
+ lines.push(o.description
94
+ ? `${n} ${escapeHtml(o.label)} — <i>${escapeHtml(o.description)}</i>`
95
+ : `${n} ${escapeHtml(o.label)}`);
86
96
  });
87
97
  }
88
98
  if (s.multiSelect)
89
- lines.push('', '(select one or more, then Submit)');
99
+ lines.push('', '<i>(select one or more, then Submit)</i>');
90
100
  return lines.join('\n');
91
101
  }
102
+ /** v0.27.0 M3 — HTML-safe "you chose" confirmation shown after a tap. */
103
+ function chosenText(s, chosen) {
104
+ return `❓ <b>${escapeHtml(s.header)}</b>\n${escapeHtml(s.question)}\n\n✓ You chose: <b>${escapeHtml(chosen)}</b>`;
105
+ }
92
106
  /** Build the inline keyboard for a question. callback_data is
93
107
  * `ccmirror:<CallbackMap id>` so the gateway routes taps to our handler. */
94
108
  function buildKeyboard(qid, s) {
@@ -216,7 +230,7 @@ export async function handleTap(ctx, api) {
216
230
  if (payload.kind === 'opt' && !s.multiSelect) {
217
231
  const label = s.options[payload.idx]?.label ?? '';
218
232
  await answerCb(ctx, `Selected: ${label}`);
219
- await editQ(s, `❓ ${s.header}\n${s.question}\n\n✓ You chose: ${label}`);
233
+ await editQ(s, chosenText(s, label));
220
234
  injectAnswer(api, ctx, label);
221
235
  _questions.delete(payload.qid);
222
236
  return;
@@ -237,7 +251,7 @@ export async function handleTap(ctx, api) {
237
251
  return;
238
252
  }
239
253
  await answerCb(ctx, `Submitted ${labels.length}`);
240
- await editQ(s, `❓ ${s.header}\n${s.question}\n\n✓ You chose: ${labels.join(', ')}`);
254
+ await editQ(s, chosenText(s, labels.join(', ')));
241
255
  injectAnswer(api, ctx, labels.join(', '));
242
256
  _questions.delete(payload.qid);
243
257
  return;
@@ -14,17 +14,11 @@
14
14
  * Tests live at tests/channels/telegram-mirror/m2-render-pipeline.test.ts.
15
15
  */
16
16
  import { renderStatusLine, renderMeters, renderTodos } from './status-line.js';
17
- import { escapeMarkdownV2 } from '../../lib/markdown-v2.js';
18
- import { markdownToMdv2 } from '../../lib/markdown-to-mdv2.js';
19
- /**
20
- * Wrap raw text in a Telegram MarkdownV2 inline code span. Inside a code span
21
- * only backtick and backslash need escaping (dots, hyphens, slashes render
22
- * literally) — which is exactly why monospace is ideal for file paths and shell
23
- * commands: no busy backslash-escaping, and they read like the CC terminal.
24
- */
25
- function code(s) {
26
- return '`' + s.replace(/[`\\]/g, '\\$&') + '`';
27
- }
17
+ import { escapeHtml, code, pre, markdownToHtml } from '../../lib/html-render.js';
18
+ // v0.27.0 M1 the card renders as Telegram HTML (parse_mode: 'HTML'), not
19
+ // MarkdownV2. HTML only needs `& < >` escaped (no per-char backslash litter)
20
+ // and supports <pre><code class="language-…"> + <blockquote> for true
21
+ // CLI-fidelity. escapeHtml / code() / markdownToHtml come from lib/html-render.
28
22
  /** Last path segment (basename) — keeps tool lines short and CLI-like, e.g.
29
23
  * "/home/a1/kris-kitchen/components/ShoppingList.tsx" → "ShoppingList.tsx". */
30
24
  function basename(p) {
@@ -73,6 +67,52 @@ function toolInputSummary(tc) {
73
67
  const collapsed = raw.replace(/\s+/g, ' ').trim();
74
68
  return collapsed.length > 48 ? collapsed.slice(0, 47) + '…' : collapsed;
75
69
  }
70
+ /**
71
+ * v0.27.0 M2 — coerce a tool's RESULT payload to display text. Claude delivers
72
+ * tool_result content as a string OR an array of `{type:'text',text}` blocks
73
+ * (via the persistent-session user-message extraction). Returns trimmed text, or
74
+ * '' when there's nothing real to show (no-fake-data — never stringify objects).
75
+ */
76
+ function toolResultText(tc) {
77
+ const r = tc.result;
78
+ if (r == null)
79
+ return '';
80
+ let s = '';
81
+ if (typeof r === 'string') {
82
+ s = r;
83
+ }
84
+ else if (Array.isArray(r)) {
85
+ s = r
86
+ .map((b) => {
87
+ if (typeof b === 'string')
88
+ return b;
89
+ if (b && typeof b === 'object' && typeof b.text === 'string') {
90
+ return b.text;
91
+ }
92
+ return '';
93
+ })
94
+ .join('');
95
+ }
96
+ return s.trim();
97
+ }
98
+ /**
99
+ * v0.27.0 M2 — truncate result text for the card: at most RESULT_MAX_LINES lines
100
+ * and RESULT_MAX_CHARS chars, with a trailing "…" when cut. Keeps the card
101
+ * readable (Telegram's 4096-char message cap) while still surfacing tool output
102
+ * the way the CLI shows it inline.
103
+ */
104
+ const RESULT_MAX_LINES = 3;
105
+ const RESULT_MAX_CHARS = 200;
106
+ function truncateResult(s) {
107
+ const lines = s.split('\n');
108
+ let cut = lines.length > RESULT_MAX_LINES;
109
+ let out = lines.slice(0, RESULT_MAX_LINES).join('\n');
110
+ if (out.length > RESULT_MAX_CHARS) {
111
+ out = out.slice(0, RESULT_MAX_CHARS);
112
+ cut = true;
113
+ }
114
+ return cut ? out.replace(/\s+$/, '') + '\n…' : out;
115
+ }
76
116
  /**
77
117
  * Format one tool-call line for inclusion in the rendered card body.
78
118
  * "✓ Bash · ls -la"
@@ -112,9 +152,9 @@ export function toolIcon(name) {
112
152
  export function renderToolLine(tc) {
113
153
  const glyph = toolGlyph(tc);
114
154
  const icon = toolIcon(tc.name);
115
- // Status glyph + emoji are MarkdownV2-safe; the tool name is escaped and the
116
- // input detail rides in a monospace code span (v0.26.5 styling).
117
- const name = escapeMarkdownV2(tc.name);
155
+ // Status glyph + emoji are HTML-safe; the tool name is HTML-escaped and the
156
+ // input detail rides in a <code> span (v0.27.0 M1 HTML styling).
157
+ const name = escapeHtml(tc.name);
118
158
  const summary = toolInputSummary(tc);
119
159
  return summary ? `${glyph} ${icon} ${name} · ${code(summary)}` : `${glyph} ${icon} ${name}`;
120
160
  }
@@ -128,11 +168,14 @@ export function renderThinkingBlock(thinkingText) {
128
168
  const trimmed = thinkingText.trim();
129
169
  if (trimmed.length === 0)
130
170
  return '';
131
- // MarkdownV2 block quote: a leading ">" makes the line a quote; the line
132
- // content is escaped so reasoning punctuation can't break the parse. The
133
- // space after ">" is cosmetic (reads as an indented quote, terminal-style).
134
- const lines = trimmed.split('\n').map((l) => `> ${escapeMarkdownV2(l)}`);
135
- return ['💭 Thinking', ...lines].join('\n');
171
+ // v0.27.0 M1 Telegram HTML <blockquote> renders the reasoning as a single
172
+ // indented quote (terminal-style), distinct from the assistant text. Content
173
+ // is HTML-escaped; newlines are preserved inside the blockquote.
174
+ const body = trimmed
175
+ .split('\n')
176
+ .map((l) => escapeHtml(l))
177
+ .join('\n');
178
+ return `💭 Thinking\n<blockquote>${body}</blockquote>`;
136
179
  }
137
180
  /**
138
181
  * Render the full turn into a Telegram-safe message body. The mirror keeps
@@ -164,29 +207,37 @@ export function renderTurn(turn, meta) {
164
207
  // handler calls setCardMeta(), the status line appears on the next repaint.
165
208
  if (meta) {
166
209
  const status = renderStatusLine(turn, meta);
167
- // v0.26.5 — bold the status line (the card header). Escaped first so the
168
- // model/version dots and brackets don't break the MarkdownV2 parse.
210
+ // v0.27.0 M1 — bold the status line (the card header) via HTML <b>. Content
211
+ // HTML-escaped first ( & < > only — model/version dots & brackets are safe).
169
212
  if (status)
170
- lines.push('*' + escapeMarkdownV2(status) + '*');
213
+ lines.push(`<b>${escapeHtml(status)}</b>`);
171
214
  // v0.26.2 M2 — meter row (context % + quota % + reset). Same no-fake-data
172
- // gating: omitted unless real values are present. Escaped (the bars/% are
173
- // safe but the escape is harmless and keeps the line parse-proof).
215
+ // gating: omitted unless real values are present. HTML-escaped for safety.
174
216
  const meters = renderMeters(meta);
175
217
  if (meters)
176
- lines.push(escapeMarkdownV2(meters));
218
+ lines.push(escapeHtml(meters));
177
219
  // v0.26.4 styling — divider between the status/telemetry block and the
178
220
  // activity block (only when a status block was actually rendered). The
179
- // heavy-bar glyph is not a MarkdownV2 special, so it needs no escaping.
221
+ // heavy-bar glyph is not HTML-significant, so it needs no escaping.
180
222
  if (lines.length > 0)
181
223
  lines.push('━━━━━━━━━━━━');
182
224
  }
183
- // Turn-status header. Kept plain (the glyph + word carry no MarkdownV2
184
- // specials) so it reads cleanly above the tool stream, terminal-style.
185
- const header = turn.state === 'done' ? '✓ Done' : '▶ Working';
225
+ // Turn-status header. v0.27.0 M4 a failed turn shows "❌ <reason>" so it
226
+ // never dies as an eternal "…"; reason is HTML-escaped (it's an error message).
227
+ const header = turn.state === 'failed'
228
+ ? `❌ ${escapeHtml(turn.failReason ?? 'Turn failed')}`
229
+ : turn.state === 'done'
230
+ ? '✓ Done'
231
+ : '▶ Working';
186
232
  lines.push(header);
187
233
  if (turn.toolCalls.length > 0) {
188
234
  for (const tc of turn.toolCalls) {
189
235
  lines.push(renderToolLine(tc));
236
+ // v0.27.0 M2 — surface the tool's RESULT as a truncated <pre><code> block
237
+ // under its line (CLI-style inline output), when there's real text.
238
+ const resultText = toolResultText(tc);
239
+ if (resultText)
240
+ lines.push(pre(truncateResult(resultText)));
190
241
  }
191
242
  }
192
243
  // v0.26.2 M4 — todo checklist (real data from the agent's TodoWrite calls).
@@ -195,7 +246,7 @@ export function renderTurn(turn, meta) {
195
246
  const todoBlock = renderTodos(meta);
196
247
  if (todoBlock) {
197
248
  lines.push('');
198
- lines.push(escapeMarkdownV2(todoBlock));
249
+ lines.push(escapeHtml(todoBlock));
199
250
  }
200
251
  // Defensive default — Turn.thinkingText is required by the interface, but
201
252
  // some test helpers and pre-M6 fixtures construct Turn-shaped objects
@@ -208,11 +259,10 @@ export function renderTurn(turn, meta) {
208
259
  const text = turn.assistantText.trim();
209
260
  if (text.length > 0) {
210
261
  lines.push('');
211
- // v0.26.5 — render the model's markdown as Telegram MarkdownV2 with
212
- // structure preserved (**bold**→*bold*, `code`, fences) instead of dumping
213
- // raw text whose unescaped specials forced the whole card to the plain-text
214
- // fallback. markdownToMdv2 escapes all remaining content per the MDV2 spec.
215
- lines.push(markdownToMdv2(text));
262
+ // v0.27.0 M1 — render the model's markdown as Telegram HTML with structure
263
+ // preserved (**bold**→<b>, `code`→<code>, ```bash fences→<pre><code
264
+ // class="language-bash">). HTML-escapes all remaining content (& < >).
265
+ lines.push(markdownToHtml(text));
216
266
  }
217
267
  return lines.join('\n');
218
268
  }
@@ -38,7 +38,7 @@ export interface StreamEvent {
38
38
  error?: unknown;
39
39
  result?: string;
40
40
  }
41
- export type TurnState = 'working' | 'done';
41
+ export type TurnState = 'working' | 'done' | 'failed';
42
42
  export interface ToolCallRecord {
43
43
  /** Tool name (e.g. "Bash", "Read", "Edit"). */
44
44
  name: string;
@@ -58,8 +58,11 @@ export interface Turn {
58
58
  state: TurnState;
59
59
  /** Monotonic timestamp (Date.now()) when the turn started. */
60
60
  startedAt: number;
61
- /** Set when the turn transitions to 'done'. */
61
+ /** Set when the turn transitions to 'done' or 'failed'. */
62
62
  endedAt?: number;
63
+ /** Set when the turn transitions to 'failed' (M4) — a short failure reason
64
+ * surfaced on the card so a turn never dies as an eternal "…". */
65
+ failReason?: string;
63
66
  /** Tool calls observed during the turn, in arrival order. */
64
67
  toolCalls: ToolCallRecord[];
65
68
  /** Accumulated assistant text (concatenation of text events). */
@@ -90,6 +93,13 @@ export declare class TurnStateMachine {
90
93
  * endedAt. Returns the finalized turn, or undefined if no turn was active.
91
94
  */
92
95
  end(chatId: string, now?: number): Turn | undefined;
96
+ /**
97
+ * Fail the active turn for chatId (M4). Transitions state to 'failed', stamps
98
+ * endedAt, and records a short reason so renderTurn can show "❌ <reason>"
99
+ * instead of leaving the card frozen at "…". Returns the failed turn, or
100
+ * undefined if no turn was active.
101
+ */
102
+ fail(chatId: string, reason: string, now?: number): Turn | undefined;
93
103
  /**
94
104
  * Returns the active or most-recently-ended turn for chatId.
95
105
  */
@@ -120,6 +120,21 @@ export class TurnStateMachine {
120
120
  turn.endedAt = now;
121
121
  return turn;
122
122
  }
123
+ /**
124
+ * Fail the active turn for chatId (M4). Transitions state to 'failed', stamps
125
+ * endedAt, and records a short reason so renderTurn can show "❌ <reason>"
126
+ * instead of leaving the card frozen at "…". Returns the failed turn, or
127
+ * undefined if no turn was active.
128
+ */
129
+ fail(chatId, reason, now = Date.now()) {
130
+ const turn = this.turns.get(chatId);
131
+ if (!turn)
132
+ return undefined;
133
+ turn.state = 'failed';
134
+ turn.failReason = reason;
135
+ turn.endedAt = now;
136
+ return turn;
137
+ }
123
138
  /**
124
139
  * Returns the active or most-recently-ended turn for chatId.
125
140
  */
@@ -80,6 +80,23 @@ export declare function pushAssistantText(text: string): void;
80
80
  * finalizes ALL active cards (one at a time in practice).
81
81
  */
82
82
  export declare function finalizeActiveCards(): Promise<void>;
83
+ /**
84
+ * Map a caught error to a short, user-readable card reason (v0.27.0 M4). Keeps
85
+ * the "❌ <reason>" header concise and classifies the common cc-openclaw-side
86
+ * failures (its own watchdog kill / dead subprocess / timeout) rather than
87
+ * dumping a raw stack.
88
+ */
89
+ export declare function classifyFailure(err: unknown): string;
90
+ /**
91
+ * Fail every active card (v0.27.0 M4). Called from the openai-compat handlers'
92
+ * catch path when cc-openclaw's OWN turn errors — its subprocess died, its
93
+ * stalled-session watchdog SIGTERMed it, or the model call threw/timed out. The
94
+ * card flips to "❌ <reason>" instead of being left as an eternal "…" (or worse,
95
+ * finalized to a misleading "✓ Done"). Scoped to cc-openclaw's own vantage; the
96
+ * gateway's recovery=none stalls that never throw here are out of M4's reach.
97
+ * Best-effort: a fail-render failure must not mask the original error.
98
+ */
99
+ export declare function failActiveCards(reason: string): Promise<void>;
83
100
  /**
84
101
  * Test-only — reset module-level repaint state. Card state lives in
85
102
  * card-state.ts so import that and clear directly if your test needs it.
@@ -274,6 +274,55 @@ export async function finalizeActiveCards() {
274
274
  cardState.delete(chatId);
275
275
  }
276
276
  }
277
+ /**
278
+ * Map a caught error to a short, user-readable card reason (v0.27.0 M4). Keeps
279
+ * the "❌ <reason>" header concise and classifies the common cc-openclaw-side
280
+ * failures (its own watchdog kill / dead subprocess / timeout) rather than
281
+ * dumping a raw stack.
282
+ */
283
+ export function classifyFailure(err) {
284
+ const msg = (err instanceof Error ? err.message : String(err ?? '')) || 'unknown error';
285
+ if (/timed out|timeout/i.test(msg))
286
+ return 'Turn timed out';
287
+ if (/not ready|closed|exited|spawn|ENOENT|killed|SIGTERM|stalled/i.test(msg))
288
+ return 'Subprocess died';
289
+ return `Turn failed: ${msg.slice(0, 60)}`;
290
+ }
291
+ /**
292
+ * Fail every active card (v0.27.0 M4). Called from the openai-compat handlers'
293
+ * catch path when cc-openclaw's OWN turn errors — its subprocess died, its
294
+ * stalled-session watchdog SIGTERMed it, or the model call threw/timed out. The
295
+ * card flips to "❌ <reason>" instead of being left as an eternal "…" (or worse,
296
+ * finalized to a misleading "✓ Done"). Scoped to cc-openclaw's own vantage; the
297
+ * gateway's recovery=none stalls that never throw here are out of M4's reach.
298
+ * Best-effort: a fail-render failure must not mask the original error.
299
+ */
300
+ export async function failActiveCards(reason) {
301
+ const chats = activeChatIds();
302
+ if (chats.length === 0)
303
+ return;
304
+ logPush('failActiveCards', chats.length, ` reason=${reason}`);
305
+ for (const chatId of chats) {
306
+ const card = cardState.get(chatId);
307
+ if (!card)
308
+ continue;
309
+ card.sm.fail(chatId, reason);
310
+ const turn = card.sm.getTurn(chatId);
311
+ if (turn) {
312
+ turn.assistantText = '';
313
+ try {
314
+ await editTg(chatId, card.messageId, renderTurn(turn, card.meta));
315
+ }
316
+ catch (err) {
317
+ const m = err.message;
318
+ if (!/message is not modified/i.test(m)) {
319
+ process.stderr.write(`[cc-openclaw/turn-bridge] fail edit failed chat=${chatId}: ${m}\n`);
320
+ }
321
+ }
322
+ }
323
+ cardState.delete(chatId);
324
+ }
325
+ }
277
326
  /**
278
327
  * Test-only — reset module-level repaint state. Card state lives in
279
328
  * card-state.ts so import that and clear directly if your test needs it.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * html-render — Telegram HTML parse_mode rendering (v0.27.0 M1).
3
+ *
4
+ * The CLI-fidelity rendering engine. Chosen over MarkdownV2 because Telegram's
5
+ * HTML mode only requires escaping `& < >` (no "every . - ! must be backslashed"
6
+ * fragility), and it supports `<pre><code class="language-bash">…</code></pre>` —
7
+ * labeled, monospaced code blocks that mirror the terminal — plus `<blockquote>`
8
+ * and `<tg-spoiler>`.
9
+ *
10
+ * Companion to markdown-v2.ts / markdown-to-mdv2.ts (the prior engine). This
11
+ * module is the HTML path the card-renderer + telegram-bot-api now use.
12
+ *
13
+ * Telegram-supported tags (Bot API): b/strong, i/em, u, s, a, code, pre,
14
+ * pre+code[class=language-X], blockquote, tg-spoiler. Everything else in text
15
+ * content must have &, <, > escaped.
16
+ */
17
+ /** Escape the three HTML-significant chars for Telegram HTML text content. */
18
+ export declare function escapeHtml(text: string | null | undefined): string;
19
+ /** Inline monospace span: `<code>escaped</code>`. */
20
+ export declare function code(s: string): string;
21
+ /**
22
+ * Fenced code block: `<pre><code class="language-LANG">escaped</code></pre>`.
23
+ * Omits the class when no language is given. Body is HTML-escaped (the only
24
+ * escaping Telegram requires inside pre/code).
25
+ */
26
+ 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
+ export declare function markdownToHtml(input: string | null | undefined): string;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * html-render — Telegram HTML parse_mode rendering (v0.27.0 M1).
3
+ *
4
+ * The CLI-fidelity rendering engine. Chosen over MarkdownV2 because Telegram's
5
+ * HTML mode only requires escaping `& < >` (no "every . - ! must be backslashed"
6
+ * fragility), and it supports `<pre><code class="language-bash">…</code></pre>` —
7
+ * labeled, monospaced code blocks that mirror the terminal — plus `<blockquote>`
8
+ * and `<tg-spoiler>`.
9
+ *
10
+ * Companion to markdown-v2.ts / markdown-to-mdv2.ts (the prior engine). This
11
+ * module is the HTML path the card-renderer + telegram-bot-api now use.
12
+ *
13
+ * Telegram-supported tags (Bot API): b/strong, i/em, u, s, a, code, pre,
14
+ * pre+code[class=language-X], blockquote, tg-spoiler. Everything else in text
15
+ * content must have &, <, > escaped.
16
+ */
17
+ /** Escape the three HTML-significant chars for Telegram HTML text content. */
18
+ export function escapeHtml(text) {
19
+ if (text == null)
20
+ return '';
21
+ return String(text)
22
+ .replace(/&/g, '&amp;')
23
+ .replace(/</g, '&lt;')
24
+ .replace(/>/g, '&gt;');
25
+ }
26
+ /** Inline monospace span: `<code>escaped</code>`. */
27
+ export function code(s) {
28
+ return `<code>${escapeHtml(s)}</code>`;
29
+ }
30
+ /**
31
+ * Fenced code block: `<pre><code class="language-LANG">escaped</code></pre>`.
32
+ * Omits the class when no language is given. Body is HTML-escaped (the only
33
+ * escaping Telegram requires inside pre/code).
34
+ */
35
+ export function pre(body, lang) {
36
+ const cls = lang ? ` class="language-${escapeHtml(lang)}"` : '';
37
+ return `<pre><code${cls}>${escapeHtml(body)}</code></pre>`;
38
+ }
39
+ /**
40
+ * Convert a subset of CommonMark Markdown to Telegram HTML with structure
41
+ * preserved (`**bold**`→`<b>`, fenced blocks→`<pre><code>`, etc.). Returns ''
42
+ * for null/undefined.
43
+ *
44
+ * Algorithm (placeholder substitution, NUL-sentinel keyed — mirrors
45
+ * markdown-to-mdv2.ts so behaviour is auditable side-by-side):
46
+ * 1. code fences → <pre><code [class]>…</code></pre>
47
+ * 2. inline code → <code>…</code>
48
+ * 3. bold (**…**) → <b>…</b>
49
+ * 4. italic (*…* / _…_) → <i>…</i>
50
+ * 5. ATX headers (#…) at line start → <b>…</b>
51
+ * 6. links [label](url) → <a href="url">label</a>
52
+ * 7. escape remaining text (& < >)
53
+ * 8. restore placeholders verbatim
54
+ */
55
+ export function markdownToHtml(input) {
56
+ if (input == null)
57
+ return '';
58
+ let text = String(input);
59
+ const codeBlocks = [];
60
+ text = text.replace(/```([a-zA-Z0-9_+\-]*)\n?([\s\S]*?)```/g, (_m, lang, body) => {
61
+ const idx = codeBlocks.length;
62
+ codeBlocks.push(pre(body.replace(/\n$/, ''), lang || undefined));
63
+ return `\x00CODEBLOCK${idx}\x00`;
64
+ });
65
+ const inlineCodes = [];
66
+ text = text.replace(/`([^`\n]+)`/g, (_m, body) => {
67
+ const idx = inlineCodes.length;
68
+ inlineCodes.push(code(body));
69
+ return `\x00INLINECODE${idx}\x00`;
70
+ });
71
+ const bolds = [];
72
+ text = text.replace(/\*\*([^*\n]+)\*\*/g, (_m, body) => {
73
+ const idx = bolds.length;
74
+ bolds.push(`<b>${escapeHtml(body)}</b>`);
75
+ return `\x00BOLD${idx}\x00`;
76
+ });
77
+ const italics = [];
78
+ text = text.replace(/(?<![\w*])\*([^*\n]+)\*(?!\w)/g, (_m, body) => {
79
+ const idx = italics.length;
80
+ italics.push(`<i>${escapeHtml(body)}</i>`);
81
+ return `\x00ITALIC${idx}\x00`;
82
+ });
83
+ text = text.replace(/(?<![\w_])_([^_\n]+)_(?!\w)/g, (_m, body) => {
84
+ const idx = italics.length;
85
+ italics.push(`<i>${escapeHtml(body)}</i>`);
86
+ return `\x00ITALIC${idx}\x00`;
87
+ });
88
+ const headers = [];
89
+ text = text.replace(/^(#{1,6})\s+(.+?)\s*#*\s*$/gm, (_m, _hashes, body) => {
90
+ const idx = headers.length;
91
+ headers.push(`<b>${escapeHtml(body)}</b>`);
92
+ return `\x00HEADER${idx}\x00`;
93
+ });
94
+ const links = [];
95
+ text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_m, label, url) => {
96
+ const idx = links.length;
97
+ // Attribute value: escape & < > and the double-quote that would close it.
98
+ const safeUrl = escapeHtml(url).replace(/"/g, '&quot;');
99
+ links.push(`<a href="${safeUrl}">${escapeHtml(label)}</a>`);
100
+ return `\x00LINK${idx}\x00`;
101
+ });
102
+ text = escapeHtml(text);
103
+ text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m, idx) => codeBlocks[Number(idx)]);
104
+ text = text.replace(/\x00INLINECODE(\d+)\x00/g, (_m, idx) => inlineCodes[Number(idx)]);
105
+ text = text.replace(/\x00BOLD(\d+)\x00/g, (_m, idx) => bolds[Number(idx)]);
106
+ text = text.replace(/\x00ITALIC(\d+)\x00/g, (_m, idx) => italics[Number(idx)]);
107
+ text = text.replace(/\x00HEADER(\d+)\x00/g, (_m, idx) => headers[Number(idx)]);
108
+ text = text.replace(/\x00LINK(\d+)\x00/g, (_m, idx) => links[Number(idx)]);
109
+ return text;
110
+ }
@@ -152,7 +152,7 @@ export async function sendTg(chatId, text, threadId, replyMarkup, replyToMessage
152
152
  base.reply_markup = replyMarkup;
153
153
  if (replyToMessageId)
154
154
  base.reply_to_message_id = Number(replyToMessageId);
155
- const res = await telegramApi('sendMessage', { ...base, text, parse_mode: 'MarkdownV2' });
155
+ const res = await telegramApi('sendMessage', { ...base, text, parse_mode: 'HTML' });
156
156
  if (res.ok)
157
157
  return res;
158
158
  return telegramApi('sendMessage', { ...base, text: text || 'Session update' });
@@ -171,7 +171,7 @@ export async function editTg(chatId, messageId, text, replyMarkup) {
171
171
  chat_id: chatId,
172
172
  message_id: messageId,
173
173
  text,
174
- parse_mode: 'MarkdownV2',
174
+ parse_mode: 'HTML',
175
175
  disable_web_page_preview: true,
176
176
  };
177
177
  if (replyMarkup)
@@ -27,7 +27,7 @@ import { getSurfaceThinkingEnabled, getTtsAutoMode } from '../lib/config.js';
27
27
  import { emit as emitTrajectory, emitTurnTrace } from '../lib/trajectory.js';
28
28
  import { formatError, ERROR_CODES } from '../lib/error-formatter.js';
29
29
  import { applyVoiceRecovery, _logVoiceDebug, detectVoiceIntent, hasTtsMarkers } from './voice-recovery.js';
30
- import { pushToolUse as mirrorPushToolUse, pushToolResult as mirrorPushToolResult, pushAssistantText as mirrorPushAssistantText, finalizeActiveCards as mirrorFinalizeActiveCards, setCardMeta as mirrorSetCardMeta, readQuotaMeta as mirrorReadQuotaMeta, } from '../channels/telegram-mirror/turn-bridge.js';
30
+ import { pushToolUse as mirrorPushToolUse, pushToolResult as mirrorPushToolResult, pushAssistantText as mirrorPushAssistantText, finalizeActiveCards as mirrorFinalizeActiveCards, failActiveCards as mirrorFailActiveCards, classifyFailure, setCardMeta as mirrorSetCardMeta, readQuotaMeta as mirrorReadQuotaMeta, } from '../channels/telegram-mirror/turn-bridge.js';
31
31
  import { cardStateDebug as mirrorCardStateDebug } from '../channels/telegram-mirror/card-state.js';
32
32
  /** Coerce a userMessage (string | UserMessageBlock[]) to a flat string
33
33
  * for voice-intent detection. Tool-result blocks aren't user prompts. */
@@ -201,6 +201,14 @@ slashCommand) {
201
201
  }
202
202
  catch (err) {
203
203
  reportStatus('idle', 'Request failed');
204
+ // v0.27.0 M4 — surface the failure on the Telegram card (❌ <reason>) so a
205
+ // cc-openclaw-side error never leaves the card frozen at "…". Best-effort.
206
+ try {
207
+ await mirrorFailActiveCards(classifyFailure(err));
208
+ }
209
+ catch {
210
+ /* card fail is cosmetic */
211
+ }
204
212
  // v0.4.3: route through formatError for errors_total + trajectory error.
205
213
  formatError(err, { code: ERROR_CODES.SESSION_ERROR, sessionId: sessionName, details: { phase: 'handleNonStreaming' } });
206
214
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -43,7 +43,7 @@ import { emit as emitTrajectory, emitTurnTrace } from '../lib/trajectory.js';
43
43
  import { formatError, ERROR_CODES } from '../lib/error-formatter.js';
44
44
  import { getSurfaceThinkingEnabled, getTtsAutoMode } from '../lib/config.js';
45
45
  import { applyVoiceRecovery, detectVoiceIntent, hasTtsMarkers, _logVoiceDebug } from './voice-recovery.js';
46
- import { pushToolUse as mirrorPushToolUse, pushToolResult as mirrorPushToolResult, pushAssistantText as mirrorPushAssistantText, finalizeActiveCards as mirrorFinalizeActiveCards, setCardMeta as mirrorSetCardMeta, readQuotaMeta as mirrorReadQuotaMeta, } from '../channels/telegram-mirror/turn-bridge.js';
46
+ import { pushToolUse as mirrorPushToolUse, pushToolResult as mirrorPushToolResult, pushAssistantText as mirrorPushAssistantText, finalizeActiveCards as mirrorFinalizeActiveCards, failActiveCards as mirrorFailActiveCards, classifyFailure, setCardMeta as mirrorSetCardMeta, readQuotaMeta as mirrorReadQuotaMeta, } from '../channels/telegram-mirror/turn-bridge.js';
47
47
  import { cardStateDebug as mirrorCardStateDebug } from '../channels/telegram-mirror/card-state.js';
48
48
  import { writePerfEvent } from '../observability/perf-telemetry.js';
49
49
  /** Coerce a userMessage (string | UserMessageBlock[]) to a flat string
@@ -532,6 +532,15 @@ slashCommand) {
532
532
  }
533
533
  catch (err) {
534
534
  reportStatus('idle', 'Request failed');
535
+ // v0.27.0 M4 — surface the failure on the Telegram card (❌ <reason>) so a
536
+ // cc-openclaw-side error never leaves the card frozen at "…" or finalized to
537
+ // a misleading "✓ Done". Best-effort; never mask the original error.
538
+ try {
539
+ await mirrorFailActiveCards(classifyFailure(err));
540
+ }
541
+ catch {
542
+ /* card fail is cosmetic */
543
+ }
535
544
  // v0.4.3: route through formatError for errors_total + trajectory error.
536
545
  formatError(err, { code: ERROR_CODES.SESSION_ERROR, sessionId: sessionName, details: { phase: 'handleStreaming' } });
537
546
  writeSSE(JSON.stringify({ error: { message: err.message, type: 'server_error' } }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a1hvdy/cc-openclaw",
3
- "version": "0.26.6",
3
+ "version": "0.27.0",
4
4
  "description": "A1xAI's Anthropic CLI bridge plugin for OpenClaw",
5
5
  "author": "@a1cy",
6
6
  "license": "MIT",