notification-orchestrator.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import webPush from 'web-push';
  2. import { notificationPreferencesDb, pushSubscriptionsDb, sessionNamesDb } from '../database/db.js';
  3. const KIND_TO_PREF_KEY = {
  4. action_required: 'actionRequired',
  5. stop: 'stop',
  6. error: 'error'
  7. };
  8. const PROVIDER_LABELS = {
  9. claude: 'Claude',
  10. pilotdeck: 'PilotDeck',
  11. cursor: 'Cursor',
  12. codex: 'Codex',
  13. gemini: 'Gemini',
  14. system: 'System'
  15. };
  16. const recentEventKeys = new Map();
  17. const DEDUPE_WINDOW_MS = 20000;
  18. const cleanupOldEventKeys = () => {
  19. const now = Date.now();
  20. for (const [key, timestamp] of recentEventKeys.entries()) {
  21. if (now - timestamp > DEDUPE_WINDOW_MS) {
  22. recentEventKeys.delete(key);
  23. }
  24. }
  25. };
  26. function shouldSendPush(preferences, event) {
  27. const webPushEnabled = Boolean(preferences?.channels?.webPush);
  28. const prefEventKey = KIND_TO_PREF_KEY[event.kind];
  29. const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
  30. return webPushEnabled && eventEnabled;
  31. }
  32. function isDuplicate(event) {
  33. cleanupOldEventKeys();
  34. const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
  35. if (recentEventKeys.has(key)) {
  36. return true;
  37. }
  38. recentEventKeys.set(key, Date.now());
  39. return false;
  40. }
  41. function createNotificationEvent({
  42. provider,
  43. sessionId = null,
  44. kind = 'info',
  45. code = 'generic.info',
  46. meta = {},
  47. severity = 'info',
  48. dedupeKey = null,
  49. requiresUserAction = false
  50. }) {
  51. return {
  52. provider,
  53. sessionId,
  54. kind,
  55. code,
  56. meta,
  57. severity,
  58. requiresUserAction,
  59. dedupeKey,
  60. createdAt: new Date().toISOString()
  61. };
  62. }
  63. function normalizeErrorMessage(error) {
  64. if (typeof error === 'string') {
  65. return error;
  66. }
  67. if (error && typeof error.message === 'string') {
  68. return error.message;
  69. }
  70. if (error == null) {
  71. return 'Unknown error';
  72. }
  73. return String(error);
  74. }
  75. function normalizeSessionName(sessionName) {
  76. if (typeof sessionName !== 'string') {
  77. return null;
  78. }
  79. const normalized = sessionName.replace(/\s+/g, ' ').trim();
  80. if (!normalized) {
  81. return null;
  82. }
  83. return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
  84. }
  85. function resolveSessionName(event) {
  86. const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
  87. if (explicitSessionName) {
  88. return explicitSessionName;
  89. }
  90. if (!event.sessionId || !event.provider) {
  91. return null;
  92. }
  93. return normalizeSessionName(sessionNamesDb.getName(event.sessionId, event.provider));
  94. }
  95. function buildPushBody(event) {
  96. const CODE_MAP = {
  97. 'permission.required': event.meta?.toolName
  98. ? `Action Required: Tool "${event.meta.toolName}" needs approval`
  99. : 'Action Required: A tool needs your approval',
  100. 'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped',
  101. 'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error',
  102. 'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification',
  103. 'push.enabled': 'Push notifications are now enabled!'
  104. };
  105. const providerLabel = PROVIDER_LABELS[event.provider] || 'Assistant';
  106. const sessionName = resolveSessionName(event);
  107. const message = CODE_MAP[event.code] || 'You have a new notification';
  108. return {
  109. title: sessionName || 'PilotDeck',
  110. body: `${providerLabel}: ${message}`,
  111. data: {
  112. sessionId: event.sessionId || null,
  113. code: event.code,
  114. provider: event.provider || null,
  115. sessionName,
  116. tag: `${event.provider || 'assistant'}:${event.sessionId || 'none'}:${event.code}`
  117. }
  118. };
  119. }
  120. async function sendWebPush(userId, event) {
  121. const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
  122. if (!subscriptions.length) return;
  123. const payload = JSON.stringify(buildPushBody(event));
  124. const results = await Promise.allSettled(
  125. subscriptions.map((sub) =>
  126. webPush.sendNotification(
  127. {
  128. endpoint: sub.endpoint,
  129. keys: {
  130. p256dh: sub.keys_p256dh,
  131. auth: sub.keys_auth
  132. }
  133. },
  134. payload
  135. )
  136. )
  137. );
  138. // Clean up gone subscriptions (410 Gone or 404)
  139. results.forEach((result, index) => {
  140. if (result.status === 'rejected') {
  141. const statusCode = result.reason?.statusCode;
  142. if (statusCode === 410 || statusCode === 404) {
  143. pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
  144. }
  145. }
  146. });
  147. }
  148. function notifyUserIfEnabled({ userId, event }) {
  149. if (!userId || !event) {
  150. return;
  151. }
  152. const preferences = notificationPreferencesDb.getPreferences(userId);
  153. if (!shouldSendPush(preferences, event)) {
  154. return;
  155. }
  156. if (isDuplicate(event)) {
  157. return;
  158. }
  159. sendWebPush(userId, event).catch((err) => {
  160. console.error('Web push send error:', err);
  161. });
  162. }
  163. function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) {
  164. notifyUserIfEnabled({
  165. userId,
  166. event: createNotificationEvent({
  167. provider,
  168. sessionId,
  169. kind: 'stop',
  170. code: 'run.stopped',
  171. meta: { stopReason, sessionName },
  172. severity: 'info',
  173. dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}`
  174. })
  175. });
  176. }
  177. function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) {
  178. const errorMessage = normalizeErrorMessage(error);
  179. notifyUserIfEnabled({
  180. userId,
  181. event: createNotificationEvent({
  182. provider,
  183. sessionId,
  184. kind: 'error',
  185. code: 'run.failed',
  186. meta: { error: errorMessage, sessionName },
  187. severity: 'error',
  188. dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}`
  189. })
  190. });
  191. }
  192. export {
  193. createNotificationEvent,
  194. notifyUserIfEnabled,
  195. notifyRunStopped,
  196. notifyRunFailed
  197. };