@2en/clawly-plugins 1.30.0-beta.1 → 1.30.0-beta.11
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 +1 -1
- package/clawly-config-defaults.json5 +132 -13
- package/config-setup.ts +148 -326
- package/gateway/config-model.ts +68 -0
- package/gateway/config-timezone.ts +19 -14
- package/gateway/cron-delivery.test.ts +16 -0
- package/gateway/index.ts +2 -2
- package/gateway/offline-push.test.ts +82 -20
- package/gateway/offline-push.ts +36 -8
- package/gateway/presence.ts +7 -4
- package/gateway-fetch.ts +12 -5
- package/index.ts +2 -2
- package/lib/calendar-cache.ts +10 -1
- package/openclaw.plugin.json +1 -0
- package/outbound.ts +20 -24
- package/package.json +4 -5
- package/tools/clawly-calendar.ts +44 -0
- package/tools/clawly-search.test.ts +48 -1
- package/tools/clawly-search.ts +14 -0
- package/tools/clawly-send-file.test.ts +400 -0
- package/tools/clawly-send-file.ts +307 -0
- package/tools/create-search-tool.ts +27 -4
- package/tools/index.ts +9 -3
- package/types.ts +1 -1
- package/gateway/node-dangerous-allowlist.ts +0 -81
- package/tools/clawly-send-image.ts +0 -228
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
* Timezone sync RPC: writes agents.defaults.userTimezone to openclaw.json
|
|
3
3
|
* without triggering a gateway restart.
|
|
4
4
|
*
|
|
5
|
+
* Uses the runtime config APIs (loadConfig + writeConfigFile) for atomic
|
|
6
|
+
* writes, env var preservation, and config validation.
|
|
7
|
+
*
|
|
5
8
|
* Methods:
|
|
6
9
|
* - clawly.config.setTimezone({ timezone }) → { changed, timezone }
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
|
-
import path from 'node:path'
|
|
10
|
-
|
|
11
12
|
import type {PluginApi} from '../types'
|
|
12
|
-
import {
|
|
13
|
+
import type {OpenClawConfig} from '../types/openclaw'
|
|
13
14
|
|
|
14
15
|
export function registerConfigTimezone(api: PluginApi) {
|
|
15
16
|
api.registerGatewayMethod('clawly.config.setTimezone', async ({params, respond}) => {
|
|
@@ -19,30 +20,34 @@ export function registerConfigTimezone(api: PluginApi) {
|
|
|
19
20
|
return
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
let config: OpenClawConfig
|
|
24
|
+
try {
|
|
25
|
+
config = {...(api.runtime.config.loadConfig() as OpenClawConfig)}
|
|
26
|
+
} catch (err) {
|
|
27
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
28
|
+
respond(true, {changed: false, timezone, error: `Load failed: ${msg}`})
|
|
25
29
|
return
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const agents = (config.agents ?? {}) as Record<string, unknown>
|
|
32
|
-
const defaults = (agents.defaults ?? {}) as Record<string, unknown>
|
|
33
|
-
const current = defaults.userTimezone
|
|
32
|
+
const currentDefaults = (config.agents as Record<string, unknown> | undefined)?.defaults as
|
|
33
|
+
| Record<string, unknown>
|
|
34
|
+
| undefined
|
|
34
35
|
|
|
35
|
-
if (
|
|
36
|
+
if (currentDefaults?.userTimezone === timezone) {
|
|
36
37
|
respond(true, {changed: false, timezone})
|
|
37
38
|
return
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
// Shallow-copy nested objects to avoid polluting the loadConfig() cache
|
|
42
|
+
// if writeConfigFile fails below.
|
|
43
|
+
const agents = {...((config.agents ?? {}) as Record<string, unknown>)}
|
|
44
|
+
const defaults = {...((agents.defaults ?? {}) as Record<string, unknown>)}
|
|
40
45
|
defaults.userTimezone = timezone
|
|
41
46
|
agents.defaults = defaults
|
|
42
47
|
config.agents = agents
|
|
43
48
|
|
|
44
49
|
try {
|
|
45
|
-
|
|
50
|
+
await api.runtime.config.writeConfigFile(config)
|
|
46
51
|
api.logger.info(`config-timezone: set userTimezone to ${timezone}`)
|
|
47
52
|
respond(true, {changed: true, timezone})
|
|
48
53
|
} catch (err) {
|
|
@@ -263,6 +263,22 @@ describe('cron-delivery', () => {
|
|
|
263
263
|
})
|
|
264
264
|
})
|
|
265
265
|
|
|
266
|
+
test('skips HEARTBEAT OK with space (bare)', async () => {
|
|
267
|
+
const {api, logs, handlers} = createMockApi()
|
|
268
|
+
registerCronDelivery(api)
|
|
269
|
+
|
|
270
|
+
await handlers.get('agent_end')!(
|
|
271
|
+
{messages: makeMessages('HEARTBEAT OK')},
|
|
272
|
+
{sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
expect(injectCalls).toHaveLength(0)
|
|
276
|
+
expect(logs).toContainEqual({
|
|
277
|
+
level: 'info',
|
|
278
|
+
msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
266
282
|
test('skips empty text', async () => {
|
|
267
283
|
const {api, logs, handlers} = createMockApi()
|
|
268
284
|
registerCronDelivery(api)
|
package/gateway/index.ts
CHANGED
|
@@ -3,8 +3,8 @@ import {registerAgentSend} from './agent'
|
|
|
3
3
|
import {registerCalendarNative} from './calendar-native'
|
|
4
4
|
import {registerAnalytics} from './analytics'
|
|
5
5
|
import {registerAudit} from './audit'
|
|
6
|
-
import {registerNodeDangerousAllowlist} from './node-dangerous-allowlist'
|
|
7
6
|
import {registerClawhub2gateway} from './clawhub2gateway'
|
|
7
|
+
import {registerConfigModel} from './config-model'
|
|
8
8
|
import {registerConfigRepair} from './config-repair'
|
|
9
9
|
import {registerConfigTimezone} from './config-timezone'
|
|
10
10
|
import {registerCronDelivery} from './cron-delivery'
|
|
@@ -58,12 +58,12 @@ export function registerGateway(api: PluginApi) {
|
|
|
58
58
|
registerCronTelemetry(api)
|
|
59
59
|
registerMessageLog(api)
|
|
60
60
|
registerAnalytics(api)
|
|
61
|
+
registerConfigModel(api)
|
|
61
62
|
registerConfigRepair(api)
|
|
62
63
|
registerConfigTimezone(api)
|
|
63
64
|
registerSessionSanitize(api)
|
|
64
65
|
registerPairing(api)
|
|
65
66
|
registerVersion(api)
|
|
66
67
|
registerAudit(api)
|
|
67
|
-
registerNodeDangerousAllowlist(api)
|
|
68
68
|
registerCalendarNative(api)
|
|
69
69
|
}
|
|
@@ -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 () => {
|
|
@@ -427,6 +446,22 @@ describe('shouldSkipPushForMessage', () => {
|
|
|
427
446
|
expect(shouldSkipPushForMessage('The token HEARTBEAT_OK is used for health checks.')).toBeNull()
|
|
428
447
|
})
|
|
429
448
|
|
|
449
|
+
test('skips heartbeat ack with space variant (HEARTBEAT OK)', () => {
|
|
450
|
+
expect(shouldSkipPushForMessage('HEARTBEAT OK')).toBe('heartbeat ack')
|
|
451
|
+
expect(shouldSkipPushForMessage('HEARTBEAT OK.')).toBe('heartbeat ack')
|
|
452
|
+
expect(shouldSkipPushForMessage('HEARTBEAT OK\n')).toBe('heartbeat ack')
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
test('skips short content ending with HEARTBEAT OK (space variant)', () => {
|
|
456
|
+
expect(shouldSkipPushForMessage('All good. HEARTBEAT OK')).toBe('heartbeat ack')
|
|
457
|
+
expect(shouldSkipPushForMessage('Nothing to report. HEARTBEAT OK。')).toBe('heartbeat ack')
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
test('skips HEARTBEAT OK at start with short status note (space variant)', () => {
|
|
461
|
+
expect(shouldSkipPushForMessage('HEARTBEAT OK — all systems nominal.')).toBe('heartbeat ack')
|
|
462
|
+
expect(shouldSkipPushForMessage('HEARTBEAT OK. Nothing to report.')).toBe('heartbeat ack')
|
|
463
|
+
})
|
|
464
|
+
|
|
430
465
|
test('skips system prompt leak', () => {
|
|
431
466
|
expect(
|
|
432
467
|
shouldSkipPushForMessage('Here is some Conversation info (untrusted metadata) text'),
|
|
@@ -565,6 +600,33 @@ describe('offline-push with filtered messages', () => {
|
|
|
565
600
|
expect(lastPushOpts?.body?.endsWith('…')).toBe(true)
|
|
566
601
|
})
|
|
567
602
|
|
|
603
|
+
test('sends push for long content and strips HEARTBEAT OK (space variant) from body', async () => {
|
|
604
|
+
const {api, logs, handlers} = createMockApi()
|
|
605
|
+
registerOfflinePush(api)
|
|
606
|
+
|
|
607
|
+
const longContent = 'A'.repeat(301)
|
|
608
|
+
await handlers.get('agent_end')!(
|
|
609
|
+
{
|
|
610
|
+
messages: [
|
|
611
|
+
{
|
|
612
|
+
role: 'assistant',
|
|
613
|
+
content: `${longContent}\n\nHEARTBEAT OK`,
|
|
614
|
+
},
|
|
615
|
+
],
|
|
616
|
+
},
|
|
617
|
+
{sessionKey: 'agent:clawly:main'},
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
expect(logs).toContainEqual({
|
|
621
|
+
level: 'info',
|
|
622
|
+
msg: expect.stringContaining('notified (session=agent:clawly:main)'),
|
|
623
|
+
})
|
|
624
|
+
// HEARTBEAT OK should be stripped from body, content truncated to 140
|
|
625
|
+
expect(lastPushOpts?.body?.length).toBe(141)
|
|
626
|
+
expect(lastPushOpts?.body?.endsWith('…')).toBe(true)
|
|
627
|
+
expect(lastPushOpts?.body).not.toContain('HEARTBEAT')
|
|
628
|
+
})
|
|
629
|
+
|
|
568
630
|
test('sends push for normal message text', async () => {
|
|
569
631
|
const {api, logs, handlers} = createMockApi()
|
|
570
632
|
registerOfflinePush(api)
|
|
@@ -580,15 +642,15 @@ describe('offline-push with filtered messages', () => {
|
|
|
580
642
|
})
|
|
581
643
|
})
|
|
582
644
|
|
|
583
|
-
test('
|
|
645
|
+
test('skips push when event has no messages (no extractable text)', async () => {
|
|
584
646
|
const {api, logs, handlers} = createMockApi()
|
|
585
647
|
registerOfflinePush(api)
|
|
586
648
|
|
|
587
649
|
await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
|
|
588
650
|
|
|
589
651
|
expect(logs).toContainEqual({
|
|
590
|
-
level: '
|
|
591
|
-
msg: expect.stringContaining('
|
|
652
|
+
level: 'warn',
|
|
653
|
+
msg: expect.stringContaining('skipped (no extractable assistant text)'),
|
|
592
654
|
})
|
|
593
655
|
})
|
|
594
656
|
})
|
package/gateway/offline-push.ts
CHANGED
|
@@ -160,12 +160,12 @@ export function shouldSkipPushForMessage(text: string): string | null {
|
|
|
160
160
|
// Heartbeat acknowledgment — strip HEARTBEAT_OK from both edges (mirrors
|
|
161
161
|
// OpenClaw's stripTokenAtEdges). Skip if remaining text ≤ ackMaxChars (300).
|
|
162
162
|
const HEARTBEAT_ACK_MAX_CHARS = 300
|
|
163
|
-
const hasAtEnd = /
|
|
164
|
-
const hasAtStart = /^[\p{P}\s]*
|
|
163
|
+
const hasAtEnd = /HEARTBEAT[_ ]OK[\p{P}\s]*$/u.test(text)
|
|
164
|
+
const hasAtStart = /^[\p{P}\s]*HEARTBEAT[_ ]OK/u.test(text)
|
|
165
165
|
if (hasAtEnd || hasAtStart) {
|
|
166
166
|
let stripped = text
|
|
167
|
-
if (hasAtEnd) stripped = stripped.replace(/
|
|
168
|
-
if (hasAtStart) stripped = stripped.replace(/^[\p{P}\s]*
|
|
167
|
+
if (hasAtEnd) stripped = stripped.replace(/HEARTBEAT[_ ]OK[\p{P}\s]*$/u, '')
|
|
168
|
+
if (hasAtStart) stripped = stripped.replace(/^[\p{P}\s]*HEARTBEAT[_ ]OK[\p{P}\s]*/u, '')
|
|
169
169
|
stripped = stripped.trim()
|
|
170
170
|
if (stripped.length <= HEARTBEAT_ACK_MAX_CHARS) return 'heartbeat ack'
|
|
171
171
|
}
|
|
@@ -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) {
|
|
@@ -280,12 +308,12 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
280
308
|
const noHeartbeat =
|
|
281
309
|
(() => {
|
|
282
310
|
if (!fullText) return null
|
|
283
|
-
const atEnd = /
|
|
284
|
-
const atStart = /^[\p{P}\s]*
|
|
311
|
+
const atEnd = /HEARTBEAT[_ ]OK[\p{P}\s]*$/u.test(fullText)
|
|
312
|
+
const atStart = /^[\p{P}\s]*HEARTBEAT[_ ]OK/u.test(fullText)
|
|
285
313
|
if (!atEnd && !atStart) return fullText.trim()
|
|
286
314
|
let s = fullText
|
|
287
|
-
if (atEnd) s = s.replace(/
|
|
288
|
-
if (atStart) s = s.replace(/^[\p{P}\s]*
|
|
315
|
+
if (atEnd) s = s.replace(/HEARTBEAT[_ ]OK[\p{P}\s]*$/u, '')
|
|
316
|
+
if (atStart) s = s.replace(/^[\p{P}\s]*HEARTBEAT[_ ]OK[\p{P}\s]*/u, '')
|
|
289
317
|
return s.trim()
|
|
290
318
|
})() ?? null
|
|
291
319
|
const cleaned = noHeartbeat ? stripPlaceholders(noHeartbeat) : null
|
package/gateway/presence.ts
CHANGED
|
@@ -27,14 +27,17 @@ export function isOnlineEntry(entry: PresenceEntry | undefined): boolean {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* Shells out to `openclaw gateway call system-presence` and
|
|
31
|
-
*
|
|
30
|
+
* Shells out to `openclaw gateway call system-presence` and returns true
|
|
31
|
+
* if any device has a `foreground` presence entry.
|
|
32
|
+
*
|
|
33
|
+
* False-negatives (beacon lapsed) are handled client-side — the mobile
|
|
34
|
+
* app suppresses `agent_end` notifications while in the foreground.
|
|
32
35
|
*/
|
|
33
36
|
export async function isClientOnline(): Promise<boolean> {
|
|
34
37
|
try {
|
|
35
38
|
const result = await $`openclaw gateway call system-presence --json`
|
|
36
|
-
const
|
|
37
|
-
const entries: PresenceEntry[] =
|
|
39
|
+
const parsed: unknown = JSON.parse(stripCliLogs(result.stdout))
|
|
40
|
+
const entries: PresenceEntry[] = Array.isArray(parsed) ? parsed : []
|
|
38
41
|
return entries.some(isOnlineEntry)
|
|
39
42
|
} catch {
|
|
40
43
|
return false
|
package/gateway-fetch.ts
CHANGED
|
@@ -11,11 +11,18 @@ export type HandlerResult = {ok: boolean; data?: unknown; error?: {code: string;
|
|
|
11
11
|
export function getGatewayConfig(api: PluginApi): GatewayCfg {
|
|
12
12
|
const cfg = api.pluginConfig && typeof api.pluginConfig === 'object' ? api.pluginConfig : {}
|
|
13
13
|
const c = cfg as Record<string, unknown>
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
|
|
15
|
+
// URL: prefer clawlyApiBaseUrl, fall back to deprecated skillGatewayBaseUrl
|
|
16
|
+
const rawUrl =
|
|
17
|
+
(typeof c.clawlyApiBaseUrl === 'string' ? (c.clawlyApiBaseUrl as string) : '') ||
|
|
18
|
+
(typeof c.skillGatewayBaseUrl === 'string' ? (c.skillGatewayBaseUrl as string) : '')
|
|
19
|
+
const baseUrl = rawUrl.replace(/\/$/, '')
|
|
20
|
+
|
|
21
|
+
// Token: prefer modelGatewayToken, fall back to deprecated skillGatewayToken
|
|
22
|
+
const token =
|
|
23
|
+
(typeof c.modelGatewayToken === 'string' ? (c.modelGatewayToken as string) : '') ||
|
|
24
|
+
(typeof c.skillGatewayToken === 'string' ? (c.skillGatewayToken as string) : '')
|
|
25
|
+
|
|
19
26
|
return {baseUrl, token}
|
|
20
27
|
}
|
|
21
28
|
|
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
|
*
|
|
@@ -80,7 +80,7 @@ export default {
|
|
|
80
80
|
registerAutoPair(api)
|
|
81
81
|
registerAutoUpdate(api)
|
|
82
82
|
|
|
83
|
-
// Email & calendar (optional — requires
|
|
83
|
+
// Email & calendar (optional — requires API base URL + token)
|
|
84
84
|
const gw = getGatewayConfig(api)
|
|
85
85
|
if (gw.baseUrl && gw.token) {
|
|
86
86
|
registerEmail(api, gw)
|
package/lib/calendar-cache.ts
CHANGED
|
@@ -23,6 +23,9 @@ export interface NativeCalendar {
|
|
|
23
23
|
type: string
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export type AlarmMethod = 'alarm' | 'alert' | 'email' | 'default' | 'sms'
|
|
27
|
+
export type AlarmInput = {relativeOffset?: number; method?: AlarmMethod}
|
|
28
|
+
|
|
26
29
|
export interface NativeCalendarEvent {
|
|
27
30
|
id: string
|
|
28
31
|
calendarId: string
|
|
@@ -35,6 +38,7 @@ export interface NativeCalendarEvent {
|
|
|
35
38
|
url: string
|
|
36
39
|
timeZone: string
|
|
37
40
|
organizer: string
|
|
41
|
+
alarms?: AlarmInput[]
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
export interface CalendarCache {
|
|
@@ -105,7 +109,12 @@ export function initCache(): void {
|
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
export function getCache(): CalendarCache | null {
|
|
108
|
-
|
|
112
|
+
// Always read from disk to avoid returning stale in-memory state.
|
|
113
|
+
// After gateway restart or ESM/CJS dual-loading, gateway methods and
|
|
114
|
+
// agent tools may hold separate module instances with divergent
|
|
115
|
+
// cachedData. Disk is the shared source of truth. The file is small
|
|
116
|
+
// (<10 KB) and tool calls are infrequent, so the I/O cost is negligible.
|
|
117
|
+
cachedData = loadCache()
|
|
109
118
|
return cachedData
|
|
110
119
|
}
|
|
111
120
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"defaultNoInput": { "type": "boolean" },
|
|
48
48
|
"defaultTimeoutMs": { "type": "number", "minimum": 1000 },
|
|
49
49
|
"configPath": { "type": "string" },
|
|
50
|
+
"clawlyApiBaseUrl": { "type": "string" },
|
|
50
51
|
"skillGatewayBaseUrl": { "type": "string" },
|
|
51
52
|
"skillGatewayToken": { "type": "string" },
|
|
52
53
|
"modelGatewayBaseUrl": { "type": "string" },
|
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.11",
|
|
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
|
}
|