@2en/clawly-plugins 1.26.0-beta.0 → 1.26.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/auto-pair.ts +5 -0
- package/config-setup.ts +113 -17
- package/gateway/analytics.ts +83 -0
- package/gateway/clawhub2gateway.ts +15 -0
- package/gateway/cron-delivery.ts +13 -2
- package/gateway/cron-telemetry.test.ts +407 -0
- package/gateway/cron-telemetry.ts +253 -0
- package/gateway/index.ts +33 -0
- package/gateway/offline-push.test.ts +209 -0
- package/gateway/offline-push.ts +107 -12
- package/gateway/otel.test.ts +88 -0
- package/gateway/otel.ts +57 -0
- package/gateway/plugins.ts +3 -0
- package/gateway/posthog.test.ts +73 -0
- package/gateway/posthog.ts +61 -0
- package/gateway/telemetry-config.test.ts +58 -0
- package/gateway/telemetry-config.ts +27 -0
- package/index.ts +7 -1
- package/model-gateway-setup.ts +5 -7
- package/openclaw.plugin.json +6 -1
- package/outbound.ts +67 -32
- package/package.json +7 -1
- package/tools/clawly-send-image.ts +228 -0
- package/tools/index.ts +2 -0
|
@@ -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
|
}
|