@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/auto-pair.ts +5 -0
- package/config-setup.ts +5 -0
- 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 +6 -0
- 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
package/outbound.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Outbound file serving — serves 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
|
|
86
|
-
|
|
87
|
-
|
|
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(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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(
|
|
184
|
+
const ext = path.extname(resolved).toLowerCase()
|
|
150
185
|
const contentType = MIME[ext] ?? 'application/octet-stream'
|
|
151
|
-
const stat = await fsp.stat(
|
|
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(
|
|
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} -> ${
|
|
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(
|
|
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} -> ${
|
|
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.
|
|
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
|
}
|