opencode-discord-notify 0.3.2 → 0.4.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.
Files changed (2) hide show
  1. package/dist/index.js +208 -111
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -1,4 +1,15 @@
1
1
  // src/index.ts
2
+ var DISCORD_FIELD_VALUE_MAX_LENGTH = 1024;
3
+ var DISCORD_EMBED_DESCRIPTION_MAX_LENGTH = 4096;
4
+ var DISCORD_THREAD_NAME_MAX_LENGTH = 100;
5
+ var ELLIPSIS = "...";
6
+ var ELLIPSIS_LENGTH = 3;
7
+ var TOAST_DURATION_MS = 8e3;
8
+ var TOAST_COOLDOWN_MS = 3e4;
9
+ var TODO_ITEM_DISPLAY_MAX_LENGTH = 200;
10
+ var HTTP_STATUS_TOO_MANY_REQUESTS = 429;
11
+ var MS_PER_SECOND = 1e3;
12
+ var DEFAULT_RATE_LIMIT_WAIT_MS = 1e4;
2
13
  var SEND_PARAM_KEYS = [
3
14
  "sessionID",
4
15
  "permissionID",
@@ -37,9 +48,13 @@ function buildFields(fields, inline = false) {
37
48
  for (const [name, rawValue] of fields) {
38
49
  const value = safeString(rawValue);
39
50
  if (!value) continue;
51
+ const truncatedValue = value.length > DISCORD_FIELD_VALUE_MAX_LENGTH ? value.slice(
52
+ 0,
53
+ DISCORD_FIELD_VALUE_MAX_LENGTH - ELLIPSIS_LENGTH
54
+ ) + ELLIPSIS : value;
40
55
  result.push({
41
56
  name,
42
- value: value.length > 1024 ? value.slice(0, 1021) + "..." : value,
57
+ value: truncatedValue,
43
58
  inline
44
59
  });
45
60
  }
@@ -117,8 +132,12 @@ function buildMention(mention, nameForLog) {
117
132
  function normalizeTodoContent(value) {
118
133
  return safeString(value).replace(/\s+/g, " ").trim();
119
134
  }
135
+ function getTodoStatusMarker(status) {
136
+ if (status === "completed") return "[\u2713]";
137
+ if (status === "in_progress") return "[\u25B6]";
138
+ return "[ ]";
139
+ }
120
140
  function buildTodoChecklist(todos) {
121
- const maxDescription = 4096;
122
141
  const items = Array.isArray(todos) ? todos : [];
123
142
  let matchCount = 0;
124
143
  let description = "";
@@ -128,10 +147,10 @@ function buildTodoChecklist(todos) {
128
147
  if (status === "cancelled") continue;
129
148
  const content = normalizeTodoContent(item?.content);
130
149
  if (!content) continue;
131
- const marker = status === "completed" ? "[\u2713]" : status === "in_progress" ? "[\u25B6]" : "[ ]";
132
- const line = `> ${marker} ${truncateText(content, 200)}`;
150
+ const marker = getTodoStatusMarker(status);
151
+ const line = `> ${marker} ${truncateText(content, TODO_ITEM_DISPLAY_MAX_LENGTH)}`;
133
152
  const nextChunk = (description ? "\n" : "") + line;
134
- if (description.length + nextChunk.length > maxDescription) {
153
+ if (description.length + nextChunk.length > DISCORD_EMBED_DESCRIPTION_MAX_LENGTH) {
135
154
  truncated = true;
136
155
  break;
137
156
  }
@@ -143,7 +162,7 @@ function buildTodoChecklist(todos) {
143
162
  }
144
163
  if (truncated || matchCount < items.length) {
145
164
  const moreLine = `${description ? "\n" : ""}> ...and more`;
146
- if (description.length + moreLine.length <= maxDescription) {
165
+ if (description.length + moreLine.length <= DISCORD_EMBED_DESCRIPTION_MAX_LENGTH) {
147
166
  description += moreLine;
148
167
  }
149
168
  }
@@ -197,10 +216,10 @@ async function postDiscordWebhook(input, deps) {
197
216
  return void 0;
198
217
  return { id: messageId, channel_id: channelId };
199
218
  }
200
- if (response.status === 429) {
219
+ if (response.status === HTTP_STATUS_TOO_MANY_REQUESTS) {
201
220
  const text2 = await response.text().catch(() => "");
202
221
  const retryAfterSeconds = parseRetryAfterFromText(text2) ?? parseRetryAfterFromHeader(response.headers);
203
- const waitMs = retryAfterSeconds === void 0 ? deps.waitOnRateLimitMs : Math.ceil(retryAfterSeconds * 1e3);
222
+ const waitMs = retryAfterSeconds === void 0 ? deps.waitOnRateLimitMs : Math.ceil(retryAfterSeconds * MS_PER_SECOND);
204
223
  await sleepImpl(waitMs);
205
224
  const retryResponse = await doRequest();
206
225
  if (!retryResponse.ok) {
@@ -208,8 +227,8 @@ async function postDiscordWebhook(input, deps) {
208
227
  await deps.maybeAlertError({
209
228
  key: `discord_webhook_error:${retryResponse.status}`,
210
229
  title: "Discord webhook rate-limited",
211
- message: `Discord webhook returned 429 (rate limited). Waited ${Math.round(
212
- waitMs / 1e3
230
+ message: `Discord webhook returned ${HTTP_STATUS_TOO_MANY_REQUESTS} (rate limited). Waited ${Math.round(
231
+ waitMs / MS_PER_SECOND
213
232
  )}s and retried, but it still failed.`,
214
233
  variant: "warning"
215
234
  });
@@ -260,14 +279,14 @@ var plugin = async ({ client }) => {
260
279
  const excludeInputContext = excludeInputContextRaw !== "0";
261
280
  const showErrorAlertRaw = (getEnv("DISCORD_WEBHOOK_SHOW_ERROR_ALERT") ?? "1").trim();
262
281
  const showErrorAlert = showErrorAlertRaw !== "0";
263
- const waitOnRateLimitMs = 1e4;
264
- const toastCooldownMs = 3e4;
282
+ const waitOnRateLimitMs = DEFAULT_RATE_LIMIT_WAIT_MS;
265
283
  const sendParams = parseSendParams(getEnv("DISCORD_SEND_PARAMS"));
266
284
  const lastAlertAtByKey = /* @__PURE__ */ new Map();
285
+ const sentTextPartIds = /* @__PURE__ */ new Set();
267
286
  const showToast = async ({ title, message, variant }) => {
268
287
  try {
269
288
  await client.tui.showToast({
270
- body: { title, message, variant, duration: 8e3 }
289
+ body: { title, message, variant, duration: TOAST_DURATION_MS }
271
290
  });
272
291
  } catch {
273
292
  }
@@ -281,7 +300,7 @@ var plugin = async ({ client }) => {
281
300
  if (!showErrorAlert) return;
282
301
  const now = Date.now();
283
302
  const last = lastAlertAtByKey.get(key);
284
- if (last !== void 0 && now - last < toastCooldownMs) return;
303
+ if (last !== void 0 && now - last < TOAST_COOLDOWN_MS) return;
285
304
  lastAlertAtByKey.set(key, now);
286
305
  await showToast({ title, message, variant });
287
306
  };
@@ -289,7 +308,7 @@ var plugin = async ({ client }) => {
289
308
  async function showMissingUrlToastOnce() {
290
309
  const now = Date.now();
291
310
  const last = lastAlertAtByKey.get(MISSING_URL_KEY);
292
- if (last !== void 0 && now - last < toastCooldownMs) return;
311
+ if (last !== void 0 && now - last < TOAST_COOLDOWN_MS) return;
293
312
  lastAlertAtByKey.set(MISSING_URL_KEY, now);
294
313
  await showToast({
295
314
  title: "Discord webhook not configured",
@@ -317,15 +336,17 @@ var plugin = async ({ client }) => {
317
336
  }
318
337
  function buildThreadName(sessionID) {
319
338
  const fromUser = normalizeThreadTitle(firstUserTextBySession.get(sessionID));
320
- if (fromUser) return fromUser.slice(0, 100);
339
+ if (fromUser) return fromUser.slice(0, DISCORD_THREAD_NAME_MAX_LENGTH);
321
340
  const fromSessionTitle = normalizeThreadTitle(
322
341
  lastSessionInfo.get(sessionID)?.title
323
342
  );
324
- if (fromSessionTitle) return fromSessionTitle.slice(0, 100);
343
+ if (fromSessionTitle)
344
+ return fromSessionTitle.slice(0, DISCORD_THREAD_NAME_MAX_LENGTH);
325
345
  const fromSessionId = normalizeThreadTitle(
326
346
  sessionID ? `session ${sessionID}` : ""
327
347
  );
328
- if (fromSessionId) return fromSessionId.slice(0, 100);
348
+ if (fromSessionId)
349
+ return fromSessionId.slice(0, DISCORD_THREAD_NAME_MAX_LENGTH);
329
350
  return "untitled";
330
351
  }
331
352
  async function ensureThread(sessionID) {
@@ -424,11 +445,52 @@ var plugin = async ({ client }) => {
424
445
  function buildPermissionMention() {
425
446
  return buildMention(permissionMention, "DISCORD_WEBHOOK_PERMISSION_MENTION");
426
447
  }
427
- function setIfChanged(map, key, next) {
428
- const prev = map.get(key);
429
- if (prev === next) return false;
430
- map.set(key, next);
431
- return true;
448
+ function escapeDiscordMention(text) {
449
+ return text.replace(/^@/g, "@\u200B");
450
+ }
451
+ async function handleTextPart(input) {
452
+ const { part, role, sessionID, messageID, replay } = input;
453
+ const partID = part?.id;
454
+ if (!partID) return;
455
+ if (!replay && role === "assistant" && !part?.time?.end) return;
456
+ if (sentTextPartIds.has(partID)) return;
457
+ sentTextPartIds.add(partID);
458
+ const text = escapeDiscordMention(safeString(part?.text));
459
+ if (role === "user" && excludeInputContext && isInputContextText(text)) {
460
+ return;
461
+ }
462
+ if (role === "user" && text.trim() === "" || text.trim() === "(empty)") {
463
+ return;
464
+ }
465
+ if (role === "user" && !firstUserTextBySession.has(sessionID)) {
466
+ const normalized = normalizeThreadTitle(text);
467
+ if (normalized) firstUserTextBySession.set(sessionID, normalized);
468
+ }
469
+ const embed = {
470
+ title: getTextPartEmbedTitle(role),
471
+ color: COLORS.info,
472
+ fields: buildFields(
473
+ filterSendFields(
474
+ [
475
+ ["sessionID", sessionID],
476
+ ["messageID", messageID],
477
+ ["partID", partID],
478
+ ["role", role]
479
+ ],
480
+ sendParams
481
+ )
482
+ ),
483
+ description: truncateText(
484
+ text || "(empty)",
485
+ DISCORD_EMBED_DESCRIPTION_MAX_LENGTH
486
+ )
487
+ };
488
+ enqueueToThread(sessionID, { embeds: [embed] });
489
+ if (role === "user") {
490
+ await flushPending(sessionID);
491
+ } else if (shouldFlush(sessionID)) {
492
+ await flushPending(sessionID);
493
+ }
432
494
  }
433
495
  return {
434
496
  event: async ({ event }) => {
@@ -465,7 +527,105 @@ var plugin = async ({ client }) => {
465
527
  };
466
528
  lastSessionInfo.set(sessionID, { title, shareUrl });
467
529
  enqueueToThread(sessionID, { embeds: [embed] });
468
- if (shouldFlush(sessionID)) await flushPending(sessionID);
530
+ return;
531
+ }
532
+ case "permission.updated": {
533
+ const p = event.properties;
534
+ const sessionID = p?.sessionID;
535
+ if (!sessionID) return;
536
+ const embed = {
537
+ title: "Permission required",
538
+ description: p?.title,
539
+ color: COLORS.warning,
540
+ timestamp: toIsoTimestamp(p?.time?.created),
541
+ fields: buildFields(
542
+ filterSendFields(
543
+ [
544
+ ["sessionID", sessionID],
545
+ ["permissionID", p?.id],
546
+ ["type", p?.type],
547
+ ["pattern", p?.pattern],
548
+ ["messageID", p?.messageID],
549
+ ["callID", p?.callID]
550
+ ],
551
+ sendParams
552
+ )
553
+ )
554
+ };
555
+ const mention = buildPermissionMention();
556
+ enqueueToThread(sessionID, {
557
+ content: mention ? `${mention.content}` : void 0,
558
+ allowed_mentions: mention?.allowed_mentions,
559
+ embeds: [embed]
560
+ });
561
+ return;
562
+ }
563
+ case "session.idle": {
564
+ const sessionID = event.properties?.sessionID;
565
+ if (!sessionID) return;
566
+ const embed = {
567
+ title: "Session completed",
568
+ color: COLORS.success,
569
+ fields: buildFields(
570
+ filterSendFields(
571
+ [["sessionID", sessionID]],
572
+ withForcedSendParams(sendParams, ["sessionID"])
573
+ )
574
+ )
575
+ };
576
+ const mention = buildCompleteMention();
577
+ enqueueToThread(sessionID, {
578
+ content: mention ? `${mention.content}` : void 0,
579
+ allowed_mentions: mention?.allowed_mentions,
580
+ embeds: [embed]
581
+ });
582
+ return;
583
+ }
584
+ case "session.error": {
585
+ const p = event.properties;
586
+ const sessionID = p?.sessionID;
587
+ const errorStr = safeString(p?.error);
588
+ const embed = {
589
+ title: "Session error",
590
+ color: COLORS.error,
591
+ description: errorStr ? errorStr.length > DISCORD_EMBED_DESCRIPTION_MAX_LENGTH ? errorStr.slice(
592
+ 0,
593
+ DISCORD_EMBED_DESCRIPTION_MAX_LENGTH - ELLIPSIS_LENGTH
594
+ ) + ELLIPSIS : errorStr : void 0,
595
+ fields: buildFields(
596
+ filterSendFields(
597
+ [["sessionID", sessionID]],
598
+ withForcedSendParams(sendParams, [
599
+ "sessionID",
600
+ "projectID",
601
+ "directory"
602
+ ])
603
+ )
604
+ )
605
+ };
606
+ if (!sessionID) return;
607
+ const mention = buildCompleteMention();
608
+ enqueueToThread(sessionID, {
609
+ content: mention ? `$Session error` : void 0,
610
+ allowed_mentions: mention?.allowed_mentions,
611
+ embeds: [embed]
612
+ });
613
+ await flushPending(sessionID);
614
+ return;
615
+ }
616
+ case "todo.updated": {
617
+ const p = event.properties;
618
+ const sessionID = p?.sessionID;
619
+ if (!sessionID) return;
620
+ const embed = {
621
+ title: "Todo updated",
622
+ color: COLORS.info,
623
+ fields: buildFields(
624
+ filterSendFields([["sessionID", sessionID]], sendParams)
625
+ ),
626
+ description: buildTodoChecklist(p?.todos)
627
+ };
628
+ enqueueToThread(sessionID, { embeds: [embed] });
469
629
  return;
470
630
  }
471
631
  case "message.updated": {
@@ -473,52 +633,22 @@ var plugin = async ({ client }) => {
473
633
  const messageID = info?.id;
474
634
  const role = info?.role;
475
635
  if (!messageID) return;
476
- if (role === "user" || role === "assistant") {
477
- messageRoleById.set(messageID, role);
478
- const pendingParts = pendingTextPartsByMessageId.get(messageID);
479
- if (pendingParts?.length) {
480
- pendingTextPartsByMessageId.delete(messageID);
481
- for (const pendingPart of pendingParts) {
482
- const sessionID = pendingPart?.sessionID;
483
- const partID = pendingPart?.id;
484
- const type = pendingPart?.type;
485
- if (!sessionID || !partID || type !== "text") continue;
486
- const text = safeString(pendingPart?.text);
487
- if (role === "user" && excludeInputContext && isInputContextText(text)) {
488
- const snapshot = JSON.stringify({
489
- type,
490
- role,
491
- skipped: "input_context"
492
- });
493
- setIfChanged(/* @__PURE__ */ new Map(), partID, snapshot);
494
- continue;
495
- }
496
- if (role === "user" && !firstUserTextBySession.has(sessionID)) {
497
- const normalized = normalizeThreadTitle(text);
498
- if (normalized)
499
- firstUserTextBySession.set(sessionID, normalized);
500
- }
501
- const embed = {
502
- title: getTextPartEmbedTitle(role),
503
- color: COLORS.info,
504
- fields: buildFields(
505
- filterSendFields(
506
- [
507
- ["sessionID", sessionID],
508
- ["messageID", messageID],
509
- ["partID", partID],
510
- ["role", role]
511
- ],
512
- sendParams
513
- )
514
- ),
515
- description: truncateText(text || "(empty)", 4096)
516
- };
517
- enqueueToThread(sessionID, { embeds: [embed] });
518
- if (role === "user") await flushPending(sessionID);
519
- else if (shouldFlush(sessionID)) await flushPending(sessionID);
520
- }
521
- }
636
+ if (role !== "user" && role !== "assistant") return;
637
+ messageRoleById.set(messageID, role);
638
+ const pendingParts = pendingTextPartsByMessageId.get(messageID);
639
+ if (!pendingParts?.length) return;
640
+ pendingTextPartsByMessageId.delete(messageID);
641
+ for (const part of pendingParts) {
642
+ const sessionID = part?.sessionID;
643
+ const partID = part?.id;
644
+ if (!sessionID || !partID || part?.type !== "text") continue;
645
+ await handleTextPart({
646
+ part,
647
+ role,
648
+ sessionID,
649
+ messageID,
650
+ replay: true
651
+ });
522
652
  }
523
653
  return;
524
654
  }
@@ -538,46 +668,12 @@ var plugin = async ({ client }) => {
538
668
  pendingTextPartsByMessageId.set(messageID, list);
539
669
  return;
540
670
  }
541
- if (type === "text") {
542
- if (role === "assistant" && !part?.time?.end) return;
543
- const text = safeString(part?.text);
544
- if (role === "user" && excludeInputContext && isInputContextText(text)) {
545
- const snapshot = JSON.stringify({
546
- type,
547
- role,
548
- skipped: "input_context"
549
- });
550
- setIfChanged(/* @__PURE__ */ new Map(), partID, snapshot);
551
- return;
552
- }
553
- if (role === "user" && !firstUserTextBySession.has(sessionID)) {
554
- const normalized = normalizeThreadTitle(text);
555
- if (normalized)
556
- firstUserTextBySession.set(sessionID, normalized);
557
- }
558
- const embed = {
559
- title: getTextPartEmbedTitle(role),
560
- color: COLORS.info,
561
- fields: buildFields(
562
- filterSendFields(
563
- [
564
- ["sessionID", sessionID],
565
- ["messageID", messageID],
566
- ["partID", partID],
567
- ["role", role]
568
- ],
569
- sendParams
570
- )
571
- ),
572
- description: truncateText(text || "(empty)", 4096)
573
- };
574
- enqueueToThread(sessionID, { embeds: [embed] });
575
- if (role === "user") {
576
- await flushPending(sessionID);
577
- } else if (shouldFlush(sessionID)) {
578
- await flushPending(sessionID);
579
- }
580
- }
671
+ await handleTextPart({
672
+ part,
673
+ role,
674
+ sessionID,
675
+ messageID
676
+ });
581
677
  return;
582
678
  }
583
679
  default:
@@ -593,7 +689,8 @@ plugin.__test__ = {
593
689
  buildTodoChecklist,
594
690
  buildFields,
595
691
  toIsoTimestamp,
596
- postDiscordWebhook
692
+ postDiscordWebhook,
693
+ getTodoStatusMarker
597
694
  };
598
695
  var index_default = plugin;
599
696
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-discord-notify",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "A plugin that posts OpenCode events to a Discord webhook.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,6 +36,7 @@
36
36
  "devDependencies": {
37
37
  "@opencode-ai/plugin": "1.0.194",
38
38
  "@types/node": "^20.19.27",
39
+ "@vitest/coverage-v8": "^4.0.16",
39
40
  "prettier": "^3.7.4",
40
41
  "prettier-plugin-organize-imports": "^4.3.0",
41
42
  "tsup": "^8.5.0",