opencode-discord-notify 0.6.0 → 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,82 +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
- - `DISCORD_WEBHOOK_FALLBACK_URL`: fallback webhook URL for text channel (optional; when set, messages containing `@everyone` or `@here` are automatically sent to this webhook as well; useful because Forum webhooks may not ping mentions due to Discord behavior)
72
-
73
- ## Notes / behavior
74
-
75
- - If `DISCORD_WEBHOOK_URL` is not set, it becomes a no-op.
76
- - If a webhook request fails, it may show an OpenCode TUI toast (controlled by `DISCORD_WEBHOOK_SHOW_ERROR_ALERT`).
77
- - On HTTP 429, it waits `retry_after` seconds if provided (otherwise ~10s) and retries once; if it still fails, it shows a warning toast.
78
- - For Forum thread creation, it appends `?wait=true` and uses `channel_id` in the response as the thread ID.
79
- - `thread_name` priority order (max 100 chars):
80
- 1. first user `text`
81
- 2. session title
82
- 3. `session <sessionID>`
83
- 4. `untitled`
84
- - If thread creation fails (e.g. on non-Forum webhooks), it falls back to posting directly to the channel.
85
- - `permission.updated` / `session.idle` may be queued until the thread name becomes available.
86
- - `session.error` is skipped when `sessionID` is missing in the upstream payload.
87
- - `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).
88
- - `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).
89
- - `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`).
90
- - `message.updated` is not posted (tracked for role inference; may post a previously-held text part later).
91
- - `message.part.updated` policy:
92
- - `text`: user is posted immediately; assistant is posted only when finalized (when `part.time.end` exists)
93
- - Embed titles are `User says` / `Agent says`
94
- - `tool`: not posted
95
- - `reasoning`: not posted (to avoid exposing internal thoughts)
96
- - `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).
97
- - When `DISCORD_WEBHOOK_FALLBACK_URL` is set:
98
- - Messages containing `@everyone` or `@here` (via `DISCORD_WEBHOOK_COMPLETE_MENTION` or `DISCORD_WEBHOOK_PERMISSION_MENTION`) are automatically sent to both the Forum webhook (as a thread post) and the fallback text channel webhook.
99
- - This ensures reliable notifications while maintaining thread structure in Forums, since Forum webhooks may not ping mentions due to Discord behavior.
100
- - Fallback messages always include `sessionID` and `thread title` fields, regardless of `DISCORD_SEND_PARAMS` settings, to provide context in the text channel. The `thread title` is the same as the Forum thread name (first user text, or session title if unavailable).
101
- - Fallback sending is independent of the Forum thread queue and happens immediately.
102
-
103
- ## Manual test
104
-
105
- 1. Start OpenCode → a new thread appears in the Forum channel on the first notification timing
106
- 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)
107
- 3. Finish the session → `session.idle` is posted (if you set `DISCORD_WEBHOOK_COMPLETE_MENTION`, it may not actually ping in Forum webhooks)
108
- 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>
109
230
 
110
231
  ## Development
111
232
 
112
- - Install deps: `npm i`
113
- - Format: `npx prettier . --write`
114
- - 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:
115
260
 
116
- ## Roadmap (planned)
261
+ - Report bugs
262
+ - Request features
263
+ - Submit improvements
264
+ - Share feedback
117
265
 
118
- - Publish as an npm package (to make install/update easier)
119
- - Support multiple webhooks / multiple channels (route by use case)
120
- - Allow customizing notifications (events, message templates, mention policy)
121
- - Consider reading a config file (e.g. `opencode-discord-notify.config.json`) and resolving values from env vars as needed
122
- - Improve Discord limitations handling (rate-limit retry, split posts, better truncation rules)
123
- - Improve CI (automate lint/format; add basic tests)
266
+ ---
124
267
 
125
- 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;
@@ -366,8 +573,22 @@ var plugin = async ({ client }) => {
366
573
  maybeAlertError,
367
574
  waitOnRateLimitMs
368
575
  };
576
+ const db = initDatabase();
577
+ const persistentQueue = new PersistentQueue({ db });
369
578
  const sessionToThread = /* @__PURE__ */ new Map();
370
- 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
+ });
371
592
  const firstUserTextBySession = /* @__PURE__ */ new Map();
372
593
  const pendingTextPartsByMessageId = /* @__PURE__ */ new Map();
373
594
  const messageRoleById = /* @__PURE__ */ new Map();
@@ -391,97 +612,47 @@ var plugin = async ({ client }) => {
391
612
  );
392
613
  if (fromSessionId)
393
614
  return fromSessionId.slice(0, DISCORD_THREAD_NAME_MAX_LENGTH);
394
- return "untitled";
395
- }
396
- async function ensureThread(sessionID) {
397
- if (!webhookUrl) return void 0;
398
- const existing = sessionToThread.get(sessionID);
399
- if (existing) return existing;
400
- const queue = pendingPostsBySession.get(sessionID);
401
- const first = queue?.[0];
402
- if (!first) return void 0;
403
- const threadName = buildThreadName(sessionID);
404
- const res = await postDiscordWebhook(
405
- {
406
- webhookUrl,
407
- wait: true,
408
- body: {
409
- ...first,
410
- thread_name: threadName,
411
- username,
412
- avatar_url: avatarUrl
413
- }
414
- },
415
- postDeps
416
- ).catch(async (e) => {
417
- await postDiscordWebhook(
418
- { webhookUrl, body: { ...first, username, avatar_url: avatarUrl } },
419
- postDeps
420
- ).catch(() => {
421
- });
422
- return void 0;
423
- });
424
- if (res?.channel_id) {
425
- sessionToThread.set(sessionID, res.channel_id);
426
- const nextQueue = pendingPostsBySession.get(sessionID);
427
- if (nextQueue?.[0] === first) {
428
- nextQueue.shift();
429
- if (nextQueue.length) pendingPostsBySession.set(sessionID, nextQueue);
430
- else pendingPostsBySession.delete(sessionID);
431
- }
432
- return res.channel_id;
433
- }
434
- 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] };
435
639
  }
436
640
  function enqueueToThread(sessionID, body) {
437
641
  if (!webhookUrl) {
438
642
  void showMissingUrlToastOnce();
439
643
  return;
440
644
  }
441
- const queue = pendingPostsBySession.get(sessionID) ?? [];
442
- queue.push(body);
443
- pendingPostsBySession.set(sessionID, queue);
444
- }
445
- async function flushPending(sessionID) {
446
- if (!webhookUrl) return;
447
- const threadId = sessionToThread.get(sessionID) ?? await ensureThread(sessionID);
448
- const queue = pendingPostsBySession.get(sessionID);
449
- if (!queue?.length) return;
450
- let sentCount = 0;
451
- try {
452
- if (threadId) {
453
- for (const body of queue) {
454
- await postDiscordWebhook(
455
- {
456
- webhookUrl,
457
- threadId,
458
- body: { ...body, username, avatar_url: avatarUrl }
459
- },
460
- postDeps
461
- );
462
- sentCount += 1;
463
- }
464
- } else {
465
- for (const body of queue) {
466
- await postDiscordWebhook(
467
- { webhookUrl, body: { ...body, username, avatar_url: avatarUrl } },
468
- postDeps
469
- );
470
- sentCount += 1;
471
- }
472
- }
473
- pendingPostsBySession.delete(sessionID);
474
- } catch (e) {
475
- const current = pendingPostsBySession.get(sessionID);
476
- if (!current?.length) throw e;
477
- const rest = current.slice(sentCount);
478
- if (rest.length) pendingPostsBySession.set(sessionID, rest);
479
- else pendingPostsBySession.delete(sessionID);
480
- throw e;
481
- }
645
+ const threadId = sessionToThread.get(sessionID) || null;
646
+ persistentQueue.enqueue({
647
+ sessionId: sessionID,
648
+ threadId,
649
+ webhookBody: body
650
+ });
482
651
  }
483
- function shouldFlush(sessionID) {
484
- return sessionToThread.has(sessionID) || firstUserTextBySession.has(sessionID);
652
+ function startWorkerIfNeeded() {
653
+ if (!queueWorker.running) {
654
+ void queueWorker.start();
655
+ }
485
656
  }
486
657
  function buildCompleteMention() {
487
658
  return buildMention(completeMention, "DISCORD_WEBHOOK_COMPLETE_MENTION");
@@ -506,10 +677,17 @@ var plugin = async ({ client }) => {
506
677
  if (role === "user" && text.trim() === "" || text.trim() === "(empty)") {
507
678
  return;
508
679
  }
509
- if (role === "user" && !firstUserTextBySession.has(sessionID)) {
680
+ const isFirstUserText = role === "user" && !firstUserTextBySession.has(sessionID);
681
+ if (isFirstUserText) {
510
682
  const normalized = normalizeThreadTitle(text);
511
683
  if (normalized) firstUserTextBySession.set(sessionID, normalized);
512
684
  }
685
+ if (role === "user" && !sessionToThread.has(sessionID)) {
686
+ const sessionCreatedBody = buildSessionCreatedEmbed(sessionID);
687
+ if (sessionCreatedBody) {
688
+ enqueueToThread(sessionID, sessionCreatedBody);
689
+ }
690
+ }
513
691
  const embed = {
514
692
  title: getTextPartEmbedTitle(role),
515
693
  color: COLORS.info,
@@ -530,11 +708,7 @@ var plugin = async ({ client }) => {
530
708
  )
531
709
  };
532
710
  enqueueToThread(sessionID, { embeds: [embed] });
533
- if (role === "user") {
534
- await flushPending(sessionID);
535
- } else if (shouldFlush(sessionID)) {
536
- await flushPending(sessionID);
537
- }
711
+ startWorkerIfNeeded();
538
712
  }
539
713
  return {
540
714
  event: async ({ event }) => {
@@ -544,29 +718,13 @@ var plugin = async ({ client }) => {
544
718
  const info = event.properties?.info;
545
719
  const sessionID = info?.id;
546
720
  if (!sessionID) return;
547
- const title = info?.title ?? "(untitled)";
548
- const shareUrl = info?.share?.url;
549
- const createdAt = toIsoTimestamp(info?.time?.created);
550
- const embed = {
551
- title: "Session started",
552
- description: title,
553
- url: shareUrl,
554
- color: COLORS.info,
555
- timestamp: createdAt,
556
- fields: buildFields(
557
- filterSendFields(
558
- [
559
- ["sessionID", sessionID],
560
- ["projectID", info?.projectID],
561
- ["directory", info?.directory],
562
- ["share", shareUrl]
563
- ],
564
- withForcedSendParams(sendParams, ["sessionID"])
565
- )
566
- )
567
- };
568
- lastSessionInfo.set(sessionID, { title, shareUrl });
569
- 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
+ });
570
728
  return;
571
729
  }
572
730
  case "permission.updated": {
@@ -610,6 +768,7 @@ var plugin = async ({ client }) => {
610
768
  },
611
769
  postDeps
612
770
  );
771
+ startWorkerIfNeeded();
613
772
  return;
614
773
  }
615
774
  case "session.idle": {
@@ -643,6 +802,7 @@ var plugin = async ({ client }) => {
643
802
  },
644
803
  postDeps
645
804
  );
805
+ startWorkerIfNeeded();
646
806
  return;
647
807
  }
648
808
  case "session.error": {
@@ -687,7 +847,7 @@ var plugin = async ({ client }) => {
687
847
  },
688
848
  postDeps
689
849
  );
690
- await flushPending(sessionID);
850
+ startWorkerIfNeeded();
691
851
  return;
692
852
  }
693
853
  case "todo.updated": {
@@ -703,6 +863,7 @@ var plugin = async ({ client }) => {
703
863
  description: buildTodoChecklist(p?.todos)
704
864
  };
705
865
  enqueueToThread(sessionID, { embeds: [embed] });
866
+ startWorkerIfNeeded();
706
867
  return;
707
868
  }
708
869
  case "message.updated": {
@@ -758,6 +919,10 @@ var plugin = async ({ client }) => {
758
919
  }
759
920
  } catch {
760
921
  }
922
+ },
923
+ __test__: {
924
+ queueWorker,
925
+ persistentQueue
761
926
  }
762
927
  };
763
928
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-discord-notify",
3
- "version": "0.6.0",
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,123 +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
- - `DISCORD_WEBHOOK_FALLBACK_URL`: フォールバック先のテキストチャネル webhook URL(任意。設定すると、`@everyone` または `@here` を含むメッセージが自動的にこの webhook にも送信されます。Forum webhook ではメンションが ping しない Discord の仕様上、有用)
70
-
71
- ## 仕様メモ
72
-
73
- - `DISCORD_WEBHOOK_URL` 未設定の場合は no-op です。
74
- - Discord webhook の送信が失敗した場合、OpenCode TUI のトーストを表示することがあります(`DISCORD_WEBHOOK_SHOW_ERROR_ALERT` で制御)。
75
- - HTTP 429 の場合は `retry_after` があればそれを優先し、なければ 10 秒程度待って 1 回だけリトライし、それでも失敗した場合は warning トーストを表示します。
76
- - Forum スレッド作成時は `?wait=true` を付け、レスポンスの `channel_id` を thread ID として利用します。
77
- - スレッド名(`thread_name`)は以下の優先度です(最大100文字)。
78
- 1. 最初の user `text`
79
- 2. session title
80
- 3. `session <sessionID>`
81
- 4. `untitled`
82
- - Forum スレッド作成に失敗した場合は、取りこぼし防止のためチャンネル直投稿にフォールバックします(テキストチャンネル webhook など)。
83
- - `permission.updated` / `session.idle` は thread がまだ作られていない場合、いったん通知をキューし、スレッド作成に必要な情報(スレッド名など)が揃ったタイミングで送信されることがあります(取りこぼし防止)。
84
- - `session.error` は upstream の payload で `sessionID` が optional のため、`sessionID` が無い場合は通知しません。
85
- - `DISCORD_WEBHOOK_COMPLETE_MENTION=@everyone`(または `@here`)を設定すると、通知本文にその文字列を含めて投稿します。ただし Forum webhook の仕様上、ping は常に発生しません(文字列として表示されるだけ)。
86
- - `DISCORD_WEBHOOK_PERMISSION_MENTION=@everyone`(または `@here`)を設定すると、`permission.updated` の通知本文にその文字列を含めて投稿します。ただし Forum webhook の仕様上、ping は常に発生しません(文字列として表示されるだけ)。
87
- - `todo.updated` は、`todos` を受信した順のままチェックリスト形式で通知します(`in_progress` は `[▶]`、`completed` は `[✓]`、`cancelled` は除外)。長い/大量の todo は Discord embed の制約に合わせて省略されることがあります(空の場合は `(no todos)` / 省略時は `...and more` を付与)。
88
- - `message.updated` は通知しません(role 判定用に追跡。role 未確定で保留した `text` part を後から通知することがあります)。
89
- - `message.part.updated` は以下の方針です。
90
- - `text`: user は即時通知。assistant は `part.time.end` がある確定時のみ通知(ストリーミング途中更新は通知しない)
91
- - embed タイトルは `User says` / `Agent says` です
92
- - `tool`: 通知しない
93
- - `reasoning`: 通知しない(内部思考が含まれる可能性があるため)
94
- - `DISCORD_SEND_PARAMS` の制御対象は embed の fields のみです(title/description/content/timestamp などは対象外)。また `share` は fields としては送りません(Session started の embed URL には `shareUrl` を使います)。
95
- - `DISCORD_WEBHOOK_FALLBACK_URL` が設定されている場合:
96
- - `@everyone` または `@here` を含むメッセージ(`DISCORD_WEBHOOK_COMPLETE_MENTION` または `DISCORD_WEBHOOK_PERMISSION_MENTION` 経由)は、Forum webhook(スレッド投稿)とフォールバック先のテキストチャネル webhook の両方に自動的に送信されます。
97
- - これにより、Forum webhook ではメンションが ping しない Discord の仕様上、確実に通知を届けることができます。
98
- - フォールバックメッセージには、`DISCORD_SEND_PARAMS` の設定に関わらず、常に `sessionID` と `thread title` フィールドが含まれます(テキストチャネルでのコンテキスト提供のため)。`thread title` は Forum のスレッド名と同じ値(最初のユーザーテキスト、または存在しない場合はセッションタイトル)です。
99
- - フォールバック送信は Forum スレッドキューとは独立しており、即座に実行されます。
100
-
101
- ## 動作確認(手動)
102
-
103
- 1. OpenCode を起動してセッション開始 → 最初の通知タイミングで Forum にスレッドが増える
104
- 2. 権限要求が出るケースを作る → 同スレッドに通知(未作成なら後続イベントの通知タイミングでスレッド作成されることがある)
105
- 3. セッション完了 → `session.idle` が通知される(`DISCORD_WEBHOOK_COMPLETE_MENTION` 設定時は本文に `@everyone` / `@here` を含めて投稿するが Forum webhook では ping は発生しない)
106
- 4. エラー発生 → `session.error` が通知される(`sessionID` 無しは通知されない / `DISCORD_WEBHOOK_COMPLETE_MENTION` 設定時は本文に `@everyone` / `@here` を含めて投稿するが Forum webhook では ping は発生しない)
107
-
108
- ## 開発
109
-
110
- - 依存のインストール: `npm i`
111
- - フォーマット: `npx prettier . --write`
112
- - プラグイン本体: `src/index.ts`
113
-
114
- ## 今後の展望(予定)
115
-
116
- - npm パッケージとして公開(インストール/更新を簡単にする)
117
- - 複数 webhook / 複数チャンネル対応(用途別に振り分け)
118
- - 通知内容のカスタマイズ(通知するイベント、本文テンプレ、メンション付与ポリシーなど)
119
- - 設定ファイル(例: `opencode-discord-notify.config.json`)を読み取り、必要に応じて環境変数から値を解決する方式も検討
120
- - Discord の制限対策を強化(レート制限時のリトライ、分割投稿、長文省略ルールの改善)
121
- - CI 整備(lint/format の自動化、簡単なテスト追加)
122
-
123
- **PR / Issue 大歓迎です。**