opencode-discord-notify 0.5.1 → 0.7.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.md CHANGED
@@ -8,35 +8,28 @@
8
8
  ![Discord Webhook](https://img.shields.io/badge/Discord-Webhook-5865F2?logo=discord&logoColor=fff)
9
9
  ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=fff)
10
10
 
11
- English | [日本語](README-JP.md)
12
-
13
11
  <p align="center">
14
12
  <img src="assets/image/sample-forum-ch.png" width="700" alt="Discord Forum channel example" />
15
13
  </p>
16
14
 
17
- A plugin that posts OpenCode events to a Discord webhook.
15
+ **Get real-time OpenCode notifications directly in your Discord server.**
18
16
 
19
- 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.
20
- 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).
17
+ This plugin creates organized threads in Discord Forum channels for each OpenCode session, keeping you updated on session events, permissions, todos, and conversations.
21
18
 
22
- ## What it does
19
+ ## Features
23
20
 
24
- - `session.created`: session started → queues a start notification (thread creation / sending may happen later when required info is available)
25
- - `permission.updated`: permission request posts a notification
26
- - `session.idle`: session finished posts a notification
27
- - `session.error`: error posts a notification (skips if `sessionID` is not present)
28
- - `todo.updated`: todo updates posts a checklist (keeps received order; excludes `cancelled`)
29
- - `message.updated`: does not notify (tracked for role inference; may emit previously-held `text` later)
30
- - `message.part.updated`: message content/tool results updates →
31
- - `text`: user text is posted immediately; assistant text is posted only when finalized (when `time.end` exists)
32
- - `tool`: not posted
33
- - `reasoning`: not posted
21
+ - **Session tracking**: Automatic thread creation for each session
22
+ - **Real-time updates**: Permission requests, session completion, errors
23
+ - **Todo checklists**: Visual todo updates with status icons
24
+ - **Smart notifications**: User messages sent immediately, agent responses when finalized
25
+ - **Mention support**: Optional `@everyone` or `@here` mentions for important events
26
+ - **Fallback webhooks**: Send critical mentions to text channels for guaranteed delivery
34
27
 
35
- ## Setup
28
+ ## Quick Start
36
29
 
37
- ### 1) Add the plugin
30
+ ### 1. Install the plugin
38
31
 
39
- Add this plugin to your `opencode.json` / `opencode.jsonc` and restart OpenCode.
32
+ Add to your `opencode.json` or `opencode.jsonc`:
40
33
 
41
34
  ```jsonc
42
35
  {
@@ -44,76 +37,232 @@ Add this plugin to your `opencode.json` / `opencode.jsonc` and restart OpenCode.
44
37
  }
45
38
  ```
46
39
 
47
- ### 2) Create a Discord webhook
48
-
49
- - Recommended: create a webhook in a Discord Forum channel.
50
- - A webhook in a regular text channel also works, but thread creation using `thread_name` is a Forum-oriented behavior.
51
-
52
- ### 3) Environment variables
53
-
54
- Required:
55
-
56
- - `DISCORD_WEBHOOK_URL`: Discord webhook URL (if not set, the plugin does nothing)
57
-
58
- Optional:
59
-
60
- - `DISCORD_WEBHOOK_USERNAME`: username for webhook posts
61
- - `DISCORD_WEBHOOK_AVATAR_URL`: avatar URL for webhook posts
62
- - `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)
63
- - `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)
64
- - `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)
65
- - `DISCORD_WEBHOOK_SHOW_ERROR_ALERT`: when set to `1`, show an OpenCode TUI toast when Discord webhook requests fail (includes 429). (default: `1`; set to `0` to disable)
66
- - `DISCORD_SEND_PARAMS`: comma-separated list of keys to include as embed fields.
67
- - **Allowed keys**: `sessionID`, `permissionID`, `type`, `pattern`, `messageID`, `callID`, `partID`, `role`, `directory`, `projectID`
68
- - **Default behavior** (unset/empty): all fields are disabled (nothing sent)
69
- - **To send all fields**: list all keys explicitly
70
- - **Note**: `session.created` always includes `sessionID` regardless
71
-
72
- ## Notes / behavior
73
-
74
- - If `DISCORD_WEBHOOK_URL` is not set, it becomes a no-op.
75
- - If a webhook request fails, it may show an OpenCode TUI toast (controlled by `DISCORD_WEBHOOK_SHOW_ERROR_ALERT`).
76
- - On HTTP 429, it waits `retry_after` seconds if provided (otherwise ~10s) and retries once; if it still fails, it shows a warning toast.
77
- - For Forum thread creation, it appends `?wait=true` and uses `channel_id` in the response as the thread ID.
78
- - `thread_name` priority order (max 100 chars):
79
- 1. first user `text`
80
- 2. session title
81
- 3. `session <sessionID>`
82
- 4. `untitled`
83
- - If thread creation fails (e.g. on non-Forum webhooks), it falls back to posting directly to the channel.
84
- - `permission.updated` / `session.idle` may be queued until the thread name becomes available.
85
- - `session.error` is skipped when `sessionID` is missing in the upstream payload.
86
- - `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).
87
- - `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).
88
- - `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`).
89
- - `message.updated` is not posted (tracked for role inference; may post a previously-held text part later).
90
- - `message.part.updated` policy:
91
- - `text`: user is posted immediately; assistant is posted only when finalized (when `part.time.end` exists)
92
- - Embed titles are `User says` / `Agent says`
93
- - `tool`: not posted
94
- - `reasoning`: not posted (to avoid exposing internal thoughts)
95
- - `DISCORD_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).
96
-
97
- ## Manual test
98
-
99
- 1. Start OpenCode → a new thread appears in the Forum channel on the first notification timing
100
- 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)
101
- 3. Finish the session → `session.idle` is posted (if you set `DISCORD_WEBHOOK_COMPLETE_MENTION`, it may not actually ping in Forum webhooks)
102
- 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)
40
+ Restart OpenCode.
41
+
42
+ ### 2. Create a Discord webhook
43
+
44
+ 1. Open your Discord server settings
45
+ 2. Go to a **Forum channel** (recommended) or text channel
46
+ 3. Navigate to **Integrations** → **Webhooks**
47
+ 4. Click **New Webhook** and copy the webhook URL
48
+
49
+ ### 3. Set the webhook URL
50
+
51
+ Set the environment variable:
52
+
53
+ ```bash
54
+ export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/..."
55
+ ```
56
+
57
+ **That's it!** Start using OpenCode and watch notifications appear in Discord.
58
+
59
+ ## Configuration
60
+
61
+ ### Environment Variables
62
+
63
+ | Variable | Required | Default | Description |
64
+ | --------------------------------------- | -------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
65
+ | `DISCORD_WEBHOOK_URL` | Yes | - | Discord webhook URL. Plugin is disabled if not set. |
66
+ | `DISCORD_WEBHOOK_USERNAME` | ❌ No | - | Custom username for webhook posts |
67
+ | `DISCORD_WEBHOOK_AVATAR_URL` | No | - | Custom avatar URL for webhook posts |
68
+ | `DISCORD_WEBHOOK_COMPLETE_MENTION` | No | - | Add `@everyone` or `@here` to session completion/error notifications |
69
+ | `DISCORD_WEBHOOK_PERMISSION_MENTION` | No | - | Add `@everyone` or `@here` to permission request notifications |
70
+ | `DISCORD_WEBHOOK_EXCLUDE_INPUT_CONTEXT` | No | `1` | Set to `0` to include file context in notifications |
71
+ | `DISCORD_WEBHOOK_SHOW_ERROR_ALERT` | No | `1` | Set to `0` to disable error toast notifications |
72
+ | `DISCORD_SEND_PARAMS` | No | - | Comma-separated embed fields: `sessionID,permissionID,type,pattern,messageID,callID,partID,role,directory,projectID` |
73
+ | `DISCORD_WEBHOOK_FALLBACK_URL` | ❌ No | - | Fallback webhook URL for text channel (sends mentions here too for guaranteed ping) |
74
+ | `DISCORD_NOTIFY_QUEUE_DB_PATH` | ❌ No | `~/.config/opencode/discord-notify-queue.db` | Custom path for the persistent queue database (automatically uses `:memory:` in test environment) |
75
+
76
+ ### Example Configuration
77
+
78
+ **Basic usage:**
79
+
80
+ ```bash
81
+ export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/your-webhook-url"
82
+ ```
83
+
84
+ **With mentions and fallback:**
85
+
86
+ ```bash
87
+ export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/forum-webhook-url"
88
+ export DISCORD_WEBHOOK_FALLBACK_URL="https://discord.com/api/webhooks/text-channel-webhook-url"
89
+ export DISCORD_WEBHOOK_COMPLETE_MENTION="@everyone"
90
+ export DISCORD_WEBHOOK_PERMISSION_MENTION="@here"
91
+ ```
92
+
93
+ **With custom appearance:**
94
+
95
+ ```bash
96
+ export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/your-webhook-url"
97
+ export DISCORD_WEBHOOK_USERNAME="OpenCode Bot"
98
+ export DISCORD_WEBHOOK_AVATAR_URL="https://example.com/avatar.png"
99
+ ```
100
+
101
+ ## Advanced
102
+
103
+ <details>
104
+ <summary><strong>Event Handling Details</strong></summary>
105
+
106
+ ### Supported Events
107
+
108
+ - **`session.created`**: Queues session start notification (sent when thread info is available)
109
+ - **`permission.updated`**: Posts permission request immediately
110
+ - **`session.idle`**: Posts session completion notification
111
+ - **`session.error`**: Posts error notification (skipped if `sessionID` missing)
112
+ - **`todo.updated`**: Posts checklist with `[▶]` (in progress), `[✓]` (completed); excludes cancelled
113
+ - **`message.updated`**: Tracked internally for role detection (not posted)
114
+ - **`message.part.updated`**:
115
+ - `text`: User messages posted immediately; agent messages posted when finalized
116
+ - `tool`: Not posted
117
+ - `reasoning`: Not posted
118
+
119
+ </details>
120
+
121
+ <details>
122
+ <summary><strong>Thread Creation Behavior</strong></summary>
123
+
124
+ ### Thread Naming Priority
125
+
126
+ Thread names use the first available option (max 100 chars):
127
+
128
+ 1. First user text message
129
+ 2. Session title
130
+ 3. `session <sessionID>`
131
+ 4. `untitled`
132
+
133
+ ### Forum vs Text Channel
134
+
135
+ - **Forum channels**: Creates a new thread per session using `thread_name`
136
+ - **Text channels**: Posts directly (threads not supported)
137
+ - **Fallback**: If thread creation fails, falls back to direct channel posting
138
+
139
+ </details>
140
+
141
+ <details>
142
+ <summary><strong>Mention & Fallback Behavior</strong></summary>
143
+
144
+ ### Why Fallback Webhooks?
145
+
146
+ Discord Forum webhooks **do not trigger pings** for `@everyone` or `@here` mentions. They appear as plain text.
147
+
148
+ To ensure you get notified:
149
+
150
+ 1. Set `DISCORD_WEBHOOK_URL` to your Forum webhook (for organized threads)
151
+ 2. Set `DISCORD_WEBHOOK_FALLBACK_URL` to a text channel webhook (for actual pings)
152
+ 3. Messages with mentions will be sent to **both** webhooks
153
+
154
+ ### Fallback Message Format
155
+
156
+ Fallback messages always include:
157
+
158
+ - `sessionID` field
159
+ - `thread title` field (same as Forum thread name)
160
+
161
+ This provides context when viewing notifications in the text channel.
162
+
163
+ </details>
164
+
165
+ <details>
166
+ <summary><strong>Error Handling & Rate Limits</strong></summary>
167
+
168
+ ### Persistent Queue (v0.7.0+)
169
+
170
+ All notifications are stored in a local SQLite database before sending:
171
+
172
+ - **Database location**: `~/.config/opencode/discord-notify-queue.db` (customizable via `DISCORD_NOTIFY_QUEUE_DB_PATH`)
173
+ - **Worker process**: Automatically processes queued messages in the background
174
+ - **Auto-start**: Worker starts when first user message is sent
175
+ - **Auto-stop**: Worker stops when queue is empty
176
+ - **Batch size**: Processes 1 message at a time to ensure thread ID consistency
177
+
178
+ **Benefits**:
179
+ - Messages survive OpenCode restarts
180
+ - Prevents data loss during network issues
181
+ - Ensures correct thread naming with user's first message
182
+
183
+ ### Retry Logic
184
+
185
+ All failed requests are automatically retried with the following behavior:
186
+
187
+ - **Max retries**: 5 attempts per message
188
+ - **Retry tracking**: Each retry updates `retry_count` and `last_error` in the queue database
189
+ - **Warning notifications**: Shows toast with retry count (e.g., "Retry 3/5. Error: ...")
190
+ - **Final failure**: After 5 failed retries, message is deleted and error notification is shown
191
+
192
+ ### HTTP 429 (Rate Limit)
193
+
194
+ - Waits for `retry_after` seconds (or ~10s if not provided)
195
+ - Automatically retries up to 5 times before discarding
196
+ - Messages remain in queue database during retry period
197
+
198
+ ### Other Failed Requests
199
+
200
+ - Shows OpenCode TUI toast notification (controlled by `DISCORD_WEBHOOK_SHOW_ERROR_ALERT`)
201
+ - Logs error details for debugging
202
+ - Messages persist in queue for up to 5 retry attempts
203
+
204
+ </details>
205
+
206
+ <details>
207
+ <summary><strong>Field Customization</strong></summary>
208
+
209
+ ### `DISCORD_SEND_PARAMS`
210
+
211
+ Controls which metadata fields appear in embeds.
212
+
213
+ **Allowed keys:**
214
+
215
+ - `sessionID`, `permissionID`, `type`, `pattern`, `messageID`, `callID`, `partID`, `role`, `directory`, `projectID`
216
+
217
+ **Default behavior (unset/empty):**
218
+
219
+ - No fields sent (cleaner embeds)
220
+
221
+ **To send all fields:**
222
+
223
+ ```bash
224
+ export DISCORD_SEND_PARAMS="sessionID,permissionID,type,pattern,messageID,callID,partID,role,directory,projectID"
225
+ ```
226
+
227
+ **Note:** `session.created` always includes `sessionID` regardless of this setting.
228
+
229
+ </details>
103
230
 
104
231
  ## Development
105
232
 
106
- - Install deps: `npm i`
107
- - Format: `npx prettier . --write`
108
- - Plugin source: `src/index.ts`
233
+ ### Setup
234
+
235
+ ```bash
236
+ npm install
237
+ ```
238
+
239
+ ### Format Code
240
+
241
+ ```bash
242
+ npx prettier . --write
243
+ ```
244
+
245
+ ### Plugin Source
246
+
247
+ Main implementation: `src/index.ts`
248
+
249
+ ## Roadmap
250
+
251
+ - [ ] Support multiple webhooks for routing by event type
252
+ - [ ] Customizable notification templates
253
+ - [ ] Configuration file support (e.g., `opencode-discord-notify.config.json`)
254
+ - [x] Enhanced rate limit handling (smarter retry logic, message queuing)
255
+ - [x] CI/CD (automated linting, formatting, testing)
256
+
257
+ ## Contributing
258
+
259
+ PRs and issues are welcome! Please feel free to:
109
260
 
110
- ## Roadmap (planned)
261
+ - Report bugs
262
+ - Request features
263
+ - Submit improvements
264
+ - Share feedback
111
265
 
112
- - Publish as an npm package (to make install/update easier)
113
- - Support multiple webhooks / multiple channels (route by use case)
114
- - Allow customizing notifications (events, message templates, mention policy)
115
- - Consider reading a config file (e.g. `opencode-discord-notify.config.json`) and resolving values from env vars as needed
116
- - Improve Discord limitations handling (rate-limit retry, split posts, better truncation rules)
117
- - Improve CI (automate lint/format; add basic tests)
266
+ ---
118
267
 
119
- PRs and issues are welcome.
268
+ **License:** MIT
package/dist/index.d.ts CHANGED
@@ -1,5 +1,30 @@
1
1
  import { Plugin } from '@opencode-ai/plugin';
2
2
 
3
+ type DiscordEmbed = {
4
+ title?: string;
5
+ description?: string;
6
+ url?: string;
7
+ color?: number;
8
+ timestamp?: string;
9
+ fields?: Array<{
10
+ name: string;
11
+ value: string;
12
+ inline?: boolean;
13
+ }>;
14
+ };
15
+ type DiscordAllowedMentions = {
16
+ parse?: Array<'everyone' | 'roles' | 'users'>;
17
+ roles?: string[];
18
+ users?: string[];
19
+ };
20
+ type DiscordExecuteWebhookBody = {
21
+ content?: string;
22
+ username?: string;
23
+ avatar_url?: string;
24
+ thread_name?: string;
25
+ embeds?: DiscordEmbed[];
26
+ allowed_mentions?: DiscordAllowedMentions;
27
+ };
3
28
  declare const plugin: Plugin;
4
29
 
5
- export { plugin as default };
30
+ export { type DiscordExecuteWebhookBody, plugin as default };
package/dist/index.js CHANGED
@@ -1,3 +1,210 @@
1
+ // src/utils/db.ts
2
+ import { Database } from "bun:sqlite";
3
+ import fs from "fs";
4
+ import os from "os";
5
+ import path from "path";
6
+ function getDbPath() {
7
+ if (process.env.NODE_ENV === "test" || process.env.VITEST === "true") {
8
+ return ":memory:";
9
+ }
10
+ return process.env.DISCORD_NOTIFY_QUEUE_DB_PATH || path.join(os.homedir(), ".config", "opencode", "discord-notify-queue.db");
11
+ }
12
+ function initDatabase() {
13
+ const dbPath = getDbPath();
14
+ if (dbPath !== ":memory:") {
15
+ const dbDir = path.dirname(dbPath);
16
+ if (!fs.existsSync(dbDir)) {
17
+ fs.mkdirSync(dbDir, { recursive: true });
18
+ }
19
+ }
20
+ const db = new Database(dbPath);
21
+ db.run("PRAGMA journal_mode = WAL;");
22
+ db.run(`
23
+ CREATE TABLE IF NOT EXISTS discord_queue (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ session_id TEXT NOT NULL,
26
+ thread_id TEXT,
27
+ webhook_body TEXT NOT NULL,
28
+ created_at INTEGER NOT NULL,
29
+ retry_count INTEGER DEFAULT 0,
30
+ last_error TEXT
31
+ );
32
+ `);
33
+ db.run(`
34
+ CREATE INDEX IF NOT EXISTS idx_session_created
35
+ ON discord_queue(session_id, created_at);
36
+ `);
37
+ return db;
38
+ }
39
+
40
+ // src/queue/persistent-queue.ts
41
+ var PersistentQueue = class {
42
+ db;
43
+ constructor(deps) {
44
+ this.db = deps.db;
45
+ }
46
+ enqueue(message) {
47
+ const query = this.db.query(`
48
+ INSERT INTO discord_queue (session_id, thread_id, webhook_body, created_at)
49
+ VALUES (?, ?, ?, ?)
50
+ `);
51
+ query.run(
52
+ message.sessionId,
53
+ message.threadId,
54
+ JSON.stringify(message.webhookBody),
55
+ Date.now()
56
+ );
57
+ }
58
+ dequeue(limit) {
59
+ const query = this.db.query(`
60
+ SELECT * FROM discord_queue
61
+ ORDER BY created_at ASC, id ASC
62
+ LIMIT ?
63
+ `);
64
+ const rows = query.all(limit);
65
+ return rows.map((row) => ({
66
+ id: row.id,
67
+ sessionId: row.session_id,
68
+ threadId: row.thread_id,
69
+ webhookBody: JSON.parse(row.webhook_body),
70
+ createdAt: row.created_at,
71
+ retryCount: row.retry_count,
72
+ lastError: row.last_error
73
+ }));
74
+ }
75
+ delete(id) {
76
+ this.db.query("DELETE FROM discord_queue WHERE id = ?").run(id);
77
+ }
78
+ updateThreadId(sessionId, threadId) {
79
+ this.db.query(
80
+ `
81
+ UPDATE discord_queue
82
+ SET thread_id = ?
83
+ WHERE session_id = ? AND thread_id IS NULL
84
+ `
85
+ ).run(threadId, sessionId);
86
+ }
87
+ count() {
88
+ const result = this.db.query("SELECT COUNT(*) as count FROM discord_queue").get();
89
+ return result.count;
90
+ }
91
+ updateRetryCount(id, retryCount, lastError) {
92
+ this.db.query(
93
+ `
94
+ UPDATE discord_queue
95
+ SET retry_count = ?, last_error = ?
96
+ WHERE id = ?
97
+ `
98
+ ).run(retryCount, lastError, id);
99
+ }
100
+ };
101
+
102
+ // src/queue/worker.ts
103
+ var POLL_INTERVAL_MS = 1e3;
104
+ var BATCH_SIZE = 1;
105
+ var MAX_RETRIES = 5;
106
+ var QueueWorker = class {
107
+ deps;
108
+ isRunning = false;
109
+ abortController;
110
+ constructor(deps) {
111
+ this.deps = deps;
112
+ }
113
+ get running() {
114
+ return this.isRunning;
115
+ }
116
+ async start() {
117
+ if (this.isRunning) return;
118
+ this.isRunning = true;
119
+ this.abortController = new AbortController();
120
+ await this.poll(this.abortController.signal);
121
+ }
122
+ stop() {
123
+ this.abortController?.abort();
124
+ this.isRunning = false;
125
+ }
126
+ async poll(signal) {
127
+ while (!signal.aborted) {
128
+ const messages = this.deps.queue.dequeue(BATCH_SIZE);
129
+ if (messages.length === 0) {
130
+ this.stop();
131
+ return;
132
+ }
133
+ for (const message of messages) {
134
+ if (signal.aborted) break;
135
+ await this.processMessage(message);
136
+ }
137
+ await this.sleep(POLL_INTERVAL_MS);
138
+ }
139
+ }
140
+ async processMessage(message) {
141
+ try {
142
+ if (!message.threadId) {
143
+ const threadName = this.deps.buildThreadName(message.sessionId);
144
+ const res = await this.deps.postWebhook(
145
+ {
146
+ webhookUrl: this.deps.webhookUrl,
147
+ wait: true,
148
+ body: {
149
+ ...message.webhookBody,
150
+ thread_name: threadName,
151
+ username: this.deps.username,
152
+ avatar_url: this.deps.avatarUrl
153
+ }
154
+ },
155
+ this.deps.postDeps
156
+ );
157
+ if (res?.channel_id) {
158
+ this.deps.queue.updateThreadId(message.sessionId, res.channel_id);
159
+ message.threadId = res.channel_id;
160
+ this.deps.onThreadCreated?.(message.sessionId, res.channel_id);
161
+ }
162
+ this.deps.queue.delete(message.id);
163
+ return;
164
+ }
165
+ await this.deps.postWebhook(
166
+ {
167
+ webhookUrl: this.deps.webhookUrl,
168
+ threadId: message.threadId,
169
+ body: {
170
+ ...message.webhookBody,
171
+ username: this.deps.username,
172
+ avatar_url: this.deps.avatarUrl
173
+ }
174
+ },
175
+ this.deps.postDeps
176
+ );
177
+ this.deps.queue.delete(message.id);
178
+ } catch (error) {
179
+ const currentRetry = message.retryCount || 0;
180
+ if (currentRetry < MAX_RETRIES) {
181
+ this.deps.queue.updateRetryCount(
182
+ message.id,
183
+ currentRetry + 1,
184
+ error.message || "Unknown error"
185
+ );
186
+ await this.deps.maybeAlertError({
187
+ key: `discord_queue_retry:${message.id}`,
188
+ title: "Discord notification retry",
189
+ message: `Failed to send notification. Retry ${currentRetry + 1}/${MAX_RETRIES}. Error: ${error.message}`,
190
+ variant: "warning"
191
+ });
192
+ return;
193
+ }
194
+ await this.deps.maybeAlertError({
195
+ key: `discord_queue_error:${message.id}`,
196
+ title: "Discord notification failed",
197
+ message: `Failed to send notification after ${MAX_RETRIES} retries. Message discarded.`,
198
+ variant: "error"
199
+ });
200
+ this.deps.queue.delete(message.id);
201
+ }
202
+ }
203
+ sleep(ms) {
204
+ return new Promise((resolve) => setTimeout(resolve, ms));
205
+ }
206
+ };
207
+
1
208
  // src/index.ts
2
209
  var DISCORD_FIELD_VALUE_MAX_LENGTH = 1024;
3
210
  var DISCORD_EMBED_DESCRIPTION_MAX_LENGTH = 4096;
@@ -260,6 +467,49 @@ async function postDiscordWebhook(input, deps) {
260
467
  `Discord webhook failed: ${response.status} ${response.statusText} ${text}`
261
468
  );
262
469
  }
470
+ async function postFallbackIfNeeded(input, deps) {
471
+ const {
472
+ body,
473
+ mention,
474
+ sessionID,
475
+ fallbackUrl,
476
+ firstUserTextBySession,
477
+ lastSessionInfo
478
+ } = input;
479
+ if (!fallbackUrl || !mention) return;
480
+ const fallbackBody = {
481
+ ...body,
482
+ // thread_nameは削除(テキストチャネルでは不要)
483
+ thread_name: void 0
484
+ };
485
+ if (fallbackBody.embeds && fallbackBody.embeds.length > 0) {
486
+ const originalEmbed = fallbackBody.embeds[0];
487
+ const threadTitle = firstUserTextBySession.get(sessionID) || lastSessionInfo.get(sessionID)?.title;
488
+ const additionalFields = buildFields([
489
+ ["sessionID", sessionID],
490
+ ["thread title", threadTitle]
491
+ ]);
492
+ fallbackBody.embeds = [
493
+ {
494
+ ...originalEmbed,
495
+ fields: [
496
+ ...originalEmbed.fields ?? [],
497
+ ...additionalFields ?? []
498
+ ]
499
+ }
500
+ ];
501
+ }
502
+ try {
503
+ await postDiscordWebhook(
504
+ {
505
+ webhookUrl: fallbackUrl,
506
+ body: fallbackBody
507
+ },
508
+ deps
509
+ );
510
+ } catch (e) {
511
+ }
512
+ }
263
513
  var GLOBAL_GUARD_KEY = "__opencode_discord_notify_registered__";
264
514
  var plugin = async ({ client }) => {
265
515
  const globalWithGuard = globalThis;
@@ -281,6 +531,7 @@ var plugin = async ({ client }) => {
281
531
  const showErrorAlert = showErrorAlertRaw !== "0";
282
532
  const waitOnRateLimitMs = DEFAULT_RATE_LIMIT_WAIT_MS;
283
533
  const sendParams = parseSendParams(getEnv("DISCORD_SEND_PARAMS"));
534
+ const fallbackWebhookUrl = (getEnv("DISCORD_WEBHOOK_FALLBACK_URL") ?? "").trim() || void 0;
284
535
  const lastAlertAtByKey = /* @__PURE__ */ new Map();
285
536
  const sentTextPartIds = /* @__PURE__ */ new Set();
286
537
  const showToast = async ({ title, message, variant }) => {
@@ -322,8 +573,22 @@ var plugin = async ({ client }) => {
322
573
  maybeAlertError,
323
574
  waitOnRateLimitMs
324
575
  };
576
+ const db = initDatabase();
577
+ const persistentQueue = new PersistentQueue({ db });
325
578
  const sessionToThread = /* @__PURE__ */ new Map();
326
- const pendingPostsBySession = /* @__PURE__ */ new Map();
579
+ const queueWorker = new QueueWorker({
580
+ queue: persistentQueue,
581
+ postWebhook: postDiscordWebhook,
582
+ postDeps,
583
+ maybeAlertError,
584
+ webhookUrl: webhookUrl ?? "",
585
+ username,
586
+ avatarUrl,
587
+ buildThreadName: (sessionID) => buildThreadName(sessionID),
588
+ onThreadCreated: (sessionID, threadID) => {
589
+ sessionToThread.set(sessionID, threadID);
590
+ }
591
+ });
327
592
  const firstUserTextBySession = /* @__PURE__ */ new Map();
328
593
  const pendingTextPartsByMessageId = /* @__PURE__ */ new Map();
329
594
  const messageRoleById = /* @__PURE__ */ new Map();
@@ -347,97 +612,47 @@ var plugin = async ({ client }) => {
347
612
  );
348
613
  if (fromSessionId)
349
614
  return fromSessionId.slice(0, DISCORD_THREAD_NAME_MAX_LENGTH);
350
- return "untitled";
351
- }
352
- async function ensureThread(sessionID) {
353
- if (!webhookUrl) return void 0;
354
- const existing = sessionToThread.get(sessionID);
355
- if (existing) return existing;
356
- const queue = pendingPostsBySession.get(sessionID);
357
- const first = queue?.[0];
358
- if (!first) return void 0;
359
- const threadName = buildThreadName(sessionID);
360
- const res = await postDiscordWebhook(
361
- {
362
- webhookUrl,
363
- wait: true,
364
- body: {
365
- ...first,
366
- thread_name: threadName,
367
- username,
368
- avatar_url: avatarUrl
369
- }
370
- },
371
- postDeps
372
- ).catch(async (e) => {
373
- await postDiscordWebhook(
374
- { webhookUrl, body: { ...first, username, avatar_url: avatarUrl } },
375
- postDeps
376
- ).catch(() => {
377
- });
378
- return void 0;
379
- });
380
- if (res?.channel_id) {
381
- sessionToThread.set(sessionID, res.channel_id);
382
- const nextQueue = pendingPostsBySession.get(sessionID);
383
- if (nextQueue?.[0] === first) {
384
- nextQueue.shift();
385
- if (nextQueue.length) pendingPostsBySession.set(sessionID, nextQueue);
386
- else pendingPostsBySession.delete(sessionID);
387
- }
388
- return res.channel_id;
389
- }
390
- return void 0;
615
+ return "(untitled)";
616
+ }
617
+ function buildSessionCreatedEmbed(sessionID) {
618
+ const info = lastSessionInfo.get(sessionID);
619
+ if (!info) return void 0;
620
+ const embed = {
621
+ title: "Session started",
622
+ description: info.title,
623
+ url: info.shareUrl,
624
+ color: COLORS.info,
625
+ timestamp: info.createdAt,
626
+ fields: buildFields(
627
+ filterSendFields(
628
+ [
629
+ ["sessionID", sessionID],
630
+ ["projectID", info.projectID],
631
+ ["directory", info.directory],
632
+ ["share", info.shareUrl]
633
+ ],
634
+ withForcedSendParams(sendParams, ["sessionID"])
635
+ )
636
+ )
637
+ };
638
+ return { embeds: [embed] };
391
639
  }
392
640
  function enqueueToThread(sessionID, body) {
393
641
  if (!webhookUrl) {
394
642
  void showMissingUrlToastOnce();
395
643
  return;
396
644
  }
397
- const queue = pendingPostsBySession.get(sessionID) ?? [];
398
- queue.push(body);
399
- pendingPostsBySession.set(sessionID, queue);
400
- }
401
- async function flushPending(sessionID) {
402
- if (!webhookUrl) return;
403
- const threadId = sessionToThread.get(sessionID) ?? await ensureThread(sessionID);
404
- const queue = pendingPostsBySession.get(sessionID);
405
- if (!queue?.length) return;
406
- let sentCount = 0;
407
- try {
408
- if (threadId) {
409
- for (const body of queue) {
410
- await postDiscordWebhook(
411
- {
412
- webhookUrl,
413
- threadId,
414
- body: { ...body, username, avatar_url: avatarUrl }
415
- },
416
- postDeps
417
- );
418
- sentCount += 1;
419
- }
420
- } else {
421
- for (const body of queue) {
422
- await postDiscordWebhook(
423
- { webhookUrl, body: { ...body, username, avatar_url: avatarUrl } },
424
- postDeps
425
- );
426
- sentCount += 1;
427
- }
428
- }
429
- pendingPostsBySession.delete(sessionID);
430
- } catch (e) {
431
- const current = pendingPostsBySession.get(sessionID);
432
- if (!current?.length) throw e;
433
- const rest = current.slice(sentCount);
434
- if (rest.length) pendingPostsBySession.set(sessionID, rest);
435
- else pendingPostsBySession.delete(sessionID);
436
- throw e;
437
- }
645
+ const threadId = sessionToThread.get(sessionID) || null;
646
+ persistentQueue.enqueue({
647
+ sessionId: sessionID,
648
+ threadId,
649
+ webhookBody: body
650
+ });
438
651
  }
439
- function shouldFlush(sessionID) {
440
- return sessionToThread.has(sessionID) || firstUserTextBySession.has(sessionID);
652
+ function startWorkerIfNeeded() {
653
+ if (!queueWorker.running) {
654
+ void queueWorker.start();
655
+ }
441
656
  }
442
657
  function buildCompleteMention() {
443
658
  return buildMention(completeMention, "DISCORD_WEBHOOK_COMPLETE_MENTION");
@@ -462,10 +677,17 @@ var plugin = async ({ client }) => {
462
677
  if (role === "user" && text.trim() === "" || text.trim() === "(empty)") {
463
678
  return;
464
679
  }
465
- if (role === "user" && !firstUserTextBySession.has(sessionID)) {
680
+ const isFirstUserText = role === "user" && !firstUserTextBySession.has(sessionID);
681
+ if (isFirstUserText) {
466
682
  const normalized = normalizeThreadTitle(text);
467
683
  if (normalized) firstUserTextBySession.set(sessionID, normalized);
468
684
  }
685
+ if (role === "user" && !sessionToThread.has(sessionID)) {
686
+ const sessionCreatedBody = buildSessionCreatedEmbed(sessionID);
687
+ if (sessionCreatedBody) {
688
+ enqueueToThread(sessionID, sessionCreatedBody);
689
+ }
690
+ }
469
691
  const embed = {
470
692
  title: getTextPartEmbedTitle(role),
471
693
  color: COLORS.info,
@@ -486,11 +708,7 @@ var plugin = async ({ client }) => {
486
708
  )
487
709
  };
488
710
  enqueueToThread(sessionID, { embeds: [embed] });
489
- if (role === "user") {
490
- await flushPending(sessionID);
491
- } else if (shouldFlush(sessionID)) {
492
- await flushPending(sessionID);
493
- }
711
+ startWorkerIfNeeded();
494
712
  }
495
713
  return {
496
714
  event: async ({ event }) => {
@@ -500,35 +718,20 @@ var plugin = async ({ client }) => {
500
718
  const info = event.properties?.info;
501
719
  const sessionID = info?.id;
502
720
  if (!sessionID) return;
503
- const title = info?.title ?? "(untitled)";
504
- const shareUrl = info?.share?.url;
505
- const createdAt = toIsoTimestamp(info?.time?.created);
506
- const embed = {
507
- title: "Session started",
508
- description: title,
509
- url: shareUrl,
510
- color: COLORS.info,
511
- timestamp: createdAt,
512
- fields: buildFields(
513
- filterSendFields(
514
- [
515
- ["sessionID", sessionID],
516
- ["projectID", info?.projectID],
517
- ["directory", info?.directory],
518
- ["share", shareUrl]
519
- ],
520
- withForcedSendParams(sendParams, ["sessionID"])
521
- )
522
- )
523
- };
524
- lastSessionInfo.set(sessionID, { title, shareUrl });
525
- enqueueToThread(sessionID, { embeds: [embed] });
721
+ lastSessionInfo.set(sessionID, {
722
+ title: info?.title,
723
+ shareUrl: info?.share?.url,
724
+ createdAt: toIsoTimestamp(info?.time?.created),
725
+ projectID: info?.projectID,
726
+ directory: info?.directory
727
+ });
526
728
  return;
527
729
  }
528
730
  case "permission.updated": {
529
731
  const p = event.properties;
530
732
  const sessionID = p?.sessionID;
531
733
  if (!sessionID) return;
734
+ const mention = buildPermissionMention();
532
735
  const embed = {
533
736
  title: "Permission required",
534
737
  description: p?.title,
@@ -548,17 +751,30 @@ var plugin = async ({ client }) => {
548
751
  )
549
752
  )
550
753
  };
551
- const mention = buildPermissionMention();
552
- enqueueToThread(sessionID, {
754
+ const body = {
553
755
  content: mention ? `${mention.content}` : void 0,
554
756
  allowed_mentions: mention?.allowed_mentions,
555
757
  embeds: [embed]
556
- });
758
+ };
759
+ enqueueToThread(sessionID, body);
760
+ await postFallbackIfNeeded(
761
+ {
762
+ body,
763
+ mention,
764
+ sessionID,
765
+ fallbackUrl: fallbackWebhookUrl,
766
+ firstUserTextBySession,
767
+ lastSessionInfo
768
+ },
769
+ postDeps
770
+ );
771
+ startWorkerIfNeeded();
557
772
  return;
558
773
  }
559
774
  case "session.idle": {
560
775
  const sessionID = event.properties?.sessionID;
561
776
  if (!sessionID) return;
777
+ const mention = buildCompleteMention();
562
778
  const embed = {
563
779
  title: "Session completed",
564
780
  color: COLORS.success,
@@ -569,12 +785,24 @@ var plugin = async ({ client }) => {
569
785
  )
570
786
  )
571
787
  };
572
- const mention = buildCompleteMention();
573
- enqueueToThread(sessionID, {
788
+ const body = {
574
789
  content: mention ? `${mention.content}` : void 0,
575
790
  allowed_mentions: mention?.allowed_mentions,
576
791
  embeds: [embed]
577
- });
792
+ };
793
+ enqueueToThread(sessionID, body);
794
+ await postFallbackIfNeeded(
795
+ {
796
+ body,
797
+ mention,
798
+ sessionID,
799
+ fallbackUrl: fallbackWebhookUrl,
800
+ firstUserTextBySession,
801
+ lastSessionInfo
802
+ },
803
+ postDeps
804
+ );
805
+ startWorkerIfNeeded();
578
806
  return;
579
807
  }
580
808
  case "session.error": {
@@ -601,12 +829,25 @@ var plugin = async ({ client }) => {
601
829
  };
602
830
  if (!sessionID) return;
603
831
  const mention = buildCompleteMention();
604
- enqueueToThread(sessionID, {
605
- content: mention ? `$Session error` : void 0,
832
+ const body = {
833
+ // 🐛 既存バグ修正: `$Session error` `${mention.content}`
834
+ content: mention ? `${mention.content}` : void 0,
606
835
  allowed_mentions: mention?.allowed_mentions,
607
836
  embeds: [embed]
608
- });
609
- await flushPending(sessionID);
837
+ };
838
+ enqueueToThread(sessionID, body);
839
+ await postFallbackIfNeeded(
840
+ {
841
+ body,
842
+ mention,
843
+ sessionID,
844
+ fallbackUrl: fallbackWebhookUrl,
845
+ firstUserTextBySession,
846
+ lastSessionInfo
847
+ },
848
+ postDeps
849
+ );
850
+ startWorkerIfNeeded();
610
851
  return;
611
852
  }
612
853
  case "todo.updated": {
@@ -622,6 +863,7 @@ var plugin = async ({ client }) => {
622
863
  description: buildTodoChecklist(p?.todos)
623
864
  };
624
865
  enqueueToThread(sessionID, { embeds: [embed] });
866
+ startWorkerIfNeeded();
625
867
  return;
626
868
  }
627
869
  case "message.updated": {
@@ -677,6 +919,10 @@ var plugin = async ({ client }) => {
677
919
  }
678
920
  } catch {
679
921
  }
922
+ },
923
+ __test__: {
924
+ queueWorker,
925
+ persistentQueue
680
926
  }
681
927
  };
682
928
  };
@@ -687,7 +933,8 @@ plugin.__test__ = {
687
933
  toIsoTimestamp,
688
934
  postDiscordWebhook,
689
935
  parseSendParams,
690
- getTodoStatusMarker
936
+ getTodoStatusMarker,
937
+ postFallbackIfNeeded
691
938
  };
692
939
  var index_default = plugin;
693
940
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-discord-notify",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "A plugin that posts OpenCode events to a Discord webhook.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,9 +25,9 @@
25
25
  "access": "public"
26
26
  },
27
27
  "scripts": {
28
- "build": "tsup src/index.ts --format esm --dts --out-dir dist --clean",
28
+ "build": "tsup src/index.ts --format esm --dts --out-dir dist --clean --external bun:sqlite",
29
29
  "format": "prettier . --write",
30
- "test": "vitest run",
30
+ "test": "bun test",
31
31
  "prepublishOnly": "npm run build"
32
32
  },
33
33
  "peerDependencies": {
package/README-JP.md DELETED
@@ -1,117 +0,0 @@
1
- # opencode-discord-notify
2
-
3
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
- [![npm version](https://img.shields.io/npm/v/opencode-discord-notify?logo=npm&logoColor=fff)](https://www.npmjs.com/package/opencode-discord-notify)
5
- [![npm downloads](https://img.shields.io/npm/dm/opencode-discord-notify?logo=npm&logoColor=fff)](https://www.npmjs.com/package/opencode-discord-notify)
6
- [![npm license](https://img.shields.io/npm/l/opencode-discord-notify?logo=npm&logoColor=fff)](https://www.npmjs.com/package/opencode-discord-notify)
7
- ![OpenCode Plugin](https://img.shields.io/badge/OpenCode-Plugin-4c8bf5)
8
- ![Discord Webhook](https://img.shields.io/badge/Discord-Webhook-5865F2?logo=discord&logoColor=fff)
9
- ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=fff)
10
-
11
- [English](README.md) | 日本語
12
-
13
- <p align="center">
14
- <img src="assets/image/sample-forum-ch.png" width="700" alt="Discord Forum channel example" />
15
- </p>
16
-
17
- OpenCode のイベントを Discord Webhook に通知するプラグインです。
18
- Discord の Forum チャンネル webhook を前提に、セッション開始時(または最初の通知タイミング)にスレッド(投稿)を作成して、その後の更新を同スレッドに流します。
19
- 通常のテキストチャンネル webhook でも利用できます(その場合はスレッドが作れないため、チャンネルへ直投稿します)。
20
-
21
- ## できること
22
-
23
- - `session.created`: セッション開始 → 開始通知をキュー(スレッド作成/送信は後続イベントで条件が揃ったタイミングで実行されることがある)
24
- - `permission.updated`: 権限要求 → 通知
25
- - `session.idle`: セッション完了 → 通知
26
- - `session.error`: エラー → 通知(`sessionID` が無いケースは通知しない)
27
- - `todo.updated`: Todo 更新 → チェックリスト形式で通知(順序は受信順 / `cancelled` は除外)
28
- - `message.updated`: メッセージ情報更新 → 通知しない(role 判定用に追跡。role 未確定で保留した `text` part を後から通知することがある)
29
- - `message.part.updated`: メッセージ本文/ツール結果更新 → `text` は user は即時通知、assistant は確定時(`time.end`)のみ通知。`tool` は通知しない(`reasoning` は通知しない / 重複イベントは抑制)
30
-
31
- ## セットアップ
32
-
33
- ### 1) プラグイン配置
34
-
35
- `opencode.json` / `opencode.jsonc` にプラグインを追加します。
36
-
37
- OpenCode を再起動してください。
38
-
39
- ```jsonc
40
- {
41
- "plugin": ["opencode-discord-notify@latest"],
42
- }
43
- ```
44
-
45
- ### 2) Discord 側の準備
46
-
47
- - Discord の Forum チャンネルで Webhook を作成してください。
48
- - テキストチャンネル webhook でも動きますが、スレッド作成(`thread_name`)は Forum 向けの挙動が前提です。
49
-
50
- ### 3) 環境変数
51
-
52
- 必須:
53
-
54
- - `DISCORD_WEBHOOK_URL`: Discord webhook URL(未設定の場合は no-op)
55
-
56
- 任意:
57
-
58
- - `DISCORD_WEBHOOK_USERNAME`: 投稿者名
59
- - `DISCORD_WEBHOOK_AVATAR_URL`: アイコン URL
60
- - `DISCORD_WEBHOOK_COMPLETE_MENTION`: `session.idle` / `session.error` の通知本文に付けるメンション(`@everyone` または `@here` のみ許容。Forum webhook の仕様上、ping は常に発生しない)
61
- - `DISCORD_WEBHOOK_PERMISSION_MENTION`: `permission.updated` の通知本文に付けるメンション(`DISCORD_WEBHOOK_COMPLETE_MENTION` へのフォールバックなし。`@everyone` または `@here` のみ許容。Forum webhook の仕様上、ping は常に発生しない)
62
- - `DISCORD_WEBHOOK_EXCLUDE_INPUT_CONTEXT`: `1` のとき input context(`<file>` から始まる user `text` part)を通知しない(デフォルト: `1` / `0` で無効化)
63
- - `DISCORD_WEBHOOK_SHOW_ERROR_ALERT`: `1` のとき Discord webhook の送信が失敗した場合に OpenCode TUI のトーストを表示します(429 含む)(デフォルト: `1` / `0` で無効化)
64
- - `DISCORD_SEND_PARAMS`: embed の fields として送るキーをカンマ区切りで指定。
65
- - **指定可能キー**: `sessionID`, `permissionID`, `type`, `pattern`, `messageID`, `callID`, `partID`, `role`, `directory`, `projectID`
66
- - **デフォルト動作**(未設定・空文字): 全て無効化(何も送信しない)
67
- - **全て送信したい場合**: 全キーを列挙してください
68
- - **注意**: `session.created` は `DISCORD_SEND_PARAMS` に関わらず `sessionID` を必ず含みます
69
-
70
- ## 仕様メモ
71
-
72
- - `DISCORD_WEBHOOK_URL` 未設定の場合は no-op です。
73
- - Discord webhook の送信が失敗した場合、OpenCode TUI のトーストを表示することがあります(`DISCORD_WEBHOOK_SHOW_ERROR_ALERT` で制御)。
74
- - HTTP 429 の場合は `retry_after` があればそれを優先し、なければ 10 秒程度待って 1 回だけリトライし、それでも失敗した場合は warning トーストを表示します。
75
- - Forum スレッド作成時は `?wait=true` を付け、レスポンスの `channel_id` を thread ID として利用します。
76
- - スレッド名(`thread_name`)は以下の優先度です(最大100文字)。
77
- 1. 最初の user `text`
78
- 2. session title
79
- 3. `session <sessionID>`
80
- 4. `untitled`
81
- - Forum スレッド作成に失敗した場合は、取りこぼし防止のためチャンネル直投稿にフォールバックします(テキストチャンネル webhook など)。
82
- - `permission.updated` / `session.idle` は thread がまだ作られていない場合、いったん通知をキューし、スレッド作成に必要な情報(スレッド名など)が揃ったタイミングで送信されることがあります(取りこぼし防止)。
83
- - `session.error` は upstream の payload で `sessionID` が optional のため、`sessionID` が無い場合は通知しません。
84
- - `DISCORD_WEBHOOK_COMPLETE_MENTION=@everyone`(または `@here`)を設定すると、通知本文にその文字列を含めて投稿します。ただし Forum webhook の仕様上、ping は常に発生しません(文字列として表示されるだけ)。
85
- - `DISCORD_WEBHOOK_PERMISSION_MENTION=@everyone`(または `@here`)を設定すると、`permission.updated` の通知本文にその文字列を含めて投稿します。ただし Forum webhook の仕様上、ping は常に発生しません(文字列として表示されるだけ)。
86
- - `todo.updated` は、`todos` を受信した順のままチェックリスト形式で通知します(`in_progress` は `[▶]`、`completed` は `[✓]`、`cancelled` は除外)。長い/大量の todo は Discord embed の制約に合わせて省略されることがあります(空の場合は `(no todos)` / 省略時は `...and more` を付与)。
87
- - `message.updated` は通知しません(role 判定用に追跡。role 未確定で保留した `text` part を後から通知することがあります)。
88
- - `message.part.updated` は以下の方針です。
89
- - `text`: user は即時通知。assistant は `part.time.end` がある確定時のみ通知(ストリーミング途中更新は通知しない)
90
- - embed タイトルは `User says` / `Agent says` です
91
- - `tool`: 通知しない
92
- - `reasoning`: 通知しない(内部思考が含まれる可能性があるため)
93
- - `DISCORD_SEND_PARAMS` の制御対象は embed の fields のみです(title/description/content/timestamp などは対象外)。また `share` は fields としては送りません(Session started の embed URL には `shareUrl` を使います)。
94
-
95
- ## 動作確認(手動)
96
-
97
- 1. OpenCode を起動してセッション開始 → 最初の通知タイミングで Forum にスレッドが増える
98
- 2. 権限要求が出るケースを作る → 同スレッドに通知(未作成なら後続イベントの通知タイミングでスレッド作成されることがある)
99
- 3. セッション完了 → `session.idle` が通知される(`DISCORD_WEBHOOK_COMPLETE_MENTION` 設定時は本文に `@everyone` / `@here` を含めて投稿するが Forum webhook では ping は発生しない)
100
- 4. エラー発生 → `session.error` が通知される(`sessionID` 無しは通知されない / `DISCORD_WEBHOOK_COMPLETE_MENTION` 設定時は本文に `@everyone` / `@here` を含めて投稿するが Forum webhook では ping は発生しない)
101
-
102
- ## 開発
103
-
104
- - 依存のインストール: `npm i`
105
- - フォーマット: `npx prettier . --write`
106
- - プラグイン本体: `src/index.ts`
107
-
108
- ## 今後の展望(予定)
109
-
110
- - npm パッケージとして公開(インストール/更新を簡単にする)
111
- - 複数 webhook / 複数チャンネル対応(用途別に振り分け)
112
- - 通知内容のカスタマイズ(通知するイベント、本文テンプレ、メンション付与ポリシーなど)
113
- - 設定ファイル(例: `opencode-discord-notify.config.json`)を読み取り、必要に応じて環境変数から値を解決する方式も検討
114
- - Discord の制限対策を強化(レート制限時のリトライ、分割投稿、長文省略ルールの改善)
115
- - CI 整備(lint/format の自動化、簡単なテスト追加)
116
-
117
- **PR / Issue 大歓迎です。**