@2en/clawly-plugins 1.24.8-beta.0 → 1.25.0-beta.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 +72 -1
- package/gateway/offline-push.test.ts +36 -0
- package/gateway/offline-push.ts +15 -1
- package/package.json +1 -1
package/gateway/notification.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Methods:
|
|
6
6
|
* - clawly.notification.setToken({ token }) → { registered: boolean }
|
|
7
7
|
* - clawly.notification.send({ body, title?, data? }) → { sent: boolean }
|
|
8
|
+
* - clawly.notification.clearBadge({}) → { cleared: boolean }
|
|
8
9
|
*
|
|
9
10
|
* Internal: sendPushNotification({ body, title?, data? })
|
|
10
11
|
*/
|
|
@@ -29,6 +30,7 @@ function extractEmoji(value: string | undefined): string | null {
|
|
|
29
30
|
|
|
30
31
|
const TOKEN_DIR = path.join(os.homedir(), '.openclaw', 'clawly')
|
|
31
32
|
const TOKEN_FILE = path.join(TOKEN_DIR, 'expo-push-token.json')
|
|
33
|
+
const BADGE_FILE = path.join(TOKEN_DIR, 'badge-count.json')
|
|
32
34
|
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'
|
|
33
35
|
|
|
34
36
|
function persistToken(token: string, api: PluginApi): void {
|
|
@@ -54,6 +56,59 @@ export function getPushToken(): string | null {
|
|
|
54
56
|
return null
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
// ── Badge counter ────────────────────────────────────────────────
|
|
60
|
+
// In-memory counter avoids TOCTOU races between concurrent agent_end
|
|
61
|
+
// handlers. File is only for persistence across process restarts.
|
|
62
|
+
|
|
63
|
+
let badgeCounter: number | null = null
|
|
64
|
+
|
|
65
|
+
function loadBadgeCount(): number {
|
|
66
|
+
if (badgeCounter != null) return badgeCounter
|
|
67
|
+
try {
|
|
68
|
+
if (fs.existsSync(BADGE_FILE)) {
|
|
69
|
+
const data = JSON.parse(fs.readFileSync(BADGE_FILE, 'utf-8'))
|
|
70
|
+
if (typeof data.count === 'number') {
|
|
71
|
+
badgeCounter = data.count
|
|
72
|
+
return badgeCounter
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
badgeCounter = 0
|
|
77
|
+
return 0
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function persistBadgeCount(count: number): void {
|
|
81
|
+
try {
|
|
82
|
+
fs.mkdirSync(TOKEN_DIR, {recursive: true})
|
|
83
|
+
fs.writeFileSync(BADGE_FILE, JSON.stringify({count}))
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Atomically increment badge counter (sync — safe in single-threaded Node.js).
|
|
89
|
+
* Persists to disk immediately so the count survives process restarts.
|
|
90
|
+
*/
|
|
91
|
+
export function incrementBadgeCount(): number {
|
|
92
|
+
const next = loadBadgeCount() + 1
|
|
93
|
+
badgeCounter = next
|
|
94
|
+
persistBadgeCount(next)
|
|
95
|
+
return next
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Roll back one increment (e.g. when push delivery fails). */
|
|
99
|
+
export function decrementBadgeCount(): void {
|
|
100
|
+
const current = loadBadgeCount()
|
|
101
|
+
if (current > 0) {
|
|
102
|
+
badgeCounter = current - 1
|
|
103
|
+
persistBadgeCount(current - 1)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resetBadgeCount(): void {
|
|
108
|
+
badgeCounter = 0
|
|
109
|
+
persistBadgeCount(0)
|
|
110
|
+
}
|
|
111
|
+
|
|
57
112
|
/**
|
|
58
113
|
* Fetch agent display identity (name, emoji) via gateway RPC.
|
|
59
114
|
* Returns null on failure so the caller can fall back to defaults.
|
|
@@ -118,6 +173,16 @@ export async function sendPushNotification(
|
|
|
118
173
|
return false
|
|
119
174
|
}
|
|
120
175
|
|
|
176
|
+
// Expo returns 200 but marks tickets as errors (e.g. DeviceNotRegistered).
|
|
177
|
+
// Single-message POST returns data as a plain object, not an array.
|
|
178
|
+
const ticket = Array.isArray(json?.data) ? json.data[0] : json?.data
|
|
179
|
+
if (ticket?.status === 'error') {
|
|
180
|
+
api.logger.warn(
|
|
181
|
+
`notification: push ticket error — ${ticket.message} (${ticket.details?.error})`,
|
|
182
|
+
)
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
|
|
121
186
|
api.logger.info(`notification: push sent — "${opts.body}"`)
|
|
122
187
|
return true
|
|
123
188
|
} catch (err) {
|
|
@@ -160,7 +225,13 @@ export function registerNotification(api: PluginApi) {
|
|
|
160
225
|
respond(sent, {sent})
|
|
161
226
|
})
|
|
162
227
|
|
|
228
|
+
api.registerGatewayMethod('clawly.notification.clearBadge', async ({respond}) => {
|
|
229
|
+
resetBadgeCount()
|
|
230
|
+
api.logger.info('notification: badge count cleared')
|
|
231
|
+
respond(true, {cleared: true})
|
|
232
|
+
})
|
|
233
|
+
|
|
163
234
|
api.logger.info(
|
|
164
|
-
'notification: registered clawly.notification.setToken + clawly.notification.send',
|
|
235
|
+
'notification: registered clawly.notification.setToken + clawly.notification.send + clawly.notification.clearBadge',
|
|
165
236
|
)
|
|
166
237
|
}
|
|
@@ -18,12 +18,21 @@ let lastPushOpts: {
|
|
|
18
18
|
data?: Record<string, unknown>
|
|
19
19
|
} | null = null
|
|
20
20
|
let lastPushExtras: Record<string, unknown> | undefined = undefined
|
|
21
|
+
let mockBadgeCount = 0
|
|
21
22
|
|
|
22
23
|
mock.module('./presence', () => ({
|
|
23
24
|
isClientOnline: async () => mockOnline,
|
|
24
25
|
}))
|
|
25
26
|
|
|
26
27
|
mock.module('./notification', () => ({
|
|
28
|
+
getPushToken: () => 'ExponentPushToken[mock]',
|
|
29
|
+
incrementBadgeCount: () => {
|
|
30
|
+
mockBadgeCount += 1
|
|
31
|
+
return mockBadgeCount
|
|
32
|
+
},
|
|
33
|
+
decrementBadgeCount: () => {
|
|
34
|
+
if (mockBadgeCount > 0) mockBadgeCount -= 1
|
|
35
|
+
},
|
|
27
36
|
sendPushNotification: async (
|
|
28
37
|
opts: {body: string; title?: string; agentId?: string; data?: Record<string, unknown>},
|
|
29
38
|
_api: PluginApi,
|
|
@@ -68,6 +77,7 @@ beforeEach(() => {
|
|
|
68
77
|
mockPushSent = true
|
|
69
78
|
lastPushOpts = null
|
|
70
79
|
lastPushExtras = undefined
|
|
80
|
+
mockBadgeCount = 0
|
|
71
81
|
})
|
|
72
82
|
|
|
73
83
|
describe('offline-push', () => {
|
|
@@ -110,6 +120,32 @@ describe('offline-push', () => {
|
|
|
110
120
|
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(2)
|
|
111
121
|
})
|
|
112
122
|
|
|
123
|
+
test('passes incrementing badge count in push extras', async () => {
|
|
124
|
+
const {api, handlers} = createMockApi()
|
|
125
|
+
registerOfflinePush(api)
|
|
126
|
+
|
|
127
|
+
const handler = handlers.get('agent_end')!
|
|
128
|
+
|
|
129
|
+
await handler({}, {sessionKey: 'agent:clawly:main'})
|
|
130
|
+
expect(lastPushExtras).toEqual({badge: 1})
|
|
131
|
+
expect(mockBadgeCount).toBe(1)
|
|
132
|
+
|
|
133
|
+
await handler({}, {sessionKey: 'agent:clawly:main'})
|
|
134
|
+
expect(lastPushExtras).toEqual({badge: 2})
|
|
135
|
+
expect(mockBadgeCount).toBe(2)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('rolls back badge count when push fails', async () => {
|
|
139
|
+
mockPushSent = false
|
|
140
|
+
const {api, handlers} = createMockApi()
|
|
141
|
+
registerOfflinePush(api)
|
|
142
|
+
|
|
143
|
+
await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
|
|
144
|
+
|
|
145
|
+
expect(lastPushExtras).toEqual({badge: 1})
|
|
146
|
+
expect(mockBadgeCount).toBe(0) // incremented then decremented
|
|
147
|
+
})
|
|
148
|
+
|
|
113
149
|
test('skips push for non-main session (e.g. telegram)', async () => {
|
|
114
150
|
const {api, logs, handlers} = createMockApi()
|
|
115
151
|
registerOfflinePush(api)
|
package/gateway/offline-push.ts
CHANGED
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type {PluginApi} from '../types'
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
decrementBadgeCount,
|
|
15
|
+
getPushToken,
|
|
16
|
+
incrementBadgeCount,
|
|
17
|
+
sendPushNotification,
|
|
18
|
+
} from './notification'
|
|
14
19
|
import {isClientOnline} from './presence'
|
|
15
20
|
|
|
16
21
|
/** Strip [[type:value]], [[word]], and MEDIA:xxx placeholders from text (canonical: apps/mobile/lib/stripPlaceholders.ts). */
|
|
@@ -141,6 +146,12 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
141
146
|
const preview = cleaned && cleaned.length > 140 ? `${cleaned.slice(0, 140)}…` : cleaned
|
|
142
147
|
const body = preview || 'Your response is ready'
|
|
143
148
|
|
|
149
|
+
if (!getPushToken()) {
|
|
150
|
+
api.logger.warn('offline-push: skipped (no push token)')
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const badge = incrementBadgeCount()
|
|
144
155
|
const sent = await sendPushNotification(
|
|
145
156
|
{
|
|
146
157
|
body,
|
|
@@ -151,10 +162,13 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
151
162
|
},
|
|
152
163
|
},
|
|
153
164
|
api,
|
|
165
|
+
{badge},
|
|
154
166
|
)
|
|
155
167
|
|
|
156
168
|
if (sent) {
|
|
157
169
|
api.logger.info(`offline-push: notified (session=${sessionKey ?? 'unknown'})`)
|
|
170
|
+
} else {
|
|
171
|
+
decrementBadgeCount()
|
|
158
172
|
}
|
|
159
173
|
} catch (err) {
|
|
160
174
|
api.logger.error(`offline-push: ${err instanceof Error ? err.message : String(err)}`)
|