@saadnvd1/agent-os 0.3.514 → 0.3.517
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 +40 -1
- package/app/api/devices/me/push/route.test.ts +209 -0
- package/app/api/devices/me/push/route.ts +115 -0
- package/app/api/devices/me/route.ts +12 -0
- package/app/api/devices/route.ts +2 -0
- package/app/api/push/test/route.test.ts +112 -0
- package/app/api/push/test/route.ts +38 -0
- package/components/Devices/DeviceRow.tsx +52 -3
- package/components/Devices/DevicesPanel.tsx +13 -0
- package/data/devices/queries.ts +30 -2
- package/lib/away/run.ts +15 -3
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +3 -0
- package/lib/connect/control-api.ts +38 -0
- package/lib/connect/serve.ts +107 -31
- package/lib/connect/supervise.ts +110 -11
- package/lib/db/migrations.ts +59 -0
- package/lib/done/index.ts +5 -1
- package/lib/load/alert.ts +5 -1
- package/lib/notify/copy.ts +190 -0
- package/lib/notify/index.ts +46 -7
- package/lib/notify/limiter.ts +6 -1
- package/lib/notify/push/encode.ts +42 -0
- package/lib/notify/push/events.ts +72 -0
- package/lib/notify/push/index.ts +183 -0
- package/lib/notify/push/outbox.ts +238 -0
- package/lib/notify/push/policy.ts +33 -0
- package/lib/notify/push/presence.ts +45 -0
- package/lib/notify/push/relay.ts +50 -0
- package/lib/notify/push/sender.ts +271 -0
- package/lib/notify/push/testing.ts +30 -0
- package/lib/notify/push/waiting.ts +29 -0
- package/lib/notify/push-types.ts +79 -0
- package/lib/notify/redact.ts +68 -0
- package/lib/orchestrator/asks.ts +5 -1
- package/lib/orchestrator/review.ts +3 -0
- package/lib/program-status/tap.ts +11 -1
- package/lib/schedules/start.ts +11 -2
- package/lib/security/demo.ts +1 -0
- package/lib/security/device-me.ts +12 -0
- package/lib/security/devices.ts +213 -5
- package/lib/security/net.ts +47 -11
- package/lib/tasks/finish.ts +2 -0
- package/lib/tasks/session.ts +26 -2
- package/package.json +1 -1
- package/server.ts +3 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The category registry: one entry per kind of event, holding everything
|
|
3
|
+
* about it (priority, whether it's on by default, the copy, the deep link).
|
|
4
|
+
* Adding an event is one entry here. Pure, so the mobile app can sync it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
PUSH_CATEGORIES,
|
|
9
|
+
PUSH_TITLE,
|
|
10
|
+
type PushCategory,
|
|
11
|
+
type PushEnvelope,
|
|
12
|
+
type PushEnvelopeV1,
|
|
13
|
+
type PushEvent,
|
|
14
|
+
type PushLevel,
|
|
15
|
+
} from "./push-types";
|
|
16
|
+
import { safeId, safeKey } from "./redact";
|
|
17
|
+
|
|
18
|
+
export interface CategoryDef {
|
|
19
|
+
level: PushLevel;
|
|
20
|
+
defaultOn: boolean;
|
|
21
|
+
/** Settings label. */
|
|
22
|
+
label: string;
|
|
23
|
+
/** The one word or phrase shown as the body. Fixed text, no event data. */
|
|
24
|
+
word: string;
|
|
25
|
+
/** App path for an id, or the fallback when the id is missing or unsafe. */
|
|
26
|
+
link: (id: string | null) => string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Where a tap goes when nothing more specific is known. */
|
|
30
|
+
export const FALLBACK_LINK = "/needs";
|
|
31
|
+
|
|
32
|
+
export const CATEGORIES: Record<PushCategory, CategoryDef> = {
|
|
33
|
+
asks: {
|
|
34
|
+
level: "time-sensitive",
|
|
35
|
+
defaultOn: true,
|
|
36
|
+
label: "Asks",
|
|
37
|
+
word: "Needs your call",
|
|
38
|
+
link: (id) => (id ? `/orchestrator?workspaceId=${id}` : FALLBACK_LINK),
|
|
39
|
+
},
|
|
40
|
+
"needs-you": {
|
|
41
|
+
level: "active",
|
|
42
|
+
defaultOn: true,
|
|
43
|
+
label: "Waiting for you",
|
|
44
|
+
word: "Needs you",
|
|
45
|
+
link: (id) => (id ? `/session/${id}` : FALLBACK_LINK),
|
|
46
|
+
},
|
|
47
|
+
review: {
|
|
48
|
+
level: "active",
|
|
49
|
+
defaultOn: true,
|
|
50
|
+
label: "Ready for review",
|
|
51
|
+
word: "Ready for review",
|
|
52
|
+
link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
|
|
53
|
+
},
|
|
54
|
+
ci: {
|
|
55
|
+
level: "active",
|
|
56
|
+
defaultOn: true,
|
|
57
|
+
label: "CI and review problems",
|
|
58
|
+
word: "Update",
|
|
59
|
+
link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
|
|
60
|
+
},
|
|
61
|
+
merged: {
|
|
62
|
+
level: "passive",
|
|
63
|
+
defaultOn: false,
|
|
64
|
+
label: "Merged",
|
|
65
|
+
word: "Update",
|
|
66
|
+
link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
|
|
67
|
+
},
|
|
68
|
+
agents: {
|
|
69
|
+
level: "active",
|
|
70
|
+
defaultOn: true,
|
|
71
|
+
label: "Messages from agents",
|
|
72
|
+
word: "Update",
|
|
73
|
+
link: (id) => (id ? `/session/${id}` : FALLBACK_LINK),
|
|
74
|
+
},
|
|
75
|
+
digest: {
|
|
76
|
+
level: "passive",
|
|
77
|
+
defaultOn: true,
|
|
78
|
+
label: "While you're away",
|
|
79
|
+
word: "Updates",
|
|
80
|
+
link: () => FALLBACK_LINK,
|
|
81
|
+
},
|
|
82
|
+
system: {
|
|
83
|
+
level: "active",
|
|
84
|
+
defaultOn: true,
|
|
85
|
+
label: "Machine alerts",
|
|
86
|
+
word: "Update",
|
|
87
|
+
link: () => "/machines",
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const isPushCategory = (c: unknown): c is PushCategory =>
|
|
92
|
+
typeof c === "string" && (PUSH_CATEGORIES as readonly string[]).includes(c);
|
|
93
|
+
|
|
94
|
+
export function defaultCategories(): PushCategory[] {
|
|
95
|
+
return PUSH_CATEGORIES.filter((c) => CATEGORIES[c].defaultOn);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The categories a device receives: its saved list, or the defaults when it
|
|
100
|
+
* has none. Unknown names are dropped so an old row survives a removed
|
|
101
|
+
* category.
|
|
102
|
+
*/
|
|
103
|
+
export function enabledCategories(
|
|
104
|
+
saved: readonly unknown[] | null | undefined
|
|
105
|
+
): PushCategory[] {
|
|
106
|
+
return saved
|
|
107
|
+
? PUSH_CATEGORIES.filter((c) => saved.includes(c))
|
|
108
|
+
: defaultCategories();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The v1 payload for an event. Built only from the registry and a vetted id
|
|
113
|
+
* and key, so it carries no event text whatever the caller passes.
|
|
114
|
+
*/
|
|
115
|
+
export function buildPushPayload(event: PushEvent): PushEnvelopeV1 {
|
|
116
|
+
const def = CATEGORIES[event.category];
|
|
117
|
+
const id = safeId(event.id);
|
|
118
|
+
return {
|
|
119
|
+
v: 1,
|
|
120
|
+
c: event.category,
|
|
121
|
+
t: PUSH_TITLE,
|
|
122
|
+
b: def.word,
|
|
123
|
+
l: def.link(id),
|
|
124
|
+
k: safeKey(event.key) ?? `${event.category}:${id ?? "-"}`,
|
|
125
|
+
p: def.level,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The app side: read what arrived. An unknown version or a malformed body
|
|
131
|
+
* gives the generic fallback, so a newer server never breaks an older app.
|
|
132
|
+
*/
|
|
133
|
+
export function parsePushEnvelope(raw: unknown): PushEnvelope {
|
|
134
|
+
const o = (raw && typeof raw === "object" ? raw : {}) as Record<
|
|
135
|
+
string,
|
|
136
|
+
unknown
|
|
137
|
+
>;
|
|
138
|
+
const category = isPushCategory(o.c) ? o.c : "system";
|
|
139
|
+
if (o.v === 1 || o.v === 2) {
|
|
140
|
+
const link =
|
|
141
|
+
typeof o.l === "string" && isAppLink(o.l)
|
|
142
|
+
? o.l
|
|
143
|
+
: CATEGORIES[category].link(null);
|
|
144
|
+
const base = {
|
|
145
|
+
c: category,
|
|
146
|
+
t: PUSH_TITLE,
|
|
147
|
+
b: typeof o.b === "string" ? o.b.slice(0, 80) : CATEGORIES[category].word,
|
|
148
|
+
l: link,
|
|
149
|
+
k: safeKey(o.k) ?? `${category}:-`,
|
|
150
|
+
p: CATEGORIES[category].level,
|
|
151
|
+
} as const;
|
|
152
|
+
// A v2 body is read as its generic v1 alert until this app can decrypt.
|
|
153
|
+
return { ...base, v: 1 };
|
|
154
|
+
}
|
|
155
|
+
return buildPushPayload({ category });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const APP_PATHS = [
|
|
159
|
+
/^\/needs$/,
|
|
160
|
+
/^\/machines$/,
|
|
161
|
+
/^\/session\/[A-Za-z0-9_-]{1,64}$/,
|
|
162
|
+
/^\/task\/[A-Za-z0-9_-]{1,64}$/,
|
|
163
|
+
/^\/orchestrator\?workspaceId=[A-Za-z0-9_-]{1,64}$/,
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
/** True for a link the registry could have produced. */
|
|
167
|
+
export function isAppLink(link: string): boolean {
|
|
168
|
+
return APP_PATHS.some((re) => re.test(link));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* True only for a payload the registry could have built: fixed title, the
|
|
173
|
+
* category's own word, a known link shape, a plain key. A sender checks this
|
|
174
|
+
* before it sends, so nothing else can leave the machine.
|
|
175
|
+
*/
|
|
176
|
+
export function isGenericPayload(p: unknown): p is PushEnvelopeV1 {
|
|
177
|
+
if (!p || typeof p !== "object") return false;
|
|
178
|
+
const o = p as Record<string, unknown>;
|
|
179
|
+
if (o.v !== 1 || !isPushCategory(o.c)) return false;
|
|
180
|
+
const def = CATEGORIES[o.c];
|
|
181
|
+
return (
|
|
182
|
+
Object.keys(o).sort().join() === "b,c,k,l,p,t,v" &&
|
|
183
|
+
o.t === PUSH_TITLE &&
|
|
184
|
+
o.b === def.word &&
|
|
185
|
+
o.p === def.level &&
|
|
186
|
+
typeof o.l === "string" &&
|
|
187
|
+
isAppLink(o.l) &&
|
|
188
|
+
safeKey(o.k) === o.k
|
|
189
|
+
);
|
|
190
|
+
}
|
package/lib/notify/index.ts
CHANGED
|
@@ -9,6 +9,9 @@ import { db, type Session } from "../db";
|
|
|
9
9
|
import { Limiter, type Outcome } from "./limiter";
|
|
10
10
|
import { holdForBatch, msToNextSweep } from "../away/state";
|
|
11
11
|
import { selectNotifier, type TelegramConfig } from "./notifiers";
|
|
12
|
+
import { pushAvailable, pushEvent } from "./push";
|
|
13
|
+
import { setFallbackSink } from "./push/sender";
|
|
14
|
+
import type { PushCategory } from "./push-types";
|
|
12
15
|
|
|
13
16
|
export type { Outcome } from "./limiter";
|
|
14
17
|
|
|
@@ -99,6 +102,23 @@ export function resumePhoneOutbox(): void {
|
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
// Held text that push couldn't deliver goes to Telegram once.
|
|
106
|
+
setFallbackSink(async (source, text) => {
|
|
107
|
+
const outcome = await limiter().push(source, text);
|
|
108
|
+
if (outcome.state === "failed") throw new Error(outcome.why);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Which phone-app category a message belongs to. A message with one goes to
|
|
112
|
+
// a registered device instead of Telegram, and to Telegram if push is off.
|
|
113
|
+
export interface PushRoute {
|
|
114
|
+
category: PushCategory;
|
|
115
|
+
id?: string | null;
|
|
116
|
+
key?: string | null;
|
|
117
|
+
collapse?: string | null;
|
|
118
|
+
/** The session it is about, so a mirror of another machine's never pushes. */
|
|
119
|
+
sessionId?: string | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
102
122
|
export class NotConfigured extends Error {
|
|
103
123
|
constructor() {
|
|
104
124
|
super(
|
|
@@ -111,10 +131,21 @@ export class NotConfigured extends Error {
|
|
|
111
131
|
// NotConfigured when nothing would send it.
|
|
112
132
|
export async function sendPhone(
|
|
113
133
|
source: string,
|
|
114
|
-
text: string
|
|
134
|
+
text: string,
|
|
135
|
+
push?: PushRoute
|
|
115
136
|
): Promise<Outcome> {
|
|
116
137
|
const body = text.trim();
|
|
117
138
|
if (!body) throw new Error("Nothing to send");
|
|
139
|
+
if (push && pushAvailable()) {
|
|
140
|
+
const { sessionId, ...event } = push;
|
|
141
|
+
const out = await pushEvent(event, {
|
|
142
|
+
sessionId,
|
|
143
|
+
fallback: { source, text: body },
|
|
144
|
+
});
|
|
145
|
+
if (out === "queued") return { state: "pushed" };
|
|
146
|
+
if (out === "folded") return { state: "held", inMs: msToNextSweep() };
|
|
147
|
+
if (out === "dropped" || out === "muted") return { state: "skipped" };
|
|
148
|
+
}
|
|
118
149
|
if (!selectNotifier(process.env, telegramConfig())) throw new NotConfigured();
|
|
119
150
|
const outcome = await limiter().push(source, body);
|
|
120
151
|
if (outcome.state === "failed")
|
|
@@ -142,15 +173,23 @@ export async function notifyFrom(
|
|
|
142
173
|
holdForBatch(session.workspace_id, session.id, body)
|
|
143
174
|
)
|
|
144
175
|
return { state: "held", inMs: msToNextSweep() };
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
176
|
+
const source = session ? `session:${session.id}` : "cli";
|
|
177
|
+
return sendPhone(source, session ? `${session.name}: ${body}` : body, {
|
|
178
|
+
category: "agents",
|
|
179
|
+
id: session?.id,
|
|
180
|
+
key: `agents:${session?.id ?? "cli"}:${Date.now()}`,
|
|
181
|
+
collapse: `agents:${session?.id ?? "cli"}`,
|
|
182
|
+
sessionId: session?.id,
|
|
183
|
+
});
|
|
149
184
|
}
|
|
150
185
|
|
|
151
186
|
// For the server's own alerts: never throws, never waits on the send.
|
|
152
|
-
export function notifyPhone(
|
|
153
|
-
|
|
187
|
+
export function notifyPhone(
|
|
188
|
+
source: string,
|
|
189
|
+
text: string,
|
|
190
|
+
push?: PushRoute
|
|
191
|
+
): void {
|
|
192
|
+
sendPhone(source, text, push).catch((error) => {
|
|
154
193
|
if (!(error instanceof NotConfigured))
|
|
155
194
|
console.error(
|
|
156
195
|
`[notify] ${source}:`,
|
package/lib/notify/limiter.ts
CHANGED
|
@@ -16,7 +16,12 @@ export type Outcome =
|
|
|
16
16
|
| { state: "held"; inMs: number }
|
|
17
17
|
| { state: "duplicate" }
|
|
18
18
|
| { state: "dropped" }
|
|
19
|
-
| { state: "failed"; why: string }
|
|
19
|
+
| { state: "failed"; why: string }
|
|
20
|
+
// Went to the phone app; Telegram only steps in if that fails.
|
|
21
|
+
| { state: "pushed" }
|
|
22
|
+
// Push had nothing to do: the device turned the category off, or Saad is
|
|
23
|
+
// at the Mac.
|
|
24
|
+
| { state: "skipped" };
|
|
20
25
|
|
|
21
26
|
export function collapse(texts: string[]): string {
|
|
22
27
|
if (texts.length === 1) return texts[0];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place an event becomes the bytes the relay is given. v1 is the
|
|
3
|
+
* generic, content-free envelope. Encrypted rich text (v2) replaces the body
|
|
4
|
+
* of this function and nothing else: callers hand over an event, never a
|
|
5
|
+
* payload.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto from "crypto";
|
|
9
|
+
import { buildPushPayload, isGenericPayload } from "../copy";
|
|
10
|
+
import type { PushEnvelope, PushEvent, PushLevel } from "../push-types";
|
|
11
|
+
|
|
12
|
+
export interface EncodedPush {
|
|
13
|
+
payload: PushEnvelope;
|
|
14
|
+
level: PushLevel;
|
|
15
|
+
priority: 10 | 5;
|
|
16
|
+
/** Opaque: a hash, so the relay and Apple learn nothing from it. */
|
|
17
|
+
collapseId: string;
|
|
18
|
+
threadId: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Same event key, same id, on every machine that sends it. */
|
|
22
|
+
export const collapseId = (key: string) =>
|
|
23
|
+
crypto.createHash("sha256").update(key).digest("hex").slice(0, 32);
|
|
24
|
+
|
|
25
|
+
export function encodePush(
|
|
26
|
+
event: PushEvent,
|
|
27
|
+
_device?: { id: string }
|
|
28
|
+
): EncodedPush {
|
|
29
|
+
const payload = buildPushPayload({ ...event, key: null });
|
|
30
|
+
// Nothing but what the registry could have built may leave the machine.
|
|
31
|
+
if (!isGenericPayload(payload))
|
|
32
|
+
throw new Error(
|
|
33
|
+
"refusing to send a push payload the registry didn't build"
|
|
34
|
+
);
|
|
35
|
+
return {
|
|
36
|
+
payload,
|
|
37
|
+
level: payload.p,
|
|
38
|
+
priority: payload.p === "passive" ? 5 : 10,
|
|
39
|
+
collapseId: collapseId(event.collapse ?? event.key ?? payload.k),
|
|
40
|
+
threadId: event.category,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One helper per event that can reach the phone. Each is fire-and-forget:
|
|
3
|
+
* a failure here must never touch the code that raised the event.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { db } from "../../db";
|
|
7
|
+
import { pushEvent } from "./index";
|
|
8
|
+
import type { PushEvent } from "../push-types";
|
|
9
|
+
|
|
10
|
+
function fire(event: PushEvent, sessionId?: string | null): void {
|
|
11
|
+
pushEvent(event, { sessionId }).catch((error) =>
|
|
12
|
+
console.error(
|
|
13
|
+
`[push] ${event.category}:`,
|
|
14
|
+
error instanceof Error ? error.message : error
|
|
15
|
+
)
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const pushAskRaised = (askId: number, workspaceId: string) =>
|
|
20
|
+
fire({ category: "asks", id: workspaceId, key: `ask:${askId}` });
|
|
21
|
+
|
|
22
|
+
export const pushNeedsYou = (sessionId: string, armedAt: number) =>
|
|
23
|
+
fire(
|
|
24
|
+
{
|
|
25
|
+
category: "needs-you",
|
|
26
|
+
id: sessionId,
|
|
27
|
+
key: `needs-you:${sessionId}:${armedAt}`,
|
|
28
|
+
collapse: `needs-you:${sessionId}`,
|
|
29
|
+
},
|
|
30
|
+
sessionId
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const forTask = (
|
|
34
|
+
category: "review" | "ci" | "merged",
|
|
35
|
+
prefix: string,
|
|
36
|
+
taskId: string,
|
|
37
|
+
sha?: string | null
|
|
38
|
+
) =>
|
|
39
|
+
fire(
|
|
40
|
+
{
|
|
41
|
+
category,
|
|
42
|
+
id: taskId,
|
|
43
|
+
key: `${prefix}:${taskId}${sha ? `:${sha}` : ""}`,
|
|
44
|
+
collapse: `task:${taskId}`,
|
|
45
|
+
},
|
|
46
|
+
taskId
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
export const pushReviewReady = (taskId: string, sha: string) =>
|
|
50
|
+
forTask("review", "review", taskId, sha);
|
|
51
|
+
export const pushCiFailed = (taskId: string, sha: string) =>
|
|
52
|
+
forTask("ci", "ci", taskId, sha);
|
|
53
|
+
export const pushReviewBlocked = (taskId: string, sha: string) =>
|
|
54
|
+
forTask("ci", "review-block", taskId, sha);
|
|
55
|
+
export const pushMerged = (taskId: string) =>
|
|
56
|
+
forTask("merged", "merged", taskId);
|
|
57
|
+
|
|
58
|
+
export const pushPlanReady = (sessionId: string, itemId: string) => {
|
|
59
|
+
// Only a task has a screen to open; a plain chat's plan goes to the list.
|
|
60
|
+
const task = db
|
|
61
|
+
.prepare(`SELECT task_status FROM sessions WHERE id = ?`)
|
|
62
|
+
.get(sessionId) as { task_status: string | null } | undefined;
|
|
63
|
+
fire(
|
|
64
|
+
{
|
|
65
|
+
category: "review",
|
|
66
|
+
id: task?.task_status ? sessionId : null,
|
|
67
|
+
key: `plan:${sessionId}:${itemId}`,
|
|
68
|
+
collapse: `plan:${sessionId}`,
|
|
69
|
+
},
|
|
70
|
+
sessionId
|
|
71
|
+
);
|
|
72
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push for the phone app. `pushEvent` decides whether an event goes out, to
|
|
3
|
+
* whom, and now or in the Away digest; the sender does the delivering.
|
|
4
|
+
* Telegram is the fallback only for what Telegram carried before push
|
|
5
|
+
* (events given a `fallback`); the newer events are push-only.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto from "crypto";
|
|
9
|
+
import { db } from "../../db";
|
|
10
|
+
import { activeAway } from "../../away/state";
|
|
11
|
+
import { listPushDevices } from "@/lib/security/devices";
|
|
12
|
+
import type { PushEvent } from "../push-types";
|
|
13
|
+
import { encodePush } from "./encode";
|
|
14
|
+
import * as outbox from "./outbox";
|
|
15
|
+
import { atMac } from "./presence";
|
|
16
|
+
import {
|
|
17
|
+
CAP_PER_HOUR,
|
|
18
|
+
HOUR_MS,
|
|
19
|
+
capExempt,
|
|
20
|
+
foldsWhenAway,
|
|
21
|
+
presenceGated,
|
|
22
|
+
} from "./policy";
|
|
23
|
+
import { linked, NOT_LINKED } from "./relay";
|
|
24
|
+
import { PushSender } from "./sender";
|
|
25
|
+
|
|
26
|
+
const g = globalThis as unknown as { __agentosPush?: PushSender };
|
|
27
|
+
|
|
28
|
+
export function sender(): PushSender {
|
|
29
|
+
return (g.__agentosPush ??= new PushSender());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether a message with a push category can go by push right now. */
|
|
33
|
+
export function pushAvailable(): boolean {
|
|
34
|
+
return linked() && !sender().unavailable() && listPushDevices().length > 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function pushStatus(): { linked: boolean; unavailable: string | null } {
|
|
38
|
+
return { linked: linked(), unavailable: sender().unavailable() };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type PushOutcome =
|
|
42
|
+
| "queued"
|
|
43
|
+
| "folded"
|
|
44
|
+
| "dropped"
|
|
45
|
+
| "muted"
|
|
46
|
+
| "unavailable";
|
|
47
|
+
|
|
48
|
+
export interface PushOptions {
|
|
49
|
+
/** Held text that goes by Telegram if no phone is reached. */
|
|
50
|
+
fallback?: { source: string; text: string } | null;
|
|
51
|
+
/** A session the event is about: a mirror of another machine's never pushes. */
|
|
52
|
+
sessionId?: string | null;
|
|
53
|
+
/** Explicit sends (a test): no presence, Away, cap or category rules. */
|
|
54
|
+
bypass?: boolean;
|
|
55
|
+
deviceIds?: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isMirrored(sessionId: string): boolean {
|
|
59
|
+
const s = db
|
|
60
|
+
.prepare(`SELECT host_id, peer_mirror FROM sessions WHERE id = ?`)
|
|
61
|
+
.get(sessionId) as
|
|
62
|
+
| { host_id: string | null; peer_mirror: number | null }
|
|
63
|
+
| undefined;
|
|
64
|
+
return !!s && (!!s.peer_mirror || (!!s.host_id && s.host_id !== "local"));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function pushEvent(
|
|
68
|
+
event: PushEvent,
|
|
69
|
+
opts: PushOptions = {}
|
|
70
|
+
): Promise<PushOutcome> {
|
|
71
|
+
if (opts.sessionId && isMirrored(opts.sessionId)) return "dropped";
|
|
72
|
+
const s = sender();
|
|
73
|
+
if (!linked() || s.unavailable()) return "unavailable";
|
|
74
|
+
const devices = listPushDevices().filter(
|
|
75
|
+
(d) => !opts.deviceIds || opts.deviceIds.includes(d.id)
|
|
76
|
+
);
|
|
77
|
+
if (!devices.length) return "unavailable";
|
|
78
|
+
const targets = opts.bypass
|
|
79
|
+
? devices
|
|
80
|
+
: devices.filter((d) => d.push.categories.includes(event.category));
|
|
81
|
+
if (!targets.length) return "muted";
|
|
82
|
+
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const key = event.key ?? `${event.category}:${event.id ?? "-"}:${now}`;
|
|
85
|
+
const enc = encodePush({ ...event, key });
|
|
86
|
+
const row = {
|
|
87
|
+
eventKey: key,
|
|
88
|
+
category: event.category,
|
|
89
|
+
level: enc.level,
|
|
90
|
+
payload: enc.payload,
|
|
91
|
+
collapseId: enc.collapseId,
|
|
92
|
+
now,
|
|
93
|
+
};
|
|
94
|
+
if (!opts.bypass) {
|
|
95
|
+
if (activeAway() && foldsWhenAway(event.category)) {
|
|
96
|
+
outbox.enqueue({ ...row, deviceId: "", state: "folded" });
|
|
97
|
+
return "folded";
|
|
98
|
+
}
|
|
99
|
+
if (presenceGated(event.category) && (await atMac())) return "dropped";
|
|
100
|
+
}
|
|
101
|
+
let queued = 0;
|
|
102
|
+
for (const d of targets) {
|
|
103
|
+
const over =
|
|
104
|
+
!opts.bypass &&
|
|
105
|
+
!capExempt(event.category) &&
|
|
106
|
+
enc.level !== "passive" &&
|
|
107
|
+
outbox.alertingSince(d.id, now) >= CAP_PER_HOUR;
|
|
108
|
+
if (over) {
|
|
109
|
+
outbox.enqueue({ ...row, deviceId: d.id, state: "capped" });
|
|
110
|
+
// One summary an hour says there was more.
|
|
111
|
+
const sum = encodePush({
|
|
112
|
+
category: "digest",
|
|
113
|
+
key: `overflow:${d.id}:${Math.floor(now / HOUR_MS)}`,
|
|
114
|
+
});
|
|
115
|
+
outbox.enqueue({
|
|
116
|
+
eventKey: `overflow:${d.id}:${Math.floor(now / HOUR_MS)}`,
|
|
117
|
+
deviceId: d.id,
|
|
118
|
+
category: "digest",
|
|
119
|
+
level: sum.level,
|
|
120
|
+
payload: sum.payload,
|
|
121
|
+
collapseId: sum.collapseId,
|
|
122
|
+
now,
|
|
123
|
+
});
|
|
124
|
+
queued++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (outbox.enqueue({ ...row, deviceId: d.id, fallback: opts.fallback }))
|
|
128
|
+
queued++;
|
|
129
|
+
}
|
|
130
|
+
if (queued) void s.kick();
|
|
131
|
+
return queued ? "queued" : "dropped";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface TestResult {
|
|
135
|
+
deviceId: string;
|
|
136
|
+
status: "sent" | "gone" | "failed";
|
|
137
|
+
error: string | null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A real send to the phones now, ignoring every quiet rule, and its answer. */
|
|
141
|
+
export async function sendTestPush(
|
|
142
|
+
deviceId?: string
|
|
143
|
+
): Promise<{ results: TestResult[]; unavailable: string | null }> {
|
|
144
|
+
const s = sender();
|
|
145
|
+
if (!linked()) return { results: [], unavailable: NOT_LINKED };
|
|
146
|
+
s.clearUnavailable();
|
|
147
|
+
const key = `test:${Date.now()}:${crypto.randomBytes(3).toString("hex")}`;
|
|
148
|
+
const devices = listPushDevices().filter(
|
|
149
|
+
(d) => !deviceId || d.id === deviceId
|
|
150
|
+
);
|
|
151
|
+
const enc = encodePush({ category: "system", key });
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
for (const d of devices)
|
|
154
|
+
outbox.enqueue({
|
|
155
|
+
eventKey: key,
|
|
156
|
+
deviceId: d.id,
|
|
157
|
+
category: "system",
|
|
158
|
+
level: enc.level,
|
|
159
|
+
payload: enc.payload,
|
|
160
|
+
collapseId: enc.collapseId,
|
|
161
|
+
now,
|
|
162
|
+
});
|
|
163
|
+
await s.kick();
|
|
164
|
+
const results = outbox.rowsFor(key).map((r) => ({
|
|
165
|
+
deviceId: r.device_id,
|
|
166
|
+
status: (r.state === "sent"
|
|
167
|
+
? "sent"
|
|
168
|
+
: r.state === "gone"
|
|
169
|
+
? "gone"
|
|
170
|
+
: "failed") as TestResult["status"],
|
|
171
|
+
error: r.state === "sent" ? null : (r.error ?? "not sent"),
|
|
172
|
+
}));
|
|
173
|
+
return { results, unavailable: s.unavailable() };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function resumePushOutbox(): void {
|
|
177
|
+
sender().resume();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** How many held events the next Away digest stands for. */
|
|
181
|
+
export function takeFoldedPushes(now = Date.now()): number {
|
|
182
|
+
return outbox.takeFolded(now);
|
|
183
|
+
}
|