@goodnesshq/opencode-notification 0.1.8 → 0.2.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.
@@ -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();
348
+ return;
349
+ }
350
+ if (key.name === "return" || key.name === "enter") {
351
+ finish();
340
352
  return;
341
353
  }
342
- if (key.name === "backspace") {
343
- if (filter.length > 0) {
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() {
@@ -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,24 @@ 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
+ telegramToken = tokenInput.trim();
591
+ if (telegramToken) {
592
+ const updatesUrl = `https://api.telegram.org/bot${telegramToken}/getUpdates`;
593
+ console.log("\nFind your Telegram chat ID:");
594
+ console.log(" 1) Send your bot a message in Telegram.");
595
+ console.log(` 2) Open ${updatesUrl} in a browser.`);
596
+ console.log(" 3) Use message.chat.id (or channel_post.chat.id for channels).\n");
597
+ }
598
+ const chatIdInput = interactive
599
+ ? await promptInput("Telegram chat ID: ")
600
+ : await rl.question("Telegram chat ID: ");
601
+ telegramChatId = chatIdInput.trim();
602
+ }
603
+
547
604
  const detailLevel = interactive
548
605
  ? await promptSelect({
549
606
  message: "Detail level:",
@@ -607,20 +664,20 @@ async function main() {
607
664
  const hasTN = await commandExists("terminal-notifier");
608
665
  const hasBrew = await commandExists("brew");
609
666
  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");
667
+ const installNow = interactive
668
+ ? await promptConfirm(
669
+ "terminal-notifier not found. Install with Homebrew now?",
670
+ false,
671
+ )
672
+ : yesNo(
673
+ await rl.question(
674
+ "terminal-notifier not found. Install with Homebrew now? (y/N) [N] ",
675
+ ),
676
+ false,
677
+ );
678
+ if (installNow) {
679
+ try {
680
+ const { execSync } = await import("node:child_process");
624
681
  execSync("brew install terminal-notifier", { stdio: "inherit" });
625
682
  } catch {
626
683
  console.log("Homebrew install failed. Falling back to AppleScript.");
@@ -649,6 +706,11 @@ async function main() {
649
706
  server: globalConfig.ntfy?.server || DEFAULTS.channels.ntfy.server,
650
707
  topic: globalConfig.ntfy?.topic || DEFAULTS.channels.ntfy.topic,
651
708
  },
709
+ telegram: {
710
+ enabled: telegramEnabled,
711
+ token: telegramToken,
712
+ chatId: telegramChatId,
713
+ },
652
714
  },
653
715
  overrides: {
654
716
  includeGroups,
@@ -668,6 +730,7 @@ async function main() {
668
730
  await ensureOpencodeDir(cwd);
669
731
  await writeJson(join(cwd, REPO_CONFIG_PATH), config);
670
732
  await copySchema(cwd);
733
+ await ensureGitignore(cwd);
671
734
  }
672
735
 
673
736
  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
@@ -35,7 +36,7 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
35
36
  - `tier`: `focus` | `full` | `custom`
36
37
  - `detailLevel`: `full` | `title-only`
37
38
  - `responseComplete`: trigger for completion notifications (default: `session.idle`)
38
- - `channels`: `mac` and `ntfy` settings
39
+ - `channels`: `mac`, `ntfy`, and `telegram` settings
39
40
  - `overrides`: include/exclude groups
40
41
  - `dedupe`: in-memory TTL settings
41
42
 
@@ -58,7 +59,8 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
58
59
  },
59
60
  "channels": {
60
61
  "mac": { "enabled": true, "method": "auto" },
61
- "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": "" }
62
64
  }
63
65
  }
64
66
  ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goodnesshq/opencode-notification",
3
- "version": "0.1.8",
4
- "description": "Per-repo notification plugin for OpenCode with macOS and ntfy delivery.",
3
+ "version": "0.2.1",
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
  }
@@ -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
  }