mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-28 11:37:39 +00:00
feat: introduce notification system and claude notifications
This commit is contained in:
149
server/services/notification-orchestrator.js
Normal file
149
server/services/notification-orchestrator.js
Normal file
@@ -0,0 +1,149 @@
|
||||
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
|
||||
};
|
||||
35
server/services/vapid-keys.js
Normal file
35
server/services/vapid-keys.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import webPush from 'web-push';
|
||||
import { db } from '../database/db.js';
|
||||
|
||||
let cachedKeys = null;
|
||||
|
||||
function ensureVapidKeys() {
|
||||
if (cachedKeys) return cachedKeys;
|
||||
|
||||
const row = db.prepare('SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1').get();
|
||||
if (row) {
|
||||
cachedKeys = { publicKey: row.public_key, privateKey: row.private_key };
|
||||
return cachedKeys;
|
||||
}
|
||||
|
||||
const keys = webPush.generateVAPIDKeys();
|
||||
db.prepare('INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)').run(keys.publicKey, keys.privateKey);
|
||||
cachedKeys = keys;
|
||||
return cachedKeys;
|
||||
}
|
||||
|
||||
function getPublicKey() {
|
||||
return ensureVapidKeys().publicKey;
|
||||
}
|
||||
|
||||
function configureWebPush() {
|
||||
const keys = ensureVapidKeys();
|
||||
webPush.setVapidDetails(
|
||||
'mailto:noreply@claudecodeui.local',
|
||||
keys.publicKey,
|
||||
keys.privateKey
|
||||
);
|
||||
console.log('Web Push notifications configured');
|
||||
}
|
||||
|
||||
export { ensureVapidKeys, getPublicKey, configureWebPush };
|
||||
Reference in New Issue
Block a user