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 +235 -92
- package/dist/index.d.ts +26 -1
- package/dist/index.js +280 -115
- package/package.json +3 -3
- package/README-JP.md +0 -123
package/README.md
CHANGED
|
@@ -8,35 +8,28 @@
|
|
|
8
8
|

|
|
9
9
|

|
|
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
|
-
|
|
15
|
+
**Get real-time OpenCode notifications directly in your Discord server.**
|
|
18
16
|
|
|
19
|
-
|
|
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
|
-
##
|
|
19
|
+
## Features
|
|
23
20
|
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
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
|
-
##
|
|
28
|
+
## Quick Start
|
|
36
29
|
|
|
37
|
-
### 1
|
|
30
|
+
### 1. Install the plugin
|
|
38
31
|
|
|
39
|
-
Add
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
261
|
+
- Report bugs
|
|
262
|
+
- Request features
|
|
263
|
+
- Submit improvements
|
|
264
|
+
- Share feedback
|
|
117
265
|
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
|
484
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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)
|
|
4
|
-
[](https://www.npmjs.com/package/opencode-discord-notify)
|
|
5
|
-
[](https://www.npmjs.com/package/opencode-discord-notify)
|
|
6
|
-
[](https://www.npmjs.com/package/opencode-discord-notify)
|
|
7
|
-

|
|
8
|
-

|
|
9
|
-

|
|
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 大歓迎です。**
|