@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.
- package/dist/src/channels/telegram-mirror/card-renderer.d.ts +8 -22
- package/dist/src/channels/telegram-mirror/card-renderer.js +227 -22
- package/dist/src/channels/telegram-mirror/commands.d.ts +16 -0
- package/dist/src/channels/telegram-mirror/commands.js +34 -0
- package/dist/src/channels/telegram-mirror/inbound-handler.js +1 -1
- package/dist/src/channels/telegram-mirror/turn-bridge.d.ts +12 -1
- package/dist/src/channels/telegram-mirror/turn-bridge.js +41 -2
- package/dist/src/constants.d.ts +27 -0
- package/dist/src/constants.js +28 -0
- package/dist/src/engines/persistent-session.d.ts +5 -0
- package/dist/src/engines/persistent-session.js +27 -0
- package/dist/src/lib/error-formatter.d.ts +14 -2
- package/dist/src/lib/error-formatter.js +23 -11
- package/dist/src/lib/error-renderer.js +3 -1
- package/dist/src/lib/html-render.d.ts +23 -16
- package/dist/src/lib/html-render.js +127 -1
- package/dist/src/lib/markdown-to-mdv2.js +2 -1
- package/dist/src/lib/telegram-bot-api.d.ts +22 -6
- package/dist/src/lib/telegram-bot-api.js +94 -14
- package/dist/src/openai-compat/non-streaming-handler.js +18 -1
- package/dist/src/openai-compat/openai-compat.js +61 -2
- package/dist/src/openai-compat/request-coalescer.d.ts +77 -0
- package/dist/src/openai-compat/request-coalescer.js +157 -0
- package/dist/src/openai-compat/streaming-handler.d.ts +9 -1
- package/dist/src/openai-compat/streaming-handler.js +40 -5
- package/dist/src/session/persisted-sessions.d.ts +11 -0
- package/dist/src/session/persisted-sessions.js +17 -0
- package/dist/src/session/session-manager.js +22 -6
- package/dist/src/session/watchdogs.d.ts +3 -0
- package/dist/src/session/watchdogs.js +6 -0
- package/dist/src/session-bootstrap/cwd-patch.js +1 -2
- package/dist/src/types.d.ts +11 -0
- package/package.json +1 -1
- package/dist/src/config/drift-detector.d.ts +0 -28
- package/dist/src/config/drift-detector.js +0 -74
- package/dist/src/lib/stale-pid-files.d.ts +0 -17
- package/dist/src/lib/stale-pid-files.js +0 -39
- package/dist/src/persistence/snapshot.d.ts +0 -18
- package/dist/src/persistence/snapshot.js +0 -31
- package/dist/src/persistence/wal.d.ts +0 -17
- package/dist/src/persistence/wal.js +0 -31
- package/dist/src/types/index.d.ts +0 -15
- package/dist/src/types/index.js +0 -15
- package/dist/src/types/session.d.ts +0 -48
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 =
|
|
133
|
-
const safeMsg =
|
|
134
|
-
const safeTs =
|
|
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}
|
|
137
|
-
|
|
138
|
-
|
|
148
|
+
`${emoji} <b>${safeCode}</b>`,
|
|
149
|
+
`<code>${safeMsg}</code>`,
|
|
150
|
+
`<i>${safeTs}</i>`,
|
|
139
151
|
];
|
|
140
152
|
if (context.sessionId) {
|
|
141
|
-
lines.push(`session:
|
|
153
|
+
lines.push(`session: <code>${escapeHtml(context.sessionId)}</code>`);
|
|
142
154
|
}
|
|
143
155
|
if (context.laptopId) {
|
|
144
|
-
lines.push(`laptop:
|
|
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]) => `${
|
|
161
|
+
.map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}`)
|
|
150
162
|
.join(', ');
|
|
151
|
-
lines.push(
|
|
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
|
-
|
|
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(/</g, '<')
|
|
44
|
+
.replace(/>/g, '>')
|
|
45
|
+
.replace(/"/g, '"')
|
|
46
|
+
.replace(/&/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_
|
|
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_
|
|
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
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
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
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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:
|
|
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
|
}
|