opencode-discord-notify 0.1.0 → 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.
package/README-JP.md CHANGED
@@ -20,24 +20,6 @@ OpenCode のイベントを Discord Webhook に通知するプラグインです
20
20
  Discord の Forum チャンネル webhook を前提に、セッション開始時(または最初の通知タイミング)にスレッド(投稿)を作成して、その後の更新を同スレッドに流します。
21
21
  通常のテキストチャンネル webhook でも利用できます(その場合はスレッドが作れないため、チャンネルへ直投稿します)。
22
22
 
23
- ## 使い方
24
-
25
- `opencode.json` / `opencode.jsonc` にプラグインを追加します。
26
-
27
- ```jsonc
28
- {
29
- "plugin": ["opencode-discord-notify@latest"],
30
- }
31
- ```
32
-
33
- バージョン固定したい場合:
34
-
35
- ```jsonc
36
- {
37
- "plugin": ["opencode-discord-notify@0.1.0"],
38
- }
39
- ```
40
-
41
23
  ## できること
42
24
 
43
25
  - `session.created`: セッション開始 → 開始通知をキュー(スレッド作成/送信は後続イベントで条件が揃ったタイミングで実行されることがある)
@@ -50,29 +32,24 @@ Discord の Forum チャンネル webhook を前提に、セッション開始
50
32
 
51
33
  ## セットアップ
52
34
 
53
- ### 1) 依存のインストール
54
-
55
- グローバルにインストールします。
56
-
57
- - `npm i -g @opencode-ai/plugin`
58
-
59
- ### 2) プラグイン配置
60
-
61
- プロジェクト直下に以下のファイルを置きます。
35
+ ### 1) プラグイン配置
62
36
 
63
- - `.opencode/plugin/discord-notification.ts`
37
+ `opencode.json` / `opencode.jsonc` にプラグインを追加します。
64
38
 
65
- (グローバルに使いたい場合は `~/.config/opencode/plugin/` 配下でもOKです)
39
+ OpenCode を再起動してください。
66
40
 
67
- > [!WARNING]
68
- > グローバル(`~/.config/opencode/plugin/`)とプロジェクト(`.opencode/plugin/`)の両方に配置すると、プラグインが二重に読み込まれて通知が重複します。どちらか一方にしてください。
41
+ ```jsonc
42
+ {
43
+ "plugin": ["opencode-discord-notify@latest"],
44
+ }
45
+ ```
69
46
 
70
- ### 3) Discord 側の準備
47
+ ### 2) Discord 側の準備
71
48
 
72
49
  - Discord の Forum チャンネルで Webhook を作成してください。
73
50
  - テキストチャンネル webhook でも動きますが、スレッド作成(`thread_name`)は Forum 向けの挙動が前提です。
74
51
 
75
- ### 4) 環境変数
52
+ ### 3) 環境変数
76
53
 
77
54
  必須:
78
55
 
@@ -85,6 +62,7 @@ Discord の Forum チャンネル webhook を前提に、セッション開始
85
62
  - `DISCORD_WEBHOOK_COMPLETE_MENTION`: `session.idle` / `session.error` の通知本文に付けるメンション(`@everyone` または `@here` のみ許容。Forum webhook の仕様上、ping は常に発生しない)
86
63
  - `DISCORD_WEBHOOK_PERMISSION_MENTION`: `permission.updated` の通知本文に付けるメンション(`DISCORD_WEBHOOK_COMPLETE_MENTION` へのフォールバックなし。`@everyone` または `@here` のみ許容。Forum webhook の仕様上、ping は常に発生しない)
87
64
  - `DISCORD_WEBHOOK_EXCLUDE_INPUT_CONTEXT`: `1` のとき input context(`<file>` から始まる user `text` part)を通知しない(デフォルト: `1` / `0` で無効化)
65
+ - `SEND_PARAMS`: embed の fields として送るキーをカンマ区切りで指定。指定可能キー: `sessionID`, `permissionID`, `type`, `pattern`, `messageID`, `callID`, `partID`, `role`, `directory`, `projectID`。未設定・空文字・空要素のみの場合は全て選択。`session.created` は `SEND_PARAMS` に関わらず `sessionID`, `projectID`, `directory` を必ず含みます。
88
66
 
89
67
  ## 仕様メモ
90
68
 
@@ -104,8 +82,10 @@ Discord の Forum チャンネル webhook を前提に、セッション開始
104
82
  - `message.updated` は通知しません(role 判定用に追跡。role 未確定で保留した `text` part を後から通知することがあります)。
105
83
  - `message.part.updated` は以下の方針です。
106
84
  - `text`: user は即時通知。assistant は `part.time.end` がある確定時のみ通知(ストリーミング途中更新は通知しない)
85
+ - embed タイトルは `User says` / `Agent says` です
107
86
  - `tool`: 通知しない
108
87
  - `reasoning`: 通知しない(内部思考が含まれる可能性があるため)
88
+ - `SEND_PARAMS` の制御対象は embed の fields のみです(title/description/content/timestamp などは対象外)。また `share` は fields としては送りません(Session started の embed URL には `shareUrl` を使います)。
109
89
 
110
90
  ## 動作確認(手動)
111
91
 
@@ -118,7 +98,7 @@ Discord の Forum チャンネル webhook を前提に、セッション開始
118
98
 
119
99
  - 依存のインストール: `npm i`
120
100
  - フォーマット: `npx prettier . --write`
121
- - プラグイン本体: `.opencode/plugin/discord-notification.ts`
101
+ - プラグイン本体: `src/index.ts`
122
102
 
123
103
  ## 今後の展望(予定)
124
104
 
package/README.md CHANGED
@@ -21,24 +21,6 @@ A plugin that posts OpenCode events to a Discord webhook.
21
21
  It is optimized for Discord Forum channel webhooks: it creates one thread per session (via `thread_name`) and posts subsequent updates to the same thread.
22
22
  It also works with regular text channel webhooks (in that case, it falls back to posting directly to the channel because threads cannot be created).
23
23
 
24
- ## Usage
25
-
26
- Add this plugin to your `opencode.json` / `opencode.jsonc`:
27
-
28
- ```jsonc
29
- {
30
- "plugin": ["opencode-discord-notify@latest"],
31
- }
32
- ```
33
-
34
- If you want to pin a version:
35
-
36
- ```jsonc
37
- {
38
- "plugin": ["opencode-discord-notify@0.1.0"],
39
- }
40
- ```
41
-
42
24
  ## What it does
43
25
 
44
26
  - `session.created`: session started → queues a start notification (thread creation / sending may happen later when required info is available)
@@ -46,37 +28,30 @@ If you want to pin a version:
46
28
  - `session.idle`: session finished → posts a notification
47
29
  - `session.error`: error → posts a notification (skips if `sessionID` is not present)
48
30
  - `todo.updated`: todo updates → posts a checklist (keeps received order; excludes `cancelled`)
49
- - `message.updated`: does not notify (tracked for role inference; may emit previously-held text later)
50
- - `message.part.updated`:
51
- - `text`: user text is posted immediately; assistant text is posted only when finalized (`time.end`)
31
+ - `message.updated`: does not notify (tracked for role inference; may emit previously-held `text` later)
32
+ - `message.part.updated`: message content/tool results updates →
33
+ - `text`: user text is posted immediately; assistant text is posted only when finalized (when `time.end` exists)
52
34
  - `tool`: not posted
53
35
  - `reasoning`: not posted
54
36
 
55
37
  ## Setup
56
38
 
57
- ### 1) Install dependencies
58
-
59
- Install the OpenCode plugin runner globally.
60
-
61
- - `npm i -g @opencode-ai/plugin`
62
-
63
- ### 2) Place the plugin file
39
+ ### 1) Add the plugin
64
40
 
65
- Put the plugin file in your project:
41
+ Add this plugin to your `opencode.json` / `opencode.jsonc` and restart OpenCode.
66
42
 
67
- - `.opencode/plugin/discord-notification.ts`
68
-
69
- (If you want to use it globally, place it under `~/.config/opencode/plugin/` instead.)
70
-
71
- > [!WARNING]
72
- > If you place the plugin in both the global directory (`~/.config/opencode/plugin/`) and the project directory (`.opencode/plugin/`), it may be loaded twice and send duplicate notifications. Choose either global or project placement, not both.
43
+ ```jsonc
44
+ {
45
+ "plugin": ["opencode-discord-notify@latest"],
46
+ }
47
+ ```
73
48
 
74
- ### 3) Create a Discord webhook
49
+ ### 2) Create a Discord webhook
75
50
 
76
51
  - Recommended: create a webhook in a Discord Forum channel.
77
52
  - A webhook in a regular text channel also works, but thread creation using `thread_name` is a Forum-oriented behavior.
78
53
 
79
- ### 4) Environment variables
54
+ ### 3) Environment variables
80
55
 
81
56
  Required:
82
57
 
@@ -88,7 +63,8 @@ Optional:
88
63
  - `DISCORD_WEBHOOK_AVATAR_URL`: avatar URL for webhook posts
89
64
  - `DISCORD_WEBHOOK_COMPLETE_MENTION`: mention to put in `session.idle` / `session.error` messages (only `@everyone` or `@here` supported; Forum webhooks may not actually ping due to Discord behavior)
90
65
  - `DISCORD_WEBHOOK_PERMISSION_MENTION`: mention to put in `permission.updated` messages (no fallback to `DISCORD_WEBHOOK_COMPLETE_MENTION`; only `@everyone` or `@here` supported; Forum webhooks may not actually ping due to Discord behavior)
91
- - `DISCORD_WEBHOOK_EXCLUDE_INPUT_CONTEXT`: when set to `1`, exclude "input context" (user text parts that start with `<file>`) from notifications (default: `1`; set to `0` to disable)
66
+ - `DISCORD_WEBHOOK_EXCLUDE_INPUT_CONTEXT`: when set to `1`, exclude "input context" (user `text` parts that start with `<file>`) from notifications (default: `1`; set to `0` to disable)
67
+ - `SEND_PARAMS`: comma-separated list of keys to include as embed fields. Allowed keys: `sessionID`, `permissionID`, `type`, `pattern`, `messageID`, `callID`, `partID`, `role`, `directory`, `projectID`. If unset, empty, or containing only empty elements, all keys are selected. `session.created` always includes `sessionID`, `projectID`, `directory` regardless.
92
68
 
93
69
  ## Notes / behavior
94
70
 
@@ -102,26 +78,37 @@ Optional:
102
78
  - If thread creation fails (e.g. on non-Forum webhooks), it falls back to posting directly to the channel.
103
79
  - `permission.updated` / `session.idle` may be queued until the thread name becomes available.
104
80
  - `session.error` is skipped when `sessionID` is missing in the upstream payload.
105
- - `DISCORD_WEBHOOK_COMPLETE_MENTION=@everyone` (or `@here`) is included as message content, but Forum webhooks may not actually ping.
106
- - `DISCORD_WEBHOOK_PERMISSION_MENTION=@everyone` (or `@here`) is included as message content for `permission.updated`, but Forum webhooks may not actually ping.
107
- - `todo.updated` posts a checklist in the order received (`in_progress` = `[▶]`, `completed` = `[✓]`, `cancelled` excluded). Long lists may be truncated to fit embed constraints.
81
+ - `DISCORD_WEBHOOK_COMPLETE_MENTION=@everyone` (or `@here`) is included as message content, but Forum webhooks may not actually ping (it may just show as plain text).
82
+ - `DISCORD_WEBHOOK_PERMISSION_MENTION=@everyone` (or `@here`) is included as message content for `permission.updated`, but Forum webhooks may not actually ping (it may just show as plain text).
83
+ - `todo.updated` posts a checklist in the order received (`in_progress` = `[▶]`, `completed` = `[✓]`, `cancelled` excluded). Long lists may be truncated to fit embed constraints (if empty: `(no todos)`; if truncated: adds `...and more`).
108
84
  - `message.updated` is not posted (tracked for role inference; may post a previously-held text part later).
109
85
  - `message.part.updated` policy:
110
86
  - `text`: user is posted immediately; assistant is posted only when finalized (when `part.time.end` exists)
87
+ - Embed titles are `User says` / `Agent says`
111
88
  - `tool`: not posted
112
89
  - `reasoning`: not posted (to avoid exposing internal thoughts)
90
+ - `SEND_PARAMS` controls embed fields only (it does not affect title/description/content/timestamp). `share` is not an embed field (but Session started uses `shareUrl` as the embed URL).
113
91
 
114
92
  ## Manual test
115
93
 
116
94
  1. Start OpenCode → a new thread appears in the Forum channel on the first notification timing
117
95
  2. Trigger a permission request → a notification is posted to the same thread (if the thread isn't created yet, it may be created later)
118
- 3. Finish the session → `session.idle` is posted
119
- 4. Trigger an error → `session.error` is posted (skipped if no `sessionID`)
96
+ 3. Finish the session → `session.idle` is posted (if you set `DISCORD_WEBHOOK_COMPLETE_MENTION`, it may not actually ping in Forum webhooks)
97
+ 4. Trigger an error → `session.error` is posted (skipped if no `sessionID`; if you set `DISCORD_WEBHOOK_COMPLETE_MENTION`, it may not actually ping in Forum webhooks)
120
98
 
121
99
  ## Development
122
100
 
123
101
  - Install deps: `npm i`
124
102
  - Format: `npx prettier . --write`
125
- - Plugin source: `.opencode/plugin/discord-notification.ts`
103
+ - Plugin source: `src/index.ts`
104
+
105
+ ## Roadmap (planned)
106
+
107
+ - Publish as an npm package (to make install/update easier)
108
+ - Support multiple webhooks / multiple channels (route by use case)
109
+ - Allow customizing notifications (events, message templates, mention policy)
110
+ - Consider reading a config file (e.g. `opencode-discord-notify.config.json`) and resolving values from env vars as needed
111
+ - Improve Discord limitations handling (rate-limit retry, split posts, better truncation rules)
112
+ - Improve CI (automate lint/format; add basic tests)
126
113
 
127
114
  PRs and issues are welcome.
package/dist/index.d.ts CHANGED
@@ -2,6 +2,4 @@ import { Plugin } from '@opencode-ai/plugin';
2
2
 
3
3
  declare const plugin: Plugin;
4
4
 
5
- declare const DiscordNotificationPlugin: Plugin;
6
-
7
- export { DiscordNotificationPlugin, plugin as default };
5
+ export { plugin as default };
package/dist/index.js CHANGED
@@ -1,4 +1,17 @@
1
1
  // src/index.ts
2
+ var SEND_PARAM_KEYS = [
3
+ "sessionID",
4
+ "permissionID",
5
+ "type",
6
+ "pattern",
7
+ "messageID",
8
+ "callID",
9
+ "partID",
10
+ "role",
11
+ "directory",
12
+ "projectID"
13
+ ];
14
+ var SEND_PARAM_KEY_SET = new Set(SEND_PARAM_KEYS);
2
15
  var COLORS = {
3
16
  info: 5793266,
4
17
  success: 5763719,
@@ -32,6 +45,23 @@ function buildFields(fields, inline = false) {
32
45
  }
33
46
  return result.length ? result : void 0;
34
47
  }
48
+ function isSendParamKey(value) {
49
+ return SEND_PARAM_KEY_SET.has(value);
50
+ }
51
+ function filterSendFields(fields, allowed) {
52
+ return fields.filter(([name]) => {
53
+ if (!isSendParamKey(name)) return false;
54
+ return allowed.has(name);
55
+ });
56
+ }
57
+ function getTextPartEmbedTitle(role) {
58
+ return role === "user" ? "User says" : "Agent says";
59
+ }
60
+ function withForcedSendParams(base, forced) {
61
+ const next = new Set(base);
62
+ for (const key of forced) next.add(key);
63
+ return next;
64
+ }
35
65
  function getEnv(name) {
36
66
  try {
37
67
  return process.env[name];
@@ -39,6 +69,17 @@ function getEnv(name) {
39
69
  return void 0;
40
70
  }
41
71
  }
72
+ function parseSendParams(raw) {
73
+ if (raw === void 0) return new Set(SEND_PARAM_KEYS);
74
+ const tokens = raw.split(",").map((v) => v.trim()).filter(Boolean);
75
+ if (!tokens.length) return new Set(SEND_PARAM_KEYS);
76
+ const result = /* @__PURE__ */ new Set();
77
+ for (const token of tokens) {
78
+ if (!SEND_PARAM_KEY_SET.has(token)) continue;
79
+ result.add(token);
80
+ }
81
+ return result;
82
+ }
42
83
  function withQuery(url, params) {
43
84
  const u = new URL(url);
44
85
  for (const [k, v] of Object.entries(params)) {
@@ -78,7 +119,16 @@ async function postDiscordWebhook(input) {
78
119
  channel_id: channelId
79
120
  };
80
121
  }
122
+ var GLOBAL_GUARD_KEY = "__opencode_discord_notify_registered__";
81
123
  var plugin = async () => {
124
+ const globalWithGuard = globalThis;
125
+ if (globalWithGuard[GLOBAL_GUARD_KEY]) {
126
+ return {
127
+ event: async () => {
128
+ }
129
+ };
130
+ }
131
+ globalWithGuard[GLOBAL_GUARD_KEY] = true;
82
132
  const webhookUrl = getEnv("DISCORD_WEBHOOK_URL");
83
133
  const username = getEnv("DISCORD_WEBHOOK_USERNAME");
84
134
  const avatarUrl = getEnv("DISCORD_WEBHOOK_AVATAR_URL");
@@ -88,6 +138,7 @@ var plugin = async () => {
88
138
  const permissionMention = permissionMentionRaw || void 0;
89
139
  const excludeInputContextRaw = (getEnv("DISCORD_WEBHOOK_EXCLUDE_INPUT_CONTEXT") ?? "1").trim();
90
140
  const excludeInputContext = excludeInputContextRaw !== "0";
141
+ const sendParams = parseSendParams(getEnv("SEND_PARAMS"));
91
142
  const sessionToThread = /* @__PURE__ */ new Map();
92
143
  const threadCreateInFlight = /* @__PURE__ */ new Map();
93
144
  const pendingPostsBySession = /* @__PURE__ */ new Map();
@@ -312,12 +363,19 @@ var plugin = async () => {
312
363
  color: COLORS.info,
313
364
  timestamp: createdAt,
314
365
  fields: buildFields(
315
- [
316
- ["sessionID", sessionID],
317
- ["projectID", projectID],
318
- ["directory", directory],
319
- ["share", shareUrl]
320
- ],
366
+ filterSendFields(
367
+ [
368
+ ["sessionID", sessionID],
369
+ ["projectID", projectID],
370
+ ["directory", directory],
371
+ ["share", shareUrl]
372
+ ],
373
+ withForcedSendParams(sendParams, [
374
+ "sessionID",
375
+ "projectID",
376
+ "directory"
377
+ ])
378
+ ),
321
379
  false
322
380
  )
323
381
  };
@@ -336,14 +394,17 @@ var plugin = async () => {
336
394
  color: COLORS.warning,
337
395
  timestamp: toIsoTimestamp(p?.time?.created),
338
396
  fields: buildFields(
339
- [
340
- ["sessionID", sessionID],
341
- ["permissionID", p?.id],
342
- ["type", p?.type],
343
- ["pattern", p?.pattern],
344
- ["messageID", p?.messageID],
345
- ["callID", p?.callID]
346
- ],
397
+ filterSendFields(
398
+ [
399
+ ["sessionID", sessionID],
400
+ ["permissionID", p?.id],
401
+ ["type", p?.type],
402
+ ["pattern", p?.pattern],
403
+ ["messageID", p?.messageID],
404
+ ["callID", p?.callID]
405
+ ],
406
+ sendParams
407
+ ),
347
408
  false
348
409
  )
349
410
  };
@@ -362,7 +423,10 @@ var plugin = async () => {
362
423
  const embed = {
363
424
  title: "Session completed",
364
425
  color: COLORS.success,
365
- fields: buildFields([["sessionID", sessionID]], false)
426
+ fields: buildFields(
427
+ filterSendFields([["sessionID", sessionID]], sendParams),
428
+ false
429
+ )
366
430
  };
367
431
  const mention = buildCompleteMention();
368
432
  enqueueToThread(sessionID, {
@@ -381,7 +445,10 @@ var plugin = async () => {
381
445
  title: "Session error",
382
446
  color: COLORS.error,
383
447
  description: errorStr ? errorStr.length > 4096 ? errorStr.slice(0, 4093) + "..." : errorStr : void 0,
384
- fields: buildFields([["sessionID", sessionID]], false)
448
+ fields: buildFields(
449
+ filterSendFields([["sessionID", sessionID]], sendParams),
450
+ false
451
+ )
385
452
  };
386
453
  if (!sessionID) return;
387
454
  const mention = buildCompleteMention();
@@ -400,7 +467,10 @@ var plugin = async () => {
400
467
  const embed = {
401
468
  title: "Todo updated",
402
469
  color: COLORS.info,
403
- fields: buildFields([["sessionID", sessionID]], false),
470
+ fields: buildFields(
471
+ filterSendFields([["sessionID", sessionID]], sendParams),
472
+ false
473
+ ),
404
474
  description: buildTodoChecklist(p?.todos)
405
475
  };
406
476
  enqueueToThread(sessionID, { embeds: [embed] });
@@ -441,15 +511,18 @@ var plugin = async () => {
441
511
  firstUserTextBySession.set(sessionID, normalized);
442
512
  }
443
513
  const embed = {
444
- title: role === "user" ? "Message part updated: text (user)" : "Message part updated: text (assistant)",
514
+ title: getTextPartEmbedTitle(role),
445
515
  color: COLORS.info,
446
516
  fields: buildFields(
447
- [
448
- ["sessionID", sessionID],
449
- ["messageID", messageID],
450
- ["partID", partID],
451
- ["role", role]
452
- ],
517
+ filterSendFields(
518
+ [
519
+ ["sessionID", sessionID],
520
+ ["messageID", messageID],
521
+ ["partID", partID],
522
+ ["role", role]
523
+ ],
524
+ sendParams
525
+ ),
453
526
  false
454
527
  ),
455
528
  description: truncateText(text || "(empty)", 4096)
@@ -498,15 +571,18 @@ var plugin = async () => {
498
571
  firstUserTextBySession.set(sessionID, normalized);
499
572
  }
500
573
  const embed = {
501
- title: role === "user" ? "Message part updated: text (user)" : "Message part updated: text (assistant)",
574
+ title: getTextPartEmbedTitle(role),
502
575
  color: COLORS.info,
503
576
  fields: buildFields(
504
- [
505
- ["sessionID", sessionID],
506
- ["messageID", messageID],
507
- ["partID", partID],
508
- ["role", role]
509
- ],
577
+ filterSendFields(
578
+ [
579
+ ["sessionID", sessionID],
580
+ ["messageID", messageID],
581
+ ["partID", partID],
582
+ ["role", role]
583
+ ],
584
+ sendParams
585
+ ),
510
586
  false
511
587
  ),
512
588
  description: truncateText(text || "(empty)", 4096)
@@ -532,8 +608,6 @@ var plugin = async () => {
532
608
  };
533
609
  };
534
610
  var index_default = plugin;
535
- var DiscordNotificationPlugin = plugin;
536
611
  export {
537
- DiscordNotificationPlugin,
538
612
  index_default as default
539
613
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-discord-notify",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A plugin that posts OpenCode events to a Discord webhook.",
5
5
  "license": "MIT",
6
6
  "type": "module",