bobs-opencode-discord-notifier 0.1.3

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 (3) hide show
  1. package/README.md +45 -0
  2. package/package.json +29 -0
  3. package/src/index.ts +141 -0
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # opencode-discord-notification
2
+
3
+ OpenCode plugin that sends Discord notifications on session completion and permission requests.
4
+
5
+ ![Example Notification](screenshots/example.png)
6
+
7
+ ## Features
8
+
9
+ - ✅ **Completion Notifications:** Get a Discord message when OpenCode finishes a long task.
10
+ - 📊 **Context Stats:** Includes context usage percentage and total tokens.
11
+ - 🤖 **Model Info:** Shows which model was used for the response.
12
+ - ⚠️ **Permission Alerts:** Real-time notifications when OpenCode is blocked waiting for terminal permissions, including the command it's trying to run.
13
+
14
+ ## Installation
15
+
16
+ Add it to your `opencode.json`:
17
+
18
+ ```json
19
+ {
20
+ "plugin": ["opencode-discord-notification@0.1.1"]
21
+ }
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ Create a configuration file at `~/.config/opencode/discord-notification-config.json`:
27
+
28
+ ```json
29
+ {
30
+ "enabled": true,
31
+ "webhookUrl": "https://discord.com/api/webhooks/...",
32
+ "username": "OpenCode Notifier",
33
+ "avatarUrl": "https://opencode.ai/logo.png"
34
+ }
35
+ ```
36
+
37
+ ## Development
38
+
39
+ 1. Clone the repo.
40
+ 2. Install dependencies: `bun install`.
41
+ 3. Type-check: `bun x tsc`.
42
+
43
+ ## License
44
+
45
+ MIT
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "bobs-opencode-discord-notifier",
3
+ "version": "0.1.3",
4
+ "description": "OpenCode plugin that sends Discord notifications on session completion and permission requests.",
5
+ "main": "src/index.ts",
6
+ "module": "src/index.ts",
7
+ "type": "module",
8
+ "files": [
9
+ "src",
10
+ "README.md"
11
+ ],
12
+ "keywords": [
13
+ "opencode",
14
+ "plugin",
15
+ "discord",
16
+ "notification",
17
+ "ai"
18
+ ],
19
+ "author": "BobBurton9000",
20
+ "license": "MIT",
21
+ "peerDependencies": {
22
+ "@opencode-ai/plugin": "*"
23
+ },
24
+ "devDependencies": {
25
+ "@opencode-ai/plugin": "latest",
26
+ "@types/node": "latest",
27
+ "typescript": "latest"
28
+ }
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,141 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { Plugin } from "@opencode-ai/plugin";
5
+
6
+ interface DiscordWebhookConfig {
7
+ webhookUrl?: string;
8
+ enabled?: boolean;
9
+ username?: string;
10
+ avatarUrl?: string;
11
+ }
12
+
13
+ export const DiscordNotificationPlugin: Plugin = async ({ client, project }) => {
14
+ return {
15
+ event: async ({ event }) => {
16
+ // 1. Handle Session Completed (Green)
17
+ if (event.type === "session.idle") {
18
+ await handleNotification(client, project, event, "idle");
19
+ }
20
+ // 2. Handle Permission Request (Orange)
21
+ else if ((event.type as string) === "permission.asked") {
22
+ await handleNotification(client, project, event, "permission");
23
+ }
24
+ },
25
+ };
26
+ };
27
+
28
+ async function handleNotification(client: any, project: any, event: any, type: "idle" | "permission") {
29
+ try {
30
+ // 1. GET CONFIGURATION
31
+ // Priority 1: project.config (opencode.json)
32
+ // Priority 2: Local file (for development or if schema is strict)
33
+ let config: DiscordWebhookConfig = project.config?.discordNotifications || {};
34
+
35
+ if (!config.webhookUrl) {
36
+ try {
37
+ const configPath = join(homedir(), ".config", "opencode", "discord-notification-config.json");
38
+ if (existsSync(configPath)) {
39
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
40
+ }
41
+ } catch (e) {}
42
+ }
43
+
44
+ if (!config.enabled || !config.webhookUrl) return;
45
+
46
+ const sessionId = event.properties?.sessionID || event.properties?.id || event.properties?.sessionId;
47
+ if (!sessionId) return;
48
+
49
+ // Only wait on idle to give tokens time to settle
50
+ if (type === "idle") {
51
+ await new Promise(resolve => setTimeout(resolve, 1500));
52
+ }
53
+
54
+ const [sRes, mRes] = await Promise.all([
55
+ client.session.get({ path: { id: sessionId } }),
56
+ client.session.messages({ path: { id: sessionId } })
57
+ ]);
58
+
59
+ const session = (sRes as any).data || sRes;
60
+ const messages = (mRes as any).data || mRes || [];
61
+
62
+ let lastText = "Response completed.";
63
+ let contextUsage = "N/A";
64
+ let modelName = session?.model?.name || "Unknown";
65
+ let totalTokensAccumulated = 0;
66
+ let pendingCommand = "";
67
+
68
+ // Analyze messages
69
+ messages.forEach((m: any) => {
70
+ const isAssistant = (m.info?.role || m.role) === "assistant";
71
+ if (isAssistant) {
72
+ const t = m.info?.tokens || m.tokens;
73
+ if (t) {
74
+ const turnTotal = (t.input || 0) + (t.output || 0) + (t.cache?.read || 0);
75
+ totalTokensAccumulated = Math.max(totalTokensAccumulated, turnTotal);
76
+ }
77
+ if (type === "permission" && m.parts) {
78
+ const toolPart = m.parts.find((p: any) => p.type === "tool" && (p.state?.status === "pending" || p.state?.status === "running"));
79
+ if (toolPart) {
80
+ const input = toolPart.state?.input || {};
81
+ pendingCommand = input.command || input.filePath || JSON.stringify(input);
82
+ }
83
+ }
84
+ }
85
+ });
86
+
87
+ if (totalTokensAccumulated > 0 && session?.model?.limit?.context) {
88
+ const percentage = ((totalTokensAccumulated / session.model.limit.context) * 100).toFixed(2);
89
+ contextUsage = `${percentage}%`;
90
+ }
91
+
92
+ const assistantMessages = messages.filter((m: any) => (m.info?.role || m.role) === "assistant");
93
+ const lastAssistant = assistantMessages[assistantMessages.length - 1];
94
+ if (lastAssistant) {
95
+ const text = lastAssistant.parts?.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n");
96
+ if (text) lastText = text;
97
+ modelName = (lastAssistant.info?.modelID || lastAssistant.modelID) || modelName;
98
+ }
99
+
100
+ const isPermission = type === "permission";
101
+ const title = isPermission ? "⚠️ Permission Required" : "✅ Response Completed";
102
+ const color = isPermission ? 0xffa500 : 0x00ff00;
103
+
104
+ let description = lastText;
105
+ const fields = [
106
+ { name: "📊 Context Usage", value: contextUsage, inline: true },
107
+ { name: "🔢 Total Tokens", value: `${totalTokensAccumulated.toLocaleString()} tokens`, inline: true },
108
+ { name: "🤖 Model", value: modelName, inline: true }
109
+ ];
110
+
111
+ if (isPermission) {
112
+ fields.unshift({
113
+ name: "🔒 Blocked Command / Action",
114
+ value: `\`\`\`bash\n${pendingCommand || "Check terminal for details"}\n\`\`\``,
115
+ inline: false
116
+ });
117
+ description = "OpenCode has paused execution and is waiting for you to authorize the operation shown above.";
118
+ }
119
+
120
+ await fetch(config.webhookUrl, {
121
+ method: "POST",
122
+ headers: { "Content-Type": "application/json" },
123
+ body: JSON.stringify({
124
+ username: config.username || "OpenCode Notifier",
125
+ avatar_url: config.avatarUrl,
126
+ embeds: [{
127
+ title,
128
+ description: description.length > 1500 ? description.substring(0, 1497) + "..." : description,
129
+ color,
130
+ fields,
131
+ footer: { text: `Session ID: ${sessionId}` },
132
+ timestamp: new Date().toISOString()
133
+ }]
134
+ }),
135
+ });
136
+ } catch (e) {
137
+ console.error("Discord Plugin Error:", e);
138
+ }
139
+ }
140
+
141
+ export default DiscordNotificationPlugin;