@2en/clawly-plugins 1.30.0-beta.2 → 1.30.0-beta.4
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/clawly-config-defaults.json5 +107 -3
- package/config-setup.ts +140 -324
- package/gateway/index.ts +0 -2
- package/gateway/offline-push.test.ts +39 -20
- package/gateway/offline-push.ts +28 -0
- package/index.ts +1 -1
- package/outbound.ts +20 -24
- package/package.json +4 -5
- package/tools/clawly-send-file.test.ts +400 -0
- package/tools/clawly-send-file.ts +307 -0
- package/tools/index.ts +2 -2
- package/types.ts +1 -1
- package/gateway/node-dangerous-allowlist.ts +0 -84
- package/tools/clawly-send-image.ts +0 -228
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tool: clawly_send_file — send a file to the user.
|
|
3
|
+
*
|
|
4
|
+
* Accepts a URL (https/http) or a local file path (or arrays of either).
|
|
5
|
+
* URLs are downloaded and cached locally. Injects a FILE:<path>?<filename> assistant
|
|
6
|
+
* message via chat.inject so the mobile client can render or display the file.
|
|
7
|
+
*
|
|
8
|
+
* Restrictions:
|
|
9
|
+
* - URLs: executable files are rejected (by MIME/extension).
|
|
10
|
+
* - Local files:
|
|
11
|
+
* - Dot files rejected (e.g. ~/.openclaw, .env, .bashrc).
|
|
12
|
+
* - Only files under $HOME or /tmp (files outside home are rejected unless in /tmp).
|
|
13
|
+
* - State dir ($OPENCLAW_STATE_DIR) blocked, except clawly/, canvas/, memory/ subdirs.
|
|
14
|
+
* - Executable files rejected (binaries + script extensions).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import crypto from 'node:crypto'
|
|
18
|
+
import fsp from 'node:fs/promises'
|
|
19
|
+
import os from 'node:os'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import {fileTypeFromBuffer, fileTypeFromFile} from 'file-type'
|
|
22
|
+
import {injectAssistantMessage, resolveSessionKey} from '../gateway/inject'
|
|
23
|
+
import type {PluginApi} from '../types'
|
|
24
|
+
|
|
25
|
+
const TOOL_NAME = 'clawly_send_file'
|
|
26
|
+
const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 // 50 MB
|
|
27
|
+
|
|
28
|
+
const parameters: Record<string, unknown> = {
|
|
29
|
+
type: 'object',
|
|
30
|
+
required: ['source'],
|
|
31
|
+
properties: {
|
|
32
|
+
source: {
|
|
33
|
+
oneOf: [
|
|
34
|
+
{type: 'string', description: 'File URL (https://...) or local file path'},
|
|
35
|
+
{
|
|
36
|
+
type: 'array',
|
|
37
|
+
items: {type: 'string'},
|
|
38
|
+
description: 'Array of file URLs or local file paths',
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
caption: {type: 'string', description: 'Optional caption text'},
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Executable blocklist ───────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/** MIME types for binary executables (ELF, Mach-O, PE, etc.) */
|
|
49
|
+
const EXECUTABLE_MIMES = new Set([
|
|
50
|
+
'application/x-executable',
|
|
51
|
+
'application/x-elf',
|
|
52
|
+
'application/x-sharedlib',
|
|
53
|
+
'application/x-pie-executable',
|
|
54
|
+
'application/x-mach-binary',
|
|
55
|
+
'application/x-mach-binary-fat',
|
|
56
|
+
'application/x-msdownload',
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
/** Extensions for executables and executable scripts */
|
|
60
|
+
const EXECUTABLE_EXTS = new Set([
|
|
61
|
+
'.exe',
|
|
62
|
+
'.com',
|
|
63
|
+
'.bat',
|
|
64
|
+
'.cmd',
|
|
65
|
+
'.sh',
|
|
66
|
+
'.bash',
|
|
67
|
+
'.csh',
|
|
68
|
+
'.zsh',
|
|
69
|
+
'.ps1',
|
|
70
|
+
'.psm1',
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
function isExecutableByMime(mime?: string): boolean {
|
|
74
|
+
return !!mime && EXECUTABLE_MIMES.has(mime)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isExecutableByExt(filePath: string): boolean {
|
|
78
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
79
|
+
return EXECUTABLE_EXTS.has(ext)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasDotFileComponent(filePath: string): boolean {
|
|
83
|
+
const normalized = path.normalize(filePath)
|
|
84
|
+
const parts = normalized.split(path.sep)
|
|
85
|
+
return parts.some((p) => p.startsWith('.') && p.length > 1)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isUnderAllowedRoot(filePath: string): boolean {
|
|
89
|
+
const resolved = path.resolve(filePath)
|
|
90
|
+
const home = os.homedir()
|
|
91
|
+
return resolved.startsWith(home + path.sep) || resolved === home || resolved.startsWith('/tmp/')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function downloadsDir(api: PluginApi): string {
|
|
97
|
+
return path.join(api.runtime.state.resolveStateDir(), 'clawly', 'downloads')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function extFromUrl(url: string): string {
|
|
101
|
+
try {
|
|
102
|
+
const pathname = new URL(url).pathname
|
|
103
|
+
const ext = path.extname(pathname).toLowerCase()
|
|
104
|
+
if (ext) return ext
|
|
105
|
+
} catch {}
|
|
106
|
+
return ''
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function localPath(source: string, ext: string, api: PluginApi): string {
|
|
110
|
+
const hash = crypto.createHash('sha256').update(source).digest('hex')
|
|
111
|
+
return path.join(downloadsDir(api), `${hash}${ext || ''}`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
115
|
+
try {
|
|
116
|
+
await fsp.access(p)
|
|
117
|
+
return true
|
|
118
|
+
} catch {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sourceFilename(source: string): string {
|
|
124
|
+
try {
|
|
125
|
+
return path.basename(new URL(source).pathname) || path.basename(source)
|
|
126
|
+
} catch {
|
|
127
|
+
return path.basename(source)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatMediaMessage(filePaths: string[], sources: string[], caption?: string): string {
|
|
132
|
+
const parts: string[] = []
|
|
133
|
+
if (caption) parts.push(caption)
|
|
134
|
+
for (let i = 0; i < filePaths.length; i++) {
|
|
135
|
+
const filename = sourceFilename(sources[i] ?? filePaths[i])
|
|
136
|
+
const pathBasename = path.basename(filePaths[i])
|
|
137
|
+
const qs = filename !== pathBasename ? `?filename=${encodeURIComponent(filename)}` : ''
|
|
138
|
+
parts.push(`FILE:${filePaths[i]}${qs}`)
|
|
139
|
+
}
|
|
140
|
+
return parts.join('\n')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Download ─────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000
|
|
146
|
+
|
|
147
|
+
async function downloadFile(url: string, api: PluginApi): Promise<string> {
|
|
148
|
+
const dir = downloadsDir(api)
|
|
149
|
+
await fsp.mkdir(dir, {recursive: true})
|
|
150
|
+
|
|
151
|
+
const controller = new AbortController()
|
|
152
|
+
const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS)
|
|
153
|
+
try {
|
|
154
|
+
const res = await fetch(url, {redirect: 'follow', signal: controller.signal})
|
|
155
|
+
if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`)
|
|
156
|
+
|
|
157
|
+
const contentLength = res.headers.get('content-length')
|
|
158
|
+
if (contentLength && Number(contentLength) > MAX_DOWNLOAD_BYTES) {
|
|
159
|
+
throw new Error(`file too large (${contentLength} bytes, max ${MAX_DOWNLOAD_BYTES})`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const chunks: Uint8Array[] = []
|
|
163
|
+
let totalSize = 0
|
|
164
|
+
const reader = res.body?.getReader()
|
|
165
|
+
if (!reader) throw new Error('no response body')
|
|
166
|
+
|
|
167
|
+
while (true) {
|
|
168
|
+
const {done, value} = await reader.read()
|
|
169
|
+
if (done) break
|
|
170
|
+
totalSize += value.byteLength
|
|
171
|
+
if (totalSize > MAX_DOWNLOAD_BYTES) {
|
|
172
|
+
reader.cancel()
|
|
173
|
+
throw new Error(`file too large (exceeded ${MAX_DOWNLOAD_BYTES} bytes)`)
|
|
174
|
+
}
|
|
175
|
+
chunks.push(value)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const buffer = Buffer.concat(chunks)
|
|
179
|
+
const type = await fileTypeFromBuffer(buffer)
|
|
180
|
+
|
|
181
|
+
if (type && isExecutableByMime(type.mime)) {
|
|
182
|
+
throw new Error('downloaded file is an executable and cannot be sent')
|
|
183
|
+
}
|
|
184
|
+
const urlExt = extFromUrl(url)
|
|
185
|
+
if (urlExt && EXECUTABLE_EXTS.has(urlExt)) {
|
|
186
|
+
throw new Error('downloaded file has executable extension and cannot be sent')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Use urlExt for cache key so resolveSource cache lookup matches
|
|
190
|
+
const dest = localPath(url, urlExt, api)
|
|
191
|
+
await fsp.writeFile(dest, buffer)
|
|
192
|
+
api.logger.info(`${TOOL_NAME}: downloaded ${url} -> ${dest} (${buffer.length} bytes)`)
|
|
193
|
+
return dest
|
|
194
|
+
} finally {
|
|
195
|
+
clearTimeout(timeout)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Tool registration ────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export function registerSendFileTool(api: PluginApi) {
|
|
202
|
+
api.registerTool({
|
|
203
|
+
name: TOOL_NAME,
|
|
204
|
+
description:
|
|
205
|
+
'Send one or more files to the user. Accepts a single URL/path or an array of URLs/paths. Supports images, documents, audio, etc. Executable files and dot files are not allowed. Local files must be under $HOME or /tmp.',
|
|
206
|
+
parameters,
|
|
207
|
+
async execute(_toolCallId, params) {
|
|
208
|
+
let sources: string[]
|
|
209
|
+
if (typeof params.source === 'string') {
|
|
210
|
+
sources = [params.source.trim()].filter(Boolean)
|
|
211
|
+
} else if (Array.isArray(params.source)) {
|
|
212
|
+
sources = params.source
|
|
213
|
+
.filter((s): s is string => typeof s === 'string')
|
|
214
|
+
.map((s) => s.trim())
|
|
215
|
+
.filter(Boolean)
|
|
216
|
+
} else {
|
|
217
|
+
sources = []
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (sources.length === 0) {
|
|
221
|
+
return {content: [{type: 'text', text: JSON.stringify({error: 'source is required'})}]}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const caption = typeof params.caption === 'string' ? params.caption.trim() : undefined
|
|
225
|
+
|
|
226
|
+
const resolveSource = async (source: string): Promise<string> => {
|
|
227
|
+
if (source.startsWith('https://') || source.startsWith('http://')) {
|
|
228
|
+
const guessExt = extFromUrl(source)
|
|
229
|
+
if (guessExt && EXECUTABLE_EXTS.has(guessExt)) {
|
|
230
|
+
throw new Error(`${source}: URL has executable extension and cannot be sent`)
|
|
231
|
+
}
|
|
232
|
+
const cached = localPath(source, guessExt, api)
|
|
233
|
+
if (await fileExists(cached)) {
|
|
234
|
+
api.logger.info(`${TOOL_NAME}: serving cached ${cached}`)
|
|
235
|
+
return cached
|
|
236
|
+
}
|
|
237
|
+
return await downloadFile(source, api)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Local file source
|
|
241
|
+
const resolved = path.resolve(source.replace(/^~/, os.homedir()))
|
|
242
|
+
if (!(await fileExists(resolved))) {
|
|
243
|
+
throw new Error(`${source}: file not found`)
|
|
244
|
+
}
|
|
245
|
+
if (!isUnderAllowedRoot(resolved)) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`${source}: file must be under $HOME or /tmp (files outside home are not allowed)`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
// Block state dir files except clawly/, canvas/, memory/ subdirs.
|
|
251
|
+
// This check runs BEFORE hasDotFileComponent because the state dir
|
|
252
|
+
// itself may contain a dot component (e.g. ~/.openclaw).
|
|
253
|
+
const stateDir = api.runtime.state.resolveStateDir()
|
|
254
|
+
const stateDirPrefix = stateDir + path.sep
|
|
255
|
+
const isUnderStateDir = resolved.startsWith(stateDirPrefix) || resolved === stateDir
|
|
256
|
+
if (isUnderStateDir) {
|
|
257
|
+
const allowedPrefixes = [
|
|
258
|
+
path.join(stateDir, 'clawly') + path.sep,
|
|
259
|
+
path.join(stateDir, 'canvas') + path.sep,
|
|
260
|
+
path.join(stateDir, 'memory') + path.sep,
|
|
261
|
+
]
|
|
262
|
+
if (!allowedPrefixes.some((p) => resolved.startsWith(p))) {
|
|
263
|
+
throw new Error(`${source}: files under the state directory are not allowed`)
|
|
264
|
+
}
|
|
265
|
+
} else if (hasDotFileComponent(resolved)) {
|
|
266
|
+
throw new Error(`${source}: dot files (e.g. .openclaw, .env) are not allowed`)
|
|
267
|
+
}
|
|
268
|
+
if (isExecutableByExt(resolved)) {
|
|
269
|
+
throw new Error(`${source}: executable files are not allowed`)
|
|
270
|
+
}
|
|
271
|
+
const type = await fileTypeFromFile(resolved)
|
|
272
|
+
if (type && isExecutableByMime(type.mime)) {
|
|
273
|
+
throw new Error(`${source}: file is an executable and cannot be sent`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const ext = path.extname(resolved) || (type?.ext ? `.${type.ext}` : '')
|
|
277
|
+
const dest = localPath(resolved, ext, api)
|
|
278
|
+
if (!(await fileExists(dest))) {
|
|
279
|
+
const dir = path.dirname(dest)
|
|
280
|
+
await fsp.mkdir(dir, {recursive: true})
|
|
281
|
+
await fsp.copyFile(resolved, dest)
|
|
282
|
+
}
|
|
283
|
+
return dest
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const resolved = await Promise.all(sources.map(resolveSource))
|
|
288
|
+
const mediaMessage = formatMediaMessage(resolved, sources, caption)
|
|
289
|
+
const sessionKey = await resolveSessionKey('clawly', api)
|
|
290
|
+
await injectAssistantMessage({sessionKey, message: mediaMessage}, api)
|
|
291
|
+
api.logger.info(`${TOOL_NAME}: injected ${resolved.length} file(s) into session`)
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
content: [
|
|
295
|
+
{type: 'text', text: JSON.stringify({sent: true, count: resolved.length, sources})},
|
|
296
|
+
],
|
|
297
|
+
}
|
|
298
|
+
} catch (err) {
|
|
299
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
300
|
+
api.logger.error(`${TOOL_NAME}: ${msg}`)
|
|
301
|
+
return {content: [{type: 'text', text: JSON.stringify({error: msg})}]}
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
api.logger.info(`tool: registered ${TOOL_NAME} agent tool`)
|
|
307
|
+
}
|
package/tools/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import {registerIsUserOnlineTool} from './clawly-is-user-online'
|
|
|
4
4
|
import {registerMsgBreakTool} from './clawly-msg-break'
|
|
5
5
|
import {registerDeepSearchTool, registerGrokSearchTool, registerSearchTool} from './clawly-search'
|
|
6
6
|
import {registerSendAppPushTool} from './clawly-send-app-push'
|
|
7
|
-
import {
|
|
7
|
+
import {registerSendFileTool} from './clawly-send-file'
|
|
8
8
|
import {registerSendMessageTool} from './clawly-send-message'
|
|
9
9
|
|
|
10
10
|
export function registerTools(api: PluginApi) {
|
|
@@ -15,6 +15,6 @@ export function registerTools(api: PluginApi) {
|
|
|
15
15
|
registerDeepSearchTool(api)
|
|
16
16
|
registerGrokSearchTool(api)
|
|
17
17
|
registerSendAppPushTool(api)
|
|
18
|
-
|
|
18
|
+
registerSendFileTool(api)
|
|
19
19
|
registerSendMessageTool(api)
|
|
20
20
|
}
|
package/types.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type PluginRuntimeCore = {
|
|
|
22
22
|
version: string
|
|
23
23
|
config: {
|
|
24
24
|
loadConfig: (...args: unknown[]) => unknown
|
|
25
|
-
writeConfigFile: (
|
|
25
|
+
writeConfigFile: (config: OpenClawConfig) => Promise<unknown>
|
|
26
26
|
}
|
|
27
27
|
system: {
|
|
28
28
|
enqueueSystemEvent: (...args: unknown[]) => void
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ensures all dangerous node commands are in gateway.nodes.allowCommands
|
|
3
|
-
* so the AI agent can invoke them on connected Clawly nodes.
|
|
4
|
-
*
|
|
5
|
-
* Dangerous commands are defined by OpenClaw's node-command-policy and
|
|
6
|
-
* must be explicitly allowlisted. Safe commands work without allowlisting.
|
|
7
|
-
*
|
|
8
|
-
* Runs on gateway_start. Writes directly to openclaw.json via
|
|
9
|
-
* writeOpenclawConfig (no restart triggered — gateway.* changes are
|
|
10
|
-
* classified as "none" by the config watcher).
|
|
11
|
-
*/
|
|
12
|
-
import path from 'node:path'
|
|
13
|
-
|
|
14
|
-
import type {PluginApi} from '../types'
|
|
15
|
-
import {readOpenclawConfig, writeOpenclawConfig} from '../model-gateway-setup'
|
|
16
|
-
|
|
17
|
-
const DANGEROUS_COMMANDS = [
|
|
18
|
-
// Browser commands (Mac nodes)
|
|
19
|
-
'browser.proxy',
|
|
20
|
-
'browser.navigate',
|
|
21
|
-
'browser.click',
|
|
22
|
-
'browser.type',
|
|
23
|
-
'browser.screenshot',
|
|
24
|
-
'browser.read',
|
|
25
|
-
'browser.tabs',
|
|
26
|
-
'browser.back',
|
|
27
|
-
'browser.scroll',
|
|
28
|
-
'browser.evaluate',
|
|
29
|
-
// Reminders + calendar (iOS nodes)
|
|
30
|
-
'reminders.add',
|
|
31
|
-
'calendar.add',
|
|
32
|
-
// Device permissions (iOS nodes) — not in OpenClaw's iOS defaults
|
|
33
|
-
'device.permissions',
|
|
34
|
-
'device.requestPermission',
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
export function registerNodeDangerousAllowlist(api: PluginApi) {
|
|
38
|
-
api.on('gateway_start', async () => {
|
|
39
|
-
let stateDir: string
|
|
40
|
-
try {
|
|
41
|
-
stateDir = api.runtime.state.resolveStateDir()
|
|
42
|
-
} catch {
|
|
43
|
-
api.logger.warn('node-dangerous-allowlist: cannot resolve state dir, skipping')
|
|
44
|
-
return
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const configPath = path.join(stateDir, 'openclaw.json')
|
|
48
|
-
let config: Record<string, unknown>
|
|
49
|
-
try {
|
|
50
|
-
config = readOpenclawConfig(configPath)
|
|
51
|
-
} catch {
|
|
52
|
-
api.logger.warn('node-dangerous-allowlist: failed to read config, skipping')
|
|
53
|
-
return
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const gateway = (config.gateway as Record<string, unknown>) ?? {}
|
|
57
|
-
const nodes = (gateway.nodes as Record<string, unknown>) ?? {}
|
|
58
|
-
const existing: string[] = Array.isArray(nodes.allowCommands)
|
|
59
|
-
? (nodes.allowCommands as string[])
|
|
60
|
-
: []
|
|
61
|
-
|
|
62
|
-
const existingSet = new Set(existing)
|
|
63
|
-
const toAdd = DANGEROUS_COMMANDS.filter((cmd) => !existingSet.has(cmd))
|
|
64
|
-
|
|
65
|
-
if (toAdd.length === 0) {
|
|
66
|
-
api.logger.info('node-dangerous-allowlist: all commands already allowlisted')
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
nodes.allowCommands = [...existing, ...toAdd]
|
|
71
|
-
gateway.nodes = nodes
|
|
72
|
-
config.gateway = gateway
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
writeOpenclawConfig(configPath, config)
|
|
76
|
-
} catch {
|
|
77
|
-
api.logger.warn('node-dangerous-allowlist: failed to write config')
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
api.logger.info(
|
|
81
|
-
`node-dangerous-allowlist: added ${toAdd.length} commands to allowlist: ${toAdd.join(', ')}`,
|
|
82
|
-
)
|
|
83
|
-
})
|
|
84
|
-
}
|
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent tool: clawly_send_image — send an image to the user.
|
|
3
|
-
*
|
|
4
|
-
* Accepts a URL (https/http) or a local file path (or arrays of either).
|
|
5
|
-
* URLs are downloaded and cached locally. Magic-byte validation ensures
|
|
6
|
-
* only real images are served. Injects a MEDIA:<path> assistant message
|
|
7
|
-
* via chat.inject so the mobile client renders inline images.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import crypto from 'node:crypto'
|
|
11
|
-
import fsp from 'node:fs/promises'
|
|
12
|
-
import path from 'node:path'
|
|
13
|
-
|
|
14
|
-
import {fileTypeFromBuffer, fileTypeFromFile} from 'file-type'
|
|
15
|
-
|
|
16
|
-
import {injectAssistantMessage, resolveSessionKey} from '../gateway/inject'
|
|
17
|
-
import type {PluginApi} from '../types'
|
|
18
|
-
|
|
19
|
-
const TOOL_NAME = 'clawly_send_image'
|
|
20
|
-
const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 // 50 MB
|
|
21
|
-
|
|
22
|
-
const parameters: Record<string, unknown> = {
|
|
23
|
-
type: 'object',
|
|
24
|
-
required: ['source'],
|
|
25
|
-
properties: {
|
|
26
|
-
source: {
|
|
27
|
-
oneOf: [
|
|
28
|
-
{type: 'string', description: 'Image URL (https://...) or local file path'},
|
|
29
|
-
{
|
|
30
|
-
type: 'array',
|
|
31
|
-
items: {type: 'string'},
|
|
32
|
-
description: 'Array of image URLs or local file paths',
|
|
33
|
-
},
|
|
34
|
-
],
|
|
35
|
-
},
|
|
36
|
-
caption: {type: 'string', description: 'Optional caption text'},
|
|
37
|
-
},
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// ── Image MIME allowlist (aligned with expo-image, excluding SVG) ────
|
|
41
|
-
|
|
42
|
-
const IMAGE_MIMES = new Set([
|
|
43
|
-
'image/jpeg',
|
|
44
|
-
'image/png',
|
|
45
|
-
'image/apng',
|
|
46
|
-
'image/gif',
|
|
47
|
-
'image/webp',
|
|
48
|
-
'image/avif',
|
|
49
|
-
'image/heic',
|
|
50
|
-
'image/bmp',
|
|
51
|
-
'image/x-icon',
|
|
52
|
-
])
|
|
53
|
-
|
|
54
|
-
// ── Helpers ──────────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
function downloadsDir(api: PluginApi): string {
|
|
57
|
-
return path.join(api.runtime.state.resolveStateDir(), 'clawly', 'downloads')
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function extFromUrl(url: string): string {
|
|
61
|
-
try {
|
|
62
|
-
const pathname = new URL(url).pathname
|
|
63
|
-
const ext = path.extname(pathname).toLowerCase()
|
|
64
|
-
if (
|
|
65
|
-
ext &&
|
|
66
|
-
['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.heic', '.ico'].includes(ext)
|
|
67
|
-
)
|
|
68
|
-
return ext
|
|
69
|
-
} catch {}
|
|
70
|
-
return '.jpg'
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function localPath(source: string, ext: string, api: PluginApi): string {
|
|
74
|
-
const hash = crypto.createHash('sha256').update(source).digest('hex')
|
|
75
|
-
return path.join(downloadsDir(api), `${hash}${ext}`)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function fileExists(p: string): Promise<boolean> {
|
|
79
|
-
try {
|
|
80
|
-
await fsp.access(p)
|
|
81
|
-
return true
|
|
82
|
-
} catch {
|
|
83
|
-
return false
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function formatMediaMessage(filePaths: string[], caption?: string): string {
|
|
88
|
-
const parts: string[] = []
|
|
89
|
-
if (caption) parts.push(caption)
|
|
90
|
-
for (const p of filePaths) parts.push(`MEDIA:${p}`)
|
|
91
|
-
return parts.join('\n')
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// ── Download ─────────────────────────────────────────────────────────
|
|
95
|
-
|
|
96
|
-
/** Downloads an image, validates it, and saves with the detected extension. Returns the final path or throws. */
|
|
97
|
-
const DOWNLOAD_TIMEOUT_MS = 30_000
|
|
98
|
-
|
|
99
|
-
async function downloadImage(url: string, api: PluginApi): Promise<string> {
|
|
100
|
-
const dir = downloadsDir(api)
|
|
101
|
-
await fsp.mkdir(dir, {recursive: true})
|
|
102
|
-
|
|
103
|
-
const controller = new AbortController()
|
|
104
|
-
const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS)
|
|
105
|
-
try {
|
|
106
|
-
const res = await fetch(url, {redirect: 'follow', signal: controller.signal})
|
|
107
|
-
if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`)
|
|
108
|
-
|
|
109
|
-
const contentLength = res.headers.get('content-length')
|
|
110
|
-
if (contentLength && Number(contentLength) > MAX_DOWNLOAD_BYTES) {
|
|
111
|
-
throw new Error(`file too large (${contentLength} bytes, max ${MAX_DOWNLOAD_BYTES})`)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const chunks: Uint8Array[] = []
|
|
115
|
-
let totalSize = 0
|
|
116
|
-
const reader = res.body?.getReader()
|
|
117
|
-
if (!reader) throw new Error('no response body')
|
|
118
|
-
|
|
119
|
-
while (true) {
|
|
120
|
-
const {done, value} = await reader.read()
|
|
121
|
-
if (done) break
|
|
122
|
-
totalSize += value.byteLength
|
|
123
|
-
if (totalSize > MAX_DOWNLOAD_BYTES) {
|
|
124
|
-
reader.cancel()
|
|
125
|
-
throw new Error(`file too large (exceeded ${MAX_DOWNLOAD_BYTES} bytes)`)
|
|
126
|
-
}
|
|
127
|
-
chunks.push(value)
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const buffer = Buffer.concat(chunks)
|
|
131
|
-
|
|
132
|
-
const type = await fileTypeFromBuffer(buffer)
|
|
133
|
-
if (!type || !IMAGE_MIMES.has(type.mime)) {
|
|
134
|
-
throw new Error('downloaded file is not a recognized image format')
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const dest = localPath(url, `.${type.ext}`, api)
|
|
138
|
-
await fsp.writeFile(dest, buffer)
|
|
139
|
-
api.logger.info(`${TOOL_NAME}: downloaded ${url} -> ${dest} (${buffer.length} bytes)`)
|
|
140
|
-
return dest
|
|
141
|
-
} finally {
|
|
142
|
-
clearTimeout(timeout)
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// ── Tool registration ────────────────────────────────────────────────
|
|
147
|
-
|
|
148
|
-
export function registerSendImageTool(api: PluginApi) {
|
|
149
|
-
api.registerTool({
|
|
150
|
-
name: TOOL_NAME,
|
|
151
|
-
description:
|
|
152
|
-
'Send one or more images to the user. Accepts a single URL/path or an array of URLs/paths. Images are displayed inline in the chat.',
|
|
153
|
-
parameters,
|
|
154
|
-
async execute(_toolCallId, params) {
|
|
155
|
-
// Normalize source to string[]
|
|
156
|
-
let sources: string[]
|
|
157
|
-
if (typeof params.source === 'string') {
|
|
158
|
-
sources = [params.source.trim()]
|
|
159
|
-
} else if (Array.isArray(params.source)) {
|
|
160
|
-
sources = params.source
|
|
161
|
-
.filter((s): s is string => typeof s === 'string')
|
|
162
|
-
.map((s) => s.trim())
|
|
163
|
-
.filter(Boolean)
|
|
164
|
-
} else {
|
|
165
|
-
sources = []
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (sources.length === 0) {
|
|
169
|
-
return {content: [{type: 'text', text: JSON.stringify({error: 'source is required'})}]}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const caption = typeof params.caption === 'string' ? params.caption.trim() : undefined
|
|
173
|
-
|
|
174
|
-
const resolveSource = async (source: string): Promise<string> => {
|
|
175
|
-
if (source.startsWith('https://') || source.startsWith('http://')) {
|
|
176
|
-
// Check cache with URL-derived extension (best guess)
|
|
177
|
-
const guessExt = extFromUrl(source)
|
|
178
|
-
const cached = localPath(source, guessExt, api)
|
|
179
|
-
if (await fileExists(cached)) {
|
|
180
|
-
api.logger.info(`${TOOL_NAME}: serving cached ${cached}`)
|
|
181
|
-
return cached
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return await downloadImage(source, api)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Local file source
|
|
188
|
-
if (!(await fileExists(source))) {
|
|
189
|
-
throw new Error(`${source}: file not found`)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const type = await fileTypeFromFile(source)
|
|
193
|
-
if (!type || !IMAGE_MIMES.has(type.mime)) {
|
|
194
|
-
throw new Error(`${source}: not a recognized image format`)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Copy to downloads dir so the outbound endpoint can serve it
|
|
198
|
-
const dest = localPath(source, `.${type.ext}`, api)
|
|
199
|
-
if (!(await fileExists(dest))) {
|
|
200
|
-
const dir = path.dirname(dest)
|
|
201
|
-
await fsp.mkdir(dir, {recursive: true})
|
|
202
|
-
await fsp.copyFile(source, dest)
|
|
203
|
-
}
|
|
204
|
-
return dest
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
try {
|
|
208
|
-
const resolved = await Promise.all(sources.map(resolveSource))
|
|
209
|
-
|
|
210
|
-
// Inject MEDIA: lines as an assistant message so they appear in the chat
|
|
211
|
-
const mediaMessage = formatMediaMessage(resolved, caption)
|
|
212
|
-
const sessionKey = await resolveSessionKey('clawly', api)
|
|
213
|
-
await injectAssistantMessage({sessionKey, message: mediaMessage}, api)
|
|
214
|
-
api.logger.info(`${TOOL_NAME}: injected ${resolved.length} image(s) into session`)
|
|
215
|
-
|
|
216
|
-
return {
|
|
217
|
-
content: [{type: 'text', text: JSON.stringify({sent: true, count: resolved.length})}],
|
|
218
|
-
}
|
|
219
|
-
} catch (err) {
|
|
220
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
221
|
-
api.logger.error(`${TOOL_NAME}: ${msg}`)
|
|
222
|
-
return {content: [{type: 'text', text: JSON.stringify({error: msg})}]}
|
|
223
|
-
}
|
|
224
|
-
},
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
api.logger.info(`tool: registered ${TOOL_NAME} agent tool`)
|
|
228
|
-
}
|