@2en/clawly-plugins 1.24.8-beta.1 → 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.
@@ -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)
@@ -10,7 +10,12 @@
10
10
  */
11
11
 
12
12
  import type {PluginApi} from '../types'
13
- import {sendPushNotification} from './notification'
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)}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.24.8-beta.1",
3
+ "version": "1.25.0-beta.2",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {