mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-28 11:37:39 +00:00
150 lines
3.6 KiB
JavaScript
150 lines
3.6 KiB
JavaScript
import webPush from 'web-push';
|
|
import { notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
|
|
|
|
const KIND_TO_PREF_KEY = {
|
|
action_required: 'actionRequired',
|
|
stop: 'stop',
|
|
error: 'error'
|
|
};
|
|
|
|
const recentEventKeys = new Map();
|
|
const DEDUPE_WINDOW_MS = 20000;
|
|
|
|
const cleanupOldEventKeys = () => {
|
|
const now = Date.now();
|
|
for (const [key, timestamp] of recentEventKeys.entries()) {
|
|
if (now - timestamp > DEDUPE_WINDOW_MS) {
|
|
recentEventKeys.delete(key);
|
|
}
|
|
}
|
|
};
|
|
|
|
function shouldSendPush(preferences, event) {
|
|
const webPushEnabled = Boolean(preferences?.channels?.webPush);
|
|
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
|
|
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
|
|
|
|
return webPushEnabled && eventEnabled;
|
|
}
|
|
|
|
function isDuplicate(event) {
|
|
cleanupOldEventKeys();
|
|
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
|
|
if (recentEventKeys.has(key)) {
|
|
return true;
|
|
}
|
|
recentEventKeys.set(key, Date.now());
|
|
return false;
|
|
}
|
|
|
|
function createNotificationEvent({
|
|
provider,
|
|
sessionId = null,
|
|
kind = 'info',
|
|
code = 'generic.info',
|
|
meta = {},
|
|
severity = 'info',
|
|
dedupeKey = null,
|
|
requiresUserAction = false
|
|
}) {
|
|
return {
|
|
provider,
|
|
sessionId,
|
|
kind,
|
|
code,
|
|
meta,
|
|
severity,
|
|
requiresUserAction,
|
|
dedupeKey,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
function buildPushBody(event) {
|
|
const CODE_MAP = {
|
|
'permission.required': {
|
|
title: 'Action Required',
|
|
body: event.meta?.toolName
|
|
? `Tool "${event.meta.toolName}" needs approval`
|
|
: 'A tool needs your approval'
|
|
},
|
|
'run.stopped': {
|
|
title: 'Run Stopped',
|
|
body: event.meta?.stopReason || 'The run has stopped'
|
|
},
|
|
'run.failed': {
|
|
title: 'Run Failed',
|
|
body: event.meta?.error ? String(event.meta.error) : 'The run encountered an error'
|
|
},
|
|
'agent.notification': {
|
|
title: 'Agent Notification',
|
|
body: event.meta?.message ? String(event.meta.message) : 'You have a new notification'
|
|
}
|
|
};
|
|
|
|
const mapped = CODE_MAP[event.code];
|
|
return {
|
|
title: mapped?.title || 'Claude Code UI',
|
|
body: mapped?.body || 'You have a new notification',
|
|
data: {
|
|
sessionId: event.sessionId || null,
|
|
code: event.code
|
|
}
|
|
};
|
|
}
|
|
|
|
async function sendWebPush(userId, event) {
|
|
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
|
|
if (!subscriptions.length) return;
|
|
|
|
const payload = JSON.stringify(buildPushBody(event));
|
|
|
|
const results = await Promise.allSettled(
|
|
subscriptions.map((sub) =>
|
|
webPush.sendNotification(
|
|
{
|
|
endpoint: sub.endpoint,
|
|
keys: {
|
|
p256dh: sub.keys_p256dh,
|
|
auth: sub.keys_auth
|
|
}
|
|
},
|
|
payload
|
|
)
|
|
)
|
|
);
|
|
|
|
// Clean up gone subscriptions (410 Gone or 404)
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'rejected') {
|
|
const statusCode = result.reason?.statusCode;
|
|
if (statusCode === 410 || statusCode === 404) {
|
|
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function notifyUserIfEnabled({ userId, event }) {
|
|
if (!userId || !event) {
|
|
return;
|
|
}
|
|
|
|
const preferences = notificationPreferencesDb.getPreferences(userId);
|
|
if (!shouldSendPush(preferences, event)) {
|
|
return;
|
|
}
|
|
if (isDuplicate(event)) {
|
|
return;
|
|
}
|
|
|
|
sendWebPush(userId, event).catch((err) => {
|
|
console.error('Web push send error:', err);
|
|
});
|
|
}
|
|
|
|
export {
|
|
createNotificationEvent,
|
|
notifyUserIfEnabled
|
|
};
|