@2en/clawly-plugins 1.25.0 → 1.26.0-beta.1

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/outbound.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  /**
2
- * TTS outbound persistencecopies ephemeral TTS audio to a persistent directory
3
- * so mobile can fetch it after the temp files are cleaned up.
2
+ * Outbound file servingserves files to the mobile client.
3
+ *
4
+ * Resolution order (HTTP route + gateway method):
5
+ * 1. Hash-mapped file: ~/.openclaw/clawly/outbound/<sha256(path)>.<ext>
6
+ * 2. Direct path: if the raw path is under an allowlisted directory
4
7
  *
5
8
  * Hook: tool_result_persist → copies TTS audioPath to ~/.openclaw/clawly/outbound/<hash>.<ext>
6
- * Method: clawly.file.getOutbound → reads outbound file by original-path hash
9
+ * Method: clawly.file.getOutbound → reads outbound file by original-path hash or direct path
7
10
  */
8
11
 
9
12
  import crypto from 'node:crypto'
@@ -74,6 +77,8 @@ export function registerOutboundHook(api: PluginApi) {
74
77
  // ── Gateway method: clawly.file.getOutbound ─────────────────────────
75
78
 
76
79
  export function registerOutboundMethods(api: PluginApi) {
80
+ const stateDir = api.runtime.state.resolveStateDir()
81
+
77
82
  api.registerGatewayMethod('clawly.file.getOutbound', async ({params, respond}) => {
78
83
  const rawPath = typeof params.path === 'string' ? params.path.trim() : ''
79
84
 
@@ -82,30 +87,18 @@ export function registerOutboundMethods(api: PluginApi) {
82
87
  return
83
88
  }
84
89
 
85
- const dest = outboundFilePath(rawPath)
86
-
87
- if (!(await fileExists(dest))) {
88
- // Fallback: TTS writes directly to /tmp/openclaw/ and the tool_result_persist hook
89
- // only fires for agent tool calls, not tts.convert RPC calls. Read the source file
90
- // directly if it lives in the known-safe TTS temp directory.
91
- if (rawPath.startsWith('/tmp/openclaw/') && (await fileExists(rawPath))) {
92
- const buffer = await fsp.readFile(rawPath)
93
- api.logger.info(
94
- `clawly.file.getOutbound: served from source ${rawPath} (${buffer.length} bytes)`,
95
- )
96
- respond(true, {base64: buffer.toString('base64')})
97
- return
98
- }
99
- api.logger.warn(`outbound: file not found: ${rawPath} ${dest}`)
90
+ const resolved = await resolveOutboundFile(rawPath, stateDir)
91
+ if (!resolved) {
92
+ api.logger.warn(`outbound: file not found: ${rawPath}`)
100
93
  respond(false, undefined, {code: 'not_found', message: 'outbound file not found'})
101
94
  return
102
95
  }
103
96
 
104
- const buffer = await fsp.readFile(dest)
105
- const base64 = buffer.toString('base64')
106
-
107
- api.logger.info(`clawly.file.getOutbound: served ${rawPath} (${buffer.length} bytes)`)
108
- respond(true, {base64})
97
+ const buffer = await fsp.readFile(resolved)
98
+ api.logger.info(
99
+ `clawly.file.getOutbound: served ${rawPath} -> ${resolved} (${buffer.length} bytes)`,
100
+ )
101
+ respond(true, {base64: buffer.toString('base64')})
109
102
  })
110
103
  }
111
104
 
@@ -119,6 +112,47 @@ const MIME: Record<string, string> = {
119
112
  '.aac': 'audio/aac',
120
113
  '.flac': 'audio/flac',
121
114
  '.webm': 'audio/webm',
115
+ '.jpg': 'image/jpeg',
116
+ '.jpeg': 'image/jpeg',
117
+ '.png': 'image/png',
118
+ '.gif': 'image/gif',
119
+ '.webp': 'image/webp',
120
+ '.bmp': 'image/bmp',
121
+ '.heic': 'image/heic',
122
+ '.avif': 'image/avif',
123
+ '.ico': 'image/x-icon',
124
+ }
125
+
126
+ /** Directories from which direct-path serving is allowed (no hash required). */
127
+ let allowedRoots: string[] | null = null
128
+
129
+ function getAllowedRoots(stateDir?: string): string[] {
130
+ if (!allowedRoots) {
131
+ const roots = [OUTBOUND_DIR + path.sep, '/tmp/']
132
+ if (stateDir) roots.push(stateDir + path.sep)
133
+ allowedRoots = roots
134
+ }
135
+ return allowedRoots
136
+ }
137
+
138
+ function isDirectPathAllowed(resolved: string, stateDir?: string): boolean {
139
+ return getAllowedRoots(stateDir).some((root) => resolved.startsWith(root))
140
+ }
141
+
142
+ /**
143
+ * Resolve which file to serve: hash-mapped first, then direct path if allowlisted.
144
+ * Returns the resolved file path or null.
145
+ */
146
+ async function resolveOutboundFile(rawPath: string, stateDir?: string): Promise<string | null> {
147
+ // 1. Hash-mapped file
148
+ const hashed = outboundFilePath(rawPath)
149
+ if (await fileExists(hashed)) return hashed
150
+
151
+ // 2. Direct path (allowlisted directories only)
152
+ const resolved = path.resolve(rawPath)
153
+ if (isDirectPathAllowed(resolved, stateDir) && (await fileExists(resolved))) return resolved
154
+
155
+ return null
122
156
  }
123
157
 
124
158
  function sendJson(res: ServerResponse, status: number, body: Record<string, unknown>) {
@@ -127,6 +161,8 @@ function sendJson(res: ServerResponse, status: number, body: Record<string, unkn
127
161
  }
128
162
 
129
163
  export function registerOutboundHttpRoute(api: PluginApi) {
164
+ const stateDir = api.runtime.state.resolveStateDir()
165
+
130
166
  api.registerHttpRoute({
131
167
  path: '/clawly/file/outbound',
132
168
  handler: async (_req: IncomingMessage, res: ServerResponse) => {
@@ -138,17 +174,16 @@ export function registerOutboundHttpRoute(api: PluginApi) {
138
174
  return
139
175
  }
140
176
 
141
- const dest = outboundFilePath(rawPath)
142
-
143
- if (!(await fileExists(dest))) {
177
+ const resolved = await resolveOutboundFile(rawPath, stateDir)
178
+ if (!resolved) {
144
179
  api.logger.warn(`outbound http: file not found: ${rawPath}`)
145
180
  sendJson(res, 404, {error: 'outbound file not found'})
146
181
  return
147
182
  }
148
183
 
149
- const ext = path.extname(dest).toLowerCase()
184
+ const ext = path.extname(resolved).toLowerCase()
150
185
  const contentType = MIME[ext] ?? 'application/octet-stream'
151
- const stat = await fsp.stat(dest)
186
+ const stat = await fsp.stat(resolved)
152
187
  const total = stat.size
153
188
  const rangeHeader = _req.headers.range
154
189
 
@@ -158,7 +193,7 @@ export function registerOutboundHttpRoute(api: PluginApi) {
158
193
  const start = Number(match[1])
159
194
  const end = match[2] ? Number(match[2]) : total - 1
160
195
  const chunkSize = end - start + 1
161
- const stream = fs.createReadStream(dest, {start, end})
196
+ const stream = fs.createReadStream(resolved, {start, end})
162
197
 
163
198
  res.writeHead(206, {
164
199
  'Content-Type': contentType,
@@ -168,13 +203,13 @@ export function registerOutboundHttpRoute(api: PluginApi) {
168
203
  })
169
204
  stream.pipe(res)
170
205
  api.logger.info(
171
- `outbound http: served ${rawPath} -> ${dest} range ${start}-${end}/${total}`,
206
+ `outbound http: served ${rawPath} -> ${resolved} range ${start}-${end}/${total}`,
172
207
  )
173
208
  return
174
209
  }
175
210
  }
176
211
 
177
- const buffer = await fsp.readFile(dest)
212
+ const buffer = await fsp.readFile(resolved)
178
213
  res.writeHead(200, {
179
214
  'Content-Type': contentType,
180
215
  'Content-Length': total,
@@ -182,7 +217,7 @@ export function registerOutboundHttpRoute(api: PluginApi) {
182
217
  })
183
218
  res.end(buffer)
184
219
 
185
- api.logger.info(`outbound http: served ${rawPath} -> ${dest} (${total} bytes)`)
220
+ api.logger.info(`outbound http: served ${rawPath} -> ${resolved} (${total} bytes)`)
186
221
  },
187
222
  })
188
223
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.25.0",
3
+ "version": "1.26.0-beta.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -9,6 +9,12 @@
9
9
  "directory": "plugins/clawly-plugins"
10
10
  },
11
11
  "dependencies": {
12
+ "@opentelemetry/api-logs": "^0.57.0",
13
+ "@opentelemetry/exporter-logs-otlp-http": "^0.57.0",
14
+ "@opentelemetry/resources": "^1.30.0",
15
+ "@opentelemetry/sdk-logs": "^0.57.0",
16
+ "posthog-node": "^5.28.0",
17
+ "file-type": "^21.3.0",
12
18
  "zx": "npm:zx@8.8.5-lite"
13
19
  },
14
20
  "files": [
@@ -0,0 +1,228 @@
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
+ }
package/tools/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type {PluginApi} from '../types'
2
2
  import {registerIsUserOnlineTool} from './clawly-is-user-online'
3
3
  import {registerSendAppPushTool} from './clawly-send-app-push'
4
+ import {registerSendImageTool} from './clawly-send-image'
4
5
  import {registerSendMessageTool} from './clawly-send-message'
5
6
 
6
7
  export function registerTools(api: PluginApi) {
7
8
  registerIsUserOnlineTool(api)
8
9
  registerSendAppPushTool(api)
10
+ registerSendImageTool(api)
9
11
  registerSendMessageTool(api)
10
12
  }