feat(gateway): enable Android notify + notification events

This commit is contained in:
Ayaan Zaidi
2026-02-28 10:11:54 +05:30
committed by Ayaan Zaidi
parent 5350f5b035
commit 9d3ccf4754
6 changed files with 160 additions and 2 deletions

View File

@@ -347,7 +347,7 @@ describe("resolveNodeCommandAllowlist", () => {
expect(allow.has("notifications.actions")).toBe(true);
expect(allow.has("device.permissions")).toBe(true);
expect(allow.has("device.health")).toBe(true);
expect(allow.has("system.notify")).toBe(false);
expect(allow.has("system.notify")).toBe(true);
});
it("can explicitly allow dangerous commands via allowCommands", () => {

View File

@@ -82,6 +82,7 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
...CAMERA_COMMANDS,
...LOCATION_COMMANDS,
...ANDROID_NOTIFICATION_COMMANDS,
NODE_SYSTEM_NOTIFY_COMMAND,
...ANDROID_DEVICE_COMMANDS,
...CONTACTS_COMMANDS,
...CALENDAR_COMMANDS,

View File

@@ -351,6 +351,64 @@ describe("voice transcript events", () => {
});
});
describe("notifications changed events", () => {
beforeEach(() => {
enqueueSystemEventMock.mockClear();
requestHeartbeatNowMock.mockClear();
});
it("enqueues notifications.changed posted events", async () => {
const ctx = buildCtx();
await handleNodeEvent(ctx, "node-n1", {
event: "notifications.changed",
payloadJSON: JSON.stringify({
change: "posted",
key: "notif-1",
packageName: "com.example.chat",
title: "Message",
text: "Ping from Alex",
}),
});
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
"Notification posted (node=node-n1 key=notif-1 package=com.example.chat): Message - Ping from Alex",
{ sessionKey: "node-node-n1", contextKey: "notification:notif-1" },
);
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "notifications-event" });
});
it("enqueues notifications.changed removed events", async () => {
const ctx = buildCtx();
await handleNodeEvent(ctx, "node-n2", {
event: "notifications.changed",
payloadJSON: JSON.stringify({
change: "removed",
key: "notif-2",
packageName: "com.example.mail",
}),
});
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
"Notification removed (node=node-n2 key=notif-2 package=com.example.mail)",
{ sessionKey: "node-node-n2", contextKey: "notification:notif-2" },
);
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "notifications-event" });
});
it("ignores notifications.changed payloads missing required fields", async () => {
const ctx = buildCtx();
await handleNodeEvent(ctx, "node-n3", {
event: "notifications.changed",
payloadJSON: JSON.stringify({
change: "posted",
}),
});
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(requestHeartbeatNowMock).not.toHaveBeenCalled();
});
});
describe("agent request events", () => {
beforeEach(() => {
agentCommandMock.mockClear();

View File

@@ -23,6 +23,7 @@ import {
import { formatForLog } from "./ws-log.js";
const MAX_EXEC_EVENT_OUTPUT_CHARS = 180;
const MAX_NOTIFICATION_EVENT_TEXT_CHARS = 120;
const VOICE_TRANSCRIPT_DEDUPE_WINDOW_MS = 1500;
const MAX_RECENT_VOICE_TRANSCRIPTS = 200;
@@ -122,6 +123,18 @@ function compactExecEventOutput(raw: string) {
return `${normalized.slice(0, safe)}`;
}
function compactNotificationEventText(raw: string) {
const normalized = raw.replace(/\s+/g, " ").trim();
if (!normalized) {
return "";
}
if (normalized.length <= MAX_NOTIFICATION_EVENT_TEXT_CHARS) {
return normalized;
}
const safe = Math.max(1, MAX_NOTIFICATION_EVENT_TEXT_CHARS - 1);
return `${normalized.slice(0, safe)}`;
}
type LoadedSessionEntry = ReturnType<typeof loadSessionEntry>;
async function touchSessionStore(params: {
@@ -441,6 +454,40 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
});
return;
}
case "notifications.changed": {
const obj = parsePayloadObject(evt.payloadJSON);
if (!obj) {
return;
}
const change = normalizeNonEmptyString(obj.change)?.toLowerCase();
if (change !== "posted" && change !== "removed") {
return;
}
const key = normalizeNonEmptyString(obj.key);
if (!key) {
return;
}
const sessionKey = normalizeNonEmptyString(obj.sessionKey) ?? `node-${nodeId}`;
const packageName = normalizeNonEmptyString(obj.packageName);
const title = compactNotificationEventText(normalizeNonEmptyString(obj.title) ?? "");
const text = compactNotificationEventText(normalizeNonEmptyString(obj.text) ?? "");
let summary = `Notification ${change} (node=${nodeId} key=${key}`;
if (packageName) {
summary += ` package=${packageName}`;
}
summary += ")";
if (change === "posted") {
const messageParts = [title, text].filter(Boolean);
if (messageParts.length > 0) {
summary += `: ${messageParts.join(" - ")}`;
}
}
enqueueSystemEvent(summary, { sessionKey, contextKey: `notification:${key}` });
requestHeartbeatNow({ reason: "notifications-event" });
return;
}
case "chat.subscribe": {
if (!evt.payloadJSON) {
return;