@2en/clawly-plugins 1.18.1 → 1.18.2
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/gateway/notification.ts +38 -2
- package/gateway/offline-push.test.ts +62 -130
- package/gateway/offline-push.ts +5 -56
- package/package.json +1 -1
package/gateway/notification.ts
CHANGED
|
@@ -13,7 +13,11 @@ import fs from 'node:fs'
|
|
|
13
13
|
import os from 'node:os'
|
|
14
14
|
import path from 'node:path'
|
|
15
15
|
|
|
16
|
+
import {$} from 'zx'
|
|
16
17
|
import type {PluginApi} from '../index'
|
|
18
|
+
import {stripCliLogs} from '../lib/stripCliLogs'
|
|
19
|
+
|
|
20
|
+
$.verbose = false
|
|
17
21
|
|
|
18
22
|
const TOKEN_DIR = path.join(os.homedir(), '.openclaw', 'clawly')
|
|
19
23
|
const TOKEN_FILE = path.join(TOKEN_DIR, 'expo-push-token.json')
|
|
@@ -42,8 +46,38 @@ export function getPushToken(): string | null {
|
|
|
42
46
|
return null
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Fetch agent display identity (name, emoji) via gateway RPC.
|
|
51
|
+
* Returns null on failure so the caller can fall back to defaults.
|
|
52
|
+
*/
|
|
53
|
+
export async function getAgentIdentity(
|
|
54
|
+
agentId: string | undefined,
|
|
55
|
+
): Promise<{name?: string; emoji?: string} | null> {
|
|
56
|
+
if (!agentId) return null
|
|
57
|
+
try {
|
|
58
|
+
const rpcParams = JSON.stringify({agentId})
|
|
59
|
+
const result = await $`openclaw gateway call agent.identity.get --json --params ${rpcParams}`
|
|
60
|
+
const parsed = JSON.parse(stripCliLogs(result.stdout))
|
|
61
|
+
return {
|
|
62
|
+
name: typeof parsed.name === 'string' ? parsed.name : undefined,
|
|
63
|
+
emoji: typeof parsed.emoji === 'string' ? parsed.emoji : undefined,
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build a push notification title from agent identity.
|
|
72
|
+
* Falls back to "🦞 Clawly" when identity is unavailable.
|
|
73
|
+
*/
|
|
74
|
+
export async function resolveAgentTitle(agentId?: string): Promise<string> {
|
|
75
|
+
const identity = await getAgentIdentity(agentId)
|
|
76
|
+
return `${identity?.emoji ?? '🦞'} ${identity?.name ?? 'Clawly'}`
|
|
77
|
+
}
|
|
78
|
+
|
|
45
79
|
export async function sendPushNotification(
|
|
46
|
-
opts: {body: string; title?: string; data?: Record<string, unknown>},
|
|
80
|
+
opts: {body: string; title?: string; agentId?: string; data?: Record<string, unknown>},
|
|
47
81
|
api: PluginApi,
|
|
48
82
|
extras?: Record<string, unknown>,
|
|
49
83
|
): Promise<boolean> {
|
|
@@ -53,6 +87,8 @@ export async function sendPushNotification(
|
|
|
53
87
|
return false
|
|
54
88
|
}
|
|
55
89
|
|
|
90
|
+
const title = opts.title ?? (await resolveAgentTitle(opts.agentId))
|
|
91
|
+
|
|
56
92
|
try {
|
|
57
93
|
const res = await fetch(EXPO_PUSH_URL, {
|
|
58
94
|
method: 'POST',
|
|
@@ -60,7 +96,7 @@ export async function sendPushNotification(
|
|
|
60
96
|
body: JSON.stringify({
|
|
61
97
|
to: token,
|
|
62
98
|
sound: 'default',
|
|
63
|
-
title
|
|
99
|
+
title,
|
|
64
100
|
body: opts.body,
|
|
65
101
|
data: opts.data,
|
|
66
102
|
...extras,
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import {beforeEach, describe, expect, mock, test} from 'bun:test'
|
|
2
2
|
import type {PluginApi} from '../index'
|
|
3
3
|
import {
|
|
4
|
-
PUSH_COOLDOWN_S,
|
|
5
|
-
_resetCooldowns,
|
|
6
4
|
getLastAssistantPreview,
|
|
7
5
|
getLastAssistantText,
|
|
8
6
|
registerOfflinePush,
|
|
@@ -13,41 +11,41 @@ import {
|
|
|
13
11
|
|
|
14
12
|
let mockOnline = false
|
|
15
13
|
let mockPushSent = true
|
|
16
|
-
let lastPushOpts: {
|
|
17
|
-
|
|
14
|
+
let lastPushOpts: {
|
|
15
|
+
body: string
|
|
16
|
+
title?: string
|
|
17
|
+
agentId?: string
|
|
18
|
+
data?: Record<string, unknown>
|
|
19
|
+
} | null = null
|
|
20
|
+
let lastPushExtras: Record<string, unknown> | undefined = undefined
|
|
18
21
|
|
|
19
22
|
mock.module('./presence', () => ({
|
|
20
23
|
isClientOnline: async () => mockOnline,
|
|
21
24
|
}))
|
|
22
25
|
|
|
23
26
|
mock.module('./notification', () => ({
|
|
24
|
-
sendPushNotification: async (
|
|
25
|
-
body: string
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
sendPushNotification: async (
|
|
28
|
+
opts: {body: string; title?: string; agentId?: string; data?: Record<string, unknown>},
|
|
29
|
+
_api: PluginApi,
|
|
30
|
+
extras?: Record<string, unknown>,
|
|
31
|
+
) => {
|
|
29
32
|
lastPushOpts = opts
|
|
33
|
+
lastPushExtras = extras
|
|
30
34
|
return mockPushSent
|
|
31
35
|
},
|
|
32
36
|
}))
|
|
33
37
|
|
|
34
|
-
mock.module('./offline-push', () => {
|
|
35
|
-
const original = require('./offline-push')
|
|
36
|
-
return {
|
|
37
|
-
...original,
|
|
38
|
-
getAgentIdentity: async () => mockIdentity,
|
|
39
|
-
}
|
|
40
|
-
})
|
|
41
|
-
|
|
42
38
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
43
39
|
|
|
40
|
+
type Handler = (event: Record<string, unknown>, ctx?: Record<string, unknown>) => Promise<void>
|
|
41
|
+
|
|
44
42
|
function createMockApi(): {
|
|
45
43
|
api: PluginApi
|
|
46
44
|
logs: {level: string; msg: string}[]
|
|
47
|
-
handlers: Map<string,
|
|
45
|
+
handlers: Map<string, Handler>
|
|
48
46
|
} {
|
|
49
47
|
const logs: {level: string; msg: string}[] = []
|
|
50
|
-
const handlers = new Map<string,
|
|
48
|
+
const handlers = new Map<string, Handler>()
|
|
51
49
|
const api = {
|
|
52
50
|
id: 'test',
|
|
53
51
|
name: 'test',
|
|
@@ -68,9 +66,8 @@ function createMockApi(): {
|
|
|
68
66
|
beforeEach(() => {
|
|
69
67
|
mockOnline = false
|
|
70
68
|
mockPushSent = true
|
|
71
|
-
mockIdentity = null
|
|
72
69
|
lastPushOpts = null
|
|
73
|
-
|
|
70
|
+
lastPushExtras = undefined
|
|
74
71
|
})
|
|
75
72
|
|
|
76
73
|
describe('offline-push', () => {
|
|
@@ -79,7 +76,7 @@ describe('offline-push', () => {
|
|
|
79
76
|
registerOfflinePush(api)
|
|
80
77
|
|
|
81
78
|
const handler = handlers.get('agent_end')!
|
|
82
|
-
await handler({sessionKey: 'sess-1'})
|
|
79
|
+
await handler({}, {sessionKey: 'sess-1'})
|
|
83
80
|
|
|
84
81
|
expect(logs).toContainEqual({
|
|
85
82
|
level: 'info',
|
|
@@ -92,130 +89,68 @@ describe('offline-push', () => {
|
|
|
92
89
|
const {api, logs, handlers} = createMockApi()
|
|
93
90
|
registerOfflinePush(api)
|
|
94
91
|
|
|
95
|
-
await handlers.get('agent_end')!({sessionKey: 'sess-1'})
|
|
92
|
+
await handlers.get('agent_end')!({}, {sessionKey: 'sess-1'})
|
|
96
93
|
|
|
97
94
|
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
|
|
98
95
|
expect(logs.filter((l) => l.msg.includes('skipped'))).toHaveLength(0)
|
|
99
96
|
})
|
|
100
97
|
|
|
101
|
-
test('
|
|
98
|
+
test('sends push for consecutive calls on same session', async () => {
|
|
102
99
|
const {api, logs, handlers} = createMockApi()
|
|
103
100
|
registerOfflinePush(api)
|
|
104
101
|
|
|
105
102
|
const handler = handlers.get('agent_end')!
|
|
106
103
|
|
|
107
|
-
|
|
108
|
-
await handler({sessionKey: 'sess-1'})
|
|
109
|
-
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(1)
|
|
104
|
+
await handler({}, {sessionKey: 'sess-1'})
|
|
105
|
+
await handler({}, {sessionKey: 'sess-1'})
|
|
110
106
|
|
|
111
|
-
|
|
112
|
-
await handler({sessionKey: 'sess-1'})
|
|
113
|
-
expect(logs).toContainEqual({
|
|
114
|
-
level: 'info',
|
|
115
|
-
msg: expect.stringContaining('skipped (cooldown, session=sess-1)'),
|
|
116
|
-
})
|
|
107
|
+
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(2)
|
|
117
108
|
})
|
|
118
109
|
|
|
119
|
-
test('
|
|
110
|
+
test('sends push when ctx is missing', async () => {
|
|
120
111
|
const {api, logs, handlers} = createMockApi()
|
|
121
112
|
registerOfflinePush(api)
|
|
122
113
|
|
|
123
114
|
const handler = handlers.get('agent_end')!
|
|
124
|
-
|
|
125
|
-
await handler({sessionKey: 'sess-1'})
|
|
126
|
-
await handler({sessionKey: 'sess-2'})
|
|
127
|
-
|
|
128
|
-
const notified = logs.filter((l) => l.msg.includes('notified'))
|
|
129
|
-
expect(notified).toHaveLength(2)
|
|
130
|
-
expect(notified[0].msg).toContain('sess-1')
|
|
131
|
-
expect(notified[1].msg).toContain('sess-2')
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
test('uses __global__ key when sessionKey is missing', async () => {
|
|
135
|
-
const {api, logs, handlers} = createMockApi()
|
|
136
|
-
registerOfflinePush(api)
|
|
137
|
-
|
|
138
|
-
const handler = handlers.get('agent_end')!
|
|
139
|
-
await handler({})
|
|
140
115
|
await handler({})
|
|
141
116
|
|
|
142
117
|
expect(logs).toContainEqual({
|
|
143
118
|
level: 'info',
|
|
144
119
|
msg: expect.stringContaining('notified (session=unknown)'),
|
|
145
120
|
})
|
|
146
|
-
expect(logs).toContainEqual({
|
|
147
|
-
level: 'info',
|
|
148
|
-
msg: expect.stringContaining('skipped (cooldown, session=__global__)'),
|
|
149
|
-
})
|
|
150
121
|
})
|
|
151
122
|
|
|
152
|
-
test('
|
|
153
|
-
mockPushSent = false
|
|
154
|
-
const {api, logs, handlers} = createMockApi()
|
|
155
|
-
registerOfflinePush(api)
|
|
156
|
-
|
|
157
|
-
const handler = handlers.get('agent_end')!
|
|
158
|
-
|
|
159
|
-
// Push returns false (e.g. no token) — cooldown should NOT be set
|
|
160
|
-
await handler({sessionKey: 'sess-1'})
|
|
161
|
-
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
|
|
162
|
-
|
|
163
|
-
// Second call should NOT be cooldown-skipped since first wasn't sent
|
|
164
|
-
mockPushSent = true
|
|
165
|
-
await handler({sessionKey: 'sess-1'})
|
|
166
|
-
expect(logs).toContainEqual({
|
|
167
|
-
level: 'info',
|
|
168
|
-
msg: expect.stringContaining('notified (session=sess-1)'),
|
|
169
|
-
})
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
test('exports PUSH_COOLDOWN_S as 30', () => {
|
|
173
|
-
expect(PUSH_COOLDOWN_S).toBe(30)
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
// ── Rich title/body tests ────────────────────────────────────
|
|
177
|
-
|
|
178
|
-
test('title shows agent emoji + name when identity is available', async () => {
|
|
179
|
-
mockIdentity = {name: 'Luna', emoji: '🐱'}
|
|
180
|
-
const {api, handlers} = createMockApi()
|
|
181
|
-
registerOfflinePush(api)
|
|
182
|
-
|
|
183
|
-
await handlers.get('agent_end')!({sessionKey: 'sess-1'})
|
|
184
|
-
|
|
185
|
-
expect(lastPushOpts?.title).toBe('🐱 Luna')
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
test('title falls back to 🦞 Clawly when identity fetch fails', async () => {
|
|
189
|
-
mockIdentity = null
|
|
123
|
+
test('passes agentId from ctx to sendPushNotification', async () => {
|
|
190
124
|
const {api, handlers} = createMockApi()
|
|
191
125
|
registerOfflinePush(api)
|
|
192
126
|
|
|
193
|
-
await handlers.get('agent_end')!({sessionKey: 'sess-1'})
|
|
127
|
+
await handlers.get('agent_end')!({}, {sessionKey: 'sess-1', agentId: 'luna'})
|
|
194
128
|
|
|
195
|
-
expect(lastPushOpts?.
|
|
129
|
+
expect(lastPushOpts?.agentId).toBe('luna')
|
|
196
130
|
})
|
|
197
131
|
|
|
198
|
-
test('
|
|
199
|
-
mockIdentity = {name: 'Atlas'}
|
|
132
|
+
test('does not pass title — delegates to sendPushNotification', async () => {
|
|
200
133
|
const {api, handlers} = createMockApi()
|
|
201
134
|
registerOfflinePush(api)
|
|
202
135
|
|
|
203
|
-
await handlers.get('agent_end')!({sessionKey: 'sess-1'})
|
|
136
|
+
await handlers.get('agent_end')!({}, {sessionKey: 'sess-1'})
|
|
204
137
|
|
|
205
|
-
expect(lastPushOpts?.title).
|
|
138
|
+
expect(lastPushOpts?.title).toBeUndefined()
|
|
206
139
|
})
|
|
207
140
|
|
|
208
141
|
test('body shows assistant message preview when messages are present', async () => {
|
|
209
142
|
const {api, handlers} = createMockApi()
|
|
210
143
|
registerOfflinePush(api)
|
|
211
144
|
|
|
212
|
-
await handlers.get('agent_end')!(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
145
|
+
await handlers.get('agent_end')!(
|
|
146
|
+
{
|
|
147
|
+
messages: [
|
|
148
|
+
{role: 'user', content: 'Hello'},
|
|
149
|
+
{role: 'assistant', content: 'Hi there! How can I help you today?'},
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
{sessionKey: 'sess-1'},
|
|
153
|
+
)
|
|
219
154
|
|
|
220
155
|
expect(lastPushOpts?.body).toBe('Hi there! How can I help you today?')
|
|
221
156
|
})
|
|
@@ -224,7 +159,7 @@ describe('offline-push', () => {
|
|
|
224
159
|
const {api, handlers} = createMockApi()
|
|
225
160
|
registerOfflinePush(api)
|
|
226
161
|
|
|
227
|
-
await handlers.get('agent_end')!({sessionKey: 'sess-1'})
|
|
162
|
+
await handlers.get('agent_end')!({}, {sessionKey: 'sess-1'})
|
|
228
163
|
|
|
229
164
|
expect(lastPushOpts?.body).toBe('Your response is ready')
|
|
230
165
|
})
|
|
@@ -233,10 +168,10 @@ describe('offline-push', () => {
|
|
|
233
168
|
const {api, handlers} = createMockApi()
|
|
234
169
|
registerOfflinePush(api)
|
|
235
170
|
|
|
236
|
-
await handlers.get('agent_end')!(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
171
|
+
await handlers.get('agent_end')!(
|
|
172
|
+
{messages: [{role: 'user', content: 'Hello'}]},
|
|
173
|
+
{sessionKey: 'sess-1'},
|
|
174
|
+
)
|
|
240
175
|
|
|
241
176
|
expect(lastPushOpts?.body).toBe('Your response is ready')
|
|
242
177
|
})
|
|
@@ -284,8 +219,6 @@ describe('getLastAssistantText', () => {
|
|
|
284
219
|
{role: 'assistant', content: 'Good reply'},
|
|
285
220
|
{role: 'assistant', content: ''},
|
|
286
221
|
]
|
|
287
|
-
// Returns '' for the last assistant message (not fall-through to earlier)
|
|
288
|
-
// so the push filter can detect and skip it
|
|
289
222
|
expect(getLastAssistantText(messages)).toBe('')
|
|
290
223
|
})
|
|
291
224
|
|
|
@@ -356,10 +289,10 @@ describe('offline-push with filtered messages', () => {
|
|
|
356
289
|
const {api, logs, handlers} = createMockApi()
|
|
357
290
|
registerOfflinePush(api)
|
|
358
291
|
|
|
359
|
-
await handlers.get('agent_end')!(
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
292
|
+
await handlers.get('agent_end')!(
|
|
293
|
+
{messages: [{role: 'assistant', content: 'NO_REPLY'}]},
|
|
294
|
+
{sessionKey: 'sess-1'},
|
|
295
|
+
)
|
|
363
296
|
|
|
364
297
|
expect(logs).toContainEqual({
|
|
365
298
|
level: 'info',
|
|
@@ -372,10 +305,10 @@ describe('offline-push with filtered messages', () => {
|
|
|
372
305
|
const {api, logs, handlers} = createMockApi()
|
|
373
306
|
registerOfflinePush(api)
|
|
374
307
|
|
|
375
|
-
await handlers.get('agent_end')!(
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
308
|
+
await handlers.get('agent_end')!(
|
|
309
|
+
{messages: [{role: 'assistant', content: 'HEARTBEAT_OK'}]},
|
|
310
|
+
{sessionKey: 'sess-1'},
|
|
311
|
+
)
|
|
379
312
|
|
|
380
313
|
expect(logs).toContainEqual({
|
|
381
314
|
level: 'info',
|
|
@@ -388,10 +321,10 @@ describe('offline-push with filtered messages', () => {
|
|
|
388
321
|
const {api, logs, handlers} = createMockApi()
|
|
389
322
|
registerOfflinePush(api)
|
|
390
323
|
|
|
391
|
-
await handlers.get('agent_end')!(
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
324
|
+
await handlers.get('agent_end')!(
|
|
325
|
+
{messages: [{role: 'assistant', content: ' '}]},
|
|
326
|
+
{sessionKey: 'sess-1'},
|
|
327
|
+
)
|
|
395
328
|
|
|
396
329
|
expect(logs).toContainEqual({
|
|
397
330
|
level: 'info',
|
|
@@ -403,10 +336,10 @@ describe('offline-push with filtered messages', () => {
|
|
|
403
336
|
const {api, logs, handlers} = createMockApi()
|
|
404
337
|
registerOfflinePush(api)
|
|
405
338
|
|
|
406
|
-
await handlers.get('agent_end')!(
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
339
|
+
await handlers.get('agent_end')!(
|
|
340
|
+
{messages: [{role: 'assistant', content: 'I finished the task you asked about!'}]},
|
|
341
|
+
{sessionKey: 'sess-1'},
|
|
342
|
+
)
|
|
410
343
|
|
|
411
344
|
expect(logs).toContainEqual({
|
|
412
345
|
level: 'info',
|
|
@@ -418,8 +351,7 @@ describe('offline-push with filtered messages', () => {
|
|
|
418
351
|
const {api, logs, handlers} = createMockApi()
|
|
419
352
|
registerOfflinePush(api)
|
|
420
353
|
|
|
421
|
-
|
|
422
|
-
await handlers.get('agent_end')!({sessionKey: 'sess-1'})
|
|
354
|
+
await handlers.get('agent_end')!({}, {sessionKey: 'sess-1'})
|
|
423
355
|
|
|
424
356
|
expect(logs).toContainEqual({
|
|
425
357
|
level: 'info',
|
package/gateway/offline-push.ts
CHANGED
|
@@ -4,49 +4,15 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Hook: agent_end → check presence → filter noise → send Expo push if offline
|
|
6
6
|
*
|
|
7
|
-
* Avoids double-push with clawly.agent.send (which pushes at dispatch
|
|
8
|
-
* time for cron/external triggers) via a 30-second cooldown window.
|
|
9
|
-
*
|
|
10
7
|
* Skips push for messages the mobile UI would filter (heartbeat acks,
|
|
11
8
|
* NO_REPLY, compaction markers, etc.) — mirrors shouldFilterMessage()
|
|
12
9
|
* logic from apps/mobile/lib/messageFilter.ts.
|
|
13
10
|
*/
|
|
14
11
|
|
|
15
|
-
import {$} from 'zx'
|
|
16
12
|
import type {PluginApi} from '../index'
|
|
17
|
-
import {stripCliLogs} from '../lib/stripCliLogs'
|
|
18
13
|
import {sendPushNotification} from './notification'
|
|
19
14
|
import {isClientOnline} from './presence'
|
|
20
15
|
|
|
21
|
-
$.verbose = false
|
|
22
|
-
|
|
23
|
-
/** Minimum seconds between consecutive push notifications per session. */
|
|
24
|
-
export const PUSH_COOLDOWN_S = 30
|
|
25
|
-
|
|
26
|
-
/** Per-session cooldown tracker. Key = sessionKey (or "__global__" for unknown). */
|
|
27
|
-
const lastPushBySession = new Map<string, number>()
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Fetch agent display identity (name, emoji) via gateway RPC.
|
|
31
|
-
* Returns null on failure so the caller can fall back to defaults.
|
|
32
|
-
*/
|
|
33
|
-
export async function getAgentIdentity(
|
|
34
|
-
agentId: string | undefined,
|
|
35
|
-
): Promise<{name?: string; emoji?: string} | null> {
|
|
36
|
-
if (!agentId) return null
|
|
37
|
-
try {
|
|
38
|
-
const rpcParams = JSON.stringify({agentId})
|
|
39
|
-
const result = await $`openclaw gateway call agent.identity.get --json --params ${rpcParams}`
|
|
40
|
-
const parsed = JSON.parse(stripCliLogs(result.stdout))
|
|
41
|
-
return {
|
|
42
|
-
name: typeof parsed.name === 'string' ? parsed.name : undefined,
|
|
43
|
-
emoji: typeof parsed.emoji === 'string' ? parsed.emoji : undefined,
|
|
44
|
-
}
|
|
45
|
-
} catch {
|
|
46
|
-
return null
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
16
|
/**
|
|
51
17
|
* Extract the last assistant message's full text from a messages array.
|
|
52
18
|
* Handles both string and `{type:'text', text}[]` content formats.
|
|
@@ -123,7 +89,7 @@ export function shouldSkipPushForMessage(text: string): string | null {
|
|
|
123
89
|
}
|
|
124
90
|
|
|
125
91
|
export function registerOfflinePush(api: PluginApi) {
|
|
126
|
-
api.on('agent_end', async (event: Record<string, unknown>) => {
|
|
92
|
+
api.on('agent_end', async (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
|
|
127
93
|
try {
|
|
128
94
|
// Skip if client is still connected — they got the response in real-time.
|
|
129
95
|
const online = await isClientOnline()
|
|
@@ -141,28 +107,17 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
141
107
|
}
|
|
142
108
|
}
|
|
143
109
|
|
|
144
|
-
|
|
145
|
-
const
|
|
110
|
+
// agentId and sessionKey live on ctx (PluginHookAgentContext), not event.
|
|
111
|
+
const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
|
|
112
|
+
const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
|
|
146
113
|
|
|
147
|
-
// Cooldown: skip if a push was sent recently for this session
|
|
148
|
-
// (e.g. by clawly.agent.send).
|
|
149
|
-
const now = Date.now() / 1000
|
|
150
|
-
const lastPush = lastPushBySession.get(cooldownKey) ?? 0
|
|
151
|
-
if (now - lastPush < PUSH_COOLDOWN_S) {
|
|
152
|
-
api.logger.info(`offline-push: skipped (cooldown, session=${cooldownKey})`)
|
|
153
|
-
return
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const agentId = typeof event.agentId === 'string' ? event.agentId : undefined
|
|
157
|
-
const identity = await getAgentIdentity(agentId)
|
|
158
|
-
const title = `${identity?.emoji ?? '🦞'} ${identity?.name ?? 'Clawly'}`
|
|
159
114
|
const preview = fullText && fullText.length > 140 ? `${fullText.slice(0, 140)}…` : fullText
|
|
160
115
|
const body = preview ?? 'Your response is ready'
|
|
161
116
|
|
|
162
117
|
const sent = await sendPushNotification(
|
|
163
118
|
{
|
|
164
|
-
title,
|
|
165
119
|
body,
|
|
120
|
+
agentId,
|
|
166
121
|
data: {
|
|
167
122
|
type: 'agent_end',
|
|
168
123
|
...(sessionKey ? {sessionKey} : {}),
|
|
@@ -172,7 +127,6 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
172
127
|
)
|
|
173
128
|
|
|
174
129
|
if (sent) {
|
|
175
|
-
lastPushBySession.set(cooldownKey, Date.now() / 1000)
|
|
176
130
|
api.logger.info(`offline-push: notified (session=${sessionKey ?? 'unknown'})`)
|
|
177
131
|
}
|
|
178
132
|
} catch (err) {
|
|
@@ -182,8 +136,3 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
182
136
|
|
|
183
137
|
api.logger.info('offline-push: registered agent_end hook')
|
|
184
138
|
}
|
|
185
|
-
|
|
186
|
-
/** @internal — exposed for testing */
|
|
187
|
-
export function _resetCooldowns() {
|
|
188
|
-
lastPushBySession.clear()
|
|
189
|
-
}
|