@pi-unipi/notify 0.1.1
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 +103 -0
- package/commands.ts +210 -0
- package/events.ts +192 -0
- package/index.ts +57 -0
- package/package.json +53 -0
- package/platforms/gotify.ts +36 -0
- package/platforms/native.ts +47 -0
- package/platforms/node-notifier.d.ts +20 -0
- package/platforms/telegram.ts +77 -0
- package/settings.ts +115 -0
- package/skills/configure-notify/SKILL.md +134 -0
- package/skills/notify/SKILL.md +56 -0
- package/tools.ts +96 -0
- package/tui/gotify-setup.ts +528 -0
- package/tui/settings-overlay.ts +251 -0
- package/tui/telegram-setup.ts +301 -0
- package/types.ts +88 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/notify — Settings TUI Component
|
|
3
|
+
*
|
|
4
|
+
* Interactive settings editor for notification configuration.
|
|
5
|
+
* Allows toggling platforms, configuring credentials, and per-event settings.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
9
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
10
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import {
|
|
12
|
+
loadConfig,
|
|
13
|
+
saveConfig,
|
|
14
|
+
validateConfig,
|
|
15
|
+
} from "../settings.js";
|
|
16
|
+
import type { NotifyConfig } from "../types.js";
|
|
17
|
+
|
|
18
|
+
/** Section types */
|
|
19
|
+
type Section = "platforms" | "events";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Settings overlay component.
|
|
23
|
+
*/
|
|
24
|
+
export class NotifySettingsOverlay implements Component {
|
|
25
|
+
private config: NotifyConfig;
|
|
26
|
+
private section: Section = "platforms";
|
|
27
|
+
private selectedIndex = 0;
|
|
28
|
+
private error: string | null = null;
|
|
29
|
+
private saved = false;
|
|
30
|
+
onClose?: () => void;
|
|
31
|
+
requestRender?: () => void;
|
|
32
|
+
private theme: Theme | null = null;
|
|
33
|
+
|
|
34
|
+
constructor() {
|
|
35
|
+
this.config = loadConfig();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setTheme(theme: Theme): void {
|
|
39
|
+
this.theme = theme;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
invalidate(): void {}
|
|
43
|
+
|
|
44
|
+
handleInput(data: string): void {
|
|
45
|
+
switch (data) {
|
|
46
|
+
case "\x1b[A": // Up
|
|
47
|
+
case "k":
|
|
48
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
49
|
+
break;
|
|
50
|
+
case "\x1b[B": // Down
|
|
51
|
+
case "j":
|
|
52
|
+
this.selectedIndex = Math.min(this.maxItems - 1, this.selectedIndex + 1);
|
|
53
|
+
break;
|
|
54
|
+
case " ": // Space - toggle
|
|
55
|
+
this.toggleCurrent();
|
|
56
|
+
break;
|
|
57
|
+
case "\t": // Tab - switch section
|
|
58
|
+
this.section = this.section === "platforms" ? "events" : "platforms";
|
|
59
|
+
this.selectedIndex = 0;
|
|
60
|
+
break;
|
|
61
|
+
case "\r": // Enter - save
|
|
62
|
+
this.save();
|
|
63
|
+
break;
|
|
64
|
+
case "\x1b": // Escape - close
|
|
65
|
+
this.onClose?.();
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private get maxItems(): number {
|
|
71
|
+
if (this.section === "platforms") return 3; // native, gotify, telegram
|
|
72
|
+
return Object.keys(this.config.events).length;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private toggleCurrent(): void {
|
|
76
|
+
if (this.section === "platforms") {
|
|
77
|
+
const platforms: Array<"native" | "gotify" | "telegram"> = [
|
|
78
|
+
"native",
|
|
79
|
+
"gotify",
|
|
80
|
+
"telegram",
|
|
81
|
+
];
|
|
82
|
+
const key = platforms[this.selectedIndex];
|
|
83
|
+
if (key) {
|
|
84
|
+
this.config[key].enabled = !this.config[key].enabled;
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
const eventKeys = Object.keys(this.config.events);
|
|
88
|
+
const key = eventKeys[this.selectedIndex];
|
|
89
|
+
if (key && this.config.events[key]) {
|
|
90
|
+
this.config.events[key].enabled = !this.config.events[key].enabled;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private save(): void {
|
|
96
|
+
const errors = validateConfig(this.config);
|
|
97
|
+
if (errors.length > 0) {
|
|
98
|
+
this.error = errors.join("; ");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.error = null;
|
|
102
|
+
saveConfig(this.config);
|
|
103
|
+
this.saved = true;
|
|
104
|
+
setTimeout(() => this.onClose?.(), 500);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Theme helpers ───────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
private fg(color: string, text: string): string {
|
|
110
|
+
if (this.theme) return this.theme.fg(color as any, text);
|
|
111
|
+
const c: Record<string, string> = {
|
|
112
|
+
accent: "\x1b[36m", success: "\x1b[32m", warning: "\x1b[33m",
|
|
113
|
+
error: "\x1b[31m", dim: "\x1b[2m", borderMuted: "\x1b[90m",
|
|
114
|
+
};
|
|
115
|
+
return `${c[color] ?? ""}${text}\x1b[0m`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private bold(text: string): string {
|
|
119
|
+
return this.theme ? this.theme.bold(text) : `\x1b[1m${text}\x1b[0m`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private frameLine(content: string, innerWidth: number): string {
|
|
123
|
+
const truncated = truncateToWidth(content, innerWidth, "");
|
|
124
|
+
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
125
|
+
return `${this.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.fg("borderMuted", "│")}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private ruleLine(innerWidth: number): string {
|
|
129
|
+
return this.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
133
|
+
const left = edge === "top" ? "┌" : "└";
|
|
134
|
+
const right = edge === "top" ? "┐" : "┘";
|
|
135
|
+
return this.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private getDialogHeight(): number {
|
|
139
|
+
const terminalRows = process.stdout.rows ?? 30;
|
|
140
|
+
return Math.max(14, Math.min(24, Math.floor(terminalRows * 0.65)));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
render(width: number): string[] {
|
|
144
|
+
const innerWidth = Math.max(22, width - 2);
|
|
145
|
+
const lines: string[] = [];
|
|
146
|
+
|
|
147
|
+
lines.push(this.borderLine(innerWidth, "top"));
|
|
148
|
+
lines.push(this.frameLine(this.fg("accent", this.bold("🔔 Notify Settings")), innerWidth));
|
|
149
|
+
lines.push(this.frameLine(this.fg("dim", "Configure notification platforms and events"), innerWidth));
|
|
150
|
+
lines.push(this.ruleLine(innerWidth));
|
|
151
|
+
|
|
152
|
+
// Section tabs
|
|
153
|
+
const platformTab =
|
|
154
|
+
this.section === "platforms"
|
|
155
|
+
? this.fg("accent", this.bold("[Platforms]"))
|
|
156
|
+
: this.fg("dim", "Platforms");
|
|
157
|
+
const eventsTab =
|
|
158
|
+
this.section === "events"
|
|
159
|
+
? this.fg("accent", this.bold("[Events]"))
|
|
160
|
+
: this.fg("dim", "Events");
|
|
161
|
+
lines.push(this.frameLine(` ${platformTab} ${eventsTab}`, innerWidth));
|
|
162
|
+
lines.push(this.ruleLine(innerWidth));
|
|
163
|
+
|
|
164
|
+
if (this.section === "platforms") {
|
|
165
|
+
this.renderPlatforms(lines, innerWidth);
|
|
166
|
+
} else {
|
|
167
|
+
this.renderEvents(lines, innerWidth);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Status messages
|
|
171
|
+
if (this.error) {
|
|
172
|
+
lines.push(this.ruleLine(innerWidth));
|
|
173
|
+
lines.push(this.frameLine(` ${this.fg("error", `⚠ ${this.error}`)}`, innerWidth));
|
|
174
|
+
}
|
|
175
|
+
if (this.saved) {
|
|
176
|
+
lines.push(this.ruleLine(innerWidth));
|
|
177
|
+
lines.push(this.frameLine(` ${this.fg("success", "✓ Settings saved")}`, innerWidth));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Footer
|
|
181
|
+
lines.push(this.ruleLine(innerWidth));
|
|
182
|
+
lines.push(this.frameLine(this.fg("dim", "↑↓ navigate · Space toggle · Tab switch · Enter save · Esc cancel"), innerWidth));
|
|
183
|
+
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
184
|
+
|
|
185
|
+
return lines;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private renderPlatforms(lines: string[], innerWidth: number): void {
|
|
189
|
+
const platforms: Array<{
|
|
190
|
+
key: "native" | "gotify" | "telegram";
|
|
191
|
+
label: string;
|
|
192
|
+
detail: string;
|
|
193
|
+
}> = [
|
|
194
|
+
{
|
|
195
|
+
key: "native",
|
|
196
|
+
label: "Native OS",
|
|
197
|
+
detail: "Desktop notifications (node-notifier)",
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
key: "gotify",
|
|
201
|
+
label: "Gotify",
|
|
202
|
+
detail: this.config.gotify.serverUrl
|
|
203
|
+
? `Server: ${this.config.gotify.serverUrl}`
|
|
204
|
+
: "Self-hosted push server",
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
key: "telegram",
|
|
208
|
+
label: "Telegram",
|
|
209
|
+
detail: this.config.telegram.botToken
|
|
210
|
+
? "Bot configured"
|
|
211
|
+
: "Bot API notifications",
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
for (let i = 0; i < platforms.length; i++) {
|
|
216
|
+
const p = platforms[i];
|
|
217
|
+
const isSelected = i === this.selectedIndex;
|
|
218
|
+
const toggleOn = this.fg("success", "●");
|
|
219
|
+
const toggleOff = this.fg("dim", "○");
|
|
220
|
+
const toggle = this.config[p.key].enabled ? toggleOn : toggleOff;
|
|
221
|
+
const label = isSelected ? this.bold(p.label) : this.fg("dim", p.label);
|
|
222
|
+
|
|
223
|
+
lines.push(
|
|
224
|
+
this.frameLine(
|
|
225
|
+
`${isSelected ? this.fg("accent", "▸") : " "} ${toggle} ${label} ${this.fg("dim", p.detail)}`,
|
|
226
|
+
innerWidth
|
|
227
|
+
)
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private renderEvents(lines: string[], innerWidth: number): void {
|
|
233
|
+
const events = Object.entries(this.config.events);
|
|
234
|
+
|
|
235
|
+
for (let i = 0; i < events.length; i++) {
|
|
236
|
+
const [key, cfg] = events[i];
|
|
237
|
+
const isSelected = i === this.selectedIndex;
|
|
238
|
+
const toggleOn = this.fg("success", "●");
|
|
239
|
+
const toggleOff = this.fg("dim", "○");
|
|
240
|
+
const toggle = cfg.enabled ? toggleOn : toggleOff;
|
|
241
|
+
const label = isSelected ? this.bold(key) : this.fg("dim", key);
|
|
242
|
+
|
|
243
|
+
lines.push(
|
|
244
|
+
this.frameLine(
|
|
245
|
+
`${isSelected ? this.fg("accent", "▸") : " "} ${toggle} ${label}`,
|
|
246
|
+
innerWidth
|
|
247
|
+
)
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/notify — Telegram Setup TUI Component
|
|
3
|
+
*
|
|
4
|
+
* Interactive overlay for setting up Telegram bot notifications.
|
|
5
|
+
* Guides user through BotFather flow and auto-detects chat ID.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
9
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
10
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import { pollForChatId } from "../platforms/telegram.js";
|
|
12
|
+
import { updateConfig } from "../settings.js";
|
|
13
|
+
|
|
14
|
+
type SetupPhase = "instructions" | "token" | "polling" | "success" | "error" | "timeout";
|
|
15
|
+
|
|
16
|
+
/** Spinner frames */
|
|
17
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Telegram setup overlay component.
|
|
21
|
+
*/
|
|
22
|
+
export class TelegramSetupOverlay implements Component {
|
|
23
|
+
private phase: SetupPhase = "instructions";
|
|
24
|
+
private botToken = "";
|
|
25
|
+
private chatId: string | null = null;
|
|
26
|
+
private error: string | null = null;
|
|
27
|
+
private spinnerFrame = 0;
|
|
28
|
+
private spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
29
|
+
private pollAbort: AbortController | null = null;
|
|
30
|
+
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
31
|
+
private startTime = Date.now();
|
|
32
|
+
private readonly TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
33
|
+
private isInPaste = false;
|
|
34
|
+
private pasteBuffer = "";
|
|
35
|
+
onClose?: () => void;
|
|
36
|
+
requestRender?: () => void;
|
|
37
|
+
private theme: Theme | null = null;
|
|
38
|
+
|
|
39
|
+
setTheme(theme: Theme): void {
|
|
40
|
+
this.theme = theme;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
invalidate(): void {}
|
|
44
|
+
|
|
45
|
+
handleInput(data: string): void {
|
|
46
|
+
switch (this.phase) {
|
|
47
|
+
case "instructions":
|
|
48
|
+
if (data === "\r" || data === " ") {
|
|
49
|
+
this.phase = "token";
|
|
50
|
+
} else if (data === "\x1b") {
|
|
51
|
+
this.cleanup();
|
|
52
|
+
this.onClose?.();
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
case "token":
|
|
56
|
+
// Handle bracketed paste mode
|
|
57
|
+
if (this.isInPaste) {
|
|
58
|
+
this.pasteBuffer += data;
|
|
59
|
+
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
|
|
60
|
+
if (endIndex !== -1) {
|
|
61
|
+
// Extract pasted content and process it
|
|
62
|
+
const pasteContent = this.pasteBuffer.substring(0, endIndex);
|
|
63
|
+
this.processTokenInput(pasteContent);
|
|
64
|
+
this.isInPaste = false;
|
|
65
|
+
this.pasteBuffer = "";
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// Detect start of bracketed paste
|
|
70
|
+
if (data.includes("\x1b[200~")) {
|
|
71
|
+
this.isInPaste = true;
|
|
72
|
+
this.pasteBuffer = data.replace("\x1b[200~", "");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (data === "\r" && this.botToken.length > 0) {
|
|
76
|
+
this.startPolling();
|
|
77
|
+
} else if (data === "\x1b") {
|
|
78
|
+
this.cleanup();
|
|
79
|
+
this.onClose?.();
|
|
80
|
+
} else if (data === "\x7f" || data === "\b") {
|
|
81
|
+
this.botToken = this.botToken.slice(0, -1);
|
|
82
|
+
} else {
|
|
83
|
+
this.processTokenInput(data);
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
case "polling":
|
|
87
|
+
if (data === "\x1b") {
|
|
88
|
+
this.cleanup();
|
|
89
|
+
this.onClose?.();
|
|
90
|
+
}
|
|
91
|
+
break;
|
|
92
|
+
case "success":
|
|
93
|
+
case "error":
|
|
94
|
+
case "timeout":
|
|
95
|
+
if (data === "\r" || data === " " || data === "\x1b") {
|
|
96
|
+
this.cleanup();
|
|
97
|
+
this.onClose?.();
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private processTokenInput(data: string): void {
|
|
104
|
+
// Ignore escape sequences (arrow keys, function keys, etc.)
|
|
105
|
+
if (data.startsWith("\x1b[")) return;
|
|
106
|
+
// Filter to valid bot token characters only
|
|
107
|
+
const cleaned = data.replace(/[^0-9:A-Za-z_-]/g, "");
|
|
108
|
+
if (cleaned.length > 0) {
|
|
109
|
+
this.botToken += cleaned;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private startPolling(): void {
|
|
114
|
+
this.phase = "polling";
|
|
115
|
+
this.startTime = Date.now();
|
|
116
|
+
this.pollAbort = new AbortController();
|
|
117
|
+
|
|
118
|
+
// Start spinner animation
|
|
119
|
+
this.spinnerTimer = setInterval(() => {
|
|
120
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
121
|
+
this.requestRender?.();
|
|
122
|
+
}, 80);
|
|
123
|
+
|
|
124
|
+
// Start polling
|
|
125
|
+
this.doPoll();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async doPoll(): Promise<void> {
|
|
129
|
+
if (this.phase !== "polling" || !this.pollAbort) return;
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const chatId = await pollForChatId(
|
|
133
|
+
this.botToken,
|
|
134
|
+
this.pollAbort.signal
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (chatId) {
|
|
138
|
+
this.chatId = chatId;
|
|
139
|
+
this.phase = "success";
|
|
140
|
+
this.saveConfig();
|
|
141
|
+
this.cleanup();
|
|
142
|
+
this.requestRender?.();
|
|
143
|
+
// Auto-close after brief delay to show success
|
|
144
|
+
setTimeout(() => this.onClose?.(), 1000);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Check timeout
|
|
149
|
+
if (Date.now() - this.startTime > this.TIMEOUT_MS) {
|
|
150
|
+
this.phase = "timeout";
|
|
151
|
+
this.error = "Timed out after 5 minutes";
|
|
152
|
+
this.cleanup();
|
|
153
|
+
this.requestRender?.();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Schedule next poll
|
|
158
|
+
this.pollTimer = setTimeout(() => this.doPoll(), 2000);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (this.pollAbort?.signal.aborted) return;
|
|
161
|
+
this.phase = "error";
|
|
162
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
163
|
+
this.cleanup();
|
|
164
|
+
this.requestRender?.();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private saveConfig(): void {
|
|
169
|
+
if (this.botToken && this.chatId) {
|
|
170
|
+
updateConfig({
|
|
171
|
+
telegram: {
|
|
172
|
+
enabled: true,
|
|
173
|
+
botToken: this.botToken,
|
|
174
|
+
chatId: this.chatId,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private cleanup(): void {
|
|
181
|
+
if (this.spinnerTimer) {
|
|
182
|
+
clearInterval(this.spinnerTimer);
|
|
183
|
+
this.spinnerTimer = null;
|
|
184
|
+
}
|
|
185
|
+
if (this.pollTimer) {
|
|
186
|
+
clearTimeout(this.pollTimer);
|
|
187
|
+
this.pollTimer = null;
|
|
188
|
+
}
|
|
189
|
+
if (this.pollAbort) {
|
|
190
|
+
this.pollAbort.abort();
|
|
191
|
+
this.pollAbort = null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Theme helpers ───────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
private fg(color: string, text: string): string {
|
|
198
|
+
if (this.theme) return this.theme.fg(color as any, text);
|
|
199
|
+
const c: Record<string, string> = {
|
|
200
|
+
accent: "\x1b[36m", success: "\x1b[32m", warning: "\x1b[33m",
|
|
201
|
+
error: "\x1b[31m", dim: "\x1b[2m", borderMuted: "\x1b[90m",
|
|
202
|
+
};
|
|
203
|
+
return `${c[color] ?? ""}${text}\x1b[0m`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private bold(text: string): string {
|
|
207
|
+
return this.theme ? this.theme.bold(text) : `\x1b[1m${text}\x1b[0m`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private frameLine(content: string, innerWidth: number): string {
|
|
211
|
+
const truncated = truncateToWidth(content, innerWidth, "");
|
|
212
|
+
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
213
|
+
return `${this.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.fg("borderMuted", "│")}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private ruleLine(innerWidth: number): string {
|
|
217
|
+
return this.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
221
|
+
const left = edge === "top" ? "┌" : "└";
|
|
222
|
+
const right = edge === "top" ? "┐" : "┘";
|
|
223
|
+
return this.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
render(width: number): string[] {
|
|
227
|
+
const innerWidth = Math.max(22, width - 2);
|
|
228
|
+
const lines: string[] = [];
|
|
229
|
+
|
|
230
|
+
lines.push(this.borderLine(innerWidth, "top"));
|
|
231
|
+
lines.push(this.frameLine(this.fg("accent", this.bold("🤖 Telegram Bot Setup")), innerWidth));
|
|
232
|
+
lines.push(this.ruleLine(innerWidth));
|
|
233
|
+
|
|
234
|
+
switch (this.phase) {
|
|
235
|
+
case "instructions":
|
|
236
|
+
lines.push(this.frameLine(this.fg("dim", "Set up Telegram notifications in 3 steps:"), innerWidth));
|
|
237
|
+
lines.push(this.frameLine("", innerWidth));
|
|
238
|
+
lines.push(this.frameLine(` ${this.bold("1.")} Open Telegram and message ${this.fg("accent", "@BotFather")}`, innerWidth));
|
|
239
|
+
lines.push(this.frameLine(` Send /newbot and follow the prompts to create a bot`, innerWidth));
|
|
240
|
+
lines.push(this.frameLine("", innerWidth));
|
|
241
|
+
lines.push(this.frameLine(` ${this.bold("2.")} Copy the bot token from BotFather`, innerWidth));
|
|
242
|
+
lines.push(this.frameLine("", innerWidth));
|
|
243
|
+
lines.push(this.frameLine(` ${this.bold("3.")} Send any message to your new bot`, innerWidth));
|
|
244
|
+
lines.push(this.frameLine(` (We'll detect your chat ID automatically)`, innerWidth));
|
|
245
|
+
lines.push(this.ruleLine(innerWidth));
|
|
246
|
+
lines.push(this.frameLine(this.fg("dim", "Press Enter to continue, Esc to cancel"), innerWidth));
|
|
247
|
+
break;
|
|
248
|
+
|
|
249
|
+
case "token":
|
|
250
|
+
lines.push(this.frameLine(this.fg("dim", "Paste your bot token from BotFather:"), innerWidth));
|
|
251
|
+
lines.push(this.frameLine("", innerWidth));
|
|
252
|
+
const display = this.fg("accent", this.bold(this.botToken || " "));
|
|
253
|
+
lines.push(this.frameLine(` ${display}${this.fg("dim", "█")}`, innerWidth));
|
|
254
|
+
lines.push(this.frameLine("", innerWidth));
|
|
255
|
+
lines.push(this.frameLine(this.fg("dim", "Enter to start polling · Esc to cancel"), innerWidth));
|
|
256
|
+
break;
|
|
257
|
+
|
|
258
|
+
case "polling": {
|
|
259
|
+
const frame = SPINNER_FRAMES[this.spinnerFrame] || "⠋";
|
|
260
|
+
const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
|
|
261
|
+
const remaining = Math.max(0, 300 - elapsed);
|
|
262
|
+
lines.push(this.frameLine(` ${this.fg("accent", frame)} ${this.bold("Waiting for first message...")}`, innerWidth));
|
|
263
|
+
lines.push(this.frameLine("", innerWidth));
|
|
264
|
+
lines.push(this.frameLine(` ${this.fg("dim", "Send any message to your bot in Telegram")}`, innerWidth));
|
|
265
|
+
lines.push(this.frameLine(` ${this.fg("dim", `Timeout: ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, "0")}`)}`, innerWidth));
|
|
266
|
+
lines.push(this.ruleLine(innerWidth));
|
|
267
|
+
lines.push(this.frameLine(this.fg("dim", "Esc to cancel"), innerWidth));
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
case "success":
|
|
272
|
+
lines.push(this.frameLine(` ${this.fg("success", "✓ Telegram bot configured!")}`, innerWidth));
|
|
273
|
+
lines.push(this.frameLine("", innerWidth));
|
|
274
|
+
lines.push(this.frameLine(` ${this.fg("dim", `Chat ID: ${this.chatId}`)}`, innerWidth));
|
|
275
|
+
lines.push(this.frameLine(` ${this.fg("dim", "Notifications will be sent to this chat")}`, innerWidth));
|
|
276
|
+
lines.push(this.ruleLine(innerWidth));
|
|
277
|
+
lines.push(this.frameLine(this.fg("dim", "Press Enter to close"), innerWidth));
|
|
278
|
+
break;
|
|
279
|
+
|
|
280
|
+
case "error":
|
|
281
|
+
lines.push(this.frameLine(` ${this.fg("error", "✗ Setup failed")}`, innerWidth));
|
|
282
|
+
lines.push(this.frameLine("", innerWidth));
|
|
283
|
+
lines.push(this.frameLine(` ${this.fg("dim", this.error || "Unknown error")}`, innerWidth));
|
|
284
|
+
lines.push(this.ruleLine(innerWidth));
|
|
285
|
+
lines.push(this.frameLine(this.fg("dim", "Press Enter to close"), innerWidth));
|
|
286
|
+
break;
|
|
287
|
+
|
|
288
|
+
case "timeout":
|
|
289
|
+
lines.push(this.frameLine(` ${this.fg("warning", "⏰ Timed out after 5 minutes")}`, innerWidth));
|
|
290
|
+
lines.push(this.frameLine("", innerWidth));
|
|
291
|
+
lines.push(this.frameLine(` ${this.fg("dim", "Make sure you sent a message to your bot in Telegram")}`, innerWidth));
|
|
292
|
+
lines.push(this.frameLine(` ${this.fg("dim", "You can try again with /unipi:notify-set-tg")}`, innerWidth));
|
|
293
|
+
lines.push(this.ruleLine(innerWidth));
|
|
294
|
+
lines.push(this.frameLine(this.fg("dim", "Press Enter to close"), innerWidth));
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
299
|
+
return lines;
|
|
300
|
+
}
|
|
301
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/notify — TypeScript type definitions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Supported notification platforms */
|
|
6
|
+
export type NotifyPlatform = "native" | "gotify" | "telegram";
|
|
7
|
+
|
|
8
|
+
/** Per-event notification configuration */
|
|
9
|
+
export interface EventNotifyConfig {
|
|
10
|
+
/** Whether this event type is enabled */
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
/** Platforms to send to (empty = use global defaults) */
|
|
13
|
+
platforms: NotifyPlatform[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Native notification platform config */
|
|
17
|
+
export interface NativeConfig {
|
|
18
|
+
/** Whether native notifications are enabled */
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
/** Windows appID to show instead of "SnoreToast" */
|
|
21
|
+
windowsAppId?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Gotify notification platform config */
|
|
25
|
+
export interface GotifyConfig {
|
|
26
|
+
/** Whether Gotify is enabled */
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
/** Gotify server URL */
|
|
29
|
+
serverUrl?: string;
|
|
30
|
+
/** Gotify app token */
|
|
31
|
+
appToken?: string;
|
|
32
|
+
/** Priority level (1-10) */
|
|
33
|
+
priority: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Telegram notification platform config */
|
|
37
|
+
export interface TelegramConfig {
|
|
38
|
+
/** Whether Telegram is enabled */
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
/** Telegram bot token */
|
|
41
|
+
botToken?: string;
|
|
42
|
+
/** Telegram chat ID */
|
|
43
|
+
chatId?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Full notification configuration */
|
|
47
|
+
export interface NotifyConfig {
|
|
48
|
+
/** Global default platforms for all events */
|
|
49
|
+
defaultPlatforms: NotifyPlatform[];
|
|
50
|
+
/** Per-event type overrides */
|
|
51
|
+
events: Record<string, EventNotifyConfig>;
|
|
52
|
+
/** Native platform settings */
|
|
53
|
+
native: NativeConfig;
|
|
54
|
+
/** Gotify settings */
|
|
55
|
+
gotify: GotifyConfig;
|
|
56
|
+
/** Telegram settings */
|
|
57
|
+
telegram: TelegramConfig;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Parameters for the notify_user agent tool */
|
|
61
|
+
export interface NotifyUserParams {
|
|
62
|
+
/** Notification message body */
|
|
63
|
+
message: string;
|
|
64
|
+
/** Notification title (default: "Pi Notification") */
|
|
65
|
+
title?: string;
|
|
66
|
+
/** Priority level */
|
|
67
|
+
priority?: "low" | "normal" | "high";
|
|
68
|
+
/** Override platforms for this notification */
|
|
69
|
+
platforms?: NotifyPlatform[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Result of sending a notification to a single platform */
|
|
73
|
+
export interface NotifyResult {
|
|
74
|
+
/** Platform that was targeted */
|
|
75
|
+
platform: NotifyPlatform;
|
|
76
|
+
/** Whether the send succeeded */
|
|
77
|
+
success: boolean;
|
|
78
|
+
/** Error message if failed */
|
|
79
|
+
error?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Notification dispatch summary */
|
|
83
|
+
export interface NotifyDispatchResult {
|
|
84
|
+
/** Results per platform */
|
|
85
|
+
results: NotifyResult[];
|
|
86
|
+
/** Whether all platforms succeeded */
|
|
87
|
+
allSuccess: boolean;
|
|
88
|
+
}
|