opencode-discord-notify 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README-JP.md CHANGED
@@ -62,11 +62,14 @@ OpenCode を再起動してください。
62
62
  - `DISCORD_WEBHOOK_COMPLETE_MENTION`: `session.idle` / `session.error` の通知本文に付けるメンション(`@everyone` または `@here` のみ許容。Forum webhook の仕様上、ping は常に発生しない)
63
63
  - `DISCORD_WEBHOOK_PERMISSION_MENTION`: `permission.updated` の通知本文に付けるメンション(`DISCORD_WEBHOOK_COMPLETE_MENTION` へのフォールバックなし。`@everyone` または `@here` のみ許容。Forum webhook の仕様上、ping は常に発生しない)
64
64
  - `DISCORD_WEBHOOK_EXCLUDE_INPUT_CONTEXT`: `1` のとき input context(`<file>` から始まる user `text` part)を通知しない(デフォルト: `1` / `0` で無効化)
65
+ - `DISCORD_WEBHOOK_SHOW_ERROR_ALERT`: `1` のとき Discord webhook の送信が失敗した場合に OpenCode TUI のトーストを表示します(429 含む)(デフォルト: `1` / `0` で無効化)
65
66
  - `SEND_PARAMS`: embed の fields として送るキーをカンマ区切りで指定。指定可能キー: `sessionID`, `permissionID`, `type`, `pattern`, `messageID`, `callID`, `partID`, `role`, `directory`, `projectID`。未設定・空文字・空要素のみの場合は全て選択。`session.created` は `SEND_PARAMS` に関わらず `sessionID`, `projectID`, `directory` を必ず含みます。
66
67
 
67
68
  ## 仕様メモ
68
69
 
69
- - `DISCORD_WEBHOOK_URL` 未設定の場合は no-op(ログに警告のみ)です。
70
+ - `DISCORD_WEBHOOK_URL` 未設定の場合は no-op です。
71
+ - Discord webhook の送信が失敗した場合、OpenCode TUI のトーストを表示することがあります(`DISCORD_WEBHOOK_SHOW_ERROR_ALERT` で制御)。
72
+ - HTTP 429 の場合は `retry_after` があればそれを優先し、なければ 10 秒程度待って 1 回だけリトライし、それでも失敗した場合は warning トーストを表示します。
70
73
  - Forum スレッド作成時は `?wait=true` を付け、レスポンスの `channel_id` を thread ID として利用します。
71
74
  - スレッド名(`thread_name`)は以下の優先度です(最大100文字)。
72
75
  1. 最初の user `text`
package/README.md CHANGED
@@ -64,11 +64,14 @@ Optional:
64
64
  - `DISCORD_WEBHOOK_COMPLETE_MENTION`: mention to put in `session.idle` / `session.error` messages (only `@everyone` or `@here` supported; Forum webhooks may not actually ping due to Discord behavior)
65
65
  - `DISCORD_WEBHOOK_PERMISSION_MENTION`: mention to put in `permission.updated` messages (no fallback to `DISCORD_WEBHOOK_COMPLETE_MENTION`; only `@everyone` or `@here` supported; Forum webhooks may not actually ping due to Discord behavior)
66
66
  - `DISCORD_WEBHOOK_EXCLUDE_INPUT_CONTEXT`: when set to `1`, exclude "input context" (user `text` parts that start with `<file>`) from notifications (default: `1`; set to `0` to disable)
67
+ - `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)
67
68
  - `SEND_PARAMS`: comma-separated list of keys to include as embed fields. Allowed keys: `sessionID`, `permissionID`, `type`, `pattern`, `messageID`, `callID`, `partID`, `role`, `directory`, `projectID`. If unset, empty, or containing only empty elements, all keys are selected. `session.created` always includes `sessionID`, `projectID`, `directory` regardless.
68
69
 
69
70
  ## Notes / behavior
70
71
 
71
- - If `DISCORD_WEBHOOK_URL` is not set, it becomes a no-op (logs a warning only).
72
+ - If `DISCORD_WEBHOOK_URL` is not set, it becomes a no-op.
73
+ - If a webhook request fails, it may show an OpenCode TUI toast (controlled by `DISCORD_WEBHOOK_SHOW_ERROR_ALERT`).
74
+ - On HTTP 429, it waits `retry_after` seconds if provided (otherwise ~10s) and retries once; if it still fails, it shows a warning toast.
72
75
  - For Forum thread creation, it appends `?wait=true` and uses `channel_id` in the response as the thread ID.
73
76
  - `thread_name` priority order (max 100 chars):
74
77
  1. first user `text`
package/dist/index.d.ts CHANGED
@@ -1,5 +1,68 @@
1
1
  import { Plugin } from '@opencode-ai/plugin';
2
2
 
3
+ type DiscordWebhookMessageResponse = {
4
+ id: string;
5
+ channel_id: string;
6
+ };
7
+ type DiscordEmbed = {
8
+ title?: string;
9
+ description?: string;
10
+ url?: string;
11
+ color?: number;
12
+ timestamp?: string;
13
+ fields?: Array<{
14
+ name: string;
15
+ value: string;
16
+ inline?: boolean;
17
+ }>;
18
+ };
19
+ type DiscordAllowedMentions = {
20
+ parse?: Array<'everyone' | 'roles' | 'users'>;
21
+ roles?: string[];
22
+ users?: string[];
23
+ };
24
+ type DiscordExecuteWebhookBody = {
25
+ content?: string;
26
+ username?: string;
27
+ avatar_url?: string;
28
+ thread_name?: string;
29
+ embeds?: DiscordEmbed[];
30
+ allowed_mentions?: DiscordAllowedMentions;
31
+ };
32
+ declare function toIsoTimestamp(ms: unknown): string | undefined;
33
+ declare function buildFields(fields: Array<[string, unknown]>, inline?: boolean): DiscordEmbed['fields'];
34
+ declare function buildMention(mention: string | undefined, nameForLog: string): {
35
+ content?: string;
36
+ allowed_mentions?: DiscordAllowedMentions;
37
+ } | undefined;
38
+ declare function buildTodoChecklist(todos: unknown): string;
39
+ type ToastVariant = 'info' | 'success' | 'warning' | 'error';
40
+ type MaybeAlertError = (input: {
41
+ key: string;
42
+ title?: string;
43
+ message: string;
44
+ variant: ToastVariant;
45
+ }) => Promise<void>;
46
+ type PostDiscordWebhookDeps = {
47
+ showErrorAlert: boolean;
48
+ maybeAlertError: MaybeAlertError;
49
+ waitOnRateLimitMs: number;
50
+ fetchImpl?: typeof fetch;
51
+ sleepImpl?: (ms: number) => Promise<void>;
52
+ };
53
+ declare function postDiscordWebhook(input: {
54
+ webhookUrl: string;
55
+ threadId?: string;
56
+ wait?: boolean;
57
+ body: DiscordExecuteWebhookBody;
58
+ }, deps: PostDiscordWebhookDeps): Promise<DiscordWebhookMessageResponse | undefined>;
3
59
  declare const plugin: Plugin;
60
+ declare const __test__: {
61
+ buildMention: typeof buildMention;
62
+ buildTodoChecklist: typeof buildTodoChecklist;
63
+ buildFields: typeof buildFields;
64
+ toIsoTimestamp: typeof toIsoTimestamp;
65
+ postDiscordWebhook: typeof postDiscordWebhook;
66
+ };
4
67
 
5
- export { plugin as default };
68
+ export { __test__, plugin as default };
package/dist/index.js CHANGED
@@ -88,45 +88,165 @@ function withQuery(url, params) {
88
88
  }
89
89
  return u.toString();
90
90
  }
91
- async function postDiscordWebhook(input) {
91
+ function sleep(ms) {
92
+ return new Promise((resolve) => setTimeout(resolve, ms));
93
+ }
94
+ function truncateText(value, maxLength) {
95
+ if (value.length <= maxLength) return value;
96
+ if (maxLength <= 3) return value.slice(0, maxLength);
97
+ return value.slice(0, maxLength - 3) + "...";
98
+ }
99
+ function buildMention(mention, nameForLog) {
100
+ void nameForLog;
101
+ if (!mention) return void 0;
102
+ if (mention === "@everyone" || mention === "@here") {
103
+ return {
104
+ content: mention,
105
+ allowed_mentions: {
106
+ parse: ["everyone"]
107
+ }
108
+ };
109
+ }
110
+ return {
111
+ content: mention,
112
+ allowed_mentions: {
113
+ parse: []
114
+ }
115
+ };
116
+ }
117
+ function normalizeTodoContent(value) {
118
+ return safeString(value).replace(/\s+/g, " ").trim();
119
+ }
120
+ function buildTodoChecklist(todos) {
121
+ const maxDescription = 4096;
122
+ const items = Array.isArray(todos) ? todos : [];
123
+ let matchCount = 0;
124
+ let description = "";
125
+ let truncated = false;
126
+ for (const item of items) {
127
+ const status = item?.status;
128
+ if (status === "cancelled") continue;
129
+ const content = normalizeTodoContent(item?.content);
130
+ if (!content) continue;
131
+ const marker = status === "completed" ? "[\u2713]" : status === "in_progress" ? "[\u25B6]" : "[ ]";
132
+ const line = `> ${marker} ${truncateText(content, 200)}`;
133
+ const nextChunk = (description ? "\n" : "") + line;
134
+ if (description.length + nextChunk.length > maxDescription) {
135
+ truncated = true;
136
+ break;
137
+ }
138
+ description += nextChunk;
139
+ matchCount += 1;
140
+ }
141
+ if (!description) {
142
+ return "> (no todos)";
143
+ }
144
+ if (truncated || matchCount < items.length) {
145
+ const moreLine = `${description ? "\n" : ""}> ...and more`;
146
+ if (description.length + moreLine.length <= maxDescription) {
147
+ description += moreLine;
148
+ }
149
+ }
150
+ return description;
151
+ }
152
+ async function postDiscordWebhook(input, deps) {
92
153
  const { webhookUrl, threadId, wait, body } = input;
154
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
155
+ const sleepImpl = deps.sleepImpl ?? sleep;
93
156
  const url = withQuery(webhookUrl, {
94
157
  thread_id: threadId,
95
158
  wait: wait ? "true" : void 0
96
159
  });
97
- const response = await fetch(url, {
160
+ const requestInit = {
98
161
  method: "POST",
99
162
  headers: {
100
163
  "content-type": "application/json"
101
164
  },
102
165
  body: JSON.stringify(body)
103
- });
104
- if (!response.ok) {
105
- const text = await response.text().catch(() => "");
106
- throw new Error(
107
- `Discord webhook failed: ${response.status} ${response.statusText} ${text}`
108
- );
109
- }
110
- if (!wait) return void 0;
111
- const json = await response.json().catch(() => void 0);
112
- if (!json || typeof json !== "object") return void 0;
113
- const channelId = json.channel_id;
114
- const messageId = json.id;
115
- if (typeof channelId !== "string" || typeof messageId !== "string")
166
+ };
167
+ const doRequest = async () => {
168
+ return await fetchImpl(url, requestInit);
169
+ };
170
+ const parseRetryAfterFromText = (text2) => {
171
+ if (!text2) return void 0;
172
+ try {
173
+ const json = JSON.parse(text2);
174
+ const value = json?.retry_after;
175
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
176
+ return value;
177
+ }
178
+ } catch {
179
+ }
116
180
  return void 0;
117
- return {
118
- id: messageId,
119
- channel_id: channelId
120
181
  };
182
+ const parseRetryAfterFromHeader = (headers) => {
183
+ const raw = headers.get("Retry-After");
184
+ if (!raw) return void 0;
185
+ const value = Number(raw);
186
+ if (!Number.isFinite(value) || value < 0) return void 0;
187
+ return value;
188
+ };
189
+ const response = await doRequest();
190
+ if (response.ok) {
191
+ if (!wait) return void 0;
192
+ const json = await response.json().catch(() => void 0);
193
+ if (!json || typeof json !== "object") return void 0;
194
+ const channelId = json.channel_id;
195
+ const messageId = json.id;
196
+ if (typeof channelId !== "string" || typeof messageId !== "string")
197
+ return void 0;
198
+ return { id: messageId, channel_id: channelId };
199
+ }
200
+ if (response.status === 429) {
201
+ const text2 = await response.text().catch(() => "");
202
+ const retryAfterSeconds = parseRetryAfterFromText(text2) ?? parseRetryAfterFromHeader(response.headers);
203
+ const waitMs = retryAfterSeconds === void 0 ? deps.waitOnRateLimitMs : Math.ceil(retryAfterSeconds * 1e3);
204
+ await sleepImpl(waitMs);
205
+ const retryResponse = await doRequest();
206
+ if (!retryResponse.ok) {
207
+ if (deps.showErrorAlert) {
208
+ await deps.maybeAlertError({
209
+ key: `discord_webhook_error:${retryResponse.status}`,
210
+ title: "Discord webhook rate-limited",
211
+ message: `Discord webhook returned 429 (rate limited). Waited ${Math.round(
212
+ waitMs / 1e3
213
+ )}s and retried, but it still failed.`,
214
+ variant: "warning"
215
+ });
216
+ }
217
+ const retryText = await retryResponse.text().catch(() => "");
218
+ throw new Error(
219
+ `Discord webhook failed: ${retryResponse.status} ${retryResponse.statusText} ${retryText}`
220
+ );
221
+ }
222
+ if (!wait) return void 0;
223
+ const json = await retryResponse.json().catch(() => void 0);
224
+ if (!json || typeof json !== "object") return void 0;
225
+ const channelId = json.channel_id;
226
+ const messageId = json.id;
227
+ if (typeof channelId !== "string" || typeof messageId !== "string")
228
+ return void 0;
229
+ return { id: messageId, channel_id: channelId };
230
+ }
231
+ if (deps.showErrorAlert) {
232
+ await deps.maybeAlertError({
233
+ key: `discord_webhook_error:${response.status}`,
234
+ title: "Discord webhook error",
235
+ message: `Discord webhook failed: ${response.status} ${response.statusText}`,
236
+ variant: "error"
237
+ });
238
+ }
239
+ const text = await response.text().catch(() => "");
240
+ throw new Error(
241
+ `Discord webhook failed: ${response.status} ${response.statusText} ${text}`
242
+ );
121
243
  }
122
244
  var GLOBAL_GUARD_KEY = "__opencode_discord_notify_registered__";
123
- var plugin = async () => {
245
+ var plugin = async ({ client }) => {
124
246
  const globalWithGuard = globalThis;
125
247
  if (globalWithGuard[GLOBAL_GUARD_KEY]) {
126
- return {
127
- event: async () => {
128
- }
129
- };
248
+ return { event: async () => {
249
+ } };
130
250
  }
131
251
  globalWithGuard[GLOBAL_GUARD_KEY] = true;
132
252
  const webhookUrl = getEnv("DISCORD_WEBHOOK_URL");
@@ -138,16 +258,44 @@ var plugin = async () => {
138
258
  const permissionMention = permissionMentionRaw || void 0;
139
259
  const excludeInputContextRaw = (getEnv("DISCORD_WEBHOOK_EXCLUDE_INPUT_CONTEXT") ?? "1").trim();
140
260
  const excludeInputContext = excludeInputContextRaw !== "0";
261
+ const showErrorAlertRaw = (getEnv("DISCORD_WEBHOOK_SHOW_ERROR_ALERT") ?? "1").trim();
262
+ const showErrorAlert = showErrorAlertRaw !== "0";
263
+ const waitOnRateLimitMs = 1e4;
264
+ const toastCooldownMs = 3e4;
141
265
  const sendParams = parseSendParams(getEnv("SEND_PARAMS"));
266
+ const lastAlertAtByKey = /* @__PURE__ */ new Map();
267
+ const showToast = async ({ title, message, variant }) => {
268
+ try {
269
+ await client.tui.showToast({
270
+ body: { title, message, variant, duration: 8e3 }
271
+ });
272
+ } catch {
273
+ }
274
+ };
275
+ const maybeAlertError = async ({
276
+ key,
277
+ title,
278
+ message,
279
+ variant
280
+ }) => {
281
+ if (!showErrorAlert) return;
282
+ const now = Date.now();
283
+ const last = lastAlertAtByKey.get(key);
284
+ if (last !== void 0 && now - last < toastCooldownMs) return;
285
+ lastAlertAtByKey.set(key, now);
286
+ await showToast({ title, message, variant });
287
+ };
288
+ const postDeps = {
289
+ showErrorAlert,
290
+ maybeAlertError,
291
+ waitOnRateLimitMs
292
+ };
142
293
  const sessionToThread = /* @__PURE__ */ new Map();
143
- const threadCreateInFlight = /* @__PURE__ */ new Map();
144
294
  const pendingPostsBySession = /* @__PURE__ */ new Map();
145
295
  const firstUserTextBySession = /* @__PURE__ */ new Map();
146
296
  const pendingTextPartsByMessageId = /* @__PURE__ */ new Map();
147
- const sessionSerial = /* @__PURE__ */ new Map();
148
- const lastSessionInfo = /* @__PURE__ */ new Map();
149
- const lastPartSnapshotById = /* @__PURE__ */ new Map();
150
297
  const messageRoleById = /* @__PURE__ */ new Map();
298
+ const lastSessionInfo = /* @__PURE__ */ new Map();
151
299
  function normalizeThreadTitle(value) {
152
300
  return safeString(value).replace(/\s+/g, " ").trim();
153
301
  }
@@ -167,182 +315,104 @@ var plugin = async () => {
167
315
  if (fromSessionId) return fromSessionId.slice(0, 100);
168
316
  return "untitled";
169
317
  }
170
- async function sendToChannel(body) {
171
- if (!webhookUrl) return;
172
- await postDiscordWebhook({
173
- webhookUrl,
174
- body: {
175
- ...body,
176
- username,
177
- avatar_url: avatarUrl
178
- }
318
+ async function ensureThread(sessionID) {
319
+ if (!webhookUrl) return void 0;
320
+ const existing = sessionToThread.get(sessionID);
321
+ if (existing) return existing;
322
+ const queue = pendingPostsBySession.get(sessionID);
323
+ const first = queue?.[0];
324
+ if (!first) return void 0;
325
+ const threadName = buildThreadName(sessionID);
326
+ const res = await postDiscordWebhook(
327
+ {
328
+ webhookUrl,
329
+ wait: true,
330
+ body: {
331
+ ...first,
332
+ thread_name: threadName,
333
+ username,
334
+ avatar_url: avatarUrl
335
+ }
336
+ },
337
+ postDeps
338
+ ).catch(async (e) => {
339
+ await postDiscordWebhook(
340
+ { webhookUrl, body: { ...first, username, avatar_url: avatarUrl } },
341
+ postDeps
342
+ ).catch(() => {
343
+ });
344
+ return void 0;
179
345
  });
346
+ if (res?.channel_id) {
347
+ sessionToThread.set(sessionID, res.channel_id);
348
+ const nextQueue = pendingPostsBySession.get(sessionID);
349
+ if (nextQueue?.[0] === first) {
350
+ nextQueue.shift();
351
+ if (nextQueue.length) pendingPostsBySession.set(sessionID, nextQueue);
352
+ else pendingPostsBySession.delete(sessionID);
353
+ }
354
+ return res.channel_id;
355
+ }
356
+ return void 0;
180
357
  }
181
358
  function enqueueToThread(sessionID, body) {
182
359
  const queue = pendingPostsBySession.get(sessionID) ?? [];
183
360
  queue.push(body);
184
361
  pendingPostsBySession.set(sessionID, queue);
185
362
  }
186
- function enqueueSerial(sessionID, task) {
187
- const prev = sessionSerial.get(sessionID) ?? Promise.resolve();
188
- const next = prev.then(task, task);
189
- sessionSerial.set(sessionID, next);
190
- next.finally(() => {
191
- if (sessionSerial.get(sessionID) === next) sessionSerial.delete(sessionID);
192
- });
193
- return next;
194
- }
195
- async function ensureThread(sessionID) {
196
- if (!webhookUrl) return void 0;
197
- const existingThreadId = sessionToThread.get(sessionID);
198
- if (existingThreadId) return existingThreadId;
199
- const inflight = threadCreateInFlight.get(sessionID);
200
- if (inflight) return await inflight;
201
- const create = (async () => {
202
- const queue = pendingPostsBySession.get(sessionID) ?? [];
203
- const first = queue.shift();
204
- if (queue.length) pendingPostsBySession.set(sessionID, queue);
205
- else pendingPostsBySession.delete(sessionID);
206
- if (!first) return void 0;
207
- const threadName = buildThreadName(sessionID);
208
- try {
209
- const res = await postDiscordWebhook({
210
- webhookUrl,
211
- wait: true,
212
- body: {
213
- ...first,
214
- thread_name: threadName,
215
- username,
216
- avatar_url: avatarUrl
217
- }
218
- });
219
- if (res?.channel_id) {
220
- sessionToThread.set(sessionID, res.channel_id);
221
- return res.channel_id;
222
- }
223
- warn(`failed to capture thread_id for session ${sessionID}`);
224
- return void 0;
225
- } catch (e) {
226
- await sendToChannel(first);
227
- return void 0;
228
- } finally {
229
- threadCreateInFlight.delete(sessionID);
230
- }
231
- })();
232
- threadCreateInFlight.set(sessionID, create);
233
- return await create;
234
- }
235
363
  async function flushPending(sessionID) {
236
- return enqueueSerial(sessionID, async () => {
237
- if (!webhookUrl) return;
238
- const threadId = sessionToThread.get(sessionID) ?? await ensureThread(sessionID);
239
- const queue = pendingPostsBySession.get(sessionID);
240
- if (!queue?.length) return;
241
- try {
242
- if (threadId) {
243
- for (const body of queue) {
244
- await postDiscordWebhook({
364
+ if (!webhookUrl) return;
365
+ const threadId = sessionToThread.get(sessionID) ?? await ensureThread(sessionID);
366
+ const queue = pendingPostsBySession.get(sessionID);
367
+ if (!queue?.length) return;
368
+ let sentCount = 0;
369
+ try {
370
+ if (threadId) {
371
+ for (const body of queue) {
372
+ await postDiscordWebhook(
373
+ {
245
374
  webhookUrl,
246
375
  threadId,
247
- body: {
248
- ...body,
249
- username,
250
- avatar_url: avatarUrl
251
- }
252
- });
253
- }
254
- } else {
255
- for (const body of queue) {
256
- await sendToChannel(body);
257
- }
376
+ body: { ...body, username, avatar_url: avatarUrl }
377
+ },
378
+ postDeps
379
+ );
380
+ sentCount += 1;
381
+ }
382
+ } else {
383
+ for (const body of queue) {
384
+ await postDiscordWebhook(
385
+ { webhookUrl, body: { ...body, username, avatar_url: avatarUrl } },
386
+ postDeps
387
+ );
388
+ sentCount += 1;
258
389
  }
259
- } finally {
260
- pendingPostsBySession.delete(sessionID);
261
390
  }
262
- });
391
+ pendingPostsBySession.delete(sessionID);
392
+ } catch (e) {
393
+ const current = pendingPostsBySession.get(sessionID);
394
+ if (!current?.length) throw e;
395
+ const rest = current.slice(sentCount);
396
+ if (rest.length) pendingPostsBySession.set(sessionID, rest);
397
+ else pendingPostsBySession.delete(sessionID);
398
+ throw e;
399
+ }
263
400
  }
264
401
  function shouldFlush(sessionID) {
265
402
  return sessionToThread.has(sessionID) || firstUserTextBySession.has(sessionID);
266
403
  }
267
- function warn(message, error) {
268
- if (error) console.warn(`[opencode-discord-notify] ${message}`, error);
269
- else console.warn(`[opencode-discord-notify] ${message}`);
270
- }
271
- function buildMention(mention, nameForLog) {
272
- if (!mention) return void 0;
273
- if (mention === "@everyone" || mention === "@here") {
274
- return {
275
- content: mention,
276
- allowed_mentions: {
277
- parse: ["everyone"]
278
- }
279
- };
280
- }
281
- warn(
282
- `${nameForLog} is set but unsupported: ${mention}. Only @everyone/@here are supported.`
283
- );
284
- return {
285
- content: mention,
286
- allowed_mentions: {
287
- parse: []
288
- }
289
- };
290
- }
291
404
  function buildCompleteMention() {
292
405
  return buildMention(completeMention, "DISCORD_WEBHOOK_COMPLETE_MENTION");
293
406
  }
294
407
  function buildPermissionMention() {
295
408
  return buildMention(permissionMention, "DISCORD_WEBHOOK_PERMISSION_MENTION");
296
409
  }
297
- function normalizeTodoContent(value) {
298
- return safeString(value).replace(/\s+/g, " ").trim();
299
- }
300
- function truncateText(value, maxLength) {
301
- if (value.length <= maxLength) return value;
302
- if (maxLength <= 3) return value.slice(0, maxLength);
303
- return value.slice(0, maxLength - 3) + "...";
304
- }
305
410
  function setIfChanged(map, key, next) {
306
411
  const prev = map.get(key);
307
412
  if (prev === next) return false;
308
413
  map.set(key, next);
309
414
  return true;
310
415
  }
311
- function buildTodoChecklist(todos) {
312
- const maxDescription = 4096;
313
- const items = Array.isArray(todos) ? todos : [];
314
- let matchCount = 0;
315
- let description = "";
316
- let truncated = false;
317
- for (const item of items) {
318
- const status = item?.status;
319
- if (status === "cancelled") continue;
320
- const content = normalizeTodoContent(item?.content);
321
- if (!content) continue;
322
- const marker = status === "completed" ? "[\u2713]" : status === "in_progress" ? "[\u25B6]" : "[ ]";
323
- const line = `> ${marker} ${truncateText(content, 200)}`;
324
- const nextChunk = (description ? "\n" : "") + line;
325
- if (description.length + nextChunk.length > maxDescription) {
326
- truncated = true;
327
- break;
328
- }
329
- description += nextChunk;
330
- matchCount += 1;
331
- }
332
- if (!description) {
333
- return "> (no todos)";
334
- }
335
- if (truncated || matchCount < items.length) {
336
- const moreLine = `${description ? "\n" : ""}> ...and more`;
337
- if (description.length + moreLine.length <= maxDescription) {
338
- description += moreLine;
339
- }
340
- }
341
- return description;
342
- }
343
- if (!webhookUrl) {
344
- warn("DISCORD_WEBHOOK_URL is not set; plugin will be a no-op");
345
- }
346
416
  return {
347
417
  event: async ({ event }) => {
348
418
  try {
@@ -352,8 +422,6 @@ var plugin = async () => {
352
422
  const sessionID = info?.id;
353
423
  if (!sessionID) return;
354
424
  const title = info?.title ?? "(untitled)";
355
- const directory = info?.directory;
356
- const projectID = info?.projectID;
357
425
  const shareUrl = info?.share?.url;
358
426
  const createdAt = toIsoTimestamp(info?.time?.created);
359
427
  const embed = {
@@ -366,8 +434,8 @@ var plugin = async () => {
366
434
  filterSendFields(
367
435
  [
368
436
  ["sessionID", sessionID],
369
- ["projectID", projectID],
370
- ["directory", directory],
437
+ ["projectID", info?.projectID],
438
+ ["directory", info?.directory],
371
439
  ["share", shareUrl]
372
440
  ],
373
441
  withForcedSendParams(sendParams, [
@@ -375,8 +443,7 @@ var plugin = async () => {
375
443
  "projectID",
376
444
  "directory"
377
445
  ])
378
- ),
379
- false
446
+ )
380
447
  )
381
448
  };
382
449
  lastSessionInfo.set(sessionID, { title, shareUrl });
@@ -384,99 +451,6 @@ var plugin = async () => {
384
451
  if (shouldFlush(sessionID)) await flushPending(sessionID);
385
452
  return;
386
453
  }
387
- case "permission.updated": {
388
- const p = event.properties;
389
- const sessionID = p?.sessionID;
390
- if (!sessionID) return;
391
- const embed = {
392
- title: "Permission required",
393
- description: p?.title,
394
- color: COLORS.warning,
395
- timestamp: toIsoTimestamp(p?.time?.created),
396
- fields: buildFields(
397
- filterSendFields(
398
- [
399
- ["sessionID", sessionID],
400
- ["permissionID", p?.id],
401
- ["type", p?.type],
402
- ["pattern", p?.pattern],
403
- ["messageID", p?.messageID],
404
- ["callID", p?.callID]
405
- ],
406
- sendParams
407
- ),
408
- false
409
- )
410
- };
411
- const mention = buildPermissionMention();
412
- enqueueToThread(sessionID, {
413
- content: mention ? `${mention.content} Permission required` : void 0,
414
- allowed_mentions: mention?.allowed_mentions,
415
- embeds: [embed]
416
- });
417
- if (shouldFlush(sessionID)) await flushPending(sessionID);
418
- return;
419
- }
420
- case "session.idle": {
421
- const sessionID = event.properties?.sessionID;
422
- if (!sessionID) return;
423
- const embed = {
424
- title: "Session completed",
425
- color: COLORS.success,
426
- fields: buildFields(
427
- filterSendFields([["sessionID", sessionID]], sendParams),
428
- false
429
- )
430
- };
431
- const mention = buildCompleteMention();
432
- enqueueToThread(sessionID, {
433
- content: mention ? `${mention.content} Session completed` : void 0,
434
- allowed_mentions: mention?.allowed_mentions,
435
- embeds: [embed]
436
- });
437
- await flushPending(sessionID);
438
- return;
439
- }
440
- case "session.error": {
441
- const p = event.properties;
442
- const sessionID = p?.sessionID;
443
- const errorStr = safeString(p?.error);
444
- const embed = {
445
- title: "Session error",
446
- color: COLORS.error,
447
- description: errorStr ? errorStr.length > 4096 ? errorStr.slice(0, 4093) + "..." : errorStr : void 0,
448
- fields: buildFields(
449
- filterSendFields([["sessionID", sessionID]], sendParams),
450
- false
451
- )
452
- };
453
- if (!sessionID) return;
454
- const mention = buildCompleteMention();
455
- enqueueToThread(sessionID, {
456
- content: mention ? `${mention.content} Session error` : void 0,
457
- allowed_mentions: mention?.allowed_mentions,
458
- embeds: [embed]
459
- });
460
- await flushPending(sessionID);
461
- return;
462
- }
463
- case "todo.updated": {
464
- const p = event.properties;
465
- const sessionID = p?.sessionID;
466
- if (!sessionID) return;
467
- const embed = {
468
- title: "Todo updated",
469
- color: COLORS.info,
470
- fields: buildFields(
471
- filterSendFields([["sessionID", sessionID]], sendParams),
472
- false
473
- ),
474
- description: buildTodoChecklist(p?.todos)
475
- };
476
- enqueueToThread(sessionID, { embeds: [embed] });
477
- if (shouldFlush(sessionID)) await flushPending(sessionID);
478
- return;
479
- }
480
454
  case "message.updated": {
481
455
  const info = event.properties?.info;
482
456
  const messageID = info?.id;
@@ -494,17 +468,14 @@ var plugin = async () => {
494
468
  if (!sessionID || !partID || type !== "text") continue;
495
469
  const text = safeString(pendingPart?.text);
496
470
  if (role === "user" && excludeInputContext && isInputContextText(text)) {
497
- const snapshot2 = JSON.stringify({
471
+ const snapshot = JSON.stringify({
498
472
  type,
499
473
  role,
500
474
  skipped: "input_context"
501
475
  });
502
- setIfChanged(lastPartSnapshotById, partID, snapshot2);
476
+ setIfChanged(/* @__PURE__ */ new Map(), partID, snapshot);
503
477
  continue;
504
478
  }
505
- const snapshot = JSON.stringify({ type, role, text });
506
- if (!setIfChanged(lastPartSnapshotById, partID, snapshot))
507
- continue;
508
479
  if (role === "user" && !firstUserTextBySession.has(sessionID)) {
509
480
  const normalized = normalizeThreadTitle(text);
510
481
  if (normalized)
@@ -522,8 +493,7 @@ var plugin = async () => {
522
493
  ["role", role]
523
494
  ],
524
495
  sendParams
525
- ),
526
- false
496
+ )
527
497
  ),
528
498
  description: truncateText(text || "(empty)", 4096)
529
499
  };
@@ -544,27 +514,25 @@ var plugin = async () => {
544
514
  const type = part?.type;
545
515
  if (!sessionID || !messageID || !partID || !type) return;
546
516
  if (type === "reasoning") return;
517
+ const role = messageRoleById.get(messageID);
518
+ if (role !== "assistant" && role !== "user") {
519
+ const list = pendingTextPartsByMessageId.get(messageID) ?? [];
520
+ list.push(part);
521
+ pendingTextPartsByMessageId.set(messageID, list);
522
+ return;
523
+ }
547
524
  if (type === "text") {
548
- const role = messageRoleById.get(messageID);
549
- if (role !== "assistant" && role !== "user") {
550
- const list = pendingTextPartsByMessageId.get(messageID) ?? [];
551
- list.push(part);
552
- pendingTextPartsByMessageId.set(messageID, list);
553
- return;
554
- }
555
525
  if (role === "assistant" && !part?.time?.end) return;
556
526
  const text = safeString(part?.text);
557
527
  if (role === "user" && excludeInputContext && isInputContextText(text)) {
558
- const snapshot2 = JSON.stringify({
528
+ const snapshot = JSON.stringify({
559
529
  type,
560
530
  role,
561
531
  skipped: "input_context"
562
532
  });
563
- setIfChanged(lastPartSnapshotById, partID, snapshot2);
533
+ setIfChanged(/* @__PURE__ */ new Map(), partID, snapshot);
564
534
  return;
565
535
  }
566
- const snapshot = JSON.stringify({ type, role, text });
567
- if (!setIfChanged(lastPartSnapshotById, partID, snapshot)) return;
568
536
  if (role === "user" && !firstUserTextBySession.has(sessionID)) {
569
537
  const normalized = normalizeThreadTitle(text);
570
538
  if (normalized)
@@ -582,8 +550,7 @@ var plugin = async () => {
582
550
  ["role", role]
583
551
  ],
584
552
  sendParams
585
- ),
586
- false
553
+ )
587
554
  ),
588
555
  description: truncateText(text || "(empty)", 4096)
589
556
  };
@@ -593,21 +560,26 @@ var plugin = async () => {
593
560
  } else if (shouldFlush(sessionID)) {
594
561
  await flushPending(sessionID);
595
562
  }
596
- return;
597
563
  }
598
- if (type === "tool") return;
599
564
  return;
600
565
  }
601
566
  default:
602
567
  return;
603
568
  }
604
- } catch (e) {
605
- warn(`failed handling event ${event.type}`, e);
569
+ } catch {
606
570
  }
607
571
  }
608
572
  };
609
573
  };
574
+ var __test__ = {
575
+ buildMention,
576
+ buildTodoChecklist,
577
+ buildFields,
578
+ toIsoTimestamp,
579
+ postDiscordWebhook
580
+ };
610
581
  var index_default = plugin;
611
582
  export {
583
+ __test__,
612
584
  index_default as default
613
585
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-discord-notify",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A plugin that posts OpenCode events to a Discord webhook.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,6 +27,7 @@
27
27
  "scripts": {
28
28
  "build": "tsup src/index.ts --format esm --dts --out-dir dist --clean",
29
29
  "format": "prettier . --write",
30
+ "test": "vitest run",
30
31
  "prepublishOnly": "npm run build"
31
32
  },
32
33
  "peerDependencies": {
@@ -38,7 +39,8 @@
38
39
  "prettier": "^3.7.4",
39
40
  "prettier-plugin-organize-imports": "^4.3.0",
40
41
  "tsup": "^8.5.0",
41
- "typescript": "^5.8.3"
42
+ "typescript": "^5.8.3",
43
+ "vitest": "^4.0.16"
42
44
  },
43
45
  "keywords": [
44
46
  "opencode",