@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.
- package/dist/src/channels/telegram-mirror/askuser.js +21 -7
- package/dist/src/channels/telegram-mirror/card-renderer.js +85 -35
- package/dist/src/channels/telegram-mirror/state-machine.d.ts +12 -2
- package/dist/src/channels/telegram-mirror/state-machine.js +15 -0
- package/dist/src/channels/telegram-mirror/turn-bridge.d.ts +17 -0
- package/dist/src/channels/telegram-mirror/turn-bridge.js +49 -0
- package/dist/src/lib/html-render.d.ts +43 -0
- package/dist/src/lib/html-render.js +110 -0
- package/dist/src/lib/telegram-bot-api.js +2 -2
- package/dist/src/openai-compat/non-streaming-handler.js +9 -1
- package/dist/src/openai-compat/streaming-handler.js +10 -1
- package/package.json +1 -1
|
@@ -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 = [`❓
|
|
79
|
-
//
|
|
80
|
-
//
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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 {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
116
|
-
// input detail rides in a
|
|
117
|
-
const 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
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
const
|
|
135
|
-
|
|
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.
|
|
168
|
-
// model/version dots
|
|
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(
|
|
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.
|
|
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(
|
|
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
|
|
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.
|
|
184
|
-
//
|
|
185
|
-
const header = turn.state === '
|
|
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(
|
|
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.
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
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, '&')
|
|
23
|
+
.replace(/</g, '<')
|
|
24
|
+
.replace(/>/g, '>');
|
|
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, '"');
|
|
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: '
|
|
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: '
|
|
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' } }));
|