@goodnesshq/opencode-notification 0.1.7 → 0.2.0

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.
@@ -21,10 +21,10 @@ const DEFAULTS = {
21
21
  enabled: true,
22
22
  title: "",
23
23
  tier: "focus",
24
- detailLevel: "full",
24
+ detailLevel: "title-only",
25
25
  responseComplete: {
26
26
  enabled: true,
27
- trigger: "message.updated",
27
+ trigger: "session.idle",
28
28
  },
29
29
  channels: {
30
30
  mac: {
@@ -36,6 +36,11 @@ const DEFAULTS = {
36
36
  server: "https://ntfy.sh",
37
37
  topic: "",
38
38
  },
39
+ telegram: {
40
+ enabled: false,
41
+ token: "",
42
+ chatId: "",
43
+ },
39
44
  },
40
45
  overrides: {
41
46
  includeGroups: [],
@@ -128,6 +133,20 @@ async function copySchema(cwd) {
128
133
  } catch {}
129
134
  }
130
135
 
136
+ async function ensureGitignore(cwd) {
137
+ const gitignorePath = join(cwd, ".gitignore");
138
+ const entry = ".opencode/oc-notify.json";
139
+ try {
140
+ const content = await readFile(gitignorePath, "utf8");
141
+ const lines = content.split(/\r?\n/);
142
+ if (lines.some((line) => line.trim() === entry)) return;
143
+ const prefix = content.endsWith("\n") || content.length === 0 ? "" : "\n";
144
+ await writeFile(gitignorePath, content + prefix + entry + "\n", "utf8");
145
+ } catch {
146
+ await writeFile(gitignorePath, entry + "\n", "utf8");
147
+ }
148
+ }
149
+
131
150
  function isInteractive() {
132
151
  if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
133
152
  if (process.env.CI) return false;
@@ -263,53 +282,50 @@ async function promptMultiSelect({
263
282
 
264
283
  const render = () => {
265
284
  const filtered = getFiltered();
266
- if (cursor > Math.max(0, filtered.length - 1)) cursor = 0;
267
-
268
- const lines = [];
269
- lines.push(message);
270
- lines.push(
271
- `Filter: ${filter || "(type to filter)"} (${selected.size} selected)`,
272
- );
273
-
274
- if (filtered.length === 0) {
275
- lines.push(" No matches");
276
- renderPrompt(lines, state);
277
- return;
278
- }
279
-
280
- const startIndex = Math.max(
281
- 0,
282
- Math.min(
283
- cursor - Math.floor(pageSize / 2),
284
- Math.max(0, filtered.length - pageSize),
285
- ),
285
+ if (cursor >= filtered.length) cursor = Math.max(0, filtered.length - 1);
286
+ const maxStart = Math.max(0, filtered.length - pageSize);
287
+ const pageStart = Math.min(
288
+ maxStart,
289
+ Math.max(0, cursor - Math.floor(pageSize / 2)),
286
290
  );
287
- const endIndex = Math.min(startIndex + pageSize, filtered.length);
288
- const visible = filtered.slice(startIndex, endIndex);
289
-
290
- for (let index = 0; index < visible.length; index += 1) {
291
- const actualIndex = startIndex + index;
292
- const choice = visible[index];
293
- const pointer = actualIndex === cursor ? ">" : " ";
294
- const checked = selected.has(choice.value) ? "[x]" : "[ ]";
295
- lines.push(` ${pointer} ${checked} ${choice.name}`);
291
+ const page = filtered.slice(pageStart, pageStart + pageSize);
292
+ const lines = [];
293
+ const header = filter ? `${message} (filter: ${filter})` : message;
294
+ lines.push(header);
295
+ lines.push("Use \u2191/\u2193 to move, space to toggle, enter to confirm");
296
+ if (!page.length) {
297
+ lines.push(" (no matches)");
298
+ } else {
299
+ for (let index = 0; index < page.length; index += 1) {
300
+ const choice = page[index];
301
+ const absoluteIndex = pageStart + index;
302
+ const pointer = absoluteIndex === cursor ? ">" : " ";
303
+ const mark = selected.has(choice.value) ? "x" : " ";
304
+ lines.push(` ${pointer} [${mark}] ${choice.name}`);
305
+ }
296
306
  }
297
-
298
307
  renderPrompt(lines, state);
299
308
  };
300
309
 
310
+ const toggle = () => {
311
+ const filtered = getFiltered();
312
+ const choice = filtered[cursor];
313
+ if (!choice) return;
314
+ if (selected.has(choice.value)) selected.delete(choice.value);
315
+ else selected.add(choice.value);
316
+ };
317
+
301
318
  const finish = () => {
302
- const ordered = choices
319
+ const values = choices
303
320
  .filter((choice) => selected.has(choice.value))
304
321
  .map((choice) => choice.value);
305
- const summary = ordered.length > 0 ? ordered.join(", ") : "(none)";
306
- renderPrompt([`${message} ${summary}`], state);
322
+ renderPrompt([`${message} ${values.join(", ")}`], state);
307
323
  process.stdout.write("\n");
308
324
  cleanup();
309
- resolve(ordered);
325
+ resolve(values);
310
326
  };
311
327
 
312
- const onKey = (char, key) => {
328
+ const onKey = (_char, key) => {
313
329
  if (!key) return;
314
330
  if (key.ctrl && key.name === "c") {
315
331
  cleanup();
@@ -327,32 +343,24 @@ async function promptMultiSelect({
327
343
  return;
328
344
  }
329
345
  if (key.name === "space") {
330
- const filtered = getFiltered();
331
- const choice = filtered[cursor];
332
- if (choice) {
333
- if (selected.has(choice.value)) {
334
- selected.delete(choice.value);
335
- } else {
336
- selected.add(choice.value);
337
- }
338
- render();
339
- }
346
+ toggle();
347
+ render();
340
348
  return;
341
349
  }
342
- if (key.name === "backspace") {
343
- if (filter.length > 0) {
350
+ if (key.name === "return" || key.name === "enter") {
351
+ finish();
352
+ return;
353
+ }
354
+ if (key.name === "backspace" || key.name === "delete") {
355
+ if (filter) {
344
356
  filter = filter.slice(0, -1);
345
357
  cursor = 0;
346
358
  render();
347
359
  }
348
360
  return;
349
361
  }
350
- if (key.name === "return" || key.name === "enter") {
351
- finish();
352
- return;
353
- }
354
- if (char && char.length === 1 && !key.ctrl && !key.meta) {
355
- filter += char;
362
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
363
+ filter += key.sequence;
356
364
  cursor = 0;
357
365
  render();
358
366
  }
@@ -368,7 +376,6 @@ async function promptMultiSelect({
368
376
  );
369
377
  }
370
378
 
371
-
372
379
  async function sendTestNotification(config, repoName) {
373
380
  const title = config.title || `${repoName}@test`;
374
381
  const subtitle = "OpenCode Notify";
@@ -429,6 +436,31 @@ async function sendTestNotification(config, repoName) {
429
436
  console.log("Test ntfy notification failed.");
430
437
  }
431
438
  }
439
+
440
+ if (config.channels.telegram.enabled) {
441
+ try {
442
+ const token = config.channels.telegram.token?.trim();
443
+ const chatId = config.channels.telegram.chatId?.trim();
444
+ if (!token || !chatId) return;
445
+ const { execFileSync } = await import("node:child_process");
446
+ const url = `https://api.telegram.org/bot${token}/sendMessage`;
447
+ const payload = [title, subtitle, message].filter(Boolean).join("\n");
448
+ execFileSync("curl", [
449
+ "-sS",
450
+ "-X",
451
+ "POST",
452
+ url,
453
+ "--data-urlencode",
454
+ `chat_id=${chatId}`,
455
+ "--data-urlencode",
456
+ `text=${payload}`,
457
+ "--data-urlencode",
458
+ "disable_web_page_preview=true",
459
+ ]);
460
+ } catch {
461
+ console.log("Test Telegram notification failed.");
462
+ }
463
+ }
432
464
  }
433
465
 
434
466
  async function main() {
@@ -492,18 +524,18 @@ async function main() {
492
524
  value: "message.part.updated",
493
525
  },
494
526
  ],
495
- defaultValue: "message.updated",
527
+ defaultValue: "session.idle",
496
528
  })
497
529
  : await (async () => {
498
530
  console.log(
499
531
  "\nResponse-complete trigger:\n 1) session.idle (coarse, low noise)\n 2) message.updated (precise)\n 3) message.part.updated (very precise, more noise)",
500
532
  );
501
- const triggerInput = await rl.question("> [2] ");
502
- return triggerInput.trim() === "1"
503
- ? "session.idle"
533
+ const triggerInput = await rl.question("> [1] ");
534
+ return triggerInput.trim() === "2"
535
+ ? "message.updated"
504
536
  : triggerInput.trim() === "3"
505
537
  ? "message.part.updated"
506
- : "message.updated";
538
+ : "session.idle";
507
539
  })();
508
540
 
509
541
  const macEnabled = interactive
@@ -519,10 +551,17 @@ async function main() {
519
551
  ? await promptConfirm("Enable ntfy notifications?", true)
520
552
  : yesNo(await rl.question("Enable ntfy notifications? (Y/n) [Y] "), true);
521
553
 
554
+ const telegramEnabled = interactive
555
+ ? await promptConfirm("Enable Telegram notifications?", false)
556
+ : yesNo(await rl.question("Enable Telegram notifications? (y/N) [N] "), false);
557
+
522
558
  let globalConfig = await readJson(GLOBAL_CONFIG_PATH);
523
559
  if (!globalConfig) globalConfig = {};
524
560
  if (!globalConfig.ntfy) globalConfig.ntfy = {};
525
561
 
562
+ let telegramToken = "";
563
+ let telegramChatId = "";
564
+
526
565
  if (ntfyEnabled) {
527
566
  const serverInput = interactive
528
567
  ? await promptInput(
@@ -544,6 +583,17 @@ async function main() {
544
583
  if (topicInput.trim()) globalConfig.ntfy.topic = topicInput.trim();
545
584
  }
546
585
 
586
+ if (telegramEnabled) {
587
+ const tokenInput = interactive
588
+ ? await promptInput("Telegram bot token: ")
589
+ : await rl.question("Telegram bot token: ");
590
+ const chatIdInput = interactive
591
+ ? await promptInput("Telegram chat ID (see docs/telegram-chat-id.md): ")
592
+ : await rl.question("Telegram chat ID (see docs/telegram-chat-id.md): ");
593
+ telegramToken = tokenInput.trim();
594
+ telegramChatId = chatIdInput.trim();
595
+ }
596
+
547
597
  const detailLevel = interactive
548
598
  ? await promptSelect({
549
599
  message: "Detail level:",
@@ -551,11 +601,11 @@ async function main() {
551
601
  { name: "full", value: "full" },
552
602
  { name: "title-only", value: "title-only" },
553
603
  ],
554
- defaultValue: "full",
604
+ defaultValue: "title-only",
555
605
  })
556
- : (await rl.question("Detail level (1=full, 2=title-only) [1] ")).trim() === "2"
557
- ? "title-only"
558
- : "full";
606
+ : (await rl.question("Detail level (1=full, 2=title-only) [2] ")).trim() === "1"
607
+ ? "full"
608
+ : "title-only";
559
609
 
560
610
  const adjustGroups = interactive
561
611
  ? await promptConfirm("Adjust event groups?", false)
@@ -607,20 +657,20 @@ async function main() {
607
657
  const hasTN = await commandExists("terminal-notifier");
608
658
  const hasBrew = await commandExists("brew");
609
659
  if (!hasTN && hasBrew) {
610
- const installNow = interactive
611
- ? await promptConfirm(
612
- "terminal-notifier not found. Install with Homebrew now?",
613
- false,
614
- )
615
- : yesNo(
616
- await rl.question(
617
- "terminal-notifier not found. Install with Homebrew now? (y/N) [N] ",
618
- ),
619
- false,
620
- );
621
- if (installNow) {
622
- try {
623
- const { execSync } = await import("node:child_process");
660
+ const installNow = interactive
661
+ ? await promptConfirm(
662
+ "terminal-notifier not found. Install with Homebrew now?",
663
+ false,
664
+ )
665
+ : yesNo(
666
+ await rl.question(
667
+ "terminal-notifier not found. Install with Homebrew now? (y/N) [N] ",
668
+ ),
669
+ false,
670
+ );
671
+ if (installNow) {
672
+ try {
673
+ const { execSync } = await import("node:child_process");
624
674
  execSync("brew install terminal-notifier", { stdio: "inherit" });
625
675
  } catch {
626
676
  console.log("Homebrew install failed. Falling back to AppleScript.");
@@ -649,6 +699,11 @@ async function main() {
649
699
  server: globalConfig.ntfy?.server || DEFAULTS.channels.ntfy.server,
650
700
  topic: globalConfig.ntfy?.topic || DEFAULTS.channels.ntfy.topic,
651
701
  },
702
+ telegram: {
703
+ enabled: telegramEnabled,
704
+ token: telegramToken,
705
+ chatId: telegramChatId,
706
+ },
652
707
  },
653
708
  overrides: {
654
709
  includeGroups,
@@ -668,6 +723,7 @@ async function main() {
668
723
  await ensureOpencodeDir(cwd);
669
724
  await writeJson(join(cwd, REPO_CONFIG_PATH), config);
670
725
  await copySchema(cwd);
726
+ await ensureGitignore(cwd);
671
727
  }
672
728
 
673
729
  const sendTest = interactive
@@ -66,6 +66,21 @@
66
66
  "type": "string"
67
67
  }
68
68
  }
69
+ },
70
+ "telegram": {
71
+ "type": "object",
72
+ "additionalProperties": false,
73
+ "properties": {
74
+ "enabled": {
75
+ "type": "boolean"
76
+ },
77
+ "token": {
78
+ "type": "string"
79
+ },
80
+ "chatId": {
81
+ "type": "string"
82
+ }
83
+ }
69
84
  }
70
85
  }
71
86
  },
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenCode Notifications
2
2
 
3
- This repo implements a per-repo notification plugin for OpenCode. It uses a repo-local config file (`.opencode/oc-notify.json`) and sends notifications to macOS Notification Center and ntfy.
3
+ This repo implements a per-repo notification plugin for OpenCode. It uses a repo-local config file (`.opencode/oc-notify.json`) and sends notifications to macOS Notification Center, ntfy, and Telegram.
4
4
 
5
5
  ## Quick Start
6
6
 
@@ -24,6 +24,7 @@ npx @goodnesshq/opencode-notification install
24
24
  This only installs repo assets and prints next steps for manual setup.
25
25
 
26
26
  Before running the installer, create a unique ntfy topic and set up mobile notifications. See `docs/ntfy-topic.md`.
27
+ If you plan to use Telegram, see `docs/telegram-chat-id.md` for how to find your chat ID.
27
28
  The installer uses an interactive, arrow-key prompt flow (with a non-interactive fallback in non-TTY environments).
28
29
 
29
30
  ## Config Schema
@@ -34,8 +35,8 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
34
35
  - `title`: override or template (supports `{repo}` and `{branch}`)
35
36
  - `tier`: `focus` | `full` | `custom`
36
37
  - `detailLevel`: `full` | `title-only`
37
- - `responseComplete`: trigger for completion notifications
38
- - `channels`: `mac` and `ntfy` settings
38
+ - `responseComplete`: trigger for completion notifications (default: `session.idle`)
39
+ - `channels`: `mac`, `ntfy`, and `telegram` settings
39
40
  - `overrides`: include/exclude groups
40
41
  - `dedupe`: in-memory TTL settings
41
42
 
@@ -51,13 +52,15 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
51
52
  {
52
53
  "enabled": true,
53
54
  "tier": "focus",
55
+ "detailLevel": "title-only",
54
56
  "responseComplete": {
55
57
  "enabled": true,
56
58
  "trigger": "session.idle"
57
59
  },
58
60
  "channels": {
59
61
  "mac": { "enabled": true, "method": "auto" },
60
- "ntfy": { "enabled": true, "server": "https://ntfy.sh", "topic": "<your-topic>" }
62
+ "ntfy": { "enabled": true, "server": "https://ntfy.sh", "topic": "<your-topic>" },
63
+ "telegram": { "enabled": false, "token": "", "chatId": "" }
61
64
  }
62
65
  }
63
66
  ```
@@ -67,10 +70,10 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
67
70
  {
68
71
  "enabled": true,
69
72
  "tier": "full",
70
- "detailLevel": "full",
73
+ "detailLevel": "title-only",
71
74
  "responseComplete": {
72
75
  "enabled": true,
73
- "trigger": "message.updated"
76
+ "trigger": "session.idle"
74
77
  }
75
78
  }
76
79
  ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goodnesshq/opencode-notification",
3
- "version": "0.1.7",
4
- "description": "Per-repo notification plugin for OpenCode with macOS and ntfy delivery.",
3
+ "version": "0.2.0",
4
+ "description": "Per-repo notification plugin for OpenCode with macOS, ntfy, and Telegram delivery.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "ocn": "bin/ocn.mjs"
@@ -18,7 +18,8 @@
18
18
  "notifications",
19
19
  "plugin",
20
20
  "ntfy",
21
- "macos"
21
+ "macos",
22
+ "telegram"
22
23
  ],
23
24
  "license": "MIT"
24
25
  }
@@ -11,10 +11,10 @@ const DEFAULTS = {
11
11
  enabled: true,
12
12
  title: "",
13
13
  tier: "focus",
14
- detailLevel: "full",
14
+ detailLevel: "title-only",
15
15
  responseComplete: {
16
16
  enabled: true,
17
- trigger: "message.updated",
17
+ trigger: "session.idle",
18
18
  },
19
19
  channels: {
20
20
  mac: {
@@ -26,6 +26,11 @@ const DEFAULTS = {
26
26
  server: "https://ntfy.sh",
27
27
  topic: "",
28
28
  },
29
+ telegram: {
30
+ enabled: false,
31
+ token: "",
32
+ chatId: "",
33
+ },
29
34
  },
30
35
  overrides: {
31
36
  includeGroups: [],
@@ -170,6 +175,20 @@ function normalizeConfig(raw, globalConfig) {
170
175
  normalizeString(globalSource.ntfy?.topic, "")
171
176
  ),
172
177
  },
178
+ telegram: {
179
+ enabled: normalizeBool(
180
+ source.channels?.telegram?.enabled,
181
+ DEFAULTS.channels.telegram.enabled
182
+ ),
183
+ token: normalizeString(
184
+ source.channels?.telegram?.token,
185
+ DEFAULTS.channels.telegram.token
186
+ ),
187
+ chatId: normalizeString(
188
+ source.channels?.telegram?.chatId,
189
+ DEFAULTS.channels.telegram.chatId
190
+ ),
191
+ },
173
192
  },
174
193
  overrides: {
175
194
  includeGroups: sanitizeGroups(normalizeArray(source.overrides?.includeGroups)),
@@ -634,6 +653,23 @@ export default async function opencodeNotify(input) {
634
653
  } catch {}
635
654
  }
636
655
 
656
+ function buildTelegramMessage(notification) {
657
+ const parts = [notification.title, notification.subtitle, notification.body].filter(Boolean);
658
+ return parts.join("\n");
659
+ }
660
+
661
+ async function notifyTelegram(notification, config) {
662
+ if (!config.channels.telegram.enabled) return;
663
+ const token = config.channels.telegram.token?.trim();
664
+ const chatId = config.channels.telegram.chatId?.trim();
665
+ if (!token || !chatId) return;
666
+ try {
667
+ const url = `https://api.telegram.org/bot${token}/sendMessage`;
668
+ const message = buildTelegramMessage(notification);
669
+ await $`curl -sS -X POST ${url} --data-urlencode ${"chat_id=" + chatId} --data-urlencode ${"text=" + message} --data-urlencode ${"disable_web_page_preview=true"}`.quiet();
670
+ } catch {}
671
+ }
672
+
637
673
  return {
638
674
  event: async ({ event }) => {
639
675
  const payload = event?.payload ?? event;
@@ -678,6 +714,7 @@ export default async function opencodeNotify(input) {
678
714
 
679
715
  await notifyMac(notification, config);
680
716
  await notifyNtfy(notification, config);
717
+ await notifyTelegram(notification, config);
681
718
  },
682
719
  };
683
720
  }