palmier 0.7.2 → 0.7.3
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/README.md +19 -8
- package/dist/commands/serve.js +14 -1
- package/dist/mcp-handler.js +4 -1
- package/dist/mcp-tools.js +393 -3
- package/dist/pwa/assets/{index-C6Lz09EY.css → index-B-ByUHPS.css} +1 -1
- package/dist/pwa/assets/index-CPIqbV9-.js +118 -0
- package/dist/pwa/assets/{web-HDs03L2B.js → web-Dwi8DLNK.js} +1 -1
- package/dist/pwa/assets/{web-CBI458eN.js → web-SlBB3mP3.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/sms-store.d.ts +11 -0
- package/dist/sms-store.js +19 -0
- package/dist/transports/http-transport.js +16 -1
- package/package.json +1 -1
- package/palmier-server/README.md +11 -3
- package/palmier-server/pwa/src/App.css +3 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +351 -0
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/server/src/index.ts +301 -0
- package/palmier-server/server/src/routes/device.ts +168 -0
- package/palmier-server/spec.md +32 -3
- package/src/commands/serve.ts +14 -1
- package/src/mcp-handler.ts +4 -1
- package/src/mcp-tools.ts +451 -3
- package/src/sms-store.ts +28 -0
- package/src/transports/http-transport.ts +16 -1
- package/test/agent-instructions.test.ts +1 -1
- package/dist/pwa/assets/index-DLxrL0hR.js +0 -118
|
@@ -230,6 +230,307 @@ async function main(): Promise<void> {
|
|
|
230
230
|
}
|
|
231
231
|
})();
|
|
232
232
|
|
|
233
|
+
// Subscribe to contacts requests from hosts
|
|
234
|
+
(async () => {
|
|
235
|
+
try {
|
|
236
|
+
const conn = await getNatsConnection();
|
|
237
|
+
const sub = conn.subscribe("host.*.fcm.contacts");
|
|
238
|
+
console.log("Listening for FCM contacts requests");
|
|
239
|
+
|
|
240
|
+
for await (const msg of sub) {
|
|
241
|
+
try {
|
|
242
|
+
const data = JSON.parse(sc.decode(msg.data)) as {
|
|
243
|
+
hostId: string;
|
|
244
|
+
requestId: string;
|
|
245
|
+
fcmToken?: string;
|
|
246
|
+
action: string;
|
|
247
|
+
name?: string;
|
|
248
|
+
phone?: string;
|
|
249
|
+
email?: string;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const subjectHostId = msg.subject.split(".")[1];
|
|
253
|
+
if (data.hostId !== subjectHostId) {
|
|
254
|
+
if (msg.reply) {
|
|
255
|
+
msg.respond(sc.encode(JSON.stringify({ error: "hostId mismatch" })));
|
|
256
|
+
}
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const fcmPayload: Record<string, string> = {
|
|
261
|
+
type: data.action === "create" ? "create-contact" : "read-contacts",
|
|
262
|
+
requestId: data.requestId,
|
|
263
|
+
hostId: data.hostId,
|
|
264
|
+
};
|
|
265
|
+
if (data.name) fcmPayload.name = data.name;
|
|
266
|
+
if (data.phone) fcmPayload.phone = data.phone;
|
|
267
|
+
if (data.email) fcmPayload.email = data.email;
|
|
268
|
+
|
|
269
|
+
console.log(`[FCM] Sending contacts ${data.action} request for host ${data.hostId}`);
|
|
270
|
+
if (data.fcmToken) {
|
|
271
|
+
await sendFcmToDevice(data.fcmToken, fcmPayload);
|
|
272
|
+
} else {
|
|
273
|
+
await sendFcmToClients(data.hostId, fcmPayload);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (msg.reply) {
|
|
277
|
+
msg.respond(sc.encode(JSON.stringify({ ok: true })));
|
|
278
|
+
}
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error("[FCM] Error handling contacts request:", err);
|
|
281
|
+
if (msg.reply) {
|
|
282
|
+
msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch (err) {
|
|
287
|
+
console.error("Failed to subscribe to FCM contacts requests:", err);
|
|
288
|
+
}
|
|
289
|
+
})();
|
|
290
|
+
|
|
291
|
+
// Subscribe to calendar requests from hosts
|
|
292
|
+
(async () => {
|
|
293
|
+
try {
|
|
294
|
+
const conn = await getNatsConnection();
|
|
295
|
+
const sub = conn.subscribe("host.*.fcm.calendar");
|
|
296
|
+
console.log("Listening for FCM calendar requests");
|
|
297
|
+
|
|
298
|
+
for await (const msg of sub) {
|
|
299
|
+
try {
|
|
300
|
+
const data = JSON.parse(sc.decode(msg.data)) as {
|
|
301
|
+
hostId: string;
|
|
302
|
+
requestId: string;
|
|
303
|
+
action: string;
|
|
304
|
+
[key: string]: string;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const subjectHostId = msg.subject.split(".")[1];
|
|
308
|
+
if (data.hostId !== subjectHostId) {
|
|
309
|
+
if (msg.reply) {
|
|
310
|
+
msg.respond(sc.encode(JSON.stringify({ error: "hostId mismatch" })));
|
|
311
|
+
}
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const fcmPayload: Record<string, string> = {
|
|
316
|
+
type: data.action === "create" ? "create-calendar-event" : "read-calendar",
|
|
317
|
+
requestId: data.requestId,
|
|
318
|
+
hostId: data.hostId,
|
|
319
|
+
};
|
|
320
|
+
// Forward optional fields
|
|
321
|
+
for (const key of ["startDate", "endDate", "title", "startTime", "endTime", "location", "description"]) {
|
|
322
|
+
if (data[key]) fcmPayload[key] = data[key];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
console.log(`[FCM] Sending calendar ${data.action} request for host ${data.hostId}`);
|
|
326
|
+
await sendFcmToClients(data.hostId, fcmPayload);
|
|
327
|
+
|
|
328
|
+
if (msg.reply) {
|
|
329
|
+
msg.respond(sc.encode(JSON.stringify({ ok: true })));
|
|
330
|
+
}
|
|
331
|
+
} catch (err) {
|
|
332
|
+
console.error("[FCM] Error handling calendar request:", err);
|
|
333
|
+
if (msg.reply) {
|
|
334
|
+
msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
} catch (err) {
|
|
339
|
+
console.error("Failed to subscribe to FCM calendar requests:", err);
|
|
340
|
+
}
|
|
341
|
+
})();
|
|
342
|
+
|
|
343
|
+
// Subscribe to send-SMS requests from hosts
|
|
344
|
+
(async () => {
|
|
345
|
+
try {
|
|
346
|
+
const conn = await getNatsConnection();
|
|
347
|
+
const sub = conn.subscribe("host.*.fcm.sms");
|
|
348
|
+
console.log("Listening for FCM SMS requests");
|
|
349
|
+
|
|
350
|
+
for await (const msg of sub) {
|
|
351
|
+
try {
|
|
352
|
+
const data = JSON.parse(sc.decode(msg.data)) as {
|
|
353
|
+
hostId: string;
|
|
354
|
+
requestId: string;
|
|
355
|
+
action: string;
|
|
356
|
+
to?: string;
|
|
357
|
+
body?: string;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const subjectHostId = msg.subject.split(".")[1];
|
|
361
|
+
if (data.hostId !== subjectHostId) {
|
|
362
|
+
if (msg.reply) {
|
|
363
|
+
msg.respond(sc.encode(JSON.stringify({ error: "hostId mismatch" })));
|
|
364
|
+
}
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const fcmPayload: Record<string, string> = {
|
|
369
|
+
type: "send-sms",
|
|
370
|
+
requestId: data.requestId,
|
|
371
|
+
hostId: data.hostId,
|
|
372
|
+
};
|
|
373
|
+
if (data.to) fcmPayload.to = data.to;
|
|
374
|
+
if (data.body) fcmPayload.body = data.body;
|
|
375
|
+
|
|
376
|
+
console.log(`[FCM] Sending SMS request for host ${data.hostId}`);
|
|
377
|
+
await sendFcmToClients(data.hostId, fcmPayload);
|
|
378
|
+
|
|
379
|
+
if (msg.reply) {
|
|
380
|
+
msg.respond(sc.encode(JSON.stringify({ ok: true })));
|
|
381
|
+
}
|
|
382
|
+
} catch (err) {
|
|
383
|
+
console.error("[FCM] Error handling SMS request:", err);
|
|
384
|
+
if (msg.reply) {
|
|
385
|
+
msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} catch (err) {
|
|
390
|
+
console.error("Failed to subscribe to FCM SMS requests:", err);
|
|
391
|
+
}
|
|
392
|
+
})();
|
|
393
|
+
|
|
394
|
+
// Subscribe to alarm requests from hosts
|
|
395
|
+
(async () => {
|
|
396
|
+
try {
|
|
397
|
+
const conn = await getNatsConnection();
|
|
398
|
+
const sub = conn.subscribe("host.*.fcm.alarm");
|
|
399
|
+
console.log("Listening for FCM alarm requests");
|
|
400
|
+
|
|
401
|
+
for await (const msg of sub) {
|
|
402
|
+
try {
|
|
403
|
+
const data = JSON.parse(sc.decode(msg.data)) as {
|
|
404
|
+
hostId: string;
|
|
405
|
+
requestId: string;
|
|
406
|
+
[key: string]: string;
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const subjectHostId = msg.subject.split(".")[1];
|
|
410
|
+
if (data.hostId !== subjectHostId) {
|
|
411
|
+
if (msg.reply) {
|
|
412
|
+
msg.respond(sc.encode(JSON.stringify({ error: "hostId mismatch" })));
|
|
413
|
+
}
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const fcmPayload: Record<string, string> = {
|
|
418
|
+
type: "set-alarm",
|
|
419
|
+
requestId: data.requestId,
|
|
420
|
+
hostId: data.hostId,
|
|
421
|
+
};
|
|
422
|
+
for (const key of ["hour", "minutes", "label", "days"]) {
|
|
423
|
+
if (data[key]) fcmPayload[key] = data[key];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
console.log(`[FCM] Sending alarm request for host ${data.hostId}`);
|
|
427
|
+
await sendFcmToClients(data.hostId, fcmPayload);
|
|
428
|
+
|
|
429
|
+
if (msg.reply) {
|
|
430
|
+
msg.respond(sc.encode(JSON.stringify({ ok: true })));
|
|
431
|
+
}
|
|
432
|
+
} catch (err) {
|
|
433
|
+
console.error("[FCM] Error handling alarm request:", err);
|
|
434
|
+
if (msg.reply) {
|
|
435
|
+
msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
} catch (err) {
|
|
440
|
+
console.error("Failed to subscribe to FCM alarm requests:", err);
|
|
441
|
+
}
|
|
442
|
+
})();
|
|
443
|
+
|
|
444
|
+
// Subscribe to battery requests from hosts
|
|
445
|
+
(async () => {
|
|
446
|
+
try {
|
|
447
|
+
const conn = await getNatsConnection();
|
|
448
|
+
const sub = conn.subscribe("host.*.fcm.battery");
|
|
449
|
+
console.log("Listening for FCM battery requests");
|
|
450
|
+
|
|
451
|
+
for await (const msg of sub) {
|
|
452
|
+
try {
|
|
453
|
+
const data = JSON.parse(sc.decode(msg.data)) as {
|
|
454
|
+
hostId: string;
|
|
455
|
+
requestId: string;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const subjectHostId = msg.subject.split(".")[1];
|
|
459
|
+
if (data.hostId !== subjectHostId) {
|
|
460
|
+
if (msg.reply) {
|
|
461
|
+
msg.respond(sc.encode(JSON.stringify({ error: "hostId mismatch" })));
|
|
462
|
+
}
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
console.log(`[FCM] Sending battery request for host ${data.hostId}`);
|
|
467
|
+
await sendFcmToClients(data.hostId, {
|
|
468
|
+
type: "read-battery",
|
|
469
|
+
requestId: data.requestId,
|
|
470
|
+
hostId: data.hostId,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
if (msg.reply) {
|
|
474
|
+
msg.respond(sc.encode(JSON.stringify({ ok: true })));
|
|
475
|
+
}
|
|
476
|
+
} catch (err) {
|
|
477
|
+
console.error("[FCM] Error handling battery request:", err);
|
|
478
|
+
if (msg.reply) {
|
|
479
|
+
msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} catch (err) {
|
|
484
|
+
console.error("Failed to subscribe to FCM battery requests:", err);
|
|
485
|
+
}
|
|
486
|
+
})();
|
|
487
|
+
|
|
488
|
+
// Subscribe to ringer mode requests from hosts
|
|
489
|
+
(async () => {
|
|
490
|
+
try {
|
|
491
|
+
const conn = await getNatsConnection();
|
|
492
|
+
const sub = conn.subscribe("host.*.fcm.ringer");
|
|
493
|
+
console.log("Listening for FCM ringer requests");
|
|
494
|
+
|
|
495
|
+
for await (const msg of sub) {
|
|
496
|
+
try {
|
|
497
|
+
const data = JSON.parse(sc.decode(msg.data)) as {
|
|
498
|
+
hostId: string;
|
|
499
|
+
requestId: string;
|
|
500
|
+
mode: string;
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const subjectHostId = msg.subject.split(".")[1];
|
|
504
|
+
if (data.hostId !== subjectHostId) {
|
|
505
|
+
if (msg.reply) {
|
|
506
|
+
msg.respond(sc.encode(JSON.stringify({ error: "hostId mismatch" })));
|
|
507
|
+
}
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
console.log(`[FCM] Sending ringer mode request for host ${data.hostId}`);
|
|
512
|
+
await sendFcmToClients(data.hostId, {
|
|
513
|
+
type: "set-ringer-mode",
|
|
514
|
+
requestId: data.requestId,
|
|
515
|
+
hostId: data.hostId,
|
|
516
|
+
mode: data.mode,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
if (msg.reply) {
|
|
520
|
+
msg.respond(sc.encode(JSON.stringify({ ok: true })));
|
|
521
|
+
}
|
|
522
|
+
} catch (err) {
|
|
523
|
+
console.error("[FCM] Error handling ringer request:", err);
|
|
524
|
+
if (msg.reply) {
|
|
525
|
+
msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} catch (err) {
|
|
530
|
+
console.error("Failed to subscribe to FCM ringer requests:", err);
|
|
531
|
+
}
|
|
532
|
+
})();
|
|
533
|
+
|
|
233
534
|
// Create Express app
|
|
234
535
|
const app = express();
|
|
235
536
|
|
|
@@ -29,4 +29,172 @@ router.post("/notifications", async (req: Request, res: Response) => {
|
|
|
29
29
|
}
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
// POST /api/device/sms - Receive an SMS from Android, relay to host via NATS
|
|
33
|
+
router.post("/sms", async (req: Request, res: Response) => {
|
|
34
|
+
try {
|
|
35
|
+
const { hostId, sms } = req.body;
|
|
36
|
+
|
|
37
|
+
if (!hostId || !sms?.id) {
|
|
38
|
+
res.status(400).json({ error: "hostId and sms with id are required" });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const conn = await getNatsConnection();
|
|
43
|
+
const sc = StringCodec();
|
|
44
|
+
conn.publish(
|
|
45
|
+
`host.${hostId}.device.sms`,
|
|
46
|
+
sc.encode(JSON.stringify(sms)),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
res.json({ ok: true });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error("Device SMS relay error:", err);
|
|
52
|
+
res.status(500).json({ error: "Internal server error" });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// POST /api/device/contacts-response - Receive contacts response from Android, relay to host via NATS
|
|
57
|
+
router.post("/contacts-response", async (req: Request, res: Response) => {
|
|
58
|
+
try {
|
|
59
|
+
const { requestId, hostId, result } = req.body;
|
|
60
|
+
|
|
61
|
+
if (!requestId || !hostId) {
|
|
62
|
+
res.status(400).json({ error: "requestId and hostId are required" });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const conn = await getNatsConnection();
|
|
67
|
+
const sc = StringCodec();
|
|
68
|
+
conn.publish(
|
|
69
|
+
`host.${hostId}.contacts.${requestId}`,
|
|
70
|
+
sc.encode(JSON.stringify(result)),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
res.json({ ok: true });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error("Device contacts response relay error:", err);
|
|
76
|
+
res.status(500).json({ error: "Internal server error" });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// POST /api/device/calendar-response - Receive calendar response from Android, relay to host via NATS
|
|
81
|
+
router.post("/calendar-response", async (req: Request, res: Response) => {
|
|
82
|
+
try {
|
|
83
|
+
const { requestId, hostId, result } = req.body;
|
|
84
|
+
|
|
85
|
+
if (!requestId || !hostId) {
|
|
86
|
+
res.status(400).json({ error: "requestId and hostId are required" });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const conn = await getNatsConnection();
|
|
91
|
+
const sc = StringCodec();
|
|
92
|
+
conn.publish(
|
|
93
|
+
`host.${hostId}.calendar.${requestId}`,
|
|
94
|
+
sc.encode(JSON.stringify(result)),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
res.json({ ok: true });
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error("Device calendar response relay error:", err);
|
|
100
|
+
res.status(500).json({ error: "Internal server error" });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// POST /api/device/sms-response - Receive SMS send response from Android, relay to host via NATS
|
|
105
|
+
router.post("/sms-response", async (req: Request, res: Response) => {
|
|
106
|
+
try {
|
|
107
|
+
const { requestId, hostId, result } = req.body;
|
|
108
|
+
|
|
109
|
+
if (!requestId || !hostId) {
|
|
110
|
+
res.status(400).json({ error: "requestId and hostId are required" });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const conn = await getNatsConnection();
|
|
115
|
+
const sc = StringCodec();
|
|
116
|
+
conn.publish(
|
|
117
|
+
`host.${hostId}.sms.${requestId}`,
|
|
118
|
+
sc.encode(JSON.stringify(result)),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
res.json({ ok: true });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error("Device SMS response relay error:", err);
|
|
124
|
+
res.status(500).json({ error: "Internal server error" });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// POST /api/device/alarm-response - Receive alarm response from Android, relay to host via NATS
|
|
129
|
+
router.post("/alarm-response", async (req: Request, res: Response) => {
|
|
130
|
+
try {
|
|
131
|
+
const { requestId, hostId, result } = req.body;
|
|
132
|
+
|
|
133
|
+
if (!requestId || !hostId) {
|
|
134
|
+
res.status(400).json({ error: "requestId and hostId are required" });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const conn = await getNatsConnection();
|
|
139
|
+
const sc = StringCodec();
|
|
140
|
+
conn.publish(
|
|
141
|
+
`host.${hostId}.alarm.${requestId}`,
|
|
142
|
+
sc.encode(JSON.stringify(result)),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
res.json({ ok: true });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.error("Device alarm response relay error:", err);
|
|
148
|
+
res.status(500).json({ error: "Internal server error" });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// POST /api/device/battery-response - Receive battery response from Android, relay to host via NATS
|
|
153
|
+
router.post("/battery-response", async (req: Request, res: Response) => {
|
|
154
|
+
try {
|
|
155
|
+
const { requestId, hostId, result } = req.body;
|
|
156
|
+
|
|
157
|
+
if (!requestId || !hostId) {
|
|
158
|
+
res.status(400).json({ error: "requestId and hostId are required" });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const conn = await getNatsConnection();
|
|
163
|
+
const sc = StringCodec();
|
|
164
|
+
conn.publish(
|
|
165
|
+
`host.${hostId}.battery.${requestId}`,
|
|
166
|
+
sc.encode(JSON.stringify(result)),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
res.json({ ok: true });
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.error("Device battery response relay error:", err);
|
|
172
|
+
res.status(500).json({ error: "Internal server error" });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// POST /api/device/ringer-response - Receive ringer mode response from Android, relay to host via NATS
|
|
177
|
+
router.post("/ringer-response", async (req: Request, res: Response) => {
|
|
178
|
+
try {
|
|
179
|
+
const { requestId, hostId, result } = req.body;
|
|
180
|
+
|
|
181
|
+
if (!requestId || !hostId) {
|
|
182
|
+
res.status(400).json({ error: "requestId and hostId are required" });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const conn = await getNatsConnection();
|
|
187
|
+
const sc = StringCodec();
|
|
188
|
+
conn.publish(
|
|
189
|
+
`host.${hostId}.ringer.${requestId}`,
|
|
190
|
+
sc.encode(JSON.stringify(result)),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
res.json({ ok: true });
|
|
194
|
+
} catch (err) {
|
|
195
|
+
console.error("Device ringer response relay error:", err);
|
|
196
|
+
res.status(500).json({ error: "Internal server error" });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
32
200
|
export default router;
|
package/palmier-server/spec.md
CHANGED
|
@@ -12,11 +12,22 @@ The host supports **Linux** (systemd) and **Windows** (Task Scheduler for both d
|
|
|
12
12
|
|
|
13
13
|
### 1.2 Components
|
|
14
14
|
|
|
15
|
-
* **Host Binary (Node.js):** Runs persistently on the user's host machine as a NATS + HTTP RPC handler. Manages file system operations (task CRUD), OS-level scheduling (systemd), and task generation. Provides a CLI with commands: `palmier init` (provisioning), `palmier pair` (generate pairing code for device pairing), `palmier clients` (manage client tokens), `palmier run <task-id>` (executes a task via the configured agent tool), `palmier uninstall` (stop daemon and remove all scheduled tasks), and `palmier serve` (persistent RPC handler, default command). The `serve` process always starts a local HTTP server (bound to `127.0.0.1` by default, or `0.0.0.0` if LAN mode is enabled) alongside the NATS transport. Exposes a localhost-only MCP server at `/mcp` (streamable HTTP transport) with tools: `notify`, `request-input`, `request-confirmation`, `device-geolocation
|
|
15
|
+
* **Host Binary (Node.js):** Runs persistently on the user's host machine as a NATS + HTTP RPC handler. Manages file system operations (task CRUD), OS-level scheduling (systemd), and task generation. Provides a CLI with commands: `palmier init` (provisioning), `palmier pair` (generate pairing code for device pairing), `palmier clients` (manage client tokens), `palmier run <task-id>` (executes a task via the configured agent tool), `palmier uninstall` (stop daemon and remove all scheduled tasks), and `palmier serve` (persistent RPC handler, default command). The `serve` process always starts a local HTTP server (bound to `127.0.0.1` by default, or `0.0.0.0` if LAN mode is enabled) alongside the NATS transport. Exposes a localhost-only MCP server at `/mcp` (streamable HTTP transport) with tools: `notify`, `request-input`, `request-confirmation`, `device-geolocation`, `read-contacts`, `create-contact`, `read-calendar`, `create-calendar-event`, `send-sms`, `set-alarm`, `read-battery`, `set-ringer-mode`; and resources: `notifications://device` (device notifications), `sms://device` (SMS messages). Tools and resources are auto-generated as REST endpoints from shared registries (`ToolDefinition[]`, `ResourceDefinition[]`) — zero duplication. Tool REST endpoints are POST with `taskId` query param; resource REST endpoints are GET. `/request-permission` remains a separate endpoint (not part of the MCP registries). MCP resources support subscriptions — clients call `resources/subscribe` and the server holds the POST response open as an SSE stream, pushing `notifications/resources/updated` notifications when the resource data changes. MCP sessions track agent names from `initialize` clientInfo for logging and UI display. `palmier run` is a short-lived process invoked by systemd. Task execution is abstracted through an `AgentTool` interface (`src/agents/agent.ts`) so different AI CLI tools can be supported — each agent implements `getPromptCommandLine()`, `getTaskRunCommandLine()`, and `init()`. The task's `agent` field (e.g., `"claude"`) selects which agent is used.
|
|
16
16
|
|
|
17
|
-
* **Web Server (Node.js):** Serves the PWA assets (React) via `app.palmier.me` (Cloudflare proxied), manages Web Push VAPID keys, and provides host registration. Uses **PostgreSQL** for persistent storage (host registrations, push subscriptions, FCM tokens). Connects to NATS via TCP to subscribe to `host-event.>` for sending push notifications (confirmations, dismissals, completion/failure). For `POST /api/push/respond` (confirmation responses via push notification action buttons), the Web Server forwards the response to the host via the `task.user_input` NATS RPC. Subscribes to `host.*.push.send` NATS subjects to relay push notification requests from the host CLI. Subscribes to `host.*.fcm.geolocation` to relay device geolocation requests via FCM. Co-located with the NATS server on the same machine.
|
|
17
|
+
* **Web Server (Node.js):** Serves the PWA assets (React) via `app.palmier.me` (Cloudflare proxied), manages Web Push VAPID keys, and provides host registration. Uses **PostgreSQL** for persistent storage (host registrations, push subscriptions, FCM tokens). Connects to NATS via TCP to subscribe to `host-event.>` for sending push notifications (confirmations, dismissals, completion/failure). For `POST /api/push/respond` (confirmation responses via push notification action buttons), the Web Server forwards the response to the host via the `task.user_input` NATS RPC. Subscribes to `host.*.push.send` NATS subjects to relay push notification requests from the host CLI. Subscribes to `host.*.fcm.geolocation` to relay device geolocation requests via FCM. Subscribes to `host.*.fcm.contacts`, `host.*.fcm.calendar`, `host.*.fcm.sms`, `host.*.fcm.alarm`, `host.*.fcm.battery`, and `host.*.fcm.ringer` to relay device capability requests via FCM. Provides HTTP endpoints for Android to post responses back (`/api/device/contacts-response`, `/api/device/calendar-response`, `/api/device/sms-response`, `/api/device/alarm-response`, `/api/device/battery-response`, `/api/device/ringer-response`). Co-located with the NATS server on the same machine.
|
|
18
18
|
|
|
19
|
-
* **Android App (Capacitor):** Native Android wrapper for the PWA. Provides FCM push messaging for receiving data messages in the background
|
|
19
|
+
* **Android App (Capacitor):** Native Android wrapper for the PWA. Provides FCM push messaging for receiving data messages in the background, `FusedLocationProviderClient` for GPS access, `NotificationListenerService` for capturing device notifications, `BroadcastReceiver` for incoming SMS, and handlers for contacts, calendar, alarms, battery, and ringer mode. All device tools work while the app is in the background via FCM data messages. When a request arrives via FCM, the appropriate handler executes the action and POSTs the result back to the Web Server. Device capabilities and their permissions:
|
|
20
|
+
- **Notifications**: `NotificationListenerService` — requires notification listener access (system settings toggle)
|
|
21
|
+
- **SMS receive**: `SmsBroadcastReceiver` — requires `RECEIVE_SMS` + `SEND_SMS` runtime permissions
|
|
22
|
+
- **SMS send**: `SmsHandler` — requires `SEND_SMS` runtime permission (shared with SMS receive toggle)
|
|
23
|
+
- **Contacts**: `ContactsHandler` — requires `READ_CONTACTS` + `WRITE_CONTACTS` runtime permissions
|
|
24
|
+
- **Calendar**: `CalendarHandler` — requires `READ_CALENDAR` + `WRITE_CALENDAR` runtime permissions
|
|
25
|
+
- **Geolocation**: `GeolocationForegroundService` — requires `ACCESS_FINE_LOCATION` runtime permission
|
|
26
|
+
- **Alarm**: `AlarmHandler` — requires `SET_ALARM` (normal permission, auto-granted)
|
|
27
|
+
- **Battery**: `BatteryHandler` — no permission required
|
|
28
|
+
- **Ringer mode**: `RingerHandler` — requires Do Not Disturb access (system settings toggle)
|
|
29
|
+
|
|
30
|
+
The notification listener excludes Palmier's own task notifications (channel `palmier_tasks`) and the default SMS app's notifications (to avoid duplicates with the SMS resource). Each capability can be toggled on/off via the app's settings menu; toggles are backed by SharedPreferences flags that handlers check before executing. See the `palmier-android` repo.
|
|
20
31
|
|
|
21
32
|
* **PWA (React):** The user-facing frontend, primarily targeting mobile devices. Connects to the NATS server via **WebSockets** at `nats.palmier.me` (DNS only, not Cloudflare proxied, to avoid interference with persistent connections). No user accounts — paired hosts are stored in localStorage.
|
|
22
33
|
|
|
@@ -135,6 +146,8 @@ All RPC requests include a `clientToken` field in the JSON payload. The host val
|
|
|
135
146
|
| Subject | Payload | Subscriber | Description |
|
|
136
147
|
|---|---|---|---|
|
|
137
148
|
| `host-event.<host_id>.<task_id>` | `{ event_type, ... }` | PWA, Web Server | Unified event subject. `event_type` is one of `"running-state"`, `"confirm-request"`, `"confirm-resolved"`, `"permission-request"`, `"permission-resolved"`, or `"report-generated"`. Payloads: running-state includes `{ running_state, name? }`, confirm-request includes `{ host_id }`, confirm-resolved includes `{ host_id, status }`, report-generated includes `{ name?, run_id, report_files }`. Same payload shape is used for both NATS and HTTP SSE. |
|
|
149
|
+
| `host.<host_id>.device.notifications` | `{ id, packageName, appName, title, text, timestamp }` | Host | Device notification from Android. Published by Web Server when it receives `POST /api/device/notifications` from the Android app. Host stores in bounded in-memory collection and exposes via MCP resource. |
|
|
150
|
+
| `host.<host_id>.device.sms` | `{ id, sender, body, timestamp }` | Host | Incoming SMS from Android. Published by Web Server when it receives `POST /api/device/sms` from the Android app. Host stores in bounded in-memory collection and exposes via MCP resource. |
|
|
138
151
|
|
|
139
152
|
### 2.5 Push Subscription Management
|
|
140
153
|
|
|
@@ -381,6 +394,14 @@ The serve daemon exposes localhost-only HTTP endpoints that agents call during t
|
|
|
381
394
|
|
|
382
395
|
* **`POST /request-permission`** — Requests permission grants. Body: `{ taskId, taskName, permissions }`. Called by `palmier run` (not agents). Returns `{ response: "granted" | "granted_all" | "aborted" }`.
|
|
383
396
|
|
|
397
|
+
### 6.2 Resource Endpoints
|
|
398
|
+
|
|
399
|
+
Resource REST endpoints are auto-generated from the `ResourceDefinition[]` registry in `mcp-tools.ts`. Each resource exposes a GET endpoint at its `restPath`. These are also available via the MCP protocol (`resources/list`, `resources/read`).
|
|
400
|
+
|
|
401
|
+
* **`GET /notifications`** — Returns recent notifications from the user's Android device as a JSON array. Each notification contains `{ id, packageName, appName, title, text, timestamp, receivedAt }`. The host maintains a bounded in-memory collection (last 50 notifications) fed by NATS subscription to `host.<host_id>.device.notifications`.
|
|
402
|
+
|
|
403
|
+
* **`GET /sms`** — Returns recent SMS messages from the user's Android device as a JSON array. Each message contains `{ id, sender, body, timestamp, receivedAt }`. The host maintains a bounded in-memory collection (last 50 messages) fed by NATS subscription to `host.<host_id>.device.sms`.
|
|
404
|
+
|
|
384
405
|
## 7. Database Schema (PostgreSQL)
|
|
385
406
|
|
|
386
407
|
```sql
|
|
@@ -415,4 +436,12 @@ All endpoints are served over HTTPS. No user authentication is required — the
|
|
|
415
436
|
| `DELETE` | `/api/push/subscribe` | Unregister a push subscription. Body: `{ hostId, endpoint }`. |
|
|
416
437
|
| `GET` | `/api/push/vapid-key` | Returns the server's VAPID public key for push subscription. |
|
|
417
438
|
| `POST` | `/api/push/respond` | Called by Service Worker to relay user responses to task confirmations. Body: `{ type, task_id, host_id, response }`. Web Server forwards the response to the host via the `host.<host_id>.rpc.task.user_input` NATS RPC. |
|
|
439
|
+
| `POST` | `/api/device/notifications` | Relay a device notification from Android to the host. Body: `{ hostId, notification: { id, packageName, appName, title, text, timestamp } }`. Publishes to NATS `host.<hostId>.device.notifications`. |
|
|
440
|
+
| `POST` | `/api/device/sms` | Relay an incoming SMS from Android to the host. Body: `{ hostId, sms: { id, sender, body, timestamp } }`. Publishes to NATS `host.<hostId>.device.sms`. |
|
|
441
|
+
| `POST` | `/api/device/contacts-response` | Relay contacts response from Android to the host. Body: `{ requestId, hostId, result }`. Publishes to NATS `host.<hostId>.contacts.<requestId>`. |
|
|
442
|
+
| `POST` | `/api/device/calendar-response` | Relay calendar response from Android to the host. Body: `{ requestId, hostId, result }`. Publishes to NATS `host.<hostId>.calendar.<requestId>`. |
|
|
443
|
+
| `POST` | `/api/device/sms-response` | Relay SMS send response from Android to the host. Body: `{ requestId, hostId, result }`. Publishes to NATS `host.<hostId>.sms.<requestId>`. |
|
|
444
|
+
| `POST` | `/api/device/alarm-response` | Relay alarm response from Android to the host. Body: `{ requestId, hostId, result }`. Publishes to NATS `host.<hostId>.alarm.<requestId>`. |
|
|
445
|
+
| `POST` | `/api/device/battery-response` | Relay battery response from Android to the host. Body: `{ requestId, hostId, result }`. Publishes to NATS `host.<hostId>.battery.<requestId>`. |
|
|
446
|
+
| `POST` | `/api/device/ringer-response` | Relay ringer mode response from Android to the host. Body: `{ requestId, hostId, result }`. Publishes to NATS `host.<hostId>.ringer.<requestId>`. |
|
|
418
447
|
| `GET` | `/health` | Health check. |
|
package/src/commands/serve.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { HostConfig } from "../types.js";
|
|
|
14
14
|
import { CONFIG_DIR } from "../config.js";
|
|
15
15
|
import { StringCodec, type NatsConnection } from "nats";
|
|
16
16
|
import { addNotification } from "../notification-store.js";
|
|
17
|
+
import { addSmsMessage } from "../sms-store.js";
|
|
17
18
|
|
|
18
19
|
const POLL_INTERVAL_MS = 30_000;
|
|
19
20
|
const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
|
|
@@ -132,7 +133,7 @@ export async function serveCommand(): Promise<void> {
|
|
|
132
133
|
if (nc) {
|
|
133
134
|
startNatsTransport(config, handleRpc, nc);
|
|
134
135
|
|
|
135
|
-
// Subscribe to device notifications from Android
|
|
136
|
+
// Subscribe to device notifications and SMS from Android
|
|
136
137
|
const sc = StringCodec();
|
|
137
138
|
const notifSub = nc.subscribe(`host.${config.hostId}.device.notifications`);
|
|
138
139
|
(async () => {
|
|
@@ -145,6 +146,18 @@ export async function serveCommand(): Promise<void> {
|
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
})();
|
|
149
|
+
|
|
150
|
+
const smsSub = nc.subscribe(`host.${config.hostId}.device.sms`);
|
|
151
|
+
(async () => {
|
|
152
|
+
for await (const msg of smsSub) {
|
|
153
|
+
try {
|
|
154
|
+
const data = JSON.parse(sc.decode(msg.data));
|
|
155
|
+
addSmsMessage({ ...data, receivedAt: Date.now() });
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.error("[nats] Failed to parse device SMS:", err);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
})();
|
|
148
161
|
}
|
|
149
162
|
|
|
150
163
|
// Start HTTP transport (loops forever)
|
package/src/mcp-handler.ts
CHANGED
|
@@ -157,12 +157,15 @@ export async function handleMcpRequest(body: string, sessionId: string | undefin
|
|
|
157
157
|
if (!resource) {
|
|
158
158
|
return { body: rpcError(id, -32602, `Unknown resource: ${uri}`) };
|
|
159
159
|
}
|
|
160
|
+
console.log(`${logPrefix} resources/read ${uri}`);
|
|
161
|
+
const content = resource.read();
|
|
162
|
+
console.log(`${logPrefix} resources/read ${uri} done: ${JSON.stringify(content).slice(0, 200)}`);
|
|
160
163
|
return {
|
|
161
164
|
body: rpcResult(id, {
|
|
162
165
|
contents: [{
|
|
163
166
|
uri: resource.uri,
|
|
164
167
|
mimeType: resource.mimeType,
|
|
165
|
-
text: JSON.stringify(
|
|
168
|
+
text: JSON.stringify(content),
|
|
166
169
|
}],
|
|
167
170
|
}),
|
|
168
171
|
};
|