diff --git a/README.md b/README.md index 2a33bf1..776c156 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,18 @@ Create a configuration file at `~/.config/opencode/discord-notification-config.j } ``` +Provide `userId` to tell the plugin who to ping—the same ID is used for message mentions, optional bot DMs, and optional bot posts to server channels. `botToken` should be a Discord bot token with the `messages.write` scope. Example: + +```json +{ + "userId": "123456789012345678", + "botToken": "bot-token-value", + "serverChannelId": "987654321098765432" +} +``` + +The plugin automatically wraps `userId` in `<@...>` for mentions and uses the same sanitized ID when calling the bot APIs. Include `serverChannelId` to have the bot post the embed into a specific guild channel (alongside the webhook and optional DM). Keep `botToken` secret and rotate it if it is ever exposed; omit `botToken` if you only want webhook notifications. + ## Development 1. Clone the repo. diff --git a/bun.lock b/bun.lock index 975e445..678d9ed 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,20 @@ "": { "name": "opencode-discord-notification", "devDependencies": { + "@opencode-ai/plugin": "latest", "@types/bun": "latest", + "typescript": "latest", }, "peerDependencies": { - "typescript": "^5", + "@opencode-ai/plugin": "*", }, }, }, "packages": { + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.10", "", { "dependencies": { "@opencode-ai/sdk": "1.2.10", "zod": "4.1.8" } }, "sha512-Z1BMqNHnD8AGAEb+kUz0b2SOuiODwdQLdCA4aVGTXqkGzhiD44OVxr85MeoJ5AMTnnea9SnJ3jp9GAQ5riXA5g=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.10", "", {}, "sha512-SyXcVqry2hitPVvQtvXOhqsWyFhSycG/+LTLYXrcq8AFmd9FR7dyBSDB3f5Ol6IPkYOegk8P2Eg2kKPNSNiKGw=="], + "@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="], "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], @@ -22,5 +28,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], } } diff --git a/src/index.ts b/src/index.ts index 337036b..0e3dd85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,17 +5,21 @@ interface DiscordWebhookConfig { enabled?: boolean; username?: string; avatarUrl?: string; + userId?: string; + botToken?: string; + serverChannelId?: string; } export const DiscordNotificationPlugin: Plugin = async ({ client, project }) => { return { event: async ({ event }) => { + const eventType = (event as any)?.type; // 1. Handle Session Completed (Green) - if (event.type === "session.idle") { + if (eventType === "session.idle") { await handleNotification(client, project, event, "idle"); } // 2. Handle Permission Request (Orange) - else if (event.type === "permission.asked") { + else if (eventType === "permission.asked") { await handleNotification(client, project, event, "permission"); } }, @@ -31,10 +35,21 @@ async function handleNotification(client: any, project: any, event: any, type: " if (!config.webhookUrl) { try { - const configPath = "/var/home/frieser/.config/opencode/discord-notification-config.json"; - const configFile = Bun.file(configPath); - if (await configFile.exists()) { - config = await configFile.json(); + const candidatePaths: string[] = []; + const processHome = (globalThis as any)?.process?.env?.HOME; + if (processHome) { + candidatePaths.push(`${processHome}/.config/opencode/discord-notification-config.json`); + } + const bunHome = (globalThis as any)?.Bun?.env?.HOME || (globalThis as any)?.Bun?.env?.home; + if (bunHome && bunHome !== processHome) { + candidatePaths.push(`${bunHome}/.config/opencode/discord-notification-config.json`); + } + for (const candidate of candidatePaths) { + const configFile = Bun.file(candidate); + if (await configFile.exists()) { + config = await configFile.json(); + break; + } } } catch (e) {} } @@ -115,25 +130,108 @@ async function handleNotification(client: any, project: any, event: any, type: " description = "OpenCode has paused execution and is waiting for you to authorize the operation shown above."; } + const embed = { + title, + description: description.length > 1500 ? description.substring(0, 1497) + "..." : description, + color, + fields, + footer: { text: `Session ID: ${sessionId}` }, + timestamp: new Date().toISOString() + }; + + const webhookPayload: Record = { + username: config.username || "OpenCode Notifier", + avatar_url: config.avatarUrl, + embeds: [embed] + }; + + const cleanUserId = config.userId ? sanitizeUserId(config.userId) : ""; + const mentionText = cleanUserId ? `<@${cleanUserId}>` : undefined; + if (mentionText) { + webhookPayload.content = mentionText; + webhookPayload.allowed_mentions = { users: [cleanUserId] }; + } + await fetch(config.webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: config.username || "OpenCode Notifier", - avatar_url: config.avatarUrl, - embeds: [{ - title, - description: description.length > 1500 ? description.substring(0, 1497) + "..." : description, - color, - fields, - footer: { text: `Session ID: ${sessionId}` }, - timestamp: new Date().toISOString() - }] - }), + body: JSON.stringify(webhookPayload), }); + + if (config.botToken && cleanUserId) { + await sendDiscordDm(config.botToken, cleanUserId, embed, mentionText); + } + + if (config.botToken && config.serverChannelId) { + await sendDiscordServerMessage(config.botToken, config.serverChannelId, embed, mentionText, cleanUserId); + } } catch (e) { console.error("Discord Plugin Error:", e); } } +async function sendDiscordDm(botToken: string, userId: string, embed: Record, mentionOverride?: string) { + try { + const channelRes = await fetch("https://discord.com/api/v10/users/@me/channels", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${botToken}` + }, + body: JSON.stringify({ recipient_id: userId }) + }); + if (!channelRes.ok) return; + const channel = (await channelRes.json()) as { id?: string }; + if (!channel?.id) return; + + const mentionText = mentionOverride ?? (userId ? `<@${sanitizeUserId(userId)}>` : ""); + const dmPayload: Record = { + embeds: [embed], + content: mentionText, + allowed_mentions: { users: [userId] } + }; + + await fetch(`https://discord.com/api/v10/channels/${channel.id}/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${botToken}` + }, + body: JSON.stringify(dmPayload) + }); + } catch (error) { + console.error("Discord Plugin DM Error:", error); + } +} + +async function sendDiscordServerMessage(botToken: string, channelId: string, embed: Record, mentionText?: string, mentionUserId?: string) { + try { + const cleanChannel = channelId.trim(); + if (!cleanChannel) return; + + const payload: Record = { + embeds: [embed] + }; + if (mentionText && mentionUserId) { + payload.content = mentionText; + payload.allowed_mentions = { users: [mentionUserId] }; + } + + await fetch(`https://discord.com/api/v10/channels/${cleanChannel}/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${botToken}` + }, + body: JSON.stringify(payload) + }); + } catch (error) { + console.error("Discord Plugin Server DM Error:", error); + } +} + +function sanitizeUserId(value: string): string { + return (value || "").trim().replace(/[^0-9]/g, ""); +} + export default DiscordNotificationPlugin;