@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
|
@@ -73,6 +73,11 @@ function createMockApi(): {
|
|
|
73
73
|
return {api, logs, handlers}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** Event with a simple assistant message — needed since the skip-on-no-text guard. */
|
|
77
|
+
const eventWithReply = {
|
|
78
|
+
messages: [{role: 'assistant', content: 'Hello from the assistant'}],
|
|
79
|
+
}
|
|
80
|
+
|
|
76
81
|
// ── Tests ────────────────────────────────────────────────────────
|
|
77
82
|
|
|
78
83
|
beforeEach(() => {
|
|
@@ -89,7 +94,7 @@ describe('offline-push', () => {
|
|
|
89
94
|
registerOfflinePush(api)
|
|
90
95
|
|
|
91
96
|
const handler = handlers.get('agent_end')!
|
|
92
|
-
await handler(
|
|
97
|
+
await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
|
|
93
98
|
|
|
94
99
|
expect(logs).toContainEqual({
|
|
95
100
|
level: 'info',
|
|
@@ -117,8 +122,8 @@ describe('offline-push', () => {
|
|
|
117
122
|
|
|
118
123
|
const handler = handlers.get('agent_end')!
|
|
119
124
|
|
|
120
|
-
await handler(
|
|
121
|
-
await handler(
|
|
125
|
+
await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
|
|
126
|
+
await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
|
|
122
127
|
|
|
123
128
|
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(2)
|
|
124
129
|
})
|
|
@@ -129,11 +134,11 @@ describe('offline-push', () => {
|
|
|
129
134
|
|
|
130
135
|
const handler = handlers.get('agent_end')!
|
|
131
136
|
|
|
132
|
-
await handler(
|
|
137
|
+
await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
|
|
133
138
|
expect(lastPushExtras).toEqual({badge: 1})
|
|
134
139
|
expect(mockBadgeCount).toBe(1)
|
|
135
140
|
|
|
136
|
-
await handler(
|
|
141
|
+
await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
|
|
137
142
|
expect(lastPushExtras).toEqual({badge: 2})
|
|
138
143
|
expect(mockBadgeCount).toBe(2)
|
|
139
144
|
})
|
|
@@ -143,7 +148,7 @@ describe('offline-push', () => {
|
|
|
143
148
|
const {api, handlers} = createMockApi()
|
|
144
149
|
registerOfflinePush(api)
|
|
145
150
|
|
|
146
|
-
await handlers.get('agent_end')!(
|
|
151
|
+
await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:main'})
|
|
147
152
|
|
|
148
153
|
expect(lastPushExtras).toEqual({badge: 1})
|
|
149
154
|
expect(mockBadgeCount).toBe(0) // incremented then decremented
|
|
@@ -153,7 +158,7 @@ describe('offline-push', () => {
|
|
|
153
158
|
const {api, logs, handlers} = createMockApi()
|
|
154
159
|
registerOfflinePush(api)
|
|
155
160
|
|
|
156
|
-
await handlers.get('agent_end')!(
|
|
161
|
+
await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:telegram:12345'})
|
|
157
162
|
|
|
158
163
|
expect(logs).toContainEqual({
|
|
159
164
|
level: 'info',
|
|
@@ -166,7 +171,9 @@ describe('offline-push', () => {
|
|
|
166
171
|
const {api, logs, handlers} = createMockApi()
|
|
167
172
|
registerOfflinePush(api)
|
|
168
173
|
|
|
169
|
-
await handlers.get('agent_end')!(
|
|
174
|
+
await handlers.get('agent_end')!(eventWithReply, {
|
|
175
|
+
sessionKey: 'agent:clawly:cron:weather-check:run:abc123',
|
|
176
|
+
})
|
|
170
177
|
|
|
171
178
|
expect(logs).toContainEqual({
|
|
172
179
|
level: 'info',
|
|
@@ -180,7 +187,7 @@ describe('offline-push', () => {
|
|
|
180
187
|
registerOfflinePush(api)
|
|
181
188
|
|
|
182
189
|
const handler = handlers.get('agent_end')!
|
|
183
|
-
await handler(
|
|
190
|
+
await handler(eventWithReply)
|
|
184
191
|
|
|
185
192
|
expect(logs).toContainEqual({
|
|
186
193
|
level: 'info',
|
|
@@ -192,7 +199,10 @@ describe('offline-push', () => {
|
|
|
192
199
|
const {api, handlers} = createMockApi()
|
|
193
200
|
registerOfflinePush(api)
|
|
194
201
|
|
|
195
|
-
await handlers.get('agent_end')!(
|
|
202
|
+
await handlers.get('agent_end')!(eventWithReply, {
|
|
203
|
+
sessionKey: 'agent:clawly:main',
|
|
204
|
+
agentId: 'luna',
|
|
205
|
+
})
|
|
196
206
|
|
|
197
207
|
expect(lastPushOpts?.agentId).toBe('luna')
|
|
198
208
|
})
|
|
@@ -201,8 +211,9 @@ describe('offline-push', () => {
|
|
|
201
211
|
const {api, handlers} = createMockApi()
|
|
202
212
|
registerOfflinePush(api)
|
|
203
213
|
|
|
204
|
-
await handlers.get('agent_end')!(
|
|
214
|
+
await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:main'})
|
|
205
215
|
|
|
216
|
+
expect(lastPushOpts).not.toBeNull()
|
|
206
217
|
expect(lastPushOpts?.title).toBeUndefined()
|
|
207
218
|
})
|
|
208
219
|
|
|
@@ -223,17 +234,21 @@ describe('offline-push', () => {
|
|
|
223
234
|
expect(lastPushOpts?.body).toBe('Hi there! How can I help you today?')
|
|
224
235
|
})
|
|
225
236
|
|
|
226
|
-
test('
|
|
227
|
-
const {api, handlers} = createMockApi()
|
|
237
|
+
test('skips push when no messages (no extractable text)', async () => {
|
|
238
|
+
const {api, logs, handlers} = createMockApi()
|
|
228
239
|
registerOfflinePush(api)
|
|
229
240
|
|
|
230
241
|
await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
|
|
231
242
|
|
|
232
|
-
expect(lastPushOpts
|
|
243
|
+
expect(lastPushOpts).toBeNull()
|
|
244
|
+
expect(logs).toContainEqual({
|
|
245
|
+
level: 'warn',
|
|
246
|
+
msg: expect.stringContaining('skipped (no extractable assistant text)'),
|
|
247
|
+
})
|
|
233
248
|
})
|
|
234
249
|
|
|
235
|
-
test('
|
|
236
|
-
const {api, handlers} = createMockApi()
|
|
250
|
+
test('skips push when messages has no assistant role', async () => {
|
|
251
|
+
const {api, logs, handlers} = createMockApi()
|
|
237
252
|
registerOfflinePush(api)
|
|
238
253
|
|
|
239
254
|
await handlers.get('agent_end')!(
|
|
@@ -241,7 +256,11 @@ describe('offline-push', () => {
|
|
|
241
256
|
{sessionKey: 'agent:clawly:main'},
|
|
242
257
|
)
|
|
243
258
|
|
|
244
|
-
expect(lastPushOpts
|
|
259
|
+
expect(lastPushOpts).toBeNull()
|
|
260
|
+
expect(logs).toContainEqual({
|
|
261
|
+
level: 'warn',
|
|
262
|
+
msg: expect.stringContaining('skipped (no extractable assistant text)'),
|
|
263
|
+
})
|
|
245
264
|
})
|
|
246
265
|
|
|
247
266
|
test('body strips [[type:value]] placeholders', async () => {
|
|
@@ -580,15 +599,15 @@ describe('offline-push with filtered messages', () => {
|
|
|
580
599
|
})
|
|
581
600
|
})
|
|
582
601
|
|
|
583
|
-
test('
|
|
602
|
+
test('skips push when event has no messages (no extractable text)', async () => {
|
|
584
603
|
const {api, logs, handlers} = createMockApi()
|
|
585
604
|
registerOfflinePush(api)
|
|
586
605
|
|
|
587
606
|
await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
|
|
588
607
|
|
|
589
608
|
expect(logs).toContainEqual({
|
|
590
|
-
level: '
|
|
591
|
-
msg: expect.stringContaining('
|
|
609
|
+
level: 'warn',
|
|
610
|
+
msg: expect.stringContaining('skipped (no extractable assistant text)'),
|
|
592
611
|
})
|
|
593
612
|
})
|
|
594
613
|
})
|
package/gateway/offline-push.ts
CHANGED
|
@@ -226,6 +226,7 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
226
226
|
|
|
227
227
|
// Extract full assistant text for filtering and preview.
|
|
228
228
|
const fullText = getLastAssistantText(event.messages)
|
|
229
|
+
const triggerText = getTriggeringUserText(event.messages)
|
|
229
230
|
|
|
230
231
|
// Skip if the message would be filtered by the mobile UI.
|
|
231
232
|
if (fullText != null) {
|
|
@@ -270,6 +271,33 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
270
271
|
return
|
|
271
272
|
}
|
|
272
273
|
|
|
274
|
+
// Defensive: if we can't extract assistant text, sending a generic
|
|
275
|
+
// "Your response is ready" is never useful — the message likely wasn't
|
|
276
|
+
// persisted to the transcript either, so the user opens the app to nothing.
|
|
277
|
+
// Log the messages structure for debugging, then bail.
|
|
278
|
+
if (fullText == null || fullText === '') {
|
|
279
|
+
const msgCount = Array.isArray(event.messages) ? event.messages.length : 'n/a'
|
|
280
|
+
const lastRoles = Array.isArray(event.messages)
|
|
281
|
+
? event.messages
|
|
282
|
+
.slice(-5)
|
|
283
|
+
.map(
|
|
284
|
+
(m: any) =>
|
|
285
|
+
`${m?.role ?? '?'}(${typeof m?.content === 'string' ? 'str' : Array.isArray(m?.content) ? `parts:${m.content.length}` : typeof m?.content})`,
|
|
286
|
+
)
|
|
287
|
+
.join(', ')
|
|
288
|
+
: 'n/a'
|
|
289
|
+
api.logger.warn(
|
|
290
|
+
`offline-push: skipped (no extractable assistant text) msgCount=${msgCount} lastRoles=[${lastRoles}] triggerText=${triggerText ? `"${triggerText.slice(0, 80)}"` : 'null'}`,
|
|
291
|
+
)
|
|
292
|
+
if (isCron) markCronPushSkipped(sessionKey!, 'no extractable text', false)
|
|
293
|
+
captureEvent('push.skipped', {
|
|
294
|
+
reason: 'no_extractable_text',
|
|
295
|
+
is_cron: isCron,
|
|
296
|
+
...(sessionKey ? {session_key: sessionKey} : {}),
|
|
297
|
+
})
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
273
301
|
// Only send push for the main clawly mobile session and cron sessions —
|
|
274
302
|
// skip channel sessions (telegram, slack, discord, etc.) which have their own delivery.
|
|
275
303
|
if (sessionKey !== undefined && sessionKey !== 'agent:clawly:main' && !isCron) {
|
package/index.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Agent tools:
|
|
18
18
|
* - clawly_is_user_online — check if user's device is connected
|
|
19
19
|
* - clawly_send_app_push — send a push notification to user's device
|
|
20
|
-
* -
|
|
20
|
+
* - clawly_send_file — send a file to the user (URL or local path under $HOME/tmp)
|
|
21
21
|
* - clawly_search — web search via Perplexity (replaces denied web_search)
|
|
22
22
|
* - clawly_send_message — send a message to user via main session agent (supports role: user|assistant)
|
|
23
23
|
*
|
package/outbound.ts
CHANGED
|
@@ -18,6 +18,7 @@ import fsp from 'node:fs/promises'
|
|
|
18
18
|
import type {IncomingMessage, ServerResponse} from 'node:http'
|
|
19
19
|
import os from 'node:os'
|
|
20
20
|
import path from 'node:path'
|
|
21
|
+
import mime from 'mime'
|
|
21
22
|
|
|
22
23
|
import type {PluginApi} from './index'
|
|
23
24
|
import {createAccessToken, guardHttpAuth, resolveGatewaySecret, sendJson} from './lib/httpAuth'
|
|
@@ -132,24 +133,7 @@ export function registerOutboundMethods(api: PluginApi) {
|
|
|
132
133
|
|
|
133
134
|
// ── HTTP route: GET /clawly/file/outbound?path=<original-path> ─────────────
|
|
134
135
|
|
|
135
|
-
|
|
136
|
-
'.mp3': 'audio/mpeg',
|
|
137
|
-
'.wav': 'audio/wav',
|
|
138
|
-
'.ogg': 'audio/ogg',
|
|
139
|
-
'.m4a': 'audio/mp4',
|
|
140
|
-
'.aac': 'audio/aac',
|
|
141
|
-
'.flac': 'audio/flac',
|
|
142
|
-
'.webm': 'audio/webm',
|
|
143
|
-
'.jpg': 'image/jpeg',
|
|
144
|
-
'.jpeg': 'image/jpeg',
|
|
145
|
-
'.png': 'image/png',
|
|
146
|
-
'.gif': 'image/gif',
|
|
147
|
-
'.webp': 'image/webp',
|
|
148
|
-
'.bmp': 'image/bmp',
|
|
149
|
-
'.heic': 'image/heic',
|
|
150
|
-
'.avif': 'image/avif',
|
|
151
|
-
'.ico': 'image/x-icon',
|
|
152
|
-
}
|
|
136
|
+
// Content-Type resolution via `mime` package (replaces hardcoded map)
|
|
153
137
|
|
|
154
138
|
/** Directories from which direct-path serving is allowed (no hash required). */
|
|
155
139
|
let allowedRoots: string[] | null = null
|
|
@@ -183,6 +167,12 @@ async function resolveOutboundFile(rawPath: string, stateDir?: string): Promise<
|
|
|
183
167
|
return null
|
|
184
168
|
}
|
|
185
169
|
|
|
170
|
+
export function buildContentDisposition(filename: string): string {
|
|
171
|
+
const asciiName = filename.replace(/[^\x20-\x7E]/g, '_').replace(/["\\]/g, '\\$&')
|
|
172
|
+
const utf8Name = encodeURIComponent(filename)
|
|
173
|
+
return `attachment; filename="${asciiName}"; filename*=UTF-8''${utf8Name}`
|
|
174
|
+
}
|
|
175
|
+
|
|
186
176
|
export function registerOutboundHttpRoute(api: PluginApi) {
|
|
187
177
|
const stateDir = api.runtime.state.resolveStateDir()
|
|
188
178
|
|
|
@@ -219,8 +209,7 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
219
209
|
return
|
|
220
210
|
}
|
|
221
211
|
|
|
222
|
-
const
|
|
223
|
-
const contentType = MIME[ext] ?? 'application/octet-stream'
|
|
212
|
+
const contentType = mime.getType(resolved) ?? 'application/octet-stream'
|
|
224
213
|
const stat = await fsp.stat(resolved)
|
|
225
214
|
const total = stat.size
|
|
226
215
|
|
|
@@ -230,6 +219,15 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
230
219
|
return
|
|
231
220
|
}
|
|
232
221
|
|
|
222
|
+
const downloadFilename = url.searchParams.get('download')
|
|
223
|
+
const baseHeaders: Record<string, string | number> = {
|
|
224
|
+
'Content-Type': contentType,
|
|
225
|
+
'Accept-Ranges': 'bytes',
|
|
226
|
+
}
|
|
227
|
+
if (downloadFilename && path.extname(downloadFilename) === path.extname(resolved)) {
|
|
228
|
+
baseHeaders['Content-Disposition'] = buildContentDisposition(downloadFilename)
|
|
229
|
+
}
|
|
230
|
+
|
|
233
231
|
const rangeHeader = _req.headers.range
|
|
234
232
|
|
|
235
233
|
if (rangeHeader) {
|
|
@@ -241,9 +239,8 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
241
239
|
const stream = fs.createReadStream(resolved, {start, end})
|
|
242
240
|
|
|
243
241
|
res.writeHead(206, {
|
|
244
|
-
|
|
242
|
+
...baseHeaders,
|
|
245
243
|
'Content-Range': `bytes ${start}-${end}/${total}`,
|
|
246
|
-
'Accept-Ranges': 'bytes',
|
|
247
244
|
'Content-Length': chunkSize,
|
|
248
245
|
})
|
|
249
246
|
stream.pipe(res)
|
|
@@ -256,9 +253,8 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
256
253
|
|
|
257
254
|
const buffer = await fsp.readFile(resolved)
|
|
258
255
|
res.writeHead(200, {
|
|
259
|
-
|
|
256
|
+
...baseHeaders,
|
|
260
257
|
'Content-Length': total,
|
|
261
|
-
'Accept-Ranges': 'bytes',
|
|
262
258
|
})
|
|
263
259
|
res.end(buffer)
|
|
264
260
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2en/clawly-plugins",
|
|
3
|
-
"version": "1.30.0-beta.
|
|
3
|
+
"version": "1.30.0-beta.4",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
"@opentelemetry/exporter-logs-otlp-http": "^0.57.0",
|
|
14
14
|
"@opentelemetry/resources": "^1.30.0",
|
|
15
15
|
"@opentelemetry/sdk-logs": "^0.57.0",
|
|
16
|
-
"posthog-node": "^5.28.0",
|
|
17
16
|
"file-type": "^21.3.0",
|
|
17
|
+
"json5": "^2.2.3",
|
|
18
|
+
"mime": "^4.1.0",
|
|
19
|
+
"posthog-node": "^5.28.0",
|
|
18
20
|
"zx": "npm:zx@8.8.5-lite"
|
|
19
21
|
},
|
|
20
22
|
"files": [
|
|
@@ -47,8 +49,5 @@
|
|
|
47
49
|
"extensions": [
|
|
48
50
|
"./index.ts"
|
|
49
51
|
]
|
|
50
|
-
},
|
|
51
|
-
"devDependencies": {
|
|
52
|
-
"json5": "^2.2.3"
|
|
53
52
|
}
|
|
54
53
|
}
|