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 +235 -86
- package/dist/index.d.ts +26 -1
- package/dist/index.js +372 -125
- package/package.json +3 -3
- package/README-JP.md +0 -117
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,76 +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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
261
|
+
- Report bugs
|
|
262
|
+
- Request features
|
|
263
|
+
- Submit improvements
|
|
264
|
+
- Share feedback
|
|
111
265
|
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
605
|
-
|
|
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
|
-
|
|
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.
|
|
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,117 +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
|
-
|
|
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 大歓迎です。**
|