fluxy-bot 0.5.3 → 0.5.5

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/worker/index.ts CHANGED
@@ -5,7 +5,8 @@ import path from 'path';
5
5
  import { loadConfig, saveConfig } from '../shared/config.js';
6
6
  import { paths, WORKSPACE_DIR } from '../shared/paths.js';
7
7
  import { log } from '../shared/logger.js';
8
- import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages } from './db.js';
8
+ import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint } from './db.js';
9
+ import webpush from 'web-push';
9
10
  import { startCodexOAuth, cancelCodexOAuth, getCodexAuthStatus, readCodexAccessToken } from './codex-auth.js';
10
11
  import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAccessToken } from './claude-auth.js';
11
12
  import { checkAvailability, registerHandle, releaseHandle, updateTunnelUrl, startHeartbeat, stopHeartbeat } from '../shared/relay.js';
@@ -34,6 +35,30 @@ initDb();
34
35
  // Ensure file storage directories exist
35
36
  ensureFileDirs();
36
37
 
38
+ // ── VAPID key management (Web Push) ──
39
+
40
+ function getOrCreateVapidKeys() {
41
+ let publicKey = getSetting('vapid_public_key');
42
+ let privateKey = getSetting('vapid_private_key');
43
+ if (!publicKey || !privateKey) {
44
+ const keys = webpush.generateVAPIDKeys();
45
+ publicKey = keys.publicKey;
46
+ privateKey = keys.privateKey;
47
+ setSetting('vapid_public_key', publicKey);
48
+ setSetting('vapid_private_key', privateKey);
49
+ log.ok('Generated new VAPID keys');
50
+ }
51
+ return { publicKey, privateKey };
52
+ }
53
+
54
+ function initWebPush() {
55
+ const { publicKey, privateKey } = getOrCreateVapidKeys();
56
+ webpush.setVapidDetails('mailto:push@fluxy.bot', publicKey, privateKey);
57
+ log.ok('Web Push initialized');
58
+ }
59
+
60
+ initWebPush();
61
+
37
62
  // Express
38
63
  const app = express();
39
64
  app.use(express.json({ limit: '10mb' }));
@@ -368,6 +393,66 @@ app.post('/api/onboard', (req, res) => {
368
393
  res.json({ ok: true });
369
394
  });
370
395
 
396
+ // ── Push notifications ──
397
+
398
+ app.get('/api/push/vapid-public-key', (_, res) => {
399
+ const key = getSetting('vapid_public_key');
400
+ if (!key) { res.status(500).json({ error: 'VAPID keys not initialized' }); return; }
401
+ res.json({ publicKey: key });
402
+ });
403
+
404
+ app.post('/api/push/subscribe', (req, res) => {
405
+ const { endpoint, keys } = req.body || {};
406
+ if (!endpoint || !keys?.p256dh || !keys?.auth) {
407
+ res.status(400).json({ error: 'Invalid subscription' });
408
+ return;
409
+ }
410
+ addPushSubscription(endpoint, keys.p256dh, keys.auth);
411
+ res.json({ ok: true });
412
+ });
413
+
414
+ app.delete('/api/push/unsubscribe', (req, res) => {
415
+ const { endpoint } = req.body || {};
416
+ if (!endpoint) { res.status(400).json({ error: 'Missing endpoint' }); return; }
417
+ removePushSubscription(endpoint);
418
+ res.json({ ok: true });
419
+ });
420
+
421
+ app.post('/api/push/send', async (req, res) => {
422
+ const { title, body, tag, url } = req.body || {};
423
+ const subs = getAllPushSubscriptions();
424
+ const payload = JSON.stringify({ title: title || 'Fluxy', body: body || '', tag: tag || 'fluxy', url: url || '/' });
425
+
426
+ const results = await Promise.allSettled(
427
+ subs.map(async (sub) => {
428
+ try {
429
+ await webpush.sendNotification(
430
+ { endpoint: sub.endpoint, keys: { p256dh: sub.keys_p256dh, auth: sub.keys_auth } },
431
+ payload,
432
+ );
433
+ } catch (err: any) {
434
+ if (err.statusCode === 410 || err.statusCode === 404) {
435
+ removePushSubscription(sub.endpoint);
436
+ log.info(`[push] Removed expired subscription: ${sub.endpoint.slice(0, 60)}...`);
437
+ } else {
438
+ log.warn(`[push] Send failed: ${err.message}`);
439
+ }
440
+ throw err;
441
+ }
442
+ }),
443
+ );
444
+
445
+ const sent = results.filter((r) => r.status === 'fulfilled').length;
446
+ res.json({ sent, total: subs.length });
447
+ });
448
+
449
+ app.get('/api/push/status', (req, res) => {
450
+ const endpoint = req.query.endpoint as string;
451
+ if (!endpoint) { res.json({ subscribed: false }); return; }
452
+ const sub = getPushSubscriptionByEndpoint(endpoint);
453
+ res.json({ subscribed: !!sub });
454
+ });
455
+
371
456
  // ── Whisper transcription ──
372
457
 
373
458
  app.post('/api/whisper/transcribe', express.json({ limit: '10mb' }), async (req, res) => {
@@ -124,6 +124,13 @@ An array of scheduled tasks:
124
124
  "schedule": "0 9 * * *",
125
125
  "task": "Write a daily summary of yesterday's notes",
126
126
  "enabled": true
127
+ },
128
+ {
129
+ "id": "remind-meeting",
130
+ "schedule": "30 14 28 2 *",
131
+ "task": "Remind Bruno about the 3pm meeting",
132
+ "enabled": true,
133
+ "oneShot": true
127
134
  }
128
135
  ]
129
136
  ```
@@ -133,8 +140,9 @@ Your human can ask you to:
133
140
  - Remove or disable a cron ("stop the daily summary")
134
141
  - Change a schedule ("move the summary to 8am")
135
142
  - List active crons ("what's scheduled?")
143
+ - Set a one-time reminder ("remind me at 3pm to call the dentist")
136
144
 
137
- Just edit `CRONS.json` with the Write or Edit tool when asked. Each cron needs: `id` (unique slug), `schedule` (cron expression), `task` (what to do), `enabled` (boolean).
145
+ Just edit `CRONS.json` with the Write or Edit tool when asked. Each cron needs: `id` (unique slug), `schedule` (cron expression), `task` (what to do), `enabled` (boolean). Optionally add `"oneShot": true` for tasks that should run once and auto-delete — the scheduler removes them after they fire. Use `oneShot` for reminders, one-time alerts, and any task that doesn't repeat.
138
146
 
139
147
  When you receive a `<CRON>cron-id</CRON>` message, look up that ID in your CRONS.json (provided in your context above) to find the task description. Execute the task, save results to the appropriate files, finish your turn.
140
148
 
@@ -1,4 +1,40 @@
1
- // Minimal service worker — required for PWA installability
1
+ // Service worker — PWA installability + push notifications
2
2
  self.addEventListener('install', () => self.skipWaiting());
3
3
  self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
4
4
  self.addEventListener('fetch', () => {});
5
+
6
+ // Push notification
7
+ self.addEventListener('push', (event) => {
8
+ let data = { title: 'Fluxy', body: 'New message' };
9
+ try {
10
+ data = event.data.json();
11
+ } catch {}
12
+
13
+ event.waitUntil(
14
+ self.registration.showNotification(data.title || 'Fluxy', {
15
+ body: data.body || '',
16
+ icon: '/fluxy-icon-192.png',
17
+ badge: '/fluxy-icon-192.png',
18
+ vibrate: [100, 50, 100],
19
+ tag: data.tag || 'fluxy-default',
20
+ data: { url: data.url || '/' },
21
+ })
22
+ );
23
+ });
24
+
25
+ // Notification click — focus or open app
26
+ self.addEventListener('notificationclick', (event) => {
27
+ event.notification.close();
28
+ const url = event.notification.data?.url || '/';
29
+
30
+ event.waitUntil(
31
+ self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
32
+ for (const client of clients) {
33
+ if (client.url.includes('/fluxy') && 'focus' in client) {
34
+ return client.focus();
35
+ }
36
+ }
37
+ return self.clients.openWindow(url);
38
+ })
39
+ );
40
+ });