Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

134 changes: 116 additions & 18 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
},
Expand All @@ -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) {}
}
Expand Down Expand Up @@ -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<string, any> = {
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<string, any>, 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<string, any> = {
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<string, any>, mentionText?: string, mentionUserId?: string) {
try {
const cleanChannel = channelId.trim();
if (!cleanChannel) return;

const payload: Record<string, any> = {
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;