@pi-unipi/notify 0.1.5 → 0.1.6
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 +24 -1
- package/commands.ts +130 -0
- package/events.ts +150 -3
- package/index.ts +6 -2
- package/package.json +1 -1
- package/platforms/ntfy.ts +48 -0
- package/settings.ts +26 -5
- package/skills/configure-notify/SKILL.md +25 -0
- package/tools.ts +3 -3
- package/tui/ntfy-setup.ts +599 -0
- package/tui/recap-model-selector.ts +301 -0
- package/tui/settings-overlay.ts +68 -8
- package/types.ts +27 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @pi-unipi/notify
|
|
2
2
|
|
|
3
|
-
Cross-platform notification extension for Pi. Sends push notifications to native OS, Gotify, and
|
|
3
|
+
Cross-platform notification extension for Pi. Sends push notifications to native OS, Gotify, Telegram, and ntfy when agent lifecycle events occur.
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
@@ -52,12 +52,35 @@ This guides you through:
|
|
|
52
52
|
2. Pasting the bot token
|
|
53
53
|
3. Auto-detecting your chat ID
|
|
54
54
|
|
|
55
|
+
### ntfy
|
|
56
|
+
|
|
57
|
+
HTTP-based pub-sub notifications via [ntfy.sh](https://ntfy.sh) or self-hosted. Run setup command:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
/unipi:notify-set-ntfy
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or configure manually:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"ntfy": {
|
|
68
|
+
"enabled": true,
|
|
69
|
+
"serverUrl": "https://ntfy.sh",
|
|
70
|
+
"topic": "your-topic-name",
|
|
71
|
+
"priority": 3
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
55
76
|
## Commands
|
|
56
77
|
|
|
57
78
|
| Command | Description |
|
|
58
79
|
|---------|-------------|
|
|
59
80
|
| `/unipi:notify-settings` | Open settings overlay to configure platforms and events |
|
|
81
|
+
| `/unipi:notify-set-gotify` | Configure Gotify server connection |
|
|
60
82
|
| `/unipi:notify-set-tg` | Interactive Telegram bot setup |
|
|
83
|
+
| `/unipi:notify-set-ntfy` | Configure ntfy topic and server |
|
|
61
84
|
| `/unipi:notify-test` | Send test notification to all enabled platforms |
|
|
62
85
|
|
|
63
86
|
## Agent Tool
|
package/commands.ts
CHANGED
|
@@ -10,10 +10,13 @@ import { NOTIFY_COMMANDS } from "@pi-unipi/core";
|
|
|
10
10
|
import { NotifySettingsOverlay } from "./tui/settings-overlay.js";
|
|
11
11
|
import { GotifySetupOverlay } from "./tui/gotify-setup.js";
|
|
12
12
|
import { TelegramSetupOverlay } from "./tui/telegram-setup.js";
|
|
13
|
+
import { NtfySetupOverlay } from "./tui/ntfy-setup.js";
|
|
14
|
+
import { RecapModelSelectorOverlay } from "./tui/recap-model-selector.js";
|
|
13
15
|
import { loadConfig } from "./settings.js";
|
|
14
16
|
import { sendNativeNotification } from "./platforms/native.js";
|
|
15
17
|
import { sendGotifyNotification } from "./platforms/gotify.js";
|
|
16
18
|
import { sendTelegramNotification } from "./platforms/telegram.js";
|
|
19
|
+
import { sendNtfyNotification } from "./platforms/ntfy.js";
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* Register notify commands.
|
|
@@ -36,6 +39,34 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
|
|
|
36
39
|
overlay.setTheme(theme);
|
|
37
40
|
overlay.onClose = () => done(undefined);
|
|
38
41
|
overlay.requestRender = () => tui.requestRender();
|
|
42
|
+
overlay.onOpenModelSelector = () => {
|
|
43
|
+
// Open model selector as nested overlay
|
|
44
|
+
ctx.ui.custom(
|
|
45
|
+
(innerTui: any, innerTheme: any, _innerKb: any, innerDone: any) => {
|
|
46
|
+
const selector = new RecapModelSelectorOverlay();
|
|
47
|
+
selector.setTheme(innerTheme);
|
|
48
|
+
selector.onClose = () => innerDone(undefined);
|
|
49
|
+
selector.requestRender = () => innerTui.requestRender();
|
|
50
|
+
return {
|
|
51
|
+
render: (w: number) => selector.render(w),
|
|
52
|
+
invalidate: () => selector.invalidate(),
|
|
53
|
+
handleInput: (data: string) => {
|
|
54
|
+
selector.handleInput(data);
|
|
55
|
+
innerTui.requestRender();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
overlay: true,
|
|
61
|
+
overlayOptions: {
|
|
62
|
+
width: "60%",
|
|
63
|
+
minWidth: 40,
|
|
64
|
+
anchor: "center",
|
|
65
|
+
margin: 4,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
};
|
|
39
70
|
return {
|
|
40
71
|
render: (w: number) => overlay.render(w),
|
|
41
72
|
invalidate: () => overlay.invalidate(),
|
|
@@ -59,6 +90,46 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
|
|
|
59
90
|
}
|
|
60
91
|
);
|
|
61
92
|
|
|
93
|
+
// /unipi:notify-recap-model — Open recap model selector directly
|
|
94
|
+
pi.registerCommand(
|
|
95
|
+
`${UNIPI_PREFIX}${NOTIFY_COMMANDS.RECAP_MODEL}`,
|
|
96
|
+
{
|
|
97
|
+
description: "Select model for notification recaps",
|
|
98
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
99
|
+
if (!ctx.hasUI) {
|
|
100
|
+
ctx.ui.notify("Model selector requires an interactive UI.", "warning");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
ctx.ui.custom(
|
|
105
|
+
(tui: any, theme: any, _keybindings: any, done: any) => {
|
|
106
|
+
const overlay = new RecapModelSelectorOverlay();
|
|
107
|
+
overlay.setTheme(theme);
|
|
108
|
+
overlay.onClose = () => done(undefined);
|
|
109
|
+
overlay.requestRender = () => tui.requestRender();
|
|
110
|
+
return {
|
|
111
|
+
render: (w: number) => overlay.render(w),
|
|
112
|
+
invalidate: () => overlay.invalidate(),
|
|
113
|
+
handleInput: (data: string) => {
|
|
114
|
+
overlay.handleInput(data);
|
|
115
|
+
tui.requestRender();
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
overlay: true,
|
|
121
|
+
overlayOptions: {
|
|
122
|
+
width: "60%",
|
|
123
|
+
minWidth: 40,
|
|
124
|
+
anchor: "center",
|
|
125
|
+
margin: 4,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
|
|
62
133
|
// /unipi:notify-set-gotify — Interactive Gotify setup
|
|
63
134
|
pi.registerCommand(
|
|
64
135
|
`${UNIPI_PREFIX}${NOTIFY_COMMANDS.SET_GOTIFY}`,
|
|
@@ -139,6 +210,46 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
|
|
|
139
210
|
}
|
|
140
211
|
);
|
|
141
212
|
|
|
213
|
+
// /unipi:notify-set-ntfy — Interactive ntfy setup
|
|
214
|
+
pi.registerCommand(
|
|
215
|
+
`${UNIPI_PREFIX}${NOTIFY_COMMANDS.SET_NTFY}`,
|
|
216
|
+
{
|
|
217
|
+
description: "Set up ntfy push notifications with connection test",
|
|
218
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
219
|
+
if (!ctx.hasUI) {
|
|
220
|
+
ctx.ui.notify("ntfy setup requires an interactive UI.", "warning");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
ctx.ui.custom(
|
|
225
|
+
(tui: any, theme: any, _keybindings: any, done: any) => {
|
|
226
|
+
const overlay = new NtfySetupOverlay();
|
|
227
|
+
overlay.setTheme(theme);
|
|
228
|
+
overlay.onClose = () => done(undefined);
|
|
229
|
+
overlay.requestRender = () => tui.requestRender();
|
|
230
|
+
return {
|
|
231
|
+
render: (w: number) => overlay.render(w),
|
|
232
|
+
invalidate: () => overlay.invalidate(),
|
|
233
|
+
handleInput: (data: string) => {
|
|
234
|
+
overlay.handleInput(data);
|
|
235
|
+
tui.requestRender();
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
overlay: true,
|
|
241
|
+
overlayOptions: {
|
|
242
|
+
width: "80%",
|
|
243
|
+
minWidth: 60,
|
|
244
|
+
anchor: "center",
|
|
245
|
+
margin: 2,
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
);
|
|
252
|
+
|
|
142
253
|
// /unipi:notify-test — Send test notification to all enabled platforms
|
|
143
254
|
pi.registerCommand(
|
|
144
255
|
`${UNIPI_PREFIX}${NOTIFY_COMMANDS.TEST}`,
|
|
@@ -199,6 +310,25 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
|
|
|
199
310
|
}
|
|
200
311
|
}
|
|
201
312
|
|
|
313
|
+
// ntfy
|
|
314
|
+
if (config.ntfy.enabled && config.ntfy.serverUrl && config.ntfy.topic) {
|
|
315
|
+
try {
|
|
316
|
+
await sendNtfyNotification(
|
|
317
|
+
config.ntfy.serverUrl,
|
|
318
|
+
config.ntfy.topic,
|
|
319
|
+
title,
|
|
320
|
+
message,
|
|
321
|
+
config.ntfy.priority,
|
|
322
|
+
config.ntfy.token
|
|
323
|
+
);
|
|
324
|
+
results.push("✓ ntfy: sent");
|
|
325
|
+
} catch (err) {
|
|
326
|
+
results.push(
|
|
327
|
+
`✗ ntfy: ${err instanceof Error ? err.message : "failed"}`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
202
332
|
if (results.length === 0) {
|
|
203
333
|
ctx.ui.notify("No platforms enabled. Use /unipi:notify-settings first.", "warning");
|
|
204
334
|
} else {
|
package/events.ts
CHANGED
|
@@ -5,12 +5,27 @@
|
|
|
5
5
|
* Supports built-in events and dynamic discovery via MODULE_READY.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
9
9
|
import { UNIPI_EVENTS, emitEvent } from "@pi-unipi/core";
|
|
10
10
|
import type { NotifyConfig, NotifyPlatform, NotifyDispatchResult } from "./types.js";
|
|
11
11
|
import { sendNativeNotification } from "./platforms/native.js";
|
|
12
12
|
import { sendGotifyNotification } from "./platforms/gotify.js";
|
|
13
13
|
import { sendTelegramNotification } from "./platforms/telegram.js";
|
|
14
|
+
import { sendNtfyNotification } from "./platforms/ntfy.js";
|
|
15
|
+
import { summarizeLastMessage } from "./summarize.js";
|
|
16
|
+
|
|
17
|
+
/** Stored session context for modelRegistry access */
|
|
18
|
+
let sessionCtx: ExtensionContext | null = null;
|
|
19
|
+
|
|
20
|
+
/** Store session context (called from index.ts on session_start) */
|
|
21
|
+
export function setSessionContext(ctx: ExtensionContext): void {
|
|
22
|
+
sessionCtx = ctx;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Clear session context (called on session_shutdown) */
|
|
26
|
+
export function clearSessionContext(): void {
|
|
27
|
+
sessionCtx = null;
|
|
28
|
+
}
|
|
14
29
|
|
|
15
30
|
/** Built-in event definitions — maps event key to pi hook + display label */
|
|
16
31
|
export const BUILTIN_EVENTS: Record<
|
|
@@ -34,8 +49,10 @@ export function registerEventListeners(
|
|
|
34
49
|
pi: ExtensionAPI,
|
|
35
50
|
config: NotifyConfig
|
|
36
51
|
): void {
|
|
37
|
-
// Register built-in events
|
|
52
|
+
// Register built-in events (except agent_end which has custom logic)
|
|
38
53
|
for (const [eventKey, def] of Object.entries(BUILTIN_EVENTS)) {
|
|
54
|
+
if (eventKey === "agent_end") continue; // handled separately below
|
|
55
|
+
|
|
39
56
|
const eventConfig = config.events[eventKey];
|
|
40
57
|
if (!eventConfig?.enabled) continue;
|
|
41
58
|
|
|
@@ -48,6 +65,51 @@ export function registerEventListeners(
|
|
|
48
65
|
(pi as any).on(def.hook, handler);
|
|
49
66
|
}
|
|
50
67
|
|
|
68
|
+
// agent_end — custom handler with session name and recap support
|
|
69
|
+
const agentEndConfig = config.events["agent_end"];
|
|
70
|
+
if (agentEndConfig?.enabled) {
|
|
71
|
+
const handler = async (payload: unknown) => {
|
|
72
|
+
const sessionName = pi.getSessionName?.();
|
|
73
|
+
const title = `Pi — ${BUILTIN_EVENTS.agent_end.label}`;
|
|
74
|
+
let message: string;
|
|
75
|
+
|
|
76
|
+
if (config.recap.enabled) {
|
|
77
|
+
// Recap mode: summarize last assistant message
|
|
78
|
+
const lastText = extractLastAssistantText(payload);
|
|
79
|
+
if (lastText && sessionCtx?.modelRegistry) {
|
|
80
|
+
const provider = extractProvider(config.recap.model);
|
|
81
|
+
const modelId = extractModelId(config.recap.model);
|
|
82
|
+
const model = sessionCtx.modelRegistry.find(provider, modelId);
|
|
83
|
+
if (model) {
|
|
84
|
+
try {
|
|
85
|
+
const apiKeyResult = await sessionCtx.modelRegistry.getApiKeyAndHeaders(model);
|
|
86
|
+
const apiKey = apiKeyResult.ok ? (apiKeyResult as { apiKey?: string }).apiKey : undefined;
|
|
87
|
+
if (apiKey) {
|
|
88
|
+
const recap = await summarizeLastMessage(lastText, apiKey, model.baseUrl, model.api, modelId);
|
|
89
|
+
message = sessionName ? `${sessionName}: ${recap}` : recap;
|
|
90
|
+
} else {
|
|
91
|
+
message = buildAgentEndMessage(sessionName);
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
message = buildAgentEndMessage(sessionName);
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
message = buildAgentEndMessage(sessionName);
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
message = buildAgentEndMessage(sessionName);
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
// No recap: use session name based message
|
|
104
|
+
message = buildAgentEndMessage(sessionName);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
(pi as any).on("agent_end", handler);
|
|
111
|
+
}
|
|
112
|
+
|
|
51
113
|
// Listen for dynamic module events
|
|
52
114
|
const moduleHandler = async (payload: unknown) => {
|
|
53
115
|
const modPayload = payload as { name?: string; tools?: string[] };
|
|
@@ -59,6 +121,16 @@ export function registerEventListeners(
|
|
|
59
121
|
(pi as any).on(UNIPI_EVENTS.MODULE_READY, moduleHandler);
|
|
60
122
|
}
|
|
61
123
|
|
|
124
|
+
/** Get all platforms that are currently enabled in config */
|
|
125
|
+
function getEnabledPlatforms(config: NotifyConfig): NotifyPlatform[] {
|
|
126
|
+
const enabled: NotifyPlatform[] = [];
|
|
127
|
+
if (config.native.enabled) enabled.push("native");
|
|
128
|
+
if (config.gotify.enabled) enabled.push("gotify");
|
|
129
|
+
if (config.telegram.enabled) enabled.push("telegram");
|
|
130
|
+
if (config.ntfy.enabled) enabled.push("ntfy");
|
|
131
|
+
return enabled;
|
|
132
|
+
}
|
|
133
|
+
|
|
62
134
|
/** No-op — cleanup handled by session teardown */
|
|
63
135
|
export function unregisterEventListeners(): void {}
|
|
64
136
|
|
|
@@ -74,13 +146,19 @@ export async function dispatchNotification(
|
|
|
74
146
|
eventType: string,
|
|
75
147
|
config: NotifyConfig
|
|
76
148
|
): Promise<NotifyDispatchResult> {
|
|
149
|
+
// Resolve platforms: event-specific → all enabled → global defaults
|
|
77
150
|
const platforms =
|
|
78
|
-
eventPlatforms.length > 0
|
|
151
|
+
eventPlatforms.length > 0
|
|
152
|
+
? eventPlatforms
|
|
153
|
+
: getEnabledPlatforms(config).length > 0
|
|
154
|
+
? getEnabledPlatforms(config)
|
|
155
|
+
: config.defaultPlatforms;
|
|
79
156
|
|
|
80
157
|
const enabledPlatforms = platforms.filter((p) => {
|
|
81
158
|
if (p === "native") return config.native.enabled;
|
|
82
159
|
if (p === "gotify") return config.gotify.enabled;
|
|
83
160
|
if (p === "telegram") return config.telegram.enabled;
|
|
161
|
+
if (p === "ntfy") return config.ntfy.enabled;
|
|
84
162
|
return false;
|
|
85
163
|
});
|
|
86
164
|
|
|
@@ -152,6 +230,19 @@ async function sendToPlatform(
|
|
|
152
230
|
message
|
|
153
231
|
);
|
|
154
232
|
break;
|
|
233
|
+
case "ntfy":
|
|
234
|
+
if (!config.ntfy.serverUrl || !config.ntfy.topic) {
|
|
235
|
+
throw new Error("ntfy: serverUrl and topic are required");
|
|
236
|
+
}
|
|
237
|
+
await sendNtfyNotification(
|
|
238
|
+
config.ntfy.serverUrl,
|
|
239
|
+
config.ntfy.topic,
|
|
240
|
+
title,
|
|
241
|
+
message,
|
|
242
|
+
config.ntfy.priority,
|
|
243
|
+
config.ntfy.token
|
|
244
|
+
);
|
|
245
|
+
break;
|
|
155
246
|
}
|
|
156
247
|
}
|
|
157
248
|
|
|
@@ -180,3 +271,59 @@ function buildEventMessage(eventKey: string, payload: unknown): string {
|
|
|
180
271
|
return p.message ? String(p.message) : "Event occurred";
|
|
181
272
|
}
|
|
182
273
|
}
|
|
274
|
+
|
|
275
|
+
/** Build agent_end message using session name */
|
|
276
|
+
function buildAgentEndMessage(sessionName: string | undefined): string {
|
|
277
|
+
if (sessionName) return `${sessionName} - Agent is complete`;
|
|
278
|
+
return "Agent is complete";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Extract text from the last assistant message in agent_end payload */
|
|
282
|
+
function extractLastAssistantText(payload: unknown): string | null {
|
|
283
|
+
const p = payload as { messages?: Array<{ role?: string; content?: unknown }> };
|
|
284
|
+
if (!p?.messages || !Array.isArray(p.messages)) return null;
|
|
285
|
+
|
|
286
|
+
// Find last assistant message
|
|
287
|
+
for (let i = p.messages.length - 1; i >= 0; i--) {
|
|
288
|
+
const msg = p.messages[i];
|
|
289
|
+
if (msg?.role !== "assistant") continue;
|
|
290
|
+
|
|
291
|
+
const content = msg.content;
|
|
292
|
+
if (typeof content === "string") return content;
|
|
293
|
+
if (Array.isArray(content)) {
|
|
294
|
+
// Extract text blocks from content array
|
|
295
|
+
const textParts: string[] = [];
|
|
296
|
+
for (const block of content) {
|
|
297
|
+
if (typeof block === "object" && block !== null) {
|
|
298
|
+
const b = block as { type?: string; text?: string };
|
|
299
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
300
|
+
textParts.push(b.text);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (textParts.length > 0) return textParts.join("\n");
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Extract provider from model reference (e.g. "openrouter/openai/gpt-oss-20b" → "openrouter") */
|
|
312
|
+
function extractProvider(modelRef: string): string {
|
|
313
|
+
const slashIdx = modelRef.indexOf("/");
|
|
314
|
+
return slashIdx > 0 ? modelRef.slice(0, slashIdx) : modelRef;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Extract model ID from full reference (e.g. "openrouter/openai/gpt-oss-20b" → "openai/gpt-oss-20b") */
|
|
318
|
+
function extractModelId(modelRef: string): string {
|
|
319
|
+
const slashIdx = modelRef.indexOf("/");
|
|
320
|
+
return slashIdx > 0 ? modelRef.slice(slashIdx + 1) : modelRef;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Resolve API key for a provider from environment variables */
|
|
324
|
+
function resolveApiKey(modelRef: string): string | undefined {
|
|
325
|
+
const provider = extractProvider(modelRef);
|
|
326
|
+
// Try standard env var patterns
|
|
327
|
+
const envKey = `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`;
|
|
328
|
+
return process.env[envKey];
|
|
329
|
+
}
|
package/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ import { loadConfig } from "./settings.js";
|
|
|
19
19
|
import {
|
|
20
20
|
registerEventListeners,
|
|
21
21
|
unregisterEventListeners,
|
|
22
|
+
setSessionContext,
|
|
23
|
+
clearSessionContext,
|
|
22
24
|
} from "./events.js";
|
|
23
25
|
|
|
24
26
|
/** Package version */
|
|
@@ -38,20 +40,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
38
40
|
registerNotifyCommands(pi);
|
|
39
41
|
|
|
40
42
|
// Session lifecycle — register events and announce module
|
|
41
|
-
pi.on("session_start", async () => {
|
|
43
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
44
|
+
setSessionContext(ctx);
|
|
42
45
|
const config = loadConfig();
|
|
43
46
|
registerEventListeners(pi, config);
|
|
44
47
|
|
|
45
48
|
emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
|
|
46
49
|
name: MODULES.NOTIFY,
|
|
47
50
|
version: VERSION,
|
|
48
|
-
commands: ["unipi:notify-settings", "unipi:notify-set-gotify", "unipi:notify-set-tg", "unipi:notify-test"],
|
|
51
|
+
commands: ["unipi:notify-settings", "unipi:notify-set-gotify", "unipi:notify-set-tg", "unipi:notify-set-ntfy", "unipi:notify-test", "unipi:notify-recap-model"],
|
|
49
52
|
tools: [NOTIFY_TOOLS.NOTIFY_USER],
|
|
50
53
|
});
|
|
51
54
|
});
|
|
52
55
|
|
|
53
56
|
// Cleanup on session shutdown
|
|
54
57
|
pi.on("session_shutdown", async () => {
|
|
58
|
+
clearSessionContext();
|
|
55
59
|
unregisterEventListeners();
|
|
56
60
|
});
|
|
57
61
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/notify — ntfy notification platform
|
|
3
|
+
*
|
|
4
|
+
* Sends push notifications to an ntfy server via HTTP POST.
|
|
5
|
+
* ntfy is a simple HTTP-based pub-sub notification service.
|
|
6
|
+
* Supports self-hosted instances and ntfy.sh (public).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Send a notification to an ntfy server */
|
|
10
|
+
export async function sendNtfyNotification(
|
|
11
|
+
serverUrl: string,
|
|
12
|
+
topic: string,
|
|
13
|
+
title: string,
|
|
14
|
+
message: string,
|
|
15
|
+
priority: number = 3,
|
|
16
|
+
token?: string
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
// ntfy supports POSTing to the server root with a JSON body that carries
|
|
19
|
+
// topic/title/message/priority. JSON bodies are UTF-8 safe, unlike HTTP
|
|
20
|
+
// headers which must be ByteString (Latin-1) and reject characters like
|
|
21
|
+
// em dash (U+2014). See https://docs.ntfy.sh/publish/#publish-as-json
|
|
22
|
+
const url = serverUrl.replace(/\/$/, "");
|
|
23
|
+
const headers: Record<string, string> = {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
if (token) {
|
|
28
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const body = JSON.stringify({
|
|
32
|
+
topic,
|
|
33
|
+
title,
|
|
34
|
+
message,
|
|
35
|
+
priority: Math.max(1, Math.min(5, priority)),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const response = await fetch(url, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers,
|
|
41
|
+
body,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
const resBody = await response.text().catch(() => "<no body>");
|
|
46
|
+
throw new Error(`ntfy API error ${response.status}: ${resBody}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
package/settings.ts
CHANGED
|
@@ -38,6 +38,15 @@ export const DEFAULT_CONFIG: NotifyConfig = {
|
|
|
38
38
|
telegram: {
|
|
39
39
|
enabled: false,
|
|
40
40
|
},
|
|
41
|
+
ntfy: {
|
|
42
|
+
enabled: false,
|
|
43
|
+
serverUrl: "https://ntfy.sh",
|
|
44
|
+
priority: 3,
|
|
45
|
+
},
|
|
46
|
+
recap: {
|
|
47
|
+
enabled: false,
|
|
48
|
+
model: "openrouter/openai/gpt-oss-20b",
|
|
49
|
+
},
|
|
41
50
|
};
|
|
42
51
|
|
|
43
52
|
/** Load config from disk, returning defaults if missing or invalid */
|
|
@@ -50,11 +59,8 @@ export function loadConfig(): NotifyConfig {
|
|
|
50
59
|
// Merge with defaults to ensure new fields are present
|
|
51
60
|
return mergeWithDefaults(parsed);
|
|
52
61
|
}
|
|
53
|
-
} catch (
|
|
54
|
-
|
|
55
|
-
`[notify] Failed to load config from ${configPath}, using defaults:`,
|
|
56
|
-
err
|
|
57
|
-
);
|
|
62
|
+
} catch (_err) {
|
|
63
|
+
// Config load failure — using defaults silently.
|
|
58
64
|
}
|
|
59
65
|
return { ...DEFAULT_CONFIG };
|
|
60
66
|
}
|
|
@@ -101,6 +107,19 @@ export function validateConfig(config: NotifyConfig): string[] {
|
|
|
101
107
|
errors.push("Gotify: priority must be between 1 and 10");
|
|
102
108
|
}
|
|
103
109
|
|
|
110
|
+
if (config.ntfy.enabled) {
|
|
111
|
+
if (!config.ntfy.serverUrl) {
|
|
112
|
+
errors.push("ntfy: serverUrl is required");
|
|
113
|
+
}
|
|
114
|
+
if (!config.ntfy.topic) {
|
|
115
|
+
errors.push("ntfy: topic is required");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (config.ntfy.priority < 1 || config.ntfy.priority > 5) {
|
|
120
|
+
errors.push("ntfy: priority must be between 1 and 5");
|
|
121
|
+
}
|
|
122
|
+
|
|
104
123
|
return errors;
|
|
105
124
|
}
|
|
106
125
|
|
|
@@ -112,5 +131,7 @@ function mergeWithDefaults(loaded: Partial<NotifyConfig>): NotifyConfig {
|
|
|
112
131
|
native: { ...DEFAULT_CONFIG.native, ...loaded.native },
|
|
113
132
|
gotify: { ...DEFAULT_CONFIG.gotify, ...loaded.gotify },
|
|
114
133
|
telegram: { ...DEFAULT_CONFIG.telegram, ...loaded.telegram },
|
|
134
|
+
ntfy: { ...DEFAULT_CONFIG.ntfy, ...loaded.ntfy },
|
|
135
|
+
recap: { ...DEFAULT_CONFIG.recap, ...loaded.recap },
|
|
115
136
|
};
|
|
116
137
|
}
|
|
@@ -47,6 +47,13 @@ Help users configure the `@pi-unipi/notify` notification system.
|
|
|
47
47
|
"enabled": false,
|
|
48
48
|
"botToken": null,
|
|
49
49
|
"chatId": null
|
|
50
|
+
},
|
|
51
|
+
"ntfy": {
|
|
52
|
+
"enabled": false,
|
|
53
|
+
"serverUrl": "https://ntfy.sh",
|
|
54
|
+
"topic": null,
|
|
55
|
+
"token": null,
|
|
56
|
+
"priority": 3
|
|
50
57
|
}
|
|
51
58
|
}
|
|
52
59
|
```
|
|
@@ -75,6 +82,20 @@ Bot API notifications. Requires:
|
|
|
75
82
|
- `botToken` — From @BotFather
|
|
76
83
|
- `chatId` — Auto-detected by `/unipi:notify-set-tg`
|
|
77
84
|
|
|
85
|
+
### ntfy (default: disabled)
|
|
86
|
+
|
|
87
|
+
Simple HTTP-based pub-sub notification service. Supports public [ntfy.sh](https://ntfy.sh) and self-hosted instances.
|
|
88
|
+
Requires:
|
|
89
|
+
- `serverUrl` — ntfy server URL (default: `https://ntfy.sh`)
|
|
90
|
+
- `topic` — Topic name to publish to (acts as a channel)
|
|
91
|
+
- `token` — Optional access token for authenticated servers
|
|
92
|
+
- `priority` — 1-5 (default: 3)
|
|
93
|
+
|
|
94
|
+
**Setup options:**
|
|
95
|
+
1. **Interactive overlay:** Run `/unipi:notify-set-ntfy` for guided setup with connection test
|
|
96
|
+
2. **Manual config:** Edit `config.json` directly with the fields above
|
|
97
|
+
3. **Agent can write config:** Read the current config, merge changes, write back
|
|
98
|
+
|
|
78
99
|
## Commands
|
|
79
100
|
|
|
80
101
|
| Command | Description |
|
|
@@ -82,6 +103,7 @@ Bot API notifications. Requires:
|
|
|
82
103
|
| `/unipi:notify-settings` | TUI overlay to toggle platforms and events |
|
|
83
104
|
| `/unipi:notify-set-gotify` | Interactive Gotify setup wizard |
|
|
84
105
|
| `/unipi:notify-set-tg` | Interactive Telegram setup wizard |
|
|
106
|
+
| `/unipi:notify-set-ntfy` | Interactive ntfy setup wizard |
|
|
85
107
|
| `/unipi:notify-test` | Send test notification to all enabled platforms |
|
|
86
108
|
|
|
87
109
|
## Events
|
|
@@ -125,6 +147,7 @@ Read the JSON, make changes, write it back. Example:
|
|
|
125
147
|
|
|
126
148
|
For Gotify: suggest running `/unipi:notify-set-gotify`
|
|
127
149
|
For Telegram: suggest running `/unipi:notify-set-tg`
|
|
150
|
+
For ntfy: suggest running `/unipi:notify-set-ntfy`
|
|
128
151
|
For general settings: suggest `/unipi:notify-settings`
|
|
129
152
|
|
|
130
153
|
## Validation rules
|
|
@@ -132,3 +155,5 @@ For general settings: suggest `/unipi:notify-settings`
|
|
|
132
155
|
- Gotify: `serverUrl` and `appToken` required when enabled
|
|
133
156
|
- Gotify: `priority` must be 1-10
|
|
134
157
|
- Telegram: `botToken` and `chatId` required when enabled
|
|
158
|
+
- ntfy: `serverUrl` and `topic` required when enabled
|
|
159
|
+
- ntfy: `priority` must be 1-5
|
package/tools.ts
CHANGED
|
@@ -26,7 +26,7 @@ const NotifyUserSchema = Type.Object({
|
|
|
26
26
|
),
|
|
27
27
|
platforms: Type.Optional(
|
|
28
28
|
Type.Array(
|
|
29
|
-
Type.String({ enum: ["native", "gotify", "telegram"] }),
|
|
29
|
+
Type.String({ enum: ["native", "gotify", "telegram", "ntfy"] }),
|
|
30
30
|
{ description: "Override platforms for this notification" }
|
|
31
31
|
)
|
|
32
32
|
),
|
|
@@ -40,7 +40,7 @@ export function registerNotifyTools(pi: ExtensionAPI): void {
|
|
|
40
40
|
name: NOTIFY_TOOLS.NOTIFY_USER,
|
|
41
41
|
label: "Notify User",
|
|
42
42
|
description:
|
|
43
|
-
"Send a notification to the user's configured platforms (native OS, Gotify, Telegram). " +
|
|
43
|
+
"Send a notification to the user's configured platforms (native OS, Gotify, Telegram, ntfy). " +
|
|
44
44
|
"Use for critical errors, completion of long-running tasks, or when the user explicitly asked to be notified.",
|
|
45
45
|
parameters: NotifyUserSchema,
|
|
46
46
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
@@ -53,7 +53,7 @@ export function registerNotifyTools(pi: ExtensionAPI): void {
|
|
|
53
53
|
message: string;
|
|
54
54
|
title?: string;
|
|
55
55
|
priority?: "low" | "normal" | "high";
|
|
56
|
-
platforms?: Array<"native" | "gotify" | "telegram">;
|
|
56
|
+
platforms?: Array<"native" | "gotify" | "telegram" | "ntfy">;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
59
|
const config = loadConfig();
|