@@ -7,6 +7,11 @@ import {
77 NotificationEventType ,
88 DEFAULT_NOTIFICATION_PREFERENCES ,
99} from "@opencode-manager/shared/schemas" ;
10+ import {
11+ getPermissionLabel ,
12+ getPermissionDetail ,
13+ getQuestionText ,
14+ } from "@opencode-manager/shared/notifications" ;
1015import { SettingsService } from "./settings" ;
1116import { sseAggregator , type SSEEvent } from "./sse-aggregator" ;
1217import { getRepoByLocalPath , getRepoBySourcePath } from "../db/queries" ;
@@ -21,33 +26,81 @@ interface VapidConfig {
2126
2227const EVENT_CONFIG : Record <
2328 string ,
24- { preferencesKey : keyof typeof DEFAULT_NOTIFICATION_PREFERENCES . events ; title : string ; bodyFn : ( props : Record < string , unknown > ) => string }
29+ {
30+ preferencesKey : keyof typeof DEFAULT_NOTIFICATION_PREFERENCES . events ;
31+ titleFn : ( props : Record < string , unknown > ) => string ;
32+ bodyFn : ( props : Record < string , unknown > ) => string ;
33+ }
2534> = {
2635 [ NotificationEventType . PERMISSION_ASKED ] : {
2736 preferencesKey : "permissionAsked" ,
28- title : "Permission Required" ,
29- bodyFn : ( ) => "OpenCode needs your approval to continue" ,
37+ titleFn : ( props ) =>
38+ getPermissionLabel (
39+ typeof props . permission === "string" ? props . permission : ""
40+ ) ,
41+ bodyFn : ( props ) => getPermissionDetail ( props ) . primary || "Approval required" ,
3042 } ,
3143 [ NotificationEventType . QUESTION_ASKED ] : {
3244 preferencesKey : "questionAsked" ,
33- title : "Question from Agent ",
34- bodyFn : ( ) => "The agent has a question for you ",
45+ titleFn : ( ) => "Question ",
46+ bodyFn : ( props ) => getQuestionText ( props ) || "A question needs your answer ",
3547 } ,
3648 [ NotificationEventType . SESSION_ERROR ] : {
3749 preferencesKey : "sessionError" ,
38- title : "Session Error",
50+ titleFn : ( ) => " Error",
3951 bodyFn : ( props ) => {
4052 const error = props . error as { message ?: string } | undefined ;
4153 return error ?. message ?? "A session encountered an error" ;
4254 } ,
4355 } ,
4456 [ NotificationEventType . SESSION_IDLE ] : {
4557 preferencesKey : "sessionIdle" ,
46- title : "Session Complete " ,
58+ titleFn : ( ) => "Session complete " ,
4759 bodyFn : ( ) => "Your session has finished processing" ,
4860 } ,
4961} ;
5062
63+ const MAX_BODY_LENGTH = 140 ;
64+
65+ export function buildEventNotificationPayload (
66+ event : SSEEvent ,
67+ context : {
68+ repoName ?: string ;
69+ repoId ?: number ;
70+ sessionId ?: string ;
71+ directory ?: string ;
72+ url : string ;
73+ }
74+ ) : PushNotificationPayload | null {
75+ const config = EVENT_CONFIG [ event . type ] ;
76+ if ( ! config ) return null ;
77+
78+ const action = config . titleFn ( event . properties ) ;
79+ const title = context . repoName
80+ ? `${ context . repoName } : ${ action } `
81+ : action ;
82+
83+ const rawBody = config . bodyFn ( event . properties ) ;
84+ const body =
85+ rawBody . length > MAX_BODY_LENGTH
86+ ? `${ rawBody . slice ( 0 , MAX_BODY_LENGTH - 1 ) } …`
87+ : rawBody ;
88+
89+ return {
90+ title,
91+ body,
92+ tag : `${ event . type } -${ context . sessionId ?? "global" } ` ,
93+ data : {
94+ eventType : event . type ,
95+ sessionId : context . sessionId ,
96+ directory : context . directory ,
97+ repoId : context . repoId ,
98+ repoName : context . repoName ,
99+ url : context . url ,
100+ } ,
101+ } ;
102+ }
103+
51104export class NotificationService {
52105 private vapidConfig : VapidConfig | null = null ;
53106 private settingsService : SettingsService ;
@@ -205,6 +258,34 @@ export class NotificationService {
205258 if ( ! this . isConfigured ( ) ) return ;
206259
207260 const userIds = this . getAllUserIds ( ) ;
261+ if ( userIds . length === 0 ) return ;
262+
263+ let notificationUrl = "/" ;
264+ let repoName = "" ;
265+ let repoId : number | undefined ;
266+
267+ if ( _directory ) {
268+ const reposBasePath = getReposPath ( ) ;
269+ const localPath = path . relative ( reposBasePath , _directory ) ;
270+ const repo = getRepoBySourcePath ( this . db , path . resolve ( _directory ) ) ?? getRepoByLocalPath ( this . db , localPath ) ;
271+
272+ if ( repo ) {
273+ repoId = repo . id ;
274+ repoName = path . basename ( repo . localPath ) ;
275+ notificationUrl = sessionId
276+ ? `/repos/${ repo . id } /sessions/${ sessionId } `
277+ : `/repos/${ repo . id } ` ;
278+ }
279+ }
280+
281+ const payload = buildEventNotificationPayload ( event , {
282+ repoName : repoName || undefined ,
283+ repoId,
284+ sessionId,
285+ directory : _directory ,
286+ url : notificationUrl ,
287+ } ) ;
288+ if ( ! payload ) return ;
208289
209290 for ( const userId of userIds ) {
210291 const settings = this . settingsService . getSettings ( userId ) ;
@@ -214,42 +295,6 @@ export class NotificationService {
214295 if ( ! notifPrefs . enabled ) continue ;
215296 if ( ! notifPrefs . events [ config . preferencesKey ] ) continue ;
216297
217- let notificationUrl = "/" ;
218- let repoName = "" ;
219- let repoId : number | undefined ;
220-
221- if ( _directory ) {
222- const reposBasePath = getReposPath ( ) ;
223- const localPath = path . relative ( reposBasePath , _directory ) ;
224- const repo = getRepoBySourcePath ( this . db , path . resolve ( _directory ) ) ?? getRepoByLocalPath ( this . db , localPath ) ;
225-
226- if ( repo ) {
227- repoId = repo . id ;
228- repoName = path . basename ( repo . localPath ) ;
229- if ( sessionId ) {
230- notificationUrl = `/repos/${ repo . id } /sessions/${ sessionId } ` ;
231- } else {
232- notificationUrl = `/repos/${ repo . id } ` ;
233- }
234- }
235- }
236-
237- const body = config . bodyFn ( event . properties ) ;
238-
239- const payload : PushNotificationPayload = {
240- title : repoName ? `[${ repoName . toUpperCase ( ) } ] ${ config . title } ` : config . title ,
241- body : body ,
242- tag : `${ event . type } -${ sessionId ?? "global" } ` ,
243- data : {
244- eventType : event . type ,
245- sessionId,
246- directory : _directory ,
247- repoId,
248- repoName,
249- url : notificationUrl ,
250- } ,
251- } ;
252-
253298 await this . sendToUser ( userId , payload ) ;
254299 }
255300 }
0 commit comments