@lobu/worker 6.1.1 → 7.1.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/core/error-handler.d.ts +0 -4
- package/dist/core/error-handler.d.ts.map +1 -1
- package/dist/core/error-handler.js +4 -15
- package/dist/core/error-handler.js.map +1 -1
- package/dist/core/types.d.ts +1 -19
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +0 -4
- package/dist/core/types.js.map +1 -1
- package/dist/core/workspace.d.ts +2 -11
- package/dist/core/workspace.d.ts.map +1 -1
- package/dist/core/workspace.js +14 -36
- package/dist/core/workspace.js.map +1 -1
- package/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
- package/dist/embedded/just-bash-bootstrap.js +60 -6
- package/dist/embedded/just-bash-bootstrap.js.map +1 -1
- package/dist/embedded/mcp-cli-commands.d.ts.map +1 -1
- package/dist/embedded/mcp-cli-commands.js +3 -38
- package/dist/embedded/mcp-cli-commands.js.map +1 -1
- package/dist/gateway/gateway-integration.js +4 -4
- package/dist/gateway/gateway-integration.js.map +1 -1
- package/dist/gateway/message-batcher.d.ts.map +1 -1
- package/dist/gateway/message-batcher.js +3 -5
- package/dist/gateway/message-batcher.js.map +1 -1
- package/dist/gateway/sse-client.d.ts +1 -0
- package/dist/gateway/sse-client.d.ts.map +1 -1
- package/dist/gateway/sse-client.js +52 -8
- package/dist/gateway/sse-client.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -24
- package/dist/index.js.map +1 -1
- package/dist/instructions/builder.d.ts.map +1 -1
- package/dist/instructions/builder.js +2 -1
- package/dist/instructions/builder.js.map +1 -1
- package/dist/openclaw/plugin-loader.d.ts.map +1 -1
- package/dist/openclaw/plugin-loader.js +8 -19
- package/dist/openclaw/plugin-loader.js.map +1 -1
- package/dist/openclaw/processor.d.ts.map +1 -1
- package/dist/openclaw/processor.js +2 -0
- package/dist/openclaw/processor.js.map +1 -1
- package/dist/openclaw/sandbox-leak.d.ts.map +1 -1
- package/dist/openclaw/sandbox-leak.js +1 -6
- package/dist/openclaw/sandbox-leak.js.map +1 -1
- package/dist/openclaw/session-context.d.ts.map +1 -1
- package/dist/openclaw/session-context.js +3 -0
- package/dist/openclaw/session-context.js.map +1 -1
- package/dist/openclaw/tool-policy.d.ts.map +1 -1
- package/dist/openclaw/tool-policy.js +5 -11
- package/dist/openclaw/tool-policy.js.map +1 -1
- package/dist/openclaw/worker.d.ts +0 -1
- package/dist/openclaw/worker.d.ts.map +1 -1
- package/dist/openclaw/worker.js +19 -85
- package/dist/openclaw/worker.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -40
- package/dist/server.js.map +1 -1
- package/dist/shared/audio-provider-suggestions.d.ts.map +1 -1
- package/dist/shared/audio-provider-suggestions.js +4 -6
- package/dist/shared/audio-provider-suggestions.js.map +1 -1
- package/dist/shared/tool-implementations.d.ts.map +1 -1
- package/dist/shared/tool-implementations.js +99 -37
- package/dist/shared/tool-implementations.js.map +1 -1
- package/package.json +14 -4
- package/src/__tests__/audio-provider-suggestions.test.ts +199 -0
- package/src/__tests__/custom-tools.test.ts +92 -0
- package/src/__tests__/embedded-just-bash-bootstrap.test.ts +128 -0
- package/src/__tests__/embedded-mcp-cli-bash.test.ts +179 -0
- package/src/__tests__/embedded-tools.test.ts +744 -0
- package/src/__tests__/exec-sandbox-extra.test.ts +0 -0
- package/src/__tests__/exec-sandbox.test.ts +550 -0
- package/src/__tests__/generated-media.test.ts +142 -0
- package/src/__tests__/instructions.test.ts +60 -0
- package/src/__tests__/mcp-cli-commands-extra.test.ts +478 -0
- package/src/__tests__/mcp-cli-commands.test.ts +383 -0
- package/src/__tests__/mcp-tool-call.test.ts +423 -0
- package/src/__tests__/memory-flush-harden.test.ts +367 -0
- package/src/__tests__/memory-flush-runtime.test.ts +138 -0
- package/src/__tests__/memory-flush.test.ts +64 -0
- package/src/__tests__/message-batcher.test.ts +247 -0
- package/src/__tests__/model-resolver-harden.test.ts +197 -0
- package/src/__tests__/model-resolver.test.ts +156 -0
- package/src/__tests__/processor-harden.test.ts +259 -0
- package/src/__tests__/processor.test.ts +225 -0
- package/src/__tests__/replace-base-prompt-identity.test.ts +41 -0
- package/src/__tests__/sandbox-leak-harden.test.ts +200 -0
- package/src/__tests__/sandbox-leak.test.ts +167 -0
- package/src/__tests__/setup.ts +102 -0
- package/src/__tests__/sse-client-harden.test.ts +588 -0
- package/src/__tests__/sse-client.test.ts +90 -0
- package/src/__tests__/tool-implementations.test.ts +196 -0
- package/src/__tests__/tool-policy-edge-cases.test.ts +263 -0
- package/src/__tests__/tool-policy.test.ts +269 -0
- package/src/__tests__/worker.test.ts +89 -0
- package/src/core/error-handler.ts +47 -0
- package/src/core/project-scanner.ts +65 -0
- package/src/core/types.ts +94 -0
- package/src/core/workspace.ts +66 -0
- package/src/embedded/exec-sandbox.ts +372 -0
- package/src/embedded/just-bash-bootstrap.ts +575 -0
- package/src/embedded/mcp-cli-commands.ts +405 -0
- package/src/gateway/gateway-integration.ts +298 -0
- package/src/gateway/message-batcher.ts +123 -0
- package/src/gateway/sse-client.ts +988 -0
- package/src/gateway/types.ts +68 -0
- package/src/index.ts +123 -0
- package/src/instructions/builder.ts +44 -0
- package/src/instructions/providers.ts +27 -0
- package/src/modules/lifecycle.ts +92 -0
- package/src/openclaw/custom-tools.ts +315 -0
- package/src/openclaw/instructions.ts +36 -0
- package/src/openclaw/model-resolver.ts +150 -0
- package/src/openclaw/plugin-loader.ts +423 -0
- package/src/openclaw/processor.ts +199 -0
- package/src/openclaw/sandbox-leak.ts +100 -0
- package/src/openclaw/session-context.ts +323 -0
- package/src/openclaw/tool-policy.ts +241 -0
- package/src/openclaw/tools.ts +277 -0
- package/src/openclaw/worker.ts +1836 -0
- package/src/server.ts +330 -0
- package/src/shared/audio-provider-suggestions.ts +130 -0
- package/src/shared/processor-utils.ts +33 -0
- package/src/shared/provider-auth-hints.ts +68 -0
- package/src/shared/tool-display-config.ts +75 -0
- package/src/shared/tool-implementations.ts +981 -0
- package/src/shared/worker-env-keys.ts +8 -0
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
import * as nodeFs from "node:fs";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { createLogger } from "@lobu/core";
|
|
5
|
+
import FormData from "form-data";
|
|
6
|
+
import { fetchAudioProviderSuggestions } from "./audio-provider-suggestions";
|
|
7
|
+
|
|
8
|
+
const logger = createLogger("shared-tools");
|
|
9
|
+
|
|
10
|
+
/** Standard text result shape used by both SDK wrappers */
|
|
11
|
+
export interface TextResult {
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
content: Array<{ [key: string]: unknown; type: "text"; text: string }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function textResult(text: string): TextResult {
|
|
17
|
+
return { content: [{ type: "text" as const, text }] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatError(error: unknown): string {
|
|
21
|
+
return error instanceof Error ? error.message : String(error);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function withErrorHandling(
|
|
25
|
+
label: string,
|
|
26
|
+
fn: () => Promise<TextResult>
|
|
27
|
+
): Promise<TextResult> {
|
|
28
|
+
return fn().catch((error) => {
|
|
29
|
+
logger.error(`${label} error:`, error);
|
|
30
|
+
return textResult(`Error: ${formatError(error)}`);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function parseErrorBody(response: Response): Promise<{ error?: string }> {
|
|
35
|
+
return response
|
|
36
|
+
.json()
|
|
37
|
+
.catch(() => ({ error: response.statusText })) as Promise<{
|
|
38
|
+
error?: string;
|
|
39
|
+
}>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface GatewayRequestOptions {
|
|
43
|
+
method?: string;
|
|
44
|
+
headers?: Record<string, string>;
|
|
45
|
+
body?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function gatewayFetch<T>(
|
|
49
|
+
gw: GatewayParams,
|
|
50
|
+
urlPath: string,
|
|
51
|
+
options: GatewayRequestOptions = {},
|
|
52
|
+
errorPrefix: string
|
|
53
|
+
): Promise<{ data?: T; error?: TextResult }> {
|
|
54
|
+
const { method, body, headers: extraHeaders } = options;
|
|
55
|
+
const headers: Record<string, string> = {
|
|
56
|
+
Authorization: `Bearer ${gw.workerToken}`,
|
|
57
|
+
...extraHeaders,
|
|
58
|
+
};
|
|
59
|
+
if (body) {
|
|
60
|
+
headers["Content-Type"] = "application/json";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let response: Response;
|
|
64
|
+
try {
|
|
65
|
+
response = await fetch(`${gw.gatewayUrl}${urlPath}`, {
|
|
66
|
+
method,
|
|
67
|
+
headers,
|
|
68
|
+
body,
|
|
69
|
+
// A stalled gateway must not hang the agent turn indefinitely.
|
|
70
|
+
signal: AbortSignal.timeout(60_000),
|
|
71
|
+
});
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
74
|
+
logger.error(`${errorPrefix}: request timed out`);
|
|
75
|
+
return { error: textResult(`Error: ${errorPrefix} (timed out)`) };
|
|
76
|
+
}
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
const errorData = await parseErrorBody(response);
|
|
82
|
+
logger.error(`${errorPrefix}: ${response.status}`, errorData);
|
|
83
|
+
return {
|
|
84
|
+
error: textResult(`Error: ${errorData.error || errorPrefix}`),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const data = (await response.json()) as T;
|
|
89
|
+
return { data };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function postLinkButton(
|
|
93
|
+
gw: GatewayParams,
|
|
94
|
+
args: {
|
|
95
|
+
url: string;
|
|
96
|
+
label: string;
|
|
97
|
+
linkType?: "settings" | "install" | "oauth";
|
|
98
|
+
body?: string;
|
|
99
|
+
}
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
const { error } = await gatewayFetch<{ id: string }>(
|
|
102
|
+
gw,
|
|
103
|
+
"/internal/interactions/create",
|
|
104
|
+
{
|
|
105
|
+
method: "POST",
|
|
106
|
+
body: JSON.stringify({
|
|
107
|
+
interactionType: "link_button",
|
|
108
|
+
url: args.url,
|
|
109
|
+
label: args.label,
|
|
110
|
+
linkType: args.linkType || "oauth",
|
|
111
|
+
body: args.body,
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
"Failed to post link button"
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (error) {
|
|
118
|
+
const text = error.content
|
|
119
|
+
.filter((c) => c.type === "text")
|
|
120
|
+
.map((c) => c.text)
|
|
121
|
+
.join("\n");
|
|
122
|
+
throw new Error(text || "Failed to post link button");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Gateway connection params shared by all tool implementations.
|
|
128
|
+
*/
|
|
129
|
+
export interface GatewayParams {
|
|
130
|
+
gatewayUrl: string;
|
|
131
|
+
workerToken: string;
|
|
132
|
+
channelId: string;
|
|
133
|
+
conversationId: string;
|
|
134
|
+
platform?: string;
|
|
135
|
+
/**
|
|
136
|
+
* Session workspace directory. Relative file paths from the model get
|
|
137
|
+
* resolved against this (not `process.cwd()`, which is the parent gateway
|
|
138
|
+
* process's directory, not the per-conversation workspace).
|
|
139
|
+
*/
|
|
140
|
+
workspaceDir?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ============================================================================
|
|
144
|
+
// Utility: Content type detection
|
|
145
|
+
// ============================================================================
|
|
146
|
+
|
|
147
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
148
|
+
".png": "image/png",
|
|
149
|
+
".jpg": "image/jpeg",
|
|
150
|
+
".jpeg": "image/jpeg",
|
|
151
|
+
".gif": "image/gif",
|
|
152
|
+
".webp": "image/webp",
|
|
153
|
+
".pdf": "application/pdf",
|
|
154
|
+
".csv": "text/csv",
|
|
155
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
156
|
+
".json": "application/json",
|
|
157
|
+
".html": "text/html",
|
|
158
|
+
".svg": "image/svg+xml",
|
|
159
|
+
".mp4": "video/mp4",
|
|
160
|
+
".webm": "video/webm",
|
|
161
|
+
".txt": "text/plain",
|
|
162
|
+
".md": "text/markdown",
|
|
163
|
+
".py": "text/x-python",
|
|
164
|
+
".js": "text/javascript",
|
|
165
|
+
".ts": "text/typescript",
|
|
166
|
+
".zip": "application/zip",
|
|
167
|
+
".tar": "application/x-tar",
|
|
168
|
+
".gz": "application/gzip",
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
function getContentType(fileName: string): string {
|
|
172
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
173
|
+
return CONTENT_TYPES[ext] || "application/octet-stream";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ============================================================================
|
|
177
|
+
// Utility: FormData buffer serialisation
|
|
178
|
+
// ============================================================================
|
|
179
|
+
|
|
180
|
+
async function formDataToBuffer(formData: FormData): Promise<Buffer> {
|
|
181
|
+
return new Promise<Buffer>((resolve, reject) => {
|
|
182
|
+
const chunks: Buffer[] = [];
|
|
183
|
+
formData.on("data", (chunk: string | Buffer) => {
|
|
184
|
+
if (typeof chunk === "string") {
|
|
185
|
+
chunks.push(Buffer.from(chunk));
|
|
186
|
+
} else {
|
|
187
|
+
chunks.push(chunk);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
formData.on("end", () => resolve(Buffer.concat(chunks)));
|
|
191
|
+
formData.on("error", (err: Error) => reject(err));
|
|
192
|
+
formData.resume();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ============================================================================
|
|
197
|
+
// UploadUserFile
|
|
198
|
+
// ============================================================================
|
|
199
|
+
|
|
200
|
+
export async function uploadUserFile(
|
|
201
|
+
gw: GatewayParams,
|
|
202
|
+
args: { file_path: string; description?: string },
|
|
203
|
+
hooks?: {
|
|
204
|
+
onUploaded?: (payload: {
|
|
205
|
+
tool: "UploadUserFile";
|
|
206
|
+
platform: string;
|
|
207
|
+
fileId: string;
|
|
208
|
+
name: string;
|
|
209
|
+
permalink: string;
|
|
210
|
+
size: number;
|
|
211
|
+
delivery?: "platform-upload" | "artifact-url";
|
|
212
|
+
artifactId?: string;
|
|
213
|
+
}) => Promise<void> | void;
|
|
214
|
+
}
|
|
215
|
+
): Promise<TextResult> {
|
|
216
|
+
return withErrorHandling("Show file tool", async () => {
|
|
217
|
+
logger.info(
|
|
218
|
+
`Show file to user: ${args.file_path}, description: ${args.description || "none"}`
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
if (!path.isAbsolute(args.file_path) && !gw.workspaceDir) {
|
|
222
|
+
return textResult(
|
|
223
|
+
`Error: Cannot resolve relative file path "${args.file_path}" — workspaceDir not set. This is a wiring bug; pass an absolute path or ensure the worker was started with a workspace.`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const requestedPath = path.isAbsolute(args.file_path)
|
|
227
|
+
? args.file_path
|
|
228
|
+
: path.join(gw.workspaceDir as string, args.file_path);
|
|
229
|
+
|
|
230
|
+
// Containment check: resolve the real path (following any symlinks) and
|
|
231
|
+
// ensure it stays inside the worker's workspace. Without this, an agent
|
|
232
|
+
// can hand us `../../etc/passwd` (or a symlink that points there) and we
|
|
233
|
+
// would happily upload it to the user.
|
|
234
|
+
let filePath: string;
|
|
235
|
+
if (gw.workspaceDir) {
|
|
236
|
+
try {
|
|
237
|
+
const workspaceReal = await fs.realpath(gw.workspaceDir);
|
|
238
|
+
const requestedReal = await fs.realpath(requestedPath);
|
|
239
|
+
const withSep = workspaceReal.endsWith(path.sep)
|
|
240
|
+
? workspaceReal
|
|
241
|
+
: workspaceReal + path.sep;
|
|
242
|
+
if (
|
|
243
|
+
requestedReal !== workspaceReal &&
|
|
244
|
+
!requestedReal.startsWith(withSep)
|
|
245
|
+
) {
|
|
246
|
+
return textResult(
|
|
247
|
+
`Error: Refusing to upload file outside workspace: ${args.file_path}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
filePath = requestedReal;
|
|
251
|
+
} catch {
|
|
252
|
+
return textResult(
|
|
253
|
+
`Error: Cannot show file - not found or is not a file: ${args.file_path}`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
filePath = requestedPath;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Use lstat so we don't dereference symlinks for the file-type check —
|
|
261
|
+
// realpath above already proved the resolved target is in-workspace.
|
|
262
|
+
const stats = await fs.lstat(filePath).catch(() => null);
|
|
263
|
+
if (!stats?.isFile()) {
|
|
264
|
+
return textResult(
|
|
265
|
+
`Error: Cannot show file - not found or is not a file: ${args.file_path}`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (stats.size === 0) {
|
|
269
|
+
return textResult(`Error: Cannot show empty file: ${args.file_path}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const fileName = path.basename(filePath);
|
|
273
|
+
const fileBuffer = await fs.readFile(filePath);
|
|
274
|
+
|
|
275
|
+
const formData = new FormData();
|
|
276
|
+
formData.append("file", fileBuffer, {
|
|
277
|
+
filename: fileName,
|
|
278
|
+
contentType: getContentType(fileName),
|
|
279
|
+
});
|
|
280
|
+
formData.append("filename", fileName);
|
|
281
|
+
if (args.description) {
|
|
282
|
+
formData.append("comment", args.description);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const formDataBuffer = await formDataToBuffer(formData);
|
|
286
|
+
const fdHeaders = formData.getHeaders();
|
|
287
|
+
|
|
288
|
+
let response: Response;
|
|
289
|
+
try {
|
|
290
|
+
response = await fetch(`${gw.gatewayUrl}/internal/files/upload`, {
|
|
291
|
+
method: "POST",
|
|
292
|
+
headers: {
|
|
293
|
+
Authorization: `Bearer ${gw.workerToken}`,
|
|
294
|
+
"X-Channel-Id": gw.channelId,
|
|
295
|
+
"X-Conversation-Id": gw.conversationId,
|
|
296
|
+
...fdHeaders,
|
|
297
|
+
"Content-Length": formDataBuffer.length.toString(),
|
|
298
|
+
},
|
|
299
|
+
body: formDataBuffer,
|
|
300
|
+
// A stalled gateway upload must not wedge the agent turn forever —
|
|
301
|
+
// a 5-minute ceiling is well above any legitimate file delivery.
|
|
302
|
+
signal: AbortSignal.timeout(300_000),
|
|
303
|
+
});
|
|
304
|
+
} catch (err) {
|
|
305
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
306
|
+
return textResult(
|
|
307
|
+
`Error: Failed to show file to user: upload timed out`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!response.ok) {
|
|
314
|
+
const error = await response.text();
|
|
315
|
+
logger.error(`Failed to show file: ${response.status} - ${error}`);
|
|
316
|
+
return textResult(
|
|
317
|
+
`Error: Failed to show file to user: ${response.status} - ${error}`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const result = (await response.json()) as {
|
|
322
|
+
fileId: string;
|
|
323
|
+
name: string;
|
|
324
|
+
permalink: string;
|
|
325
|
+
delivery?: "platform-upload" | "artifact-url";
|
|
326
|
+
artifactId?: string;
|
|
327
|
+
};
|
|
328
|
+
logger.info(
|
|
329
|
+
`Successfully showed file to user: ${result.fileId} - ${result.name}`
|
|
330
|
+
);
|
|
331
|
+
await hooks?.onUploaded?.({
|
|
332
|
+
tool: "UploadUserFile",
|
|
333
|
+
platform: gw.platform || "unknown",
|
|
334
|
+
fileId: result.fileId,
|
|
335
|
+
name: result.name || fileName,
|
|
336
|
+
permalink: result.permalink,
|
|
337
|
+
size: stats.size,
|
|
338
|
+
...(result.delivery ? { delivery: result.delivery } : {}),
|
|
339
|
+
...(result.artifactId ? { artifactId: result.artifactId } : {}),
|
|
340
|
+
});
|
|
341
|
+
return textResult(`Successfully showed ${fileName} to the user`);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ============================================================================
|
|
346
|
+
// AskUserQuestion
|
|
347
|
+
// ============================================================================
|
|
348
|
+
|
|
349
|
+
export async function askUserQuestion(
|
|
350
|
+
gw: GatewayParams,
|
|
351
|
+
args: { question: string; options: unknown }
|
|
352
|
+
): Promise<TextResult> {
|
|
353
|
+
return withErrorHandling("AskUserQuestion", async () => {
|
|
354
|
+
logger.info(`AskUserQuestion: ${args.question}`);
|
|
355
|
+
|
|
356
|
+
const { error } = await gatewayFetch<{ id: string }>(
|
|
357
|
+
gw,
|
|
358
|
+
"/internal/interactions/create",
|
|
359
|
+
{
|
|
360
|
+
method: "POST",
|
|
361
|
+
body: JSON.stringify({
|
|
362
|
+
interactionType: "question",
|
|
363
|
+
question: args.question,
|
|
364
|
+
options: args.options,
|
|
365
|
+
}),
|
|
366
|
+
},
|
|
367
|
+
"Failed to post question"
|
|
368
|
+
);
|
|
369
|
+
if (error) return error;
|
|
370
|
+
|
|
371
|
+
return textResult(
|
|
372
|
+
"Question posted with buttons. End your turn now — the user's click will arrive as a new inbound message that resumes this session."
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ============================================================================
|
|
378
|
+
// MCP auth tools
|
|
379
|
+
// ============================================================================
|
|
380
|
+
|
|
381
|
+
export async function startMcpLogin(
|
|
382
|
+
gw: GatewayParams,
|
|
383
|
+
args: { mcpId: string }
|
|
384
|
+
): Promise<TextResult> {
|
|
385
|
+
return withErrorHandling(`${args.mcpId}_login`, async () => {
|
|
386
|
+
logger.info(`Start MCP login: ${args.mcpId}`);
|
|
387
|
+
|
|
388
|
+
const statusPath = `/internal/device-auth/status?mcpId=${encodeURIComponent(
|
|
389
|
+
args.mcpId
|
|
390
|
+
)}`;
|
|
391
|
+
const statusResult = await gatewayFetch<{ authenticated: boolean }>(
|
|
392
|
+
gw,
|
|
393
|
+
statusPath,
|
|
394
|
+
{},
|
|
395
|
+
`Failed to check auth status for ${args.mcpId}`
|
|
396
|
+
);
|
|
397
|
+
if (statusResult.error) return statusResult.error;
|
|
398
|
+
|
|
399
|
+
if (statusResult.data?.authenticated) {
|
|
400
|
+
return textResult(
|
|
401
|
+
JSON.stringify({
|
|
402
|
+
status: "already_authenticated",
|
|
403
|
+
mcp_id: args.mcpId,
|
|
404
|
+
message: `${args.mcpId} is already authenticated.`,
|
|
405
|
+
})
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const startResult = await gatewayFetch<{
|
|
410
|
+
userCode: string;
|
|
411
|
+
verificationUri: string;
|
|
412
|
+
verificationUriComplete?: string;
|
|
413
|
+
expiresIn: number;
|
|
414
|
+
}>(
|
|
415
|
+
gw,
|
|
416
|
+
"/internal/device-auth/start",
|
|
417
|
+
{
|
|
418
|
+
method: "POST",
|
|
419
|
+
body: JSON.stringify({ mcpId: args.mcpId }),
|
|
420
|
+
},
|
|
421
|
+
`Failed to start login for ${args.mcpId}`
|
|
422
|
+
);
|
|
423
|
+
if (startResult.error) return startResult.error;
|
|
424
|
+
|
|
425
|
+
const verificationUrl =
|
|
426
|
+
startResult.data?.verificationUriComplete ||
|
|
427
|
+
startResult.data?.verificationUri;
|
|
428
|
+
if (verificationUrl) {
|
|
429
|
+
await postLinkButton(gw, {
|
|
430
|
+
url: verificationUrl,
|
|
431
|
+
label: `Connect ${args.mcpId}`,
|
|
432
|
+
linkType: "oauth",
|
|
433
|
+
body: `Sign in to ${args.mcpId} so I can use its tools on your behalf.`,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return textResult(
|
|
438
|
+
JSON.stringify({
|
|
439
|
+
status: "login_started",
|
|
440
|
+
mcp_id: args.mcpId,
|
|
441
|
+
verification_url: verificationUrl,
|
|
442
|
+
verification_uri: startResult.data?.verificationUri,
|
|
443
|
+
user_code: startResult.data?.userCode,
|
|
444
|
+
expires_in_seconds: startResult.data?.expiresIn,
|
|
445
|
+
interaction_posted: Boolean(verificationUrl),
|
|
446
|
+
message: verificationUrl
|
|
447
|
+
? `Authentication required for ${args.mcpId}. The login link has been sent directly to the user. Do not repeat the URL unless they ask.`
|
|
448
|
+
: `Authentication required for ${args.mcpId}. Show the user the verification URL and code, then wait for them to finish login.`,
|
|
449
|
+
})
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export async function checkMcpLogin(
|
|
455
|
+
gw: GatewayParams,
|
|
456
|
+
args: { mcpId: string }
|
|
457
|
+
): Promise<TextResult> {
|
|
458
|
+
return withErrorHandling(`${args.mcpId}_login_check`, async () => {
|
|
459
|
+
logger.info(`Check MCP login: ${args.mcpId}`);
|
|
460
|
+
|
|
461
|
+
const statusPath = `/internal/device-auth/status?mcpId=${encodeURIComponent(
|
|
462
|
+
args.mcpId
|
|
463
|
+
)}`;
|
|
464
|
+
const statusResult = await gatewayFetch<{ authenticated: boolean }>(
|
|
465
|
+
gw,
|
|
466
|
+
statusPath,
|
|
467
|
+
{},
|
|
468
|
+
`Failed to check auth status for ${args.mcpId}`
|
|
469
|
+
);
|
|
470
|
+
if (statusResult.error) return statusResult.error;
|
|
471
|
+
|
|
472
|
+
if (statusResult.data?.authenticated) {
|
|
473
|
+
const { invalidateSessionContextCache } = await import(
|
|
474
|
+
"../openclaw/session-context"
|
|
475
|
+
);
|
|
476
|
+
invalidateSessionContextCache();
|
|
477
|
+
return textResult(
|
|
478
|
+
JSON.stringify({
|
|
479
|
+
status: "already_authenticated",
|
|
480
|
+
mcp_id: args.mcpId,
|
|
481
|
+
authenticated: true,
|
|
482
|
+
refreshed_session_context: true,
|
|
483
|
+
message: `${args.mcpId} is already authenticated. Newly available MCP tools will be refreshed for the next message.`,
|
|
484
|
+
})
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const pollResult = await gatewayFetch<{
|
|
489
|
+
status: "pending" | "complete" | "error";
|
|
490
|
+
message?: string;
|
|
491
|
+
}>(
|
|
492
|
+
gw,
|
|
493
|
+
"/internal/device-auth/poll",
|
|
494
|
+
{
|
|
495
|
+
method: "POST",
|
|
496
|
+
body: JSON.stringify({ mcpId: args.mcpId }),
|
|
497
|
+
},
|
|
498
|
+
`Failed to check login progress for ${args.mcpId}`
|
|
499
|
+
);
|
|
500
|
+
if (pollResult.error) return pollResult.error;
|
|
501
|
+
|
|
502
|
+
const pollStatus = pollResult.data?.status || "error";
|
|
503
|
+
if (pollStatus === "complete") {
|
|
504
|
+
const { invalidateSessionContextCache } = await import(
|
|
505
|
+
"../openclaw/session-context"
|
|
506
|
+
);
|
|
507
|
+
invalidateSessionContextCache();
|
|
508
|
+
return textResult(
|
|
509
|
+
JSON.stringify({
|
|
510
|
+
status: "complete",
|
|
511
|
+
mcp_id: args.mcpId,
|
|
512
|
+
authenticated: true,
|
|
513
|
+
refreshed_session_context: true,
|
|
514
|
+
message: `${args.mcpId} authentication completed successfully. Newly available MCP tools will be refreshed for the next message.`,
|
|
515
|
+
})
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (pollStatus === "pending") {
|
|
520
|
+
return textResult(
|
|
521
|
+
JSON.stringify({
|
|
522
|
+
status: "pending",
|
|
523
|
+
mcp_id: args.mcpId,
|
|
524
|
+
authenticated: false,
|
|
525
|
+
message: `Authentication for ${args.mcpId} is still pending. Wait for the user to complete login in their browser.`,
|
|
526
|
+
})
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return textResult(
|
|
531
|
+
JSON.stringify({
|
|
532
|
+
status: "error",
|
|
533
|
+
mcp_id: args.mcpId,
|
|
534
|
+
authenticated: false,
|
|
535
|
+
message:
|
|
536
|
+
pollResult.data?.message ||
|
|
537
|
+
`Authentication for ${args.mcpId} failed or expired.`,
|
|
538
|
+
})
|
|
539
|
+
);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export async function logoutMcp(
|
|
544
|
+
gw: GatewayParams,
|
|
545
|
+
args: { mcpId: string }
|
|
546
|
+
): Promise<TextResult> {
|
|
547
|
+
return withErrorHandling(`${args.mcpId}_logout`, async () => {
|
|
548
|
+
logger.info(`Logout MCP: ${args.mcpId}`);
|
|
549
|
+
|
|
550
|
+
const { data, error } = await gatewayFetch<{ success: boolean }>(
|
|
551
|
+
gw,
|
|
552
|
+
`/internal/device-auth/credential?mcpId=${encodeURIComponent(args.mcpId)}`,
|
|
553
|
+
{ method: "DELETE" },
|
|
554
|
+
`Failed to log out from ${args.mcpId}`
|
|
555
|
+
);
|
|
556
|
+
if (error) return error;
|
|
557
|
+
|
|
558
|
+
return textResult(
|
|
559
|
+
JSON.stringify({
|
|
560
|
+
status: data?.success ? "logged_out" : "already_logged_out",
|
|
561
|
+
mcp_id: args.mcpId,
|
|
562
|
+
authenticated: false,
|
|
563
|
+
message: data?.success
|
|
564
|
+
? `${args.mcpId} has been logged out.`
|
|
565
|
+
: `${args.mcpId} was not logged in.`,
|
|
566
|
+
})
|
|
567
|
+
);
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ============================================================================
|
|
572
|
+
// Utility: Upload generated file (image/audio) to gateway
|
|
573
|
+
// ============================================================================
|
|
574
|
+
|
|
575
|
+
async function uploadGeneratedFile(
|
|
576
|
+
gw: GatewayParams,
|
|
577
|
+
buffer: ArrayBuffer,
|
|
578
|
+
filename: string,
|
|
579
|
+
mimeType: string,
|
|
580
|
+
extraHeaders?: Record<string, string>
|
|
581
|
+
): Promise<TextResult | null> {
|
|
582
|
+
let tempPath: string | null = null;
|
|
583
|
+
try {
|
|
584
|
+
tempPath = `/tmp/${filename}_${Date.now()}`;
|
|
585
|
+
await fs.writeFile(tempPath, Buffer.from(buffer));
|
|
586
|
+
|
|
587
|
+
const formData = new FormData();
|
|
588
|
+
formData.append("file", nodeFs.createReadStream(tempPath), {
|
|
589
|
+
filename,
|
|
590
|
+
contentType: mimeType,
|
|
591
|
+
});
|
|
592
|
+
formData.append("filename", filename);
|
|
593
|
+
formData.append("comment", "Generated content");
|
|
594
|
+
|
|
595
|
+
const formDataBuffer = await formDataToBuffer(formData);
|
|
596
|
+
const fdHeaders = formData.getHeaders();
|
|
597
|
+
|
|
598
|
+
let uploadResponse: Response;
|
|
599
|
+
try {
|
|
600
|
+
uploadResponse = await fetch(`${gw.gatewayUrl}/internal/files/upload`, {
|
|
601
|
+
method: "POST",
|
|
602
|
+
headers: {
|
|
603
|
+
Authorization: `Bearer ${gw.workerToken}`,
|
|
604
|
+
"X-Channel-Id": gw.channelId,
|
|
605
|
+
"X-Conversation-Id": gw.conversationId,
|
|
606
|
+
...fdHeaders,
|
|
607
|
+
"Content-Length": formDataBuffer.length.toString(),
|
|
608
|
+
...extraHeaders,
|
|
609
|
+
},
|
|
610
|
+
body: formDataBuffer,
|
|
611
|
+
signal: AbortSignal.timeout(300_000),
|
|
612
|
+
});
|
|
613
|
+
} catch (err) {
|
|
614
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
615
|
+
return textResult(`Generated content but upload timed out`);
|
|
616
|
+
}
|
|
617
|
+
throw err;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (!uploadResponse.ok) {
|
|
621
|
+
const uploadError = await uploadResponse.text();
|
|
622
|
+
return textResult(`Generated content but failed to send: ${uploadError}`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return null;
|
|
626
|
+
} finally {
|
|
627
|
+
if (tempPath) {
|
|
628
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ============================================================================
|
|
634
|
+
// GenerateImage
|
|
635
|
+
// ============================================================================
|
|
636
|
+
|
|
637
|
+
function imageExtFromMime(mimeType: string): string {
|
|
638
|
+
if (mimeType.includes("jpeg")) return "jpg";
|
|
639
|
+
if (mimeType.includes("webp")) return "webp";
|
|
640
|
+
return "png";
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export async function generateImage(
|
|
644
|
+
gw: GatewayParams,
|
|
645
|
+
args: {
|
|
646
|
+
prompt: string;
|
|
647
|
+
size?: "1024x1024" | "1024x1536" | "1536x1024" | "auto";
|
|
648
|
+
quality?: "low" | "medium" | "high" | "auto";
|
|
649
|
+
background?: "transparent" | "opaque" | "auto";
|
|
650
|
+
format?: "png" | "jpeg" | "webp";
|
|
651
|
+
}
|
|
652
|
+
): Promise<TextResult> {
|
|
653
|
+
return withErrorHandling("GenerateImage", async () => {
|
|
654
|
+
logger.info(`GenerateImage: ${args.prompt.substring(0, 80)}...`);
|
|
655
|
+
|
|
656
|
+
const capResponse = await fetch(
|
|
657
|
+
`${gw.gatewayUrl}/internal/images/capabilities`,
|
|
658
|
+
{
|
|
659
|
+
headers: { Authorization: `Bearer ${gw.workerToken}` },
|
|
660
|
+
signal: AbortSignal.timeout(30_000),
|
|
661
|
+
}
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
if (capResponse.ok) {
|
|
665
|
+
const capabilities = (await capResponse.json()) as {
|
|
666
|
+
available: boolean;
|
|
667
|
+
providers?: Array<{ provider: string; name: string }>;
|
|
668
|
+
};
|
|
669
|
+
if (!capabilities.available) {
|
|
670
|
+
const providerList =
|
|
671
|
+
capabilities.providers?.map((p) => p.name).join(", ") || "OpenAI";
|
|
672
|
+
return textResult(
|
|
673
|
+
`Image generation is not configured. Supported providers: ${providerList}.\n\nAsk an admin to connect one of these providers for the base agent.`
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const response = await fetch(`${gw.gatewayUrl}/internal/images/generate`, {
|
|
679
|
+
method: "POST",
|
|
680
|
+
headers: {
|
|
681
|
+
Authorization: `Bearer ${gw.workerToken}`,
|
|
682
|
+
"Content-Type": "application/json",
|
|
683
|
+
},
|
|
684
|
+
body: JSON.stringify({
|
|
685
|
+
prompt: args.prompt,
|
|
686
|
+
size: args.size,
|
|
687
|
+
quality: args.quality,
|
|
688
|
+
background: args.background,
|
|
689
|
+
format: args.format,
|
|
690
|
+
}),
|
|
691
|
+
// Image gen can take a while at high quality, but never minutes — cap
|
|
692
|
+
// the wait so a stalled upstream provider doesn't hang the agent turn.
|
|
693
|
+
signal: AbortSignal.timeout(120_000),
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
if (!response.ok) {
|
|
697
|
+
const errorData = (await parseErrorBody(response)) as {
|
|
698
|
+
error?: string;
|
|
699
|
+
availableProviders?: string[];
|
|
700
|
+
};
|
|
701
|
+
const errorMessage = errorData.error || "Unknown error";
|
|
702
|
+
const lowerError = errorMessage.toLowerCase();
|
|
703
|
+
const missingImagePermission =
|
|
704
|
+
lowerError.includes("missing scopes") ||
|
|
705
|
+
lowerError.includes("missing_scope") ||
|
|
706
|
+
(lowerError.includes("scope") &&
|
|
707
|
+
(lowerError.includes("image") ||
|
|
708
|
+
lowerError.includes("model.request")));
|
|
709
|
+
|
|
710
|
+
if (errorData.availableProviders?.length) {
|
|
711
|
+
return textResult(
|
|
712
|
+
`Image generation failed: ${errorMessage}.\n\nAsk an admin to connect one of the supported providers for the base agent.`
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (missingImagePermission) {
|
|
717
|
+
return textResult(
|
|
718
|
+
`Image generation failed because the current credential lacks required image permissions.\n\nAsk an admin to connect a provider with image generation access for the base agent.`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return textResult(`Error generating image: ${errorMessage}`);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const imageBuffer = await response.arrayBuffer();
|
|
726
|
+
const mimeType = response.headers.get("Content-Type") || "image/png";
|
|
727
|
+
const provider = response.headers.get("X-Image-Provider") || "unknown";
|
|
728
|
+
const ext = imageExtFromMime(mimeType);
|
|
729
|
+
|
|
730
|
+
const uploadError = await uploadGeneratedFile(
|
|
731
|
+
gw,
|
|
732
|
+
imageBuffer,
|
|
733
|
+
`generated_image.${ext}`,
|
|
734
|
+
mimeType
|
|
735
|
+
);
|
|
736
|
+
if (uploadError) return uploadError;
|
|
737
|
+
|
|
738
|
+
logger.info(`Image generated and sent using ${provider}`);
|
|
739
|
+
return textResult(`Image sent successfully (generated with ${provider}).`);
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ============================================================================
|
|
744
|
+
// GenerateAudio
|
|
745
|
+
// ============================================================================
|
|
746
|
+
|
|
747
|
+
function audioExtFromMime(mimeType: string): string {
|
|
748
|
+
if (mimeType.includes("opus")) return "opus";
|
|
749
|
+
if (mimeType.includes("ogg")) return "ogg";
|
|
750
|
+
return "mp3";
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export async function generateAudio(
|
|
754
|
+
gw: GatewayParams,
|
|
755
|
+
args: { text: string; voice?: string; speed?: number }
|
|
756
|
+
): Promise<TextResult> {
|
|
757
|
+
return withErrorHandling("GenerateAudio", async () => {
|
|
758
|
+
logger.info(`GenerateAudio: ${args.text.substring(0, 50)}...`);
|
|
759
|
+
|
|
760
|
+
const suggestions = await fetchAudioProviderSuggestions({
|
|
761
|
+
gatewayUrl: gw.gatewayUrl,
|
|
762
|
+
workerToken: gw.workerToken,
|
|
763
|
+
});
|
|
764
|
+
const providerList =
|
|
765
|
+
suggestions.providerDisplayList || "an audio-capable provider";
|
|
766
|
+
|
|
767
|
+
if (suggestions.available === false) {
|
|
768
|
+
return textResult(
|
|
769
|
+
`Audio generation is not configured. To enable it, ask an admin to connect one of the available providers for the base agent: ${providerList}.`
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const response = await fetch(`${gw.gatewayUrl}/internal/audio/synthesize`, {
|
|
774
|
+
method: "POST",
|
|
775
|
+
headers: {
|
|
776
|
+
Authorization: `Bearer ${gw.workerToken}`,
|
|
777
|
+
"Content-Type": "application/json",
|
|
778
|
+
},
|
|
779
|
+
body: JSON.stringify({
|
|
780
|
+
text: args.text,
|
|
781
|
+
voice: args.voice,
|
|
782
|
+
speed: args.speed,
|
|
783
|
+
}),
|
|
784
|
+
signal: AbortSignal.timeout(120_000),
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
if (!response.ok) {
|
|
788
|
+
const errorData = (await parseErrorBody(response)) as {
|
|
789
|
+
error?: string;
|
|
790
|
+
availableProviders?: string[];
|
|
791
|
+
};
|
|
792
|
+
const errorMessage = errorData.error || "Unknown error";
|
|
793
|
+
const lowerError = errorMessage.toLowerCase();
|
|
794
|
+
const missingOpenAiAudioScope =
|
|
795
|
+
(lowerError.includes("missing scopes") ||
|
|
796
|
+
lowerError.includes("missing_scope")) &&
|
|
797
|
+
lowerError.includes("api.model.audio.request");
|
|
798
|
+
|
|
799
|
+
if (errorData.availableProviders?.length) {
|
|
800
|
+
return textResult(
|
|
801
|
+
`Audio generation failed: ${errorMessage}. No provider configured.\n\nAsk an admin to connect an audio provider for the base agent.`
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (missingOpenAiAudioScope) {
|
|
806
|
+
return textResult(
|
|
807
|
+
`Audio generation failed because the current OpenAI token lacks api.model.audio.request.\n\nAsk an admin to connect a provider with audio permission for the base agent, or to connect an alternative audio provider (${providerList}).`
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return textResult(`Error generating audio: ${errorMessage}`);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const audioBuffer = await response.arrayBuffer();
|
|
815
|
+
const mimeType = response.headers.get("Content-Type") || "audio/mpeg";
|
|
816
|
+
const provider = response.headers.get("X-Audio-Provider") || "unknown";
|
|
817
|
+
const ext = audioExtFromMime(mimeType);
|
|
818
|
+
|
|
819
|
+
const uploadError = await uploadGeneratedFile(
|
|
820
|
+
gw,
|
|
821
|
+
audioBuffer,
|
|
822
|
+
`voice_response.${ext}`,
|
|
823
|
+
mimeType,
|
|
824
|
+
{ "X-Voice-Message": "true" }
|
|
825
|
+
);
|
|
826
|
+
if (uploadError) return uploadError;
|
|
827
|
+
|
|
828
|
+
logger.info(`Audio generated and sent using ${provider}`);
|
|
829
|
+
return textResult(
|
|
830
|
+
`Voice message sent successfully (generated with ${provider}).`
|
|
831
|
+
);
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ============================================================================
|
|
836
|
+
// GetChannelHistory
|
|
837
|
+
// ============================================================================
|
|
838
|
+
|
|
839
|
+
export async function getChannelHistory(
|
|
840
|
+
gw: GatewayParams,
|
|
841
|
+
args: { limit?: number; before?: string }
|
|
842
|
+
): Promise<TextResult> {
|
|
843
|
+
return withErrorHandling("GetChannelHistory", async () => {
|
|
844
|
+
const limit = Math.min(Math.max(args.limit || 50, 1), 100);
|
|
845
|
+
const platform = gw.platform || "slack";
|
|
846
|
+
logger.info(
|
|
847
|
+
`GetChannelHistory: limit=${limit}, before=${args.before || "none"}`
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
const params = new URLSearchParams({
|
|
851
|
+
platform,
|
|
852
|
+
channelId: gw.channelId,
|
|
853
|
+
conversationId: gw.conversationId,
|
|
854
|
+
limit: String(limit),
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
if (args.before) {
|
|
858
|
+
params.set("before", args.before);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
interface HistoryResult {
|
|
862
|
+
messages: Array<{
|
|
863
|
+
timestamp: string;
|
|
864
|
+
user: string;
|
|
865
|
+
text: string;
|
|
866
|
+
isBot?: boolean;
|
|
867
|
+
}>;
|
|
868
|
+
nextCursor: string | null;
|
|
869
|
+
hasMore: boolean;
|
|
870
|
+
note?: string;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const { data, error } = await gatewayFetch<HistoryResult>(
|
|
874
|
+
gw,
|
|
875
|
+
`/internal/history?${params}`,
|
|
876
|
+
{},
|
|
877
|
+
"Failed to fetch channel history"
|
|
878
|
+
);
|
|
879
|
+
if (error) return error;
|
|
880
|
+
const history = data!;
|
|
881
|
+
|
|
882
|
+
if (history.note) {
|
|
883
|
+
return textResult(history.note);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (history.messages.length === 0) {
|
|
887
|
+
return textResult("No messages found in channel history.");
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const formatted = history.messages
|
|
891
|
+
.map((msg) => {
|
|
892
|
+
const time = new Date(msg.timestamp).toLocaleString();
|
|
893
|
+
const sender = msg.isBot ? `[Bot] ${msg.user}` : msg.user;
|
|
894
|
+
return `[${time}] ${sender}: ${msg.text}`;
|
|
895
|
+
})
|
|
896
|
+
.join("\n\n");
|
|
897
|
+
|
|
898
|
+
let result = `Found ${history.messages.length} messages:\n\n${formatted}`;
|
|
899
|
+
|
|
900
|
+
if (history.hasMore && history.nextCursor) {
|
|
901
|
+
result += `\n\n---\nMore messages available. Use before="${history.nextCursor}" to fetch older messages.`;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return textResult(result);
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// ============================================================================
|
|
909
|
+
// MCP Tools (route to MCP proxy /mcp/{mcpId}/tools/{toolName})
|
|
910
|
+
// ============================================================================
|
|
911
|
+
|
|
912
|
+
export async function callMcpTool(
|
|
913
|
+
gw: GatewayParams,
|
|
914
|
+
mcpId: string,
|
|
915
|
+
toolName: string,
|
|
916
|
+
args: Record<string, unknown>
|
|
917
|
+
): Promise<TextResult> {
|
|
918
|
+
return withErrorHandling(`${mcpId}/${toolName}`, async () => {
|
|
919
|
+
let response: Response;
|
|
920
|
+
try {
|
|
921
|
+
response = await fetch(
|
|
922
|
+
`${gw.gatewayUrl}/mcp/${mcpId}/tools/${toolName}`,
|
|
923
|
+
{
|
|
924
|
+
method: "POST",
|
|
925
|
+
headers: {
|
|
926
|
+
Authorization: `Bearer ${gw.workerToken}`,
|
|
927
|
+
"Content-Type": "application/json",
|
|
928
|
+
},
|
|
929
|
+
body: JSON.stringify(args),
|
|
930
|
+
// Third-party MCP server on the other side — give it a generous
|
|
931
|
+
// budget but never wait forever.
|
|
932
|
+
signal: AbortSignal.timeout(120_000),
|
|
933
|
+
}
|
|
934
|
+
);
|
|
935
|
+
} catch (err) {
|
|
936
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
937
|
+
return textResult(`Error: MCP tool ${mcpId}/${toolName} timed out`);
|
|
938
|
+
}
|
|
939
|
+
throw err;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// MCP proxy returns JSON on success, but a misbehaving upstream (502
|
|
943
|
+
// HTML, plain-text 500, empty body) would otherwise crash the tool call
|
|
944
|
+
// with "Unexpected token < in JSON". Treat parse failure as a transport
|
|
945
|
+
// error message instead of letting it bubble out as an unhandled throw.
|
|
946
|
+
let data: {
|
|
947
|
+
content?: Array<{ type: string; text: string }>;
|
|
948
|
+
error?: string;
|
|
949
|
+
isError?: boolean;
|
|
950
|
+
};
|
|
951
|
+
try {
|
|
952
|
+
data = (await response.json()) as {
|
|
953
|
+
content?: Array<{ type: string; text: string }>;
|
|
954
|
+
error?: string;
|
|
955
|
+
isError?: boolean;
|
|
956
|
+
};
|
|
957
|
+
} catch (parseErr) {
|
|
958
|
+
const parseMsg =
|
|
959
|
+
parseErr instanceof Error ? parseErr.message : String(parseErr);
|
|
960
|
+
return textResult(
|
|
961
|
+
`Error: ${toolName} returned a non-JSON response (status ${response.status}): ${parseMsg}`
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (!response.ok || data.isError) {
|
|
966
|
+
const contentText = data.content
|
|
967
|
+
?.filter((c) => c.type === "text")
|
|
968
|
+
.map((c) => c.text)
|
|
969
|
+
.join("\n");
|
|
970
|
+
const errorMsg =
|
|
971
|
+
data.error || contentText || `${toolName} failed (${response.status})`;
|
|
972
|
+
return textResult(`Error: ${errorMsg}`);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const text = data.content
|
|
976
|
+
?.filter((c) => c.type === "text")
|
|
977
|
+
.map((c) => c.text)
|
|
978
|
+
.join("\n");
|
|
979
|
+
return textResult(text || `${toolName} completed.`);
|
|
980
|
+
});
|
|
981
|
+
}
|