Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
60fd8c8
Remove messaging feature from all dashboards
openhands-agent Jun 24, 2026
48cab46
Add notification system with email and Discord DM support
openhands-agent Jun 24, 2026
036c1c0
Add Toggle component and fix Notifications import
openhands-agent Jun 24, 2026
20cc19a
Merge pull request #3 from friendlyfoe241-cyber/test
friendlyfoe241-cyber Jun 24, 2026
4e680aa
Fix bell visibility and styling
openhands-agent Jun 24, 2026
3c8f56f
Merge test branch: notification system with bell, email, and Discord …
openhands-agent Jun 24, 2026
a7faf77
Fix settings button in bell dropdown - use anchor tag with proper nav…
openhands-agent Jun 24, 2026
46d4744
Fix settings link to respect editor vs researcher paths
openhands-agent Jun 24, 2026
69648b8
Fix settings link for all dashboard types (editor, moderator, researc…
openhands-agent Jun 25, 2026
dcca7cb
Add debug logging and user guard to Notifications
openhands-agent Jun 25, 2026
395af52
Add error boundary to catch silent rendering errors
openhands-agent Jun 25, 2026
4b57a81
Debug: always show Discord section with debug badge
openhands-agent Jun 25, 2026
a8759ab
Remove debug badge and console.log
openhands-agent Jun 25, 2026
431007c
Fix sendDiscordDM to actually send DMs via Discord API
openhands-agent Jun 25, 2026
595565c
Fix inviteToProject to use notifyUser for Discord DM support
openhands-agent Jun 25, 2026
db66cb8
Allow Discord DM by username - bot searches shared servers for user
openhands-agent Jun 25, 2026
3f9dcb2
Fix user lookup to use members list instead of search endpoint
openhands-agent Jun 25, 2026
482faa6
Fix: Discord notifications now default to enabled
openhands-agent Jun 25, 2026
c99bb2e
Fix: Discord notifications default to enabled when user has username
openhands-agent Jun 25, 2026
dbb7032
Fix: Add project notification type and default Discord to enabled
openhands-agent Jun 25, 2026
3426e47
Fix: pushNotif now triggers email/Discord via notifyUser
openhands-agent Jun 25, 2026
8866c52
Add Send Test DM button to Notifications settings
openhands-agent Jun 25, 2026
4888242
Fix: Use api wrapper for notify test
openhands-agent Jun 25, 2026
cc7818d
Fix: Prevent infinite loop between notifyUser and pushNotif
openhands-agent Jun 25, 2026
adca751
Fix: Simplified Discord user lookup - use direct guild endpoint
openhands-agent Jun 25, 2026
027e226
Fix: Better error handling for Discord API auth failures
openhands-agent Jun 25, 2026
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
22 changes: 22 additions & 0 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,28 @@ app.post('/api/editor/settings/test', requireAuth, editorOnly, directorOnly, wra
res.json(result);
}));

// Send a test Discord DM to the current user
app.post('/api/notify/test', requireAuth, wrap(async (req, res) => {
const user = req.user;
if (!user.discord) return res.status(400).json({ error: 'No Discord username set. Add it in Discord settings above.' });

const result = await notify.sendDiscordDM({
discordUsername: user.discord,
content: '🎉 This is a test message from Synthica!\n\nYour Discord notifications are working correctly.',
embed: {
title: '✅ Discord Connected!',
description: 'You will now receive project notifications via DM.',
color: 2589,
footer: { text: 'Synthica Notifications' },
},
});

if (!result.ok && !result.skipped) {
return res.status(400).json({ error: result.error || 'Failed to send DM' });
}
res.json({ ok: true });
}));

// --- Track 4: Researcher dashboard -----------------------------------------
const researcherOnly = (req, res, next) => {
if (req.user.kind !== 'researcher' && !req.user.allViewsDemo) {
Expand Down
162 changes: 162 additions & 0 deletions backend/src/email.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,165 @@ export function emailDecision({ authorEmail, authorName, title, decision }) {
signoff: 'Thank you for contributing,',
});
}

// ============================================================
// Notification email templates for platform events
// ============================================================

// New reply/comment on post
export function emailNewComment({ toEmail, toName, authorName, postTitle, postLink }) {
const first = String(toName || 'there').split(/\s+/)[0];
return actionEmail({
to: toEmail,
subject: `${authorName} commented on your post`,
heading: 'New comment on your post',
intro: `Hi ${esc(first)},`,
blocks: [
`<strong>${esc(authorName)}</strong> commented on your post<strong>${esc(postTitle)}</strong>`,
],
button: { label: 'View comment', url: `${SITE}${postLink}` },
signoff: 'Stay engaged,',
});
}

// Project invitation received
export function emailProjectInvite({ toEmail, toName, inviterName, projectTitle, projectLink }) {
const first = String(toName || 'there').split(/\s+/)[0];
return actionEmail({
to: toEmail,
subject: `You've been invited to join: ${projectTitle}`,
heading: 'Project invitation',
intro: `Hi ${esc(first)},`,
blocks: [
`<strong>${esc(inviterName)}</strong> invited you to join the project <strong>${esc(projectTitle)}</strong>`,
],
button: { label: 'View project', url: `${SITE}${projectLink}` },
signoff: 'Happy collaborating,',
});
}

// Application status update
export function emailApplicationUpdate({ toEmail, toName, programName, status, statusMessage, link }) {
const first = String(toName || 'there').split(/\s+/)[0];
const statusLabel = status === 'approved' ? 'approved' : status === 'rejected' ? 'not approved' : 'updated';
return actionEmail({
to: toEmail,
subject: `Your application to ${programName} was ${statusLabel}`,
heading: `Application ${statusLabel}`,
intro: `Hi ${esc(first)},`,
blocks: [
statusMessage || `Your application to <strong>${esc(programName)}</strong> has been ${statusLabel}.`,
],
button: link ? { label: 'View application', url: `${SITE}${link}` } : undefined,
signoff: 'Best regards,',
});
}

// Mentor request received
export function emailMentorRequest({ toEmail, toName, menteeName, menteeMessage, requestLink }) {
const first = String(toName || 'there').split(/\s+/)[0];
return actionEmail({
to: toEmail,
subject: `New mentorship request from ${menteeName}`,
heading: 'New mentorship request',
intro: `Hi ${esc(first)},`,
blocks: [
`<strong>${esc(menteeName)}</strong> requested your mentorship.`,
menteeMessage ? `Message: <em>"${esc(menteeMessage)}"</em>` : '',
],
button: { label: 'View request', url: `${SITE}${requestLink}` },
signoff: 'Happy mentoring,',
});
}

// New community announcement
export function emailAnnouncement({ toEmail, toName, authorName, title, content, link }) {
const first = String(toName || 'there').split(/\s+/)[0];
return actionEmail({
to: toEmail,
subject: `Announcement: ${title}`,
heading: esc(title),
intro: `Hi ${esc(first)},`,
blocks: [
`From <strong>${esc(authorName)}</strong>:`,
content || '',
],
button: link ? { label: 'Read more', url: `${SITE}${link}` } : undefined,
signoff: 'Stay connected,',
});
}

// Role/privilege granted
export function emailRoleGranted({ toEmail, toName, roleName, roleDescription, dashboardLink }) {
const first = String(toName || 'there').split(/\s+/)[0];
return actionEmail({
to: toEmail,
subject: `You've been granted: ${roleName}`,
heading: 'New role assigned',
intro: `Hi ${esc(first)},`,
blocks: [
`Congratulations! You've been assigned the <strong>${esc(roleName)}</strong> role.`,
roleDescription || '',
],
button: { label: 'Go to dashboard', url: `${SITE}${dashboardLink}` },
signoff: 'Congratulations,',
});
}

// Independent researcher proposal update
export function emailProposalUpdate({ toEmail, toName, proposalTitle, status, feedback, link }) {
const first = String(toName || 'there').split(/\s+/)[0];
const statusLabel = status === 'approved' ? 'approved' : status === 'rejected' ? 'not approved' : 'under review';
return actionEmail({
to: toEmail,
subject: `Your proposal "${proposalTitle}" was ${statusLabel}`,
heading: `Proposal ${statusLabel}`,
intro: `Hi ${esc(first)},`,
blocks: [
`Your proposal <strong>${esc(proposalTitle)}</strong> has been ${statusLabel}.`,
feedback ? `Feedback: ${esc(feedback)}` : '',
],
button: link ? { label: 'View proposal', url: `${SITE}${link}` } : undefined,
signoff: 'Best of luck,',
});
}

// Competition/listing update
export function emailListingUpdate({ toEmail, toName, listingTitle, status, listingLink }) {
const first = String(toName || 'there').split(/\s+/)[0];
return actionEmail({
to: toEmail,
subject: `Update on your ${listingTitle} application`,
heading: 'Application update',
intro: `Hi ${esc(first)},`,
blocks: [
`There's an update regarding your application to <strong>${esc(listingTitle)}</strong>.`,
],
button: { label: 'View details', url: `${SITE}${listingLink}` },
signoff: 'Stay tuned,',
});
}

// General notification helper - sends any notification type as email
export function sendNotificationEmail({ toEmail, toName, type, data }) {
if (!toEmail) return { ok: false, reason: 'no email' };

const templates = {
comment: () => emailNewComment({ toEmail, toName, ...data }),
project_invite: () => emailProjectInvite({ toEmail, toName, ...data }),
application_update: () => emailApplicationUpdate({ toEmail, toName, ...data }),
mentor_request: () => emailMentorRequest({ toEmail, toName, ...data }),
announcement: () => emailAnnouncement({ toEmail, toName, ...data }),
role_granted: () => emailRoleGranted({ toEmail, toName, ...data }),
proposal_update: () => emailProposalUpdate({ toEmail, toName, ...data }),
listing_update: () => emailListingUpdate({ toEmail, toName, ...data }),
};

const template = templates[type];
if (!template) {
console.warn(`[email] unknown notification type: ${type}`);
return { ok: false, reason: 'unknown type' };
}

return template();
}
158 changes: 156 additions & 2 deletions backend/src/notify.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
// or via the DISCORD_WEBHOOK_URL env var (which also makes it survive restarts).

let webhookUrl = (process.env.DISCORD_WEBHOOK_URL || '').trim();
// Discord bot token for sending DMs to users
const DISCORD_BOT_TOKEN = (process.env.DISCORD_BOT_TOKEN || '').trim();
// Discord guild invite link for users to join
export const DISCORD_SERVER_LINK = 'https://discord.com/invite/8wPzZkGy5Z';
// A second, generic channel — point it at a WhatsApp relay (Twilio/Make/Zapier
// webhook) that forwards a JSON {text} payload to a WhatsApp number/group.
let whatsappUrl = (process.env.WHATSAPP_WEBHOOK_URL || '').trim();
Expand All @@ -35,16 +39,17 @@ export const setWhatsapp = (url) => {
whatsappUrl = (url || '').trim();
return whatsappUrl;
};
export const hasDiscordBot = () => !!DISCORD_BOT_TOKEN;

// POST JSON with a hard timeout. Resolves to a small result object and never
// throws, so fire-and-forget callers can't trigger unhandled rejections.
async function postJson(url, payload) {
async function postJson(url, payload, headers = { 'Content-Type': 'application/json' }) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers,
body: JSON.stringify(payload),
signal: controller.signal,
});
Expand All @@ -68,6 +73,155 @@ export async function postDiscord(payload) {
return postJson(webhookUrl, payload);
}

// Get Discord user ID from username (requires the bot to share a server with the user)
async function getDiscordUserId(username) {
if (!DISCORD_BOT_TOKEN) return null;
try {
// Search for user by username (Discord API limitation: this only works if bot shares a server)
// Alternative: require users to provide their Discord User ID directly
const res = await fetch(`https://discord.com/api/v10/users/@me`, {
headers: { Authorization: `Bot ${DISCORD_BOT_TOKEN}` },
});
// For now, we return the username as-is - actual DM requires User ID
// Users need to provide their Discord User ID for DMs to work reliably
return username; // Return as-is; the DM function will handle the actual sending
} catch (e) {
console.warn('[discord] failed to get user ID:', e.message);
return null;
}
}

// Send a DM to a Discord user via bot (requires user to be in a shared server)
export async function sendDiscordDM({ discordUsername, content, embed }) {
if (!DISCORD_BOT_TOKEN) {
console.log('[discord] (no DISCORD_BOT_TOKEN) would DM', discordUsername);
return { ok: false, skipped: true, error: 'no bot token' };
}
try {
let userId = discordUsername;

// If it's a username (not just numbers), try to find the user ID
if (!/^\d+$/.test(discordUsername)) {
console.log(`[discord] Looking up user "${discordUsername}" in guild...`);

let apiFailed = false;
try {
const membersRes = await fetch(
`https://discord.com/api/v10/guilds/1512337763536601169/members?limit=1000`,
{ headers: { 'Authorization': `Bot ${DISCORD_BOT_TOKEN}` } }
);

if (!membersRes.ok) {
const errText = await membersRes.text();
console.error('[discord] guild API error:', errText);
// Check if it's an auth issue
if (membersRes.status === 401 || membersRes.status === 403) {
return { ok: false, error: 'Cannot access Discord guild. Bot token may be invalid or expired.' };
}
apiFailed = true;
} else {
const members = await membersRes.json();
const match = members.find(m =>
m.user?.username?.toLowerCase() === discordUsername.toLowerCase() ||
m.nick?.toLowerCase() === discordUsername.toLowerCase()
);

if (match?.user?.id) {
userId = match.user.id;
console.log(`[discord] Found user "${discordUsername}" as ${userId}`);
}
}
} catch (e) {
console.warn(`[discord] Could not search guild members:`, e.message);
apiFailed = true;
}

// If API failed or user not found, return appropriate error
if (apiFailed || !/^\d+$/.test(userId)) {
return {
ok: false,
error: `User "${discordUsername}" not found. Make sure they are in the Synthica Discord server.`
};
}

// If we still don't have a user ID, the user might not be in the server
if (!/^\d+$/.test(userId)) {
return {
ok: false,
error: `User "${discordUsername}" not found. Make sure they are in the Synthica Discord server.`
};
}
}

// Create a DM channel with the user
const dmResponse = await fetch('https://discord.com/api/v10/users/@me/channels', {
method: 'POST',
headers: { 'Authorization': `Bot ${DISCORD_BOT_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ recipient_id: userId }),
});

if (!dmResponse.ok) {
const err = await dmResponse.text();
console.error('[discord] failed to create DM channel:', err);
if (err.includes('403') || err.includes('Cannot send messages to this user')) {
return { ok: false, error: 'User has DMs disabled. They need to allow DMs from server members in Discord settings.' };
}
return { ok: false, error: `Failed to create DM: ${err}` };
}

const dmChannel = await dmResponse.json();

// Send the message
const payload = { channel_id: dmChannel.id };
if (content) payload.content = content;
if (embed) payload.embeds = [embed];

const msgRes = await fetch(`https://discord.com/api/v10/channels/${dmChannel.id}/messages`, {
method: 'POST',
headers: { 'Authorization': `Bot ${DISCORD_BOT_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});

if (!msgRes.ok) {
const err = await msgRes.text();
console.error('[discord] failed to send message:', err);
return { ok: false, error: err };
}

return { ok: true };
} catch (e) {
console.error('[discord] DM error:', e);
return { ok: false, error: e.message };
}
}

function formatDiscordNotification({ type, title, body, link }) {
const fields = [];
if (title) fields.push({ name: 'Notification', value: title, inline: false });
if (body) fields.push({ name: 'Details', value: body, inline: false });
if (link) fields.push({ name: 'Action', value: `[View on Synthica](${link})`, inline: false });

return {
username: 'Synthica',
embeds: [{
title: `🔔 ${title || 'New notification'}`,
description: body || '',
color: 0x2589ed, // Synthica brand blue
fields,
footer: { text: 'Synthica Notifications' },
timestamp: new Date().toISOString(),
}],
};
}

// Send notification via Discord DM (fire-and-forget)
export function notifyDiscord({ discordUsername, type, title, body, link }) {
if (!DISCORD_BOT_TOKEN && !discordUsername) return;
const payload = formatDiscordNotification({ type, title, body, link });
// Fire and forget
sendDiscordDM({ discordUsername, content: null, embed: payload.embeds[0] }).catch(() => {});
}

// Called from the workflow whenever a paper advances or is declined.
export function notifyMove({ title, paperId, category, label, decision }) {
if (!webhookUrl && !whatsappUrl) return;
Expand Down
Loading
Loading