opencode-discord-notify 0.1.1 → 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 +7 -1
- package/README.md +7 -1
- package/dist/index.d.ts +64 -1
- package/dist/index.js +340 -301
- package/package.json +4 -2
package/README-JP.md
CHANGED
|
@@ -62,10 +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` で無効化)
|
|
66
|
+
- `SEND_PARAMS`: embed の fields として送るキーをカンマ区切りで指定。指定可能キー: `sessionID`, `permissionID`, `type`, `pattern`, `messageID`, `callID`, `partID`, `role`, `directory`, `projectID`。未設定・空文字・空要素のみの場合は全て選択。`session.created` は `SEND_PARAMS` に関わらず `sessionID`, `projectID`, `directory` を必ず含みます。
|
|
65
67
|
|
|
66
68
|
## 仕様メモ
|
|
67
69
|
|
|
68
|
-
- `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 トーストを表示します。
|
|
69
73
|
- Forum スレッド作成時は `?wait=true` を付け、レスポンスの `channel_id` を thread ID として利用します。
|
|
70
74
|
- スレッド名(`thread_name`)は以下の優先度です(最大100文字)。
|
|
71
75
|
1. 最初の user `text`
|
|
@@ -81,8 +85,10 @@ OpenCode を再起動してください。
|
|
|
81
85
|
- `message.updated` は通知しません(role 判定用に追跡。role 未確定で保留した `text` part を後から通知することがあります)。
|
|
82
86
|
- `message.part.updated` は以下の方針です。
|
|
83
87
|
- `text`: user は即時通知。assistant は `part.time.end` がある確定時のみ通知(ストリーミング途中更新は通知しない)
|
|
88
|
+
- embed タイトルは `User says` / `Agent says` です
|
|
84
89
|
- `tool`: 通知しない
|
|
85
90
|
- `reasoning`: 通知しない(内部思考が含まれる可能性があるため)
|
|
91
|
+
- `SEND_PARAMS` の制御対象は embed の fields のみです(title/description/content/timestamp などは対象外)。また `share` は fields としては送りません(Session started の embed URL には `shareUrl` を使います)。
|
|
86
92
|
|
|
87
93
|
## 動作確認(手動)
|
|
88
94
|
|
package/README.md
CHANGED
|
@@ -64,10 +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)
|
|
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.
|
|
67
69
|
|
|
68
70
|
## Notes / behavior
|
|
69
71
|
|
|
70
|
-
- If `DISCORD_WEBHOOK_URL` is not set, it becomes a no-op
|
|
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.
|
|
71
75
|
- For Forum thread creation, it appends `?wait=true` and uses `channel_id` in the response as the thread ID.
|
|
72
76
|
- `thread_name` priority order (max 100 chars):
|
|
73
77
|
1. first user `text`
|
|
@@ -83,8 +87,10 @@ Optional:
|
|
|
83
87
|
- `message.updated` is not posted (tracked for role inference; may post a previously-held text part later).
|
|
84
88
|
- `message.part.updated` policy:
|
|
85
89
|
- `text`: user is posted immediately; assistant is posted only when finalized (when `part.time.end` exists)
|
|
90
|
+
- Embed titles are `User says` / `Agent says`
|
|
86
91
|
- `tool`: not posted
|
|
87
92
|
- `reasoning`: not posted (to avoid exposing internal thoughts)
|
|
93
|
+
- `SEND_PARAMS` controls embed fields only (it does not affect title/description/content/timestamp). `share` is not an embed field (but Session started uses `shareUrl` as the embed URL).
|
|
88
94
|
|
|
89
95
|
## Manual test
|
|
90
96
|
|
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
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
+
var SEND_PARAM_KEYS = [
|
|
3
|
+
"sessionID",
|
|
4
|
+
"permissionID",
|
|
5
|
+
"type",
|
|
6
|
+
"pattern",
|
|
7
|
+
"messageID",
|
|
8
|
+
"callID",
|
|
9
|
+
"partID",
|
|
10
|
+
"role",
|
|
11
|
+
"directory",
|
|
12
|
+
"projectID"
|
|
13
|
+
];
|
|
14
|
+
var SEND_PARAM_KEY_SET = new Set(SEND_PARAM_KEYS);
|
|
2
15
|
var COLORS = {
|
|
3
16
|
info: 5793266,
|
|
4
17
|
success: 5763719,
|
|
@@ -32,6 +45,23 @@ function buildFields(fields, inline = false) {
|
|
|
32
45
|
}
|
|
33
46
|
return result.length ? result : void 0;
|
|
34
47
|
}
|
|
48
|
+
function isSendParamKey(value) {
|
|
49
|
+
return SEND_PARAM_KEY_SET.has(value);
|
|
50
|
+
}
|
|
51
|
+
function filterSendFields(fields, allowed) {
|
|
52
|
+
return fields.filter(([name]) => {
|
|
53
|
+
if (!isSendParamKey(name)) return false;
|
|
54
|
+
return allowed.has(name);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function getTextPartEmbedTitle(role) {
|
|
58
|
+
return role === "user" ? "User says" : "Agent says";
|
|
59
|
+
}
|
|
60
|
+
function withForcedSendParams(base, forced) {
|
|
61
|
+
const next = new Set(base);
|
|
62
|
+
for (const key of forced) next.add(key);
|
|
63
|
+
return next;
|
|
64
|
+
}
|
|
35
65
|
function getEnv(name) {
|
|
36
66
|
try {
|
|
37
67
|
return process.env[name];
|
|
@@ -39,6 +69,17 @@ function getEnv(name) {
|
|
|
39
69
|
return void 0;
|
|
40
70
|
}
|
|
41
71
|
}
|
|
72
|
+
function parseSendParams(raw) {
|
|
73
|
+
if (raw === void 0) return new Set(SEND_PARAM_KEYS);
|
|
74
|
+
const tokens = raw.split(",").map((v) => v.trim()).filter(Boolean);
|
|
75
|
+
if (!tokens.length) return new Set(SEND_PARAM_KEYS);
|
|
76
|
+
const result = /* @__PURE__ */ new Set();
|
|
77
|
+
for (const token of tokens) {
|
|
78
|
+
if (!SEND_PARAM_KEY_SET.has(token)) continue;
|
|
79
|
+
result.add(token);
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
42
83
|
function withQuery(url, params) {
|
|
43
84
|
const u = new URL(url);
|
|
44
85
|
for (const [k, v] of Object.entries(params)) {
|
|
@@ -47,45 +88,165 @@ function withQuery(url, params) {
|
|
|
47
88
|
}
|
|
48
89
|
return u.toString();
|
|
49
90
|
}
|
|
50
|
-
|
|
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) {
|
|
51
153
|
const { webhookUrl, threadId, wait, body } = input;
|
|
154
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
155
|
+
const sleepImpl = deps.sleepImpl ?? sleep;
|
|
52
156
|
const url = withQuery(webhookUrl, {
|
|
53
157
|
thread_id: threadId,
|
|
54
158
|
wait: wait ? "true" : void 0
|
|
55
159
|
});
|
|
56
|
-
const
|
|
160
|
+
const requestInit = {
|
|
57
161
|
method: "POST",
|
|
58
162
|
headers: {
|
|
59
163
|
"content-type": "application/json"
|
|
60
164
|
},
|
|
61
165
|
body: JSON.stringify(body)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
}
|
|
75
180
|
return void 0;
|
|
76
|
-
return {
|
|
77
|
-
id: messageId,
|
|
78
|
-
channel_id: channelId
|
|
79
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
|
+
);
|
|
80
243
|
}
|
|
81
244
|
var GLOBAL_GUARD_KEY = "__opencode_discord_notify_registered__";
|
|
82
|
-
var plugin = async () => {
|
|
245
|
+
var plugin = async ({ client }) => {
|
|
83
246
|
const globalWithGuard = globalThis;
|
|
84
247
|
if (globalWithGuard[GLOBAL_GUARD_KEY]) {
|
|
85
|
-
return {
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
};
|
|
248
|
+
return { event: async () => {
|
|
249
|
+
} };
|
|
89
250
|
}
|
|
90
251
|
globalWithGuard[GLOBAL_GUARD_KEY] = true;
|
|
91
252
|
const webhookUrl = getEnv("DISCORD_WEBHOOK_URL");
|
|
@@ -97,15 +258,44 @@ var plugin = async () => {
|
|
|
97
258
|
const permissionMention = permissionMentionRaw || void 0;
|
|
98
259
|
const excludeInputContextRaw = (getEnv("DISCORD_WEBHOOK_EXCLUDE_INPUT_CONTEXT") ?? "1").trim();
|
|
99
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;
|
|
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
|
+
};
|
|
100
293
|
const sessionToThread = /* @__PURE__ */ new Map();
|
|
101
|
-
const threadCreateInFlight = /* @__PURE__ */ new Map();
|
|
102
294
|
const pendingPostsBySession = /* @__PURE__ */ new Map();
|
|
103
295
|
const firstUserTextBySession = /* @__PURE__ */ new Map();
|
|
104
296
|
const pendingTextPartsByMessageId = /* @__PURE__ */ new Map();
|
|
105
|
-
const sessionSerial = /* @__PURE__ */ new Map();
|
|
106
|
-
const lastSessionInfo = /* @__PURE__ */ new Map();
|
|
107
|
-
const lastPartSnapshotById = /* @__PURE__ */ new Map();
|
|
108
297
|
const messageRoleById = /* @__PURE__ */ new Map();
|
|
298
|
+
const lastSessionInfo = /* @__PURE__ */ new Map();
|
|
109
299
|
function normalizeThreadTitle(value) {
|
|
110
300
|
return safeString(value).replace(/\s+/g, " ").trim();
|
|
111
301
|
}
|
|
@@ -125,182 +315,104 @@ var plugin = async () => {
|
|
|
125
315
|
if (fromSessionId) return fromSessionId.slice(0, 100);
|
|
126
316
|
return "untitled";
|
|
127
317
|
}
|
|
128
|
-
async function
|
|
129
|
-
if (!webhookUrl) return;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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;
|
|
137
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;
|
|
138
357
|
}
|
|
139
358
|
function enqueueToThread(sessionID, body) {
|
|
140
359
|
const queue = pendingPostsBySession.get(sessionID) ?? [];
|
|
141
360
|
queue.push(body);
|
|
142
361
|
pendingPostsBySession.set(sessionID, queue);
|
|
143
362
|
}
|
|
144
|
-
function enqueueSerial(sessionID, task) {
|
|
145
|
-
const prev = sessionSerial.get(sessionID) ?? Promise.resolve();
|
|
146
|
-
const next = prev.then(task, task);
|
|
147
|
-
sessionSerial.set(sessionID, next);
|
|
148
|
-
next.finally(() => {
|
|
149
|
-
if (sessionSerial.get(sessionID) === next) sessionSerial.delete(sessionID);
|
|
150
|
-
});
|
|
151
|
-
return next;
|
|
152
|
-
}
|
|
153
|
-
async function ensureThread(sessionID) {
|
|
154
|
-
if (!webhookUrl) return void 0;
|
|
155
|
-
const existingThreadId = sessionToThread.get(sessionID);
|
|
156
|
-
if (existingThreadId) return existingThreadId;
|
|
157
|
-
const inflight = threadCreateInFlight.get(sessionID);
|
|
158
|
-
if (inflight) return await inflight;
|
|
159
|
-
const create = (async () => {
|
|
160
|
-
const queue = pendingPostsBySession.get(sessionID) ?? [];
|
|
161
|
-
const first = queue.shift();
|
|
162
|
-
if (queue.length) pendingPostsBySession.set(sessionID, queue);
|
|
163
|
-
else pendingPostsBySession.delete(sessionID);
|
|
164
|
-
if (!first) return void 0;
|
|
165
|
-
const threadName = buildThreadName(sessionID);
|
|
166
|
-
try {
|
|
167
|
-
const res = await postDiscordWebhook({
|
|
168
|
-
webhookUrl,
|
|
169
|
-
wait: true,
|
|
170
|
-
body: {
|
|
171
|
-
...first,
|
|
172
|
-
thread_name: threadName,
|
|
173
|
-
username,
|
|
174
|
-
avatar_url: avatarUrl
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
if (res?.channel_id) {
|
|
178
|
-
sessionToThread.set(sessionID, res.channel_id);
|
|
179
|
-
return res.channel_id;
|
|
180
|
-
}
|
|
181
|
-
warn(`failed to capture thread_id for session ${sessionID}`);
|
|
182
|
-
return void 0;
|
|
183
|
-
} catch (e) {
|
|
184
|
-
await sendToChannel(first);
|
|
185
|
-
return void 0;
|
|
186
|
-
} finally {
|
|
187
|
-
threadCreateInFlight.delete(sessionID);
|
|
188
|
-
}
|
|
189
|
-
})();
|
|
190
|
-
threadCreateInFlight.set(sessionID, create);
|
|
191
|
-
return await create;
|
|
192
|
-
}
|
|
193
363
|
async function flushPending(sessionID) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
+
{
|
|
203
374
|
webhookUrl,
|
|
204
375
|
threadId,
|
|
205
|
-
body: {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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;
|
|
216
389
|
}
|
|
217
|
-
} finally {
|
|
218
|
-
pendingPostsBySession.delete(sessionID);
|
|
219
390
|
}
|
|
220
|
-
|
|
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
|
+
}
|
|
221
400
|
}
|
|
222
401
|
function shouldFlush(sessionID) {
|
|
223
402
|
return sessionToThread.has(sessionID) || firstUserTextBySession.has(sessionID);
|
|
224
403
|
}
|
|
225
|
-
function warn(message, error) {
|
|
226
|
-
if (error) console.warn(`[opencode-discord-notify] ${message}`, error);
|
|
227
|
-
else console.warn(`[opencode-discord-notify] ${message}`);
|
|
228
|
-
}
|
|
229
|
-
function buildMention(mention, nameForLog) {
|
|
230
|
-
if (!mention) return void 0;
|
|
231
|
-
if (mention === "@everyone" || mention === "@here") {
|
|
232
|
-
return {
|
|
233
|
-
content: mention,
|
|
234
|
-
allowed_mentions: {
|
|
235
|
-
parse: ["everyone"]
|
|
236
|
-
}
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
warn(
|
|
240
|
-
`${nameForLog} is set but unsupported: ${mention}. Only @everyone/@here are supported.`
|
|
241
|
-
);
|
|
242
|
-
return {
|
|
243
|
-
content: mention,
|
|
244
|
-
allowed_mentions: {
|
|
245
|
-
parse: []
|
|
246
|
-
}
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
404
|
function buildCompleteMention() {
|
|
250
405
|
return buildMention(completeMention, "DISCORD_WEBHOOK_COMPLETE_MENTION");
|
|
251
406
|
}
|
|
252
407
|
function buildPermissionMention() {
|
|
253
408
|
return buildMention(permissionMention, "DISCORD_WEBHOOK_PERMISSION_MENTION");
|
|
254
409
|
}
|
|
255
|
-
function normalizeTodoContent(value) {
|
|
256
|
-
return safeString(value).replace(/\s+/g, " ").trim();
|
|
257
|
-
}
|
|
258
|
-
function truncateText(value, maxLength) {
|
|
259
|
-
if (value.length <= maxLength) return value;
|
|
260
|
-
if (maxLength <= 3) return value.slice(0, maxLength);
|
|
261
|
-
return value.slice(0, maxLength - 3) + "...";
|
|
262
|
-
}
|
|
263
410
|
function setIfChanged(map, key, next) {
|
|
264
411
|
const prev = map.get(key);
|
|
265
412
|
if (prev === next) return false;
|
|
266
413
|
map.set(key, next);
|
|
267
414
|
return true;
|
|
268
415
|
}
|
|
269
|
-
function buildTodoChecklist(todos) {
|
|
270
|
-
const maxDescription = 4096;
|
|
271
|
-
const items = Array.isArray(todos) ? todos : [];
|
|
272
|
-
let matchCount = 0;
|
|
273
|
-
let description = "";
|
|
274
|
-
let truncated = false;
|
|
275
|
-
for (const item of items) {
|
|
276
|
-
const status = item?.status;
|
|
277
|
-
if (status === "cancelled") continue;
|
|
278
|
-
const content = normalizeTodoContent(item?.content);
|
|
279
|
-
if (!content) continue;
|
|
280
|
-
const marker = status === "completed" ? "[\u2713]" : status === "in_progress" ? "[\u25B6]" : "[ ]";
|
|
281
|
-
const line = `> ${marker} ${truncateText(content, 200)}`;
|
|
282
|
-
const nextChunk = (description ? "\n" : "") + line;
|
|
283
|
-
if (description.length + nextChunk.length > maxDescription) {
|
|
284
|
-
truncated = true;
|
|
285
|
-
break;
|
|
286
|
-
}
|
|
287
|
-
description += nextChunk;
|
|
288
|
-
matchCount += 1;
|
|
289
|
-
}
|
|
290
|
-
if (!description) {
|
|
291
|
-
return "> (no todos)";
|
|
292
|
-
}
|
|
293
|
-
if (truncated || matchCount < items.length) {
|
|
294
|
-
const moreLine = `${description ? "\n" : ""}> ...and more`;
|
|
295
|
-
if (description.length + moreLine.length <= maxDescription) {
|
|
296
|
-
description += moreLine;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
return description;
|
|
300
|
-
}
|
|
301
|
-
if (!webhookUrl) {
|
|
302
|
-
warn("DISCORD_WEBHOOK_URL is not set; plugin will be a no-op");
|
|
303
|
-
}
|
|
304
416
|
return {
|
|
305
417
|
event: async ({ event }) => {
|
|
306
418
|
try {
|
|
@@ -310,8 +422,6 @@ var plugin = async () => {
|
|
|
310
422
|
const sessionID = info?.id;
|
|
311
423
|
if (!sessionID) return;
|
|
312
424
|
const title = info?.title ?? "(untitled)";
|
|
313
|
-
const directory = info?.directory;
|
|
314
|
-
const projectID = info?.projectID;
|
|
315
425
|
const shareUrl = info?.share?.url;
|
|
316
426
|
const createdAt = toIsoTimestamp(info?.time?.created);
|
|
317
427
|
const embed = {
|
|
@@ -321,13 +431,19 @@ var plugin = async () => {
|
|
|
321
431
|
color: COLORS.info,
|
|
322
432
|
timestamp: createdAt,
|
|
323
433
|
fields: buildFields(
|
|
324
|
-
|
|
325
|
-
[
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
434
|
+
filterSendFields(
|
|
435
|
+
[
|
|
436
|
+
["sessionID", sessionID],
|
|
437
|
+
["projectID", info?.projectID],
|
|
438
|
+
["directory", info?.directory],
|
|
439
|
+
["share", shareUrl]
|
|
440
|
+
],
|
|
441
|
+
withForcedSendParams(sendParams, [
|
|
442
|
+
"sessionID",
|
|
443
|
+
"projectID",
|
|
444
|
+
"directory"
|
|
445
|
+
])
|
|
446
|
+
)
|
|
331
447
|
)
|
|
332
448
|
};
|
|
333
449
|
lastSessionInfo.set(sessionID, { title, shareUrl });
|
|
@@ -335,87 +451,6 @@ var plugin = async () => {
|
|
|
335
451
|
if (shouldFlush(sessionID)) await flushPending(sessionID);
|
|
336
452
|
return;
|
|
337
453
|
}
|
|
338
|
-
case "permission.updated": {
|
|
339
|
-
const p = event.properties;
|
|
340
|
-
const sessionID = p?.sessionID;
|
|
341
|
-
if (!sessionID) return;
|
|
342
|
-
const embed = {
|
|
343
|
-
title: "Permission required",
|
|
344
|
-
description: p?.title,
|
|
345
|
-
color: COLORS.warning,
|
|
346
|
-
timestamp: toIsoTimestamp(p?.time?.created),
|
|
347
|
-
fields: buildFields(
|
|
348
|
-
[
|
|
349
|
-
["sessionID", sessionID],
|
|
350
|
-
["permissionID", p?.id],
|
|
351
|
-
["type", p?.type],
|
|
352
|
-
["pattern", p?.pattern],
|
|
353
|
-
["messageID", p?.messageID],
|
|
354
|
-
["callID", p?.callID]
|
|
355
|
-
],
|
|
356
|
-
false
|
|
357
|
-
)
|
|
358
|
-
};
|
|
359
|
-
const mention = buildPermissionMention();
|
|
360
|
-
enqueueToThread(sessionID, {
|
|
361
|
-
content: mention ? `${mention.content} Permission required` : void 0,
|
|
362
|
-
allowed_mentions: mention?.allowed_mentions,
|
|
363
|
-
embeds: [embed]
|
|
364
|
-
});
|
|
365
|
-
if (shouldFlush(sessionID)) await flushPending(sessionID);
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
case "session.idle": {
|
|
369
|
-
const sessionID = event.properties?.sessionID;
|
|
370
|
-
if (!sessionID) return;
|
|
371
|
-
const embed = {
|
|
372
|
-
title: "Session completed",
|
|
373
|
-
color: COLORS.success,
|
|
374
|
-
fields: buildFields([["sessionID", sessionID]], false)
|
|
375
|
-
};
|
|
376
|
-
const mention = buildCompleteMention();
|
|
377
|
-
enqueueToThread(sessionID, {
|
|
378
|
-
content: mention ? `${mention.content} Session completed` : void 0,
|
|
379
|
-
allowed_mentions: mention?.allowed_mentions,
|
|
380
|
-
embeds: [embed]
|
|
381
|
-
});
|
|
382
|
-
await flushPending(sessionID);
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
case "session.error": {
|
|
386
|
-
const p = event.properties;
|
|
387
|
-
const sessionID = p?.sessionID;
|
|
388
|
-
const errorStr = safeString(p?.error);
|
|
389
|
-
const embed = {
|
|
390
|
-
title: "Session error",
|
|
391
|
-
color: COLORS.error,
|
|
392
|
-
description: errorStr ? errorStr.length > 4096 ? errorStr.slice(0, 4093) + "..." : errorStr : void 0,
|
|
393
|
-
fields: buildFields([["sessionID", sessionID]], false)
|
|
394
|
-
};
|
|
395
|
-
if (!sessionID) return;
|
|
396
|
-
const mention = buildCompleteMention();
|
|
397
|
-
enqueueToThread(sessionID, {
|
|
398
|
-
content: mention ? `${mention.content} Session error` : void 0,
|
|
399
|
-
allowed_mentions: mention?.allowed_mentions,
|
|
400
|
-
embeds: [embed]
|
|
401
|
-
});
|
|
402
|
-
await flushPending(sessionID);
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
case "todo.updated": {
|
|
406
|
-
const p = event.properties;
|
|
407
|
-
const sessionID = p?.sessionID;
|
|
408
|
-
if (!sessionID) return;
|
|
409
|
-
const embed = {
|
|
410
|
-
title: "Todo updated",
|
|
411
|
-
color: COLORS.info,
|
|
412
|
-
fields: buildFields([["sessionID", sessionID]], false),
|
|
413
|
-
description: buildTodoChecklist(p?.todos)
|
|
414
|
-
};
|
|
415
|
-
enqueueToThread(sessionID, { embeds: [embed] });
|
|
416
|
-
if (shouldFlush(sessionID)) await flushPending(sessionID);
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
454
|
case "message.updated": {
|
|
420
455
|
const info = event.properties?.info;
|
|
421
456
|
const messageID = info?.id;
|
|
@@ -433,33 +468,32 @@ var plugin = async () => {
|
|
|
433
468
|
if (!sessionID || !partID || type !== "text") continue;
|
|
434
469
|
const text = safeString(pendingPart?.text);
|
|
435
470
|
if (role === "user" && excludeInputContext && isInputContextText(text)) {
|
|
436
|
-
const
|
|
471
|
+
const snapshot = JSON.stringify({
|
|
437
472
|
type,
|
|
438
473
|
role,
|
|
439
474
|
skipped: "input_context"
|
|
440
475
|
});
|
|
441
|
-
setIfChanged(
|
|
476
|
+
setIfChanged(/* @__PURE__ */ new Map(), partID, snapshot);
|
|
442
477
|
continue;
|
|
443
478
|
}
|
|
444
|
-
const snapshot = JSON.stringify({ type, role, text });
|
|
445
|
-
if (!setIfChanged(lastPartSnapshotById, partID, snapshot))
|
|
446
|
-
continue;
|
|
447
479
|
if (role === "user" && !firstUserTextBySession.has(sessionID)) {
|
|
448
480
|
const normalized = normalizeThreadTitle(text);
|
|
449
481
|
if (normalized)
|
|
450
482
|
firstUserTextBySession.set(sessionID, normalized);
|
|
451
483
|
}
|
|
452
484
|
const embed = {
|
|
453
|
-
title: role
|
|
485
|
+
title: getTextPartEmbedTitle(role),
|
|
454
486
|
color: COLORS.info,
|
|
455
487
|
fields: buildFields(
|
|
456
|
-
|
|
457
|
-
[
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
488
|
+
filterSendFields(
|
|
489
|
+
[
|
|
490
|
+
["sessionID", sessionID],
|
|
491
|
+
["messageID", messageID],
|
|
492
|
+
["partID", partID],
|
|
493
|
+
["role", role]
|
|
494
|
+
],
|
|
495
|
+
sendParams
|
|
496
|
+
)
|
|
463
497
|
),
|
|
464
498
|
description: truncateText(text || "(empty)", 4096)
|
|
465
499
|
};
|
|
@@ -480,43 +514,43 @@ var plugin = async () => {
|
|
|
480
514
|
const type = part?.type;
|
|
481
515
|
if (!sessionID || !messageID || !partID || !type) return;
|
|
482
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
|
+
}
|
|
483
524
|
if (type === "text") {
|
|
484
|
-
const role = messageRoleById.get(messageID);
|
|
485
|
-
if (role !== "assistant" && role !== "user") {
|
|
486
|
-
const list = pendingTextPartsByMessageId.get(messageID) ?? [];
|
|
487
|
-
list.push(part);
|
|
488
|
-
pendingTextPartsByMessageId.set(messageID, list);
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
525
|
if (role === "assistant" && !part?.time?.end) return;
|
|
492
526
|
const text = safeString(part?.text);
|
|
493
527
|
if (role === "user" && excludeInputContext && isInputContextText(text)) {
|
|
494
|
-
const
|
|
528
|
+
const snapshot = JSON.stringify({
|
|
495
529
|
type,
|
|
496
530
|
role,
|
|
497
531
|
skipped: "input_context"
|
|
498
532
|
});
|
|
499
|
-
setIfChanged(
|
|
533
|
+
setIfChanged(/* @__PURE__ */ new Map(), partID, snapshot);
|
|
500
534
|
return;
|
|
501
535
|
}
|
|
502
|
-
const snapshot = JSON.stringify({ type, role, text });
|
|
503
|
-
if (!setIfChanged(lastPartSnapshotById, partID, snapshot)) return;
|
|
504
536
|
if (role === "user" && !firstUserTextBySession.has(sessionID)) {
|
|
505
537
|
const normalized = normalizeThreadTitle(text);
|
|
506
538
|
if (normalized)
|
|
507
539
|
firstUserTextBySession.set(sessionID, normalized);
|
|
508
540
|
}
|
|
509
541
|
const embed = {
|
|
510
|
-
title: role
|
|
542
|
+
title: getTextPartEmbedTitle(role),
|
|
511
543
|
color: COLORS.info,
|
|
512
544
|
fields: buildFields(
|
|
513
|
-
|
|
514
|
-
[
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
545
|
+
filterSendFields(
|
|
546
|
+
[
|
|
547
|
+
["sessionID", sessionID],
|
|
548
|
+
["messageID", messageID],
|
|
549
|
+
["partID", partID],
|
|
550
|
+
["role", role]
|
|
551
|
+
],
|
|
552
|
+
sendParams
|
|
553
|
+
)
|
|
520
554
|
),
|
|
521
555
|
description: truncateText(text || "(empty)", 4096)
|
|
522
556
|
};
|
|
@@ -526,21 +560,26 @@ var plugin = async () => {
|
|
|
526
560
|
} else if (shouldFlush(sessionID)) {
|
|
527
561
|
await flushPending(sessionID);
|
|
528
562
|
}
|
|
529
|
-
return;
|
|
530
563
|
}
|
|
531
|
-
if (type === "tool") return;
|
|
532
564
|
return;
|
|
533
565
|
}
|
|
534
566
|
default:
|
|
535
567
|
return;
|
|
536
568
|
}
|
|
537
|
-
} catch
|
|
538
|
-
warn(`failed handling event ${event.type}`, e);
|
|
569
|
+
} catch {
|
|
539
570
|
}
|
|
540
571
|
}
|
|
541
572
|
};
|
|
542
573
|
};
|
|
574
|
+
var __test__ = {
|
|
575
|
+
buildMention,
|
|
576
|
+
buildTodoChecklist,
|
|
577
|
+
buildFields,
|
|
578
|
+
toIsoTimestamp,
|
|
579
|
+
postDiscordWebhook
|
|
580
|
+
};
|
|
543
581
|
var index_default = plugin;
|
|
544
582
|
export {
|
|
583
|
+
__test__,
|
|
545
584
|
index_default as default
|
|
546
585
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-discord-notify",
|
|
3
|
-
"version": "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",
|