@a1hvdy/cc-openclaw 0.27.1 → 0.27.2
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 +2 -0
- package/dist/src/channels/telegram-mirror/commands.js +19 -12
- package/dist/src/channels/telegram-mirror/inbound-handler.d.ts +18 -0
- package/dist/src/channels/telegram-mirror/inbound-handler.js +20 -7
- package/dist/src/lib/probes.d.ts +50 -0
- package/dist/src/lib/probes.js +96 -0
- package/dist/src/lib/telegram-bot-api.d.ts +30 -0
- package/dist/src/lib/telegram-bot-api.js +87 -0
- package/dist/src/openai-compat/message-extractor.js +4 -0
- package/package.json +1 -1
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { CallbackMap } from './callback-mapping.js';
|
|
24
24
|
import { sendTg, editTg, telegramApi } from '../../lib/telegram-bot-api.js';
|
|
25
25
|
import { escapeHtml } from '../../lib/html-render.js';
|
|
26
|
+
import { probeInjectionEnqueued } from '../../lib/probes.js';
|
|
26
27
|
/** Namespace prefix for callback_data so api.registerInteractiveHandler routes
|
|
27
28
|
* taps here. Matched at the first ':' by the gateway (must be [A-Za-z0-9._-]+). */
|
|
28
29
|
export const ASKUSER_NS = 'ccmirror';
|
|
@@ -165,6 +166,7 @@ function injectAnswer(api, ctx, text) {
|
|
|
165
166
|
return;
|
|
166
167
|
}
|
|
167
168
|
try {
|
|
169
|
+
probeInjectionEnqueued(sessionKey, 'askuser'); // P0-A (observe-only, gated)
|
|
168
170
|
api.enqueueNextTurnInjection({
|
|
169
171
|
sessionKey,
|
|
170
172
|
text: `[User answered the AskUserQuestion]: ${text}`,
|
|
@@ -98,17 +98,21 @@ export function handleNew(ctx) {
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
const existing = getBySlug(slug);
|
|
101
|
-
// Session-name comes from the engine
|
|
101
|
+
// Session-name comes from the engine when a real turn fronts this slug; until
|
|
102
|
+
// then we store a placeholder. The registry IS the real state that /sessions
|
|
103
|
+
// and /status read — so the confirmation reflects the actual registry count
|
|
104
|
+
// (planning D-5/D-2), not a false "engine wire-up lands in M5" promise.
|
|
102
105
|
const sessionName = existing?.sessionName ?? `pending-${slug}-${Date.now()}`;
|
|
103
106
|
register(slug, sessionName);
|
|
107
|
+
const total = list().length;
|
|
104
108
|
return {
|
|
105
109
|
actions: [
|
|
106
110
|
{
|
|
107
111
|
type: 'sendMessage',
|
|
108
112
|
chat_id: ctx.chatId,
|
|
109
113
|
text: existing
|
|
110
|
-
? `Session "${slug}" already registered
|
|
111
|
-
: `Session "${slug}" registered.
|
|
114
|
+
? `Session "${slug}" already registered (${total} total). Open it from /sessions.`
|
|
115
|
+
: `Session "${slug}" registered (${total} total). Open it from /sessions.`,
|
|
112
116
|
},
|
|
113
117
|
],
|
|
114
118
|
};
|
|
@@ -128,13 +132,14 @@ export function handleStop(ctx) {
|
|
|
128
132
|
};
|
|
129
133
|
}
|
|
130
134
|
const removed = unregister(slug);
|
|
135
|
+
const remaining = list().length;
|
|
131
136
|
return {
|
|
132
137
|
actions: [
|
|
133
138
|
{
|
|
134
139
|
type: 'sendMessage',
|
|
135
140
|
chat_id: ctx.chatId,
|
|
136
141
|
text: removed
|
|
137
|
-
? `Session "${slug}" stopped.`
|
|
142
|
+
? `Session "${slug}" stopped (${remaining} remaining).`
|
|
138
143
|
: `No registered session named "${slug}".`,
|
|
139
144
|
},
|
|
140
145
|
],
|
|
@@ -166,16 +171,17 @@ export function handleStatus(ctx) {
|
|
|
166
171
|
}
|
|
167
172
|
// ── /compact ─────────────────────────────────────────────────────────────
|
|
168
173
|
export function handleCompact(ctx) {
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
174
|
+
// D-6 (planning): honest stub. The Telegram bridge has no session-control
|
|
175
|
+
// primitive to trigger context compaction on the running session — the only
|
|
176
|
+
// plugin levers are enqueueNextTurnInjection (text, next-turn only) and
|
|
177
|
+
// registerInteractiveHandler. So /compact is CLI-only until/unless OpenClaw
|
|
178
|
+
// exposes a control primitive. Claiming "queued" would be a lie (it never runs).
|
|
173
179
|
return {
|
|
174
180
|
actions: [
|
|
175
181
|
{
|
|
176
182
|
type: 'sendMessage',
|
|
177
183
|
chat_id: ctx.chatId,
|
|
178
|
-
text:
|
|
184
|
+
text: "⚠️ /compact is CLI-only — the Telegram bridge can't trigger context compaction (no session-control primitive). Run it from Claude Code directly.",
|
|
179
185
|
},
|
|
180
186
|
],
|
|
181
187
|
};
|
|
@@ -212,14 +218,15 @@ export function handleCost(ctx) {
|
|
|
212
218
|
}
|
|
213
219
|
// ── /rewind ──────────────────────────────────────────────────────────────
|
|
214
220
|
export function handleRewind(ctx) {
|
|
215
|
-
//
|
|
216
|
-
//
|
|
221
|
+
// D-6 (planning): honest stub — same rationale as /compact. Rewinding a
|
|
222
|
+
// running session needs a session-control primitive the plugin can't reach;
|
|
223
|
+
// it's CLI-only. "Queued" would never actually run, so we say so plainly.
|
|
217
224
|
return {
|
|
218
225
|
actions: [
|
|
219
226
|
{
|
|
220
227
|
type: 'sendMessage',
|
|
221
228
|
chat_id: ctx.chatId,
|
|
222
|
-
text:
|
|
229
|
+
text: "⚠️ /rewind is CLI-only — the Telegram bridge can't rewind a session (no session-control primitive). Run it from Claude Code directly.",
|
|
223
230
|
},
|
|
224
231
|
],
|
|
225
232
|
};
|
|
@@ -22,9 +22,14 @@
|
|
|
22
22
|
* Single shared CallbackMap + ComposeBuffer per process so callback
|
|
23
23
|
* resolution and compose state survive across handler invocations.
|
|
24
24
|
*/
|
|
25
|
+
import { type TelegramAction } from './commands.js';
|
|
25
26
|
import { CallbackMap } from './callback-mapping.js';
|
|
26
27
|
import { ComposeBuffer } from './compose-buffer.js';
|
|
27
28
|
import { type InteractiveCtx, type InjectApi } from './askuser.js';
|
|
29
|
+
interface InboundLogger {
|
|
30
|
+
info: (msg: string) => void;
|
|
31
|
+
warn: (msg: string) => void;
|
|
32
|
+
}
|
|
28
33
|
export interface InboundHandlerApi {
|
|
29
34
|
on(event: string, handler: (...args: unknown[]) => unknown | Promise<unknown>): void;
|
|
30
35
|
logger?: {
|
|
@@ -50,6 +55,19 @@ export interface HandlerState {
|
|
|
50
55
|
composeBuffer: ComposeBuffer;
|
|
51
56
|
}
|
|
52
57
|
export declare function createHandlerState(): HandlerState;
|
|
58
|
+
/**
|
|
59
|
+
* Forward a single TelegramAction to the actual Telegram API. Returns
|
|
60
|
+
* the API response (or {ok:false} on failure). Pure I/O — no state
|
|
61
|
+
* mutation. Exported for unit testing (planning M-B/B2).
|
|
62
|
+
*
|
|
63
|
+
* planning M-B/B2 (D-3): the `sendDocument` branch is now wired to the
|
|
64
|
+
* multipart `sendDocumentTg` helper (was a no-op warn). NOTE: the PRODUCER of
|
|
65
|
+
* sendDocument actions — ExitPlanMode detection → buildPlanAttachment — is
|
|
66
|
+
* milestone M-B/B3, deferred pending probe P0-B. Until B3 lands this branch is
|
|
67
|
+
* dormant forwarding infrastructure, not yet a user-reachable feature.
|
|
68
|
+
*/
|
|
69
|
+
export declare function forwardAction(action: TelegramAction, threadId: number | undefined, logger: InboundLogger): Promise<void>;
|
|
53
70
|
/** Test-only — reset module-level dispatch + card state. */
|
|
54
71
|
export declare function _resetSubscriptionForTests(): void;
|
|
55
72
|
export declare function registerInboundHandler(api: InboundHandlerApi, state?: HandlerState): HandlerState;
|
|
73
|
+
export {};
|
|
@@ -23,13 +23,14 @@
|
|
|
23
23
|
* resolution and compose state survive across handler invocations.
|
|
24
24
|
*/
|
|
25
25
|
import { dispatchCommand, parseSlash, COMMAND_HANDLERS } from './commands.js';
|
|
26
|
-
import { sendTg, editTg } from '../../lib/telegram-bot-api.js';
|
|
26
|
+
import { sendTg, editTg, sendDocumentTg } from '../../lib/telegram-bot-api.js';
|
|
27
27
|
import { CallbackMap } from './callback-mapping.js';
|
|
28
28
|
import { ComposeBuffer } from './compose-buffer.js';
|
|
29
29
|
import { TurnStateMachine } from './state-machine.js';
|
|
30
30
|
import { renderTurn } from './card-renderer.js';
|
|
31
31
|
import { cardState as _cardState, cardStateDebug } from './card-state.js';
|
|
32
32
|
import { handleTap, handleTapData, isAskUserCallback, rememberSessionKey, ASKUSER_NS, } from './askuser.js';
|
|
33
|
+
import { probeInboundShape, probeToolUse } from '../../lib/probes.js';
|
|
33
34
|
const PLUGIN_TAG = '[cc-openclaw/telegram-mirror/inbound]';
|
|
34
35
|
// v0.26.3 M5 — register the AskUserQuestion interactive tap handler exactly
|
|
35
36
|
// once per process (registerInboundHandler runs on every register() call).
|
|
@@ -76,12 +77,15 @@ const MIRROR_COMMANDS = new Set(Object.keys(COMMAND_HANDLERS));
|
|
|
76
77
|
/**
|
|
77
78
|
* Forward a single TelegramAction to the actual Telegram API. Returns
|
|
78
79
|
* the API response (or {ok:false} on failure). Pure I/O — no state
|
|
79
|
-
* mutation.
|
|
80
|
+
* mutation. Exported for unit testing (planning M-B/B2).
|
|
80
81
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
82
|
+
* planning M-B/B2 (D-3): the `sendDocument` branch is now wired to the
|
|
83
|
+
* multipart `sendDocumentTg` helper (was a no-op warn). NOTE: the PRODUCER of
|
|
84
|
+
* sendDocument actions — ExitPlanMode detection → buildPlanAttachment — is
|
|
85
|
+
* milestone M-B/B3, deferred pending probe P0-B. Until B3 lands this branch is
|
|
86
|
+
* dormant forwarding infrastructure, not yet a user-reachable feature.
|
|
83
87
|
*/
|
|
84
|
-
async function forwardAction(action, threadId, logger) {
|
|
88
|
+
export async function forwardAction(action, threadId, logger) {
|
|
85
89
|
try {
|
|
86
90
|
if (action.type === 'sendMessage') {
|
|
87
91
|
await sendTg(String(action.chat_id), action.text, threadId !== undefined ? String(threadId) : undefined, action.reply_markup);
|
|
@@ -91,8 +95,15 @@ async function forwardAction(action, threadId, logger) {
|
|
|
91
95
|
await editTg(String(action.chat_id), action.message_id, action.text, action.reply_markup);
|
|
92
96
|
return;
|
|
93
97
|
}
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
if (action.type === 'sendDocument') {
|
|
99
|
+
await sendDocumentTg(String(action.chat_id), action.filename, action.content, {
|
|
100
|
+
caption: action.caption,
|
|
101
|
+
replyMarkup: action.reply_markup,
|
|
102
|
+
threadId,
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
logger.warn(`${PLUGIN_TAG} action type "${action.type}" not forwarded (no handler)`);
|
|
96
107
|
}
|
|
97
108
|
catch (err) {
|
|
98
109
|
logger.warn(`${PLUGIN_TAG} forwardAction failed: ${err.message}`);
|
|
@@ -205,6 +216,7 @@ export function registerInboundHandler(api, state = createHandlerState()) {
|
|
|
205
216
|
// Per-event-id dedup at dispatch layer (purpose=slash).
|
|
206
217
|
if (_seenOrMark('slash', _eventId(event)))
|
|
207
218
|
return undefined;
|
|
219
|
+
probeInboundShape(event); // P0-C inbound surface (observe-only, gated)
|
|
208
220
|
// Extract text from the canonical (2026.5.x) `event.content` field;
|
|
209
221
|
// fall back to legacy nested paths if a future gateway version reverts.
|
|
210
222
|
const text = (typeof event.content === 'string' ? event.content : undefined) ??
|
|
@@ -413,6 +425,7 @@ export function registerInboundHandler(api, state = createHandlerState()) {
|
|
|
413
425
|
const event = args[0];
|
|
414
426
|
dumpShapeOnce('before_tool_call', event);
|
|
415
427
|
const ev = event;
|
|
428
|
+
probeToolUse(ev); // P0-B ExitPlanMode detection (observe-only, gated)
|
|
416
429
|
const evId = _eventId({ sessionKey: (ev?.sessionKey ?? ev?.ctx?.['SessionKey']), timestamp: ev?.timestamp, content: `tool_use:${String((ev?.id ?? ev?.tool_use_id ?? ''))}` });
|
|
417
430
|
if (_seenOrMark('tool_use', evId))
|
|
418
431
|
return undefined;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/lib/probes.ts — Phase-0 empirical probe instrumentation (planning P0-A/B/C).
|
|
3
|
+
*
|
|
4
|
+
* OBSERVE-ONLY. Gated by `CC_OPENCLAW_PROBE=1` so it is completely silent — zero
|
|
5
|
+
* behavior change, no log output — in normal operation. The operator (A1)
|
|
6
|
+
* enables it for a single probe session, exercises the relevant Telegram
|
|
7
|
+
* interaction, then greps stderr for the `[cc-openclaw/probe]` markers. The
|
|
8
|
+
* runbook (PROBES-RUNBOOK in the planning dir) has the exact steps + how to read
|
|
9
|
+
* the results.
|
|
10
|
+
*
|
|
11
|
+
* These resolve the load-bearing seams that CANNOT be read from source because
|
|
12
|
+
* they depend on OpenClaw gateway runtime behavior:
|
|
13
|
+
* P0-A: does `enqueueNextTurnInjection` trigger a run, or only stage context
|
|
14
|
+
* for the next user message? (decides feature #1 Approve + #3 /send)
|
|
15
|
+
* P0-B: does an `ExitPlanMode` tool_use ever fire on the bypassPermissions
|
|
16
|
+
* Telegram path? (decides feature #1's trigger)
|
|
17
|
+
* P0-C: does a Telegram photo reach the plugin as an image block — at
|
|
18
|
+
* before_dispatch and/or in the openai-compat request body (where
|
|
19
|
+
* message-extractor strips non-text parts) — or is it gateway-stripped?
|
|
20
|
+
* (decides whether feature #2 is plugin-side feasible at all)
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* P0-A — log each `enqueueNextTurnInjection` call site. The operator correlates
|
|
24
|
+
* this with whether a reply arrives in Telegram WITHOUT typing a follow-up
|
|
25
|
+
* message: if it does, injection triggers a run; if not, it only stages context.
|
|
26
|
+
*/
|
|
27
|
+
export declare function probeInjectionEnqueued(sessionKey: string, source: string): void;
|
|
28
|
+
/**
|
|
29
|
+
* P0-B — log when a tool_use is `ExitPlanMode` (and any other tool name, for
|
|
30
|
+
* context). Looks across the known event field paths, mirroring
|
|
31
|
+
* inbound-handler.extractToolUse so it works whatever shape the gateway uses.
|
|
32
|
+
*/
|
|
33
|
+
export declare function probeToolUse(ev: Record<string, unknown> | undefined): void;
|
|
34
|
+
/**
|
|
35
|
+
* P0-C (inbound surface) — dump the before_dispatch event, flagging whether it
|
|
36
|
+
* carries any media field, so the operator sees whether photo/document surface
|
|
37
|
+
* to the plugin at all.
|
|
38
|
+
*/
|
|
39
|
+
export declare function probeInboundShape(event: unknown): void;
|
|
40
|
+
/**
|
|
41
|
+
* P0-C (openai-compat body) — the PRECISE probe. Does an image block survive to
|
|
42
|
+
* the request body, where `message-extractor.ts` strips non-text parts? Logs
|
|
43
|
+
* each non-text content-part type. If image parts appear here, feature #2 is
|
|
44
|
+
* feasible plugin-side (preserve them through extractUserMessage); if nothing
|
|
45
|
+
* non-text ever appears, the image is gateway-stripped upstream → hands-off-blocked.
|
|
46
|
+
*/
|
|
47
|
+
export declare function probeMultimodalContent(messages: Array<{
|
|
48
|
+
role?: string;
|
|
49
|
+
content?: unknown;
|
|
50
|
+
}> | undefined): void;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/lib/probes.ts — Phase-0 empirical probe instrumentation (planning P0-A/B/C).
|
|
3
|
+
*
|
|
4
|
+
* OBSERVE-ONLY. Gated by `CC_OPENCLAW_PROBE=1` so it is completely silent — zero
|
|
5
|
+
* behavior change, no log output — in normal operation. The operator (A1)
|
|
6
|
+
* enables it for a single probe session, exercises the relevant Telegram
|
|
7
|
+
* interaction, then greps stderr for the `[cc-openclaw/probe]` markers. The
|
|
8
|
+
* runbook (PROBES-RUNBOOK in the planning dir) has the exact steps + how to read
|
|
9
|
+
* the results.
|
|
10
|
+
*
|
|
11
|
+
* These resolve the load-bearing seams that CANNOT be read from source because
|
|
12
|
+
* they depend on OpenClaw gateway runtime behavior:
|
|
13
|
+
* P0-A: does `enqueueNextTurnInjection` trigger a run, or only stage context
|
|
14
|
+
* for the next user message? (decides feature #1 Approve + #3 /send)
|
|
15
|
+
* P0-B: does an `ExitPlanMode` tool_use ever fire on the bypassPermissions
|
|
16
|
+
* Telegram path? (decides feature #1's trigger)
|
|
17
|
+
* P0-C: does a Telegram photo reach the plugin as an image block — at
|
|
18
|
+
* before_dispatch and/or in the openai-compat request body (where
|
|
19
|
+
* message-extractor strips non-text parts) — or is it gateway-stripped?
|
|
20
|
+
* (decides whether feature #2 is plugin-side feasible at all)
|
|
21
|
+
*/
|
|
22
|
+
const TAG = '[cc-openclaw/probe]';
|
|
23
|
+
/** Read on every call so the operator can flip it without restarting. */
|
|
24
|
+
function probeOn() {
|
|
25
|
+
return process.env.CC_OPENCLAW_PROBE === '1';
|
|
26
|
+
}
|
|
27
|
+
function emit(line) {
|
|
28
|
+
// stderr so PM2 captures it regardless of stdout-only filtering.
|
|
29
|
+
process.stderr.write(`${TAG} ${line} ts=${Date.now()}\n`);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* P0-A — log each `enqueueNextTurnInjection` call site. The operator correlates
|
|
33
|
+
* this with whether a reply arrives in Telegram WITHOUT typing a follow-up
|
|
34
|
+
* message: if it does, injection triggers a run; if not, it only stages context.
|
|
35
|
+
*/
|
|
36
|
+
export function probeInjectionEnqueued(sessionKey, source) {
|
|
37
|
+
if (!probeOn())
|
|
38
|
+
return;
|
|
39
|
+
emit(`P0-A injection-enqueued source=${source} sessionKey=${sessionKey}`);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* P0-B — log when a tool_use is `ExitPlanMode` (and any other tool name, for
|
|
43
|
+
* context). Looks across the known event field paths, mirroring
|
|
44
|
+
* inbound-handler.extractToolUse so it works whatever shape the gateway uses.
|
|
45
|
+
*/
|
|
46
|
+
export function probeToolUse(ev) {
|
|
47
|
+
if (!probeOn() || !ev)
|
|
48
|
+
return;
|
|
49
|
+
const tool = ev.tool;
|
|
50
|
+
const name = ev.toolName ??
|
|
51
|
+
tool?.['name'] ??
|
|
52
|
+
ev.name;
|
|
53
|
+
if (name === 'ExitPlanMode')
|
|
54
|
+
emit('P0-B ExitPlanMode-fired');
|
|
55
|
+
else if (name)
|
|
56
|
+
emit(`P0-B tool_use name=${name}`);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* P0-C (inbound surface) — dump the before_dispatch event, flagging whether it
|
|
60
|
+
* carries any media field, so the operator sees whether photo/document surface
|
|
61
|
+
* to the plugin at all.
|
|
62
|
+
*/
|
|
63
|
+
export function probeInboundShape(event) {
|
|
64
|
+
if (!probeOn())
|
|
65
|
+
return;
|
|
66
|
+
try {
|
|
67
|
+
const ev = event;
|
|
68
|
+
const msg = ev?.raw?.message;
|
|
69
|
+
const hasMedia = !!(msg && (msg.photo || msg.document || msg.video || msg.voice || msg.audio || msg.sticker));
|
|
70
|
+
const dump = JSON.stringify(event, (_k, v) => (typeof v === 'function' ? '[fn]' : v));
|
|
71
|
+
emit(`P0-C before_dispatch hasMedia=${hasMedia} shape=${dump.slice(0, 1200)}`);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
emit(`P0-C inbound dump failed: ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* P0-C (openai-compat body) — the PRECISE probe. Does an image block survive to
|
|
79
|
+
* the request body, where `message-extractor.ts` strips non-text parts? Logs
|
|
80
|
+
* each non-text content-part type. If image parts appear here, feature #2 is
|
|
81
|
+
* feasible plugin-side (preserve them through extractUserMessage); if nothing
|
|
82
|
+
* non-text ever appears, the image is gateway-stripped upstream → hands-off-blocked.
|
|
83
|
+
*/
|
|
84
|
+
export function probeMultimodalContent(messages) {
|
|
85
|
+
if (!probeOn() || !messages)
|
|
86
|
+
return;
|
|
87
|
+
for (const m of messages) {
|
|
88
|
+
if (!Array.isArray(m.content))
|
|
89
|
+
continue;
|
|
90
|
+
const parts = m.content;
|
|
91
|
+
const nonText = parts.filter((p) => p && p.type && p.type !== 'text').map((p) => p.type);
|
|
92
|
+
if (nonText.length > 0) {
|
|
93
|
+
emit(`P0-C openai-body role=${m.role ?? '?'} nonTextParts=${nonText.join(',')}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -98,3 +98,33 @@ export declare function sendTg(chatId: string | number, text: string, threadId?:
|
|
|
98
98
|
* plain-text fallback.
|
|
99
99
|
*/
|
|
100
100
|
export declare function editTg(chatId: string | number, messageId: number, text: string, replyMarkup?: unknown): Promise<TelegramApiResponse>;
|
|
101
|
+
export interface SendDocumentOptions {
|
|
102
|
+
caption?: string;
|
|
103
|
+
parseMode?: 'HTML' | 'MarkdownV2';
|
|
104
|
+
threadId?: string | number;
|
|
105
|
+
replyMarkup?: unknown;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Build a multipart/form-data body for sendDocument. PURE — no I/O — so the
|
|
109
|
+
* encoding (the R-3 risk) is unit-testable without a network round-trip.
|
|
110
|
+
*
|
|
111
|
+
* The document is sent as an inline InputFile (Content-Type text/markdown). The
|
|
112
|
+
* boundary MUST NOT appear in any field value or the file content; callers use a
|
|
113
|
+
* random 16-byte boundary (sendDocumentTg) so collision is astronomically
|
|
114
|
+
* unlikely against Markdown plan bodies.
|
|
115
|
+
*/
|
|
116
|
+
export declare function buildDocumentMultipart(opts: {
|
|
117
|
+
boundary: string;
|
|
118
|
+
chatId: string | number;
|
|
119
|
+
filename: string;
|
|
120
|
+
content: string;
|
|
121
|
+
caption?: string;
|
|
122
|
+
parseMode?: 'HTML' | 'MarkdownV2';
|
|
123
|
+
threadId?: string | number;
|
|
124
|
+
replyMarkup?: unknown;
|
|
125
|
+
}): Buffer;
|
|
126
|
+
/**
|
|
127
|
+
* Upload a text document (e.g. a plan .md) to a chat via sendDocument. Returns
|
|
128
|
+
* the API response, or {ok:false} on network/encoding failure (never throws).
|
|
129
|
+
*/
|
|
130
|
+
export declare function sendDocumentTg(chatId: string | number, filename: string, content: string, opts?: SendDocumentOptions): Promise<TelegramApiResponse>;
|
|
@@ -27,6 +27,7 @@ import { request as httpsRequest } from 'node:https';
|
|
|
27
27
|
import { readFileSync } from 'node:fs';
|
|
28
28
|
import { homedir } from 'node:os';
|
|
29
29
|
import { join } from 'node:path';
|
|
30
|
+
import { randomBytes } from 'node:crypto';
|
|
30
31
|
export const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
|
31
32
|
const PLUGIN_TAG = '[cc-openclaw/telegram-bot-api]';
|
|
32
33
|
// ─── Bot token state ───────────────────────────────────────────────────────
|
|
@@ -201,3 +202,89 @@ export async function editTg(chatId, messageId, text, replyMarkup) {
|
|
|
201
202
|
return { ok: false };
|
|
202
203
|
}
|
|
203
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Build a multipart/form-data body for sendDocument. PURE — no I/O — so the
|
|
207
|
+
* encoding (the R-3 risk) is unit-testable without a network round-trip.
|
|
208
|
+
*
|
|
209
|
+
* The document is sent as an inline InputFile (Content-Type text/markdown). The
|
|
210
|
+
* boundary MUST NOT appear in any field value or the file content; callers use a
|
|
211
|
+
* random 16-byte boundary (sendDocumentTg) so collision is astronomically
|
|
212
|
+
* unlikely against Markdown plan bodies.
|
|
213
|
+
*/
|
|
214
|
+
export function buildDocumentMultipart(opts) {
|
|
215
|
+
const { boundary } = opts;
|
|
216
|
+
const parts = [];
|
|
217
|
+
const textField = (name, value) => {
|
|
218
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`, 'utf8'));
|
|
219
|
+
};
|
|
220
|
+
textField('chat_id', String(opts.chatId));
|
|
221
|
+
if (opts.caption)
|
|
222
|
+
textField('caption', opts.caption);
|
|
223
|
+
if (opts.parseMode)
|
|
224
|
+
textField('parse_mode', opts.parseMode);
|
|
225
|
+
if (opts.threadId !== undefined)
|
|
226
|
+
textField('message_thread_id', String(opts.threadId));
|
|
227
|
+
if (opts.replyMarkup)
|
|
228
|
+
textField('reply_markup', JSON.stringify(opts.replyMarkup));
|
|
229
|
+
// The document file part — header, then raw content, then CRLF.
|
|
230
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="document"; filename="${opts.filename}"\r\nContent-Type: text/markdown\r\n\r\n`, 'utf8'));
|
|
231
|
+
parts.push(Buffer.from(opts.content, 'utf8'));
|
|
232
|
+
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8'));
|
|
233
|
+
return Buffer.concat(parts);
|
|
234
|
+
}
|
|
235
|
+
/** Low-level multipart POST. Mirrors `telegramApi` but sets a multipart
|
|
236
|
+
* Content-Type + a Buffer body. */
|
|
237
|
+
function telegramApiMultipart(method, boundary, body) {
|
|
238
|
+
return new Promise((resolve, reject) => {
|
|
239
|
+
const options = {
|
|
240
|
+
hostname: 'api.telegram.org',
|
|
241
|
+
path: `/bot${_botToken}/${method}`,
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: {
|
|
244
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
245
|
+
'Content-Length': body.length,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
const req = httpsRequest(options, (res) => {
|
|
249
|
+
let data = '';
|
|
250
|
+
res.on('data', (chunk) => (data += chunk));
|
|
251
|
+
res.on('end', () => {
|
|
252
|
+
try {
|
|
253
|
+
resolve(JSON.parse(data));
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
resolve({ ok: false, description: 'JSON parse error' });
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
req.on('error', (err) => reject(err));
|
|
261
|
+
req.setTimeout(15_000, () => {
|
|
262
|
+
req.destroy(new Error('Telegram API timeout'));
|
|
263
|
+
});
|
|
264
|
+
req.write(body);
|
|
265
|
+
req.end();
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Upload a text document (e.g. a plan .md) to a chat via sendDocument. Returns
|
|
270
|
+
* the API response, or {ok:false} on network/encoding failure (never throws).
|
|
271
|
+
*/
|
|
272
|
+
export async function sendDocumentTg(chatId, filename, content, opts = {}) {
|
|
273
|
+
try {
|
|
274
|
+
const boundary = `----ccopenclaw${randomBytes(16).toString('hex')}`;
|
|
275
|
+
const body = buildDocumentMultipart({
|
|
276
|
+
boundary,
|
|
277
|
+
chatId,
|
|
278
|
+
filename,
|
|
279
|
+
content,
|
|
280
|
+
caption: opts.caption,
|
|
281
|
+
parseMode: opts.parseMode,
|
|
282
|
+
threadId: opts.threadId,
|
|
283
|
+
replyMarkup: opts.replyMarkup,
|
|
284
|
+
});
|
|
285
|
+
return await telegramApiMultipart('sendDocument', boundary, body);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return { ok: false };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -32,6 +32,7 @@ import { serializeToolResults, serializeToolResultsAsBlocks, } from './tool-resu
|
|
|
32
32
|
import { isToolStreamMode } from './mode-flags.js';
|
|
33
33
|
import { detectSlashCommand, maybeInlineSkill } from './skill-resolver.js';
|
|
34
34
|
import { isOpenaiCompatNewConvoHeuristic } from '../lib/config.js';
|
|
35
|
+
import { probeMultimodalContent } from '../lib/probes.js';
|
|
35
36
|
/**
|
|
36
37
|
* Extract the relevant parts from an OpenAI messages array.
|
|
37
38
|
*
|
|
@@ -59,6 +60,9 @@ export function extractUserMessage(messages, headers) {
|
|
|
59
60
|
if (!messages || messages.length === 0) {
|
|
60
61
|
throw new Error('messages array is empty');
|
|
61
62
|
}
|
|
63
|
+
// P0-C openai-body probe (observe-only, gated): does an image block survive to
|
|
64
|
+
// the request body before textOf() below strips non-text parts? See lib/probes.ts.
|
|
65
|
+
probeMultimodalContent(messages);
|
|
62
66
|
// Normalize content from any message: OpenAI API allows content as a string
|
|
63
67
|
// OR an array of content parts (e.g. multimodal messages with text + images).
|
|
64
68
|
// We need a string for the CLI, so arrays are joined.
|