@memberjunction/server 5.41.0 → 5.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  2. package/dist/agentSessions/SessionJanitor.js +5 -1
  3. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  4. package/dist/agentSessions/SessionManager.d.ts +15 -2
  5. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  6. package/dist/agentSessions/SessionManager.js +50 -11
  7. package/dist/agentSessions/SessionManager.js.map +1 -1
  8. package/dist/agentSessions/index.d.ts +1 -0
  9. package/dist/agentSessions/index.d.ts.map +1 -1
  10. package/dist/agentSessions/index.js +5 -0
  11. package/dist/agentSessions/index.js.map +1 -1
  12. package/dist/agentSessions/remoteBrowserGoalEngine.d.ts +102 -0
  13. package/dist/agentSessions/remoteBrowserGoalEngine.d.ts.map +1 -0
  14. package/dist/agentSessions/remoteBrowserGoalEngine.js +164 -0
  15. package/dist/agentSessions/remoteBrowserGoalEngine.js.map +1 -0
  16. package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts +66 -0
  17. package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts.map +1 -0
  18. package/dist/agentSessions/remoteBrowserGoalRegistry.js +86 -0
  19. package/dist/agentSessions/remoteBrowserGoalRegistry.js.map +1 -0
  20. package/dist/auth/initializeProviders.d.ts.map +1 -1
  21. package/dist/auth/initializeProviders.js +4 -7
  22. package/dist/auth/initializeProviders.js.map +1 -1
  23. package/dist/config.d.ts +7 -0
  24. package/dist/config.d.ts.map +1 -1
  25. package/dist/config.js +12 -2
  26. package/dist/config.js.map +1 -1
  27. package/dist/generated/generated.d.ts +612 -0
  28. package/dist/generated/generated.d.ts.map +1 -1
  29. package/dist/generated/generated.js +6934 -3626
  30. package/dist/generated/generated.js.map +1 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +38 -17
  33. package/dist/index.js.map +1 -1
  34. package/dist/integration/CustomColumnPromoter.d.ts +108 -0
  35. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -0
  36. package/dist/integration/CustomColumnPromoter.js +508 -0
  37. package/dist/integration/CustomColumnPromoter.js.map +1 -0
  38. package/dist/logging/StartupLogger.d.ts +57 -1
  39. package/dist/logging/StartupLogger.d.ts.map +1 -1
  40. package/dist/logging/StartupLogger.js +115 -6
  41. package/dist/logging/StartupLogger.js.map +1 -1
  42. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +46 -0
  43. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -0
  44. package/dist/resolvers/ExecuteRemoteOperationResolver.js +192 -0
  45. package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -0
  46. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +101 -1
  47. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  48. package/dist/resolvers/IntegrationDiscoveryResolver.js +629 -58
  49. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  50. package/dist/resolvers/RealtimeBridgeResolver.d.ts +130 -0
  51. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -0
  52. package/dist/resolvers/RealtimeBridgeResolver.js +580 -0
  53. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -0
  54. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +78 -5
  55. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  56. package/dist/resolvers/RemoteBrowserActionResolver.js +227 -15
  57. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  58. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  59. package/dist/resolvers/RunAIAgentResolver.js +0 -7
  60. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  61. package/dist/resolvers/RunTemplateResolver.d.ts +8 -0
  62. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  63. package/dist/resolvers/RunTemplateResolver.js +8 -0
  64. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  65. package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
  66. package/dist/rest/setupRESTEndpoints.js +5 -3
  67. package/dist/rest/setupRESTEndpoints.js.map +1 -1
  68. package/package.json +83 -78
  69. package/src/__tests__/RealtimeBridgeResolver.test.ts +100 -0
  70. package/src/__tests__/RemoteBrowserAudioStream.test.ts +8 -1
  71. package/src/__tests__/RemoteBrowserGoalEngine.test.ts +144 -0
  72. package/src/__tests__/remoteBrowserGoalRegistry.test.ts +81 -0
  73. package/src/agentSessions/SessionJanitor.ts +15 -1
  74. package/src/agentSessions/SessionManager.ts +59 -10
  75. package/src/agentSessions/index.ts +5 -0
  76. package/src/agentSessions/remoteBrowserGoalEngine.ts +191 -0
  77. package/src/agentSessions/remoteBrowserGoalRegistry.ts +122 -0
  78. package/src/auth/initializeProviders.ts +4 -7
  79. package/src/config.ts +13 -2
  80. package/src/generated/generated.ts +2502 -198
  81. package/src/index.ts +38 -17
  82. package/src/integration/CustomColumnPromoter.ts +597 -0
  83. package/src/logging/StartupLogger.ts +130 -6
  84. package/src/resolvers/ExecuteRemoteOperationResolver.ts +164 -0
  85. package/src/resolvers/IntegrationDiscoveryResolver.ts +490 -53
  86. package/src/resolvers/RealtimeBridgeResolver.ts +493 -0
  87. package/src/resolvers/RemoteBrowserActionResolver.ts +232 -17
  88. package/src/resolvers/RunAIAgentResolver.ts +1 -9
  89. package/src/resolvers/RunTemplateResolver.ts +8 -0
  90. package/src/rest/setupRESTEndpoints.ts +5 -3
@@ -0,0 +1,493 @@
1
+ import { Resolver, Mutation, Query, Arg, Ctx, ObjectType, InputType, Field } from 'type-graphql';
2
+ import { randomUUID } from 'crypto';
3
+ import { LogError, LogStatusEx, UserInfo, IMetadataProvider } from '@memberjunction/core';
4
+ import { LiveKitTokenService, LiveKitAgentRoomCoordinator, LiveKitEgressService } from '@memberjunction/livekit-room-server';
5
+ import { AppContext } from '../types.js';
6
+ import { ResolverBase } from '../generic/ResolverBase.js';
7
+ import { GetReadWriteProvider } from '../util.js';
8
+ import { CreateBridgeRealtimeSession, FinalizeBridgeCoAgentRuns, GetRealtimeModelVoices, CreateBridgeRoomTranscriptSink, RealtimeTurnModeratorDecision } from '@memberjunction/ai-agents';
9
+ import { AIBridgeEngine } from '@memberjunction/ai-bridge-server';
10
+ import { SessionManager } from '../agentSessions/SessionManager.js';
11
+ import { NotificationEngine } from '@memberjunction/notifications';
12
+
13
+ /**
14
+ * Binds the agent realtime-session factory onto the LiveKit room coordinator's model-session creation seam.
15
+ * Module-load side effect — it runs when MJServer builds its GraphQL schema (which imports this resolver),
16
+ * so `StartLiveKitAgentRoomSession` can open a real model session. `@memberjunction/server` is the natural
17
+ * home for the binding: it is the one package that depends on BOTH `@memberjunction/ai-agents` (the factory)
18
+ * and `@memberjunction/livekit-room-server` (the coordinator), keeping each of those decoupled from the
19
+ * other. Idempotent (latest-wins).
20
+ */
21
+ LiveKitAgentRoomCoordinator.Instance.SetSessionFactory(CreateBridgeRealtimeSession);
22
+
23
+ /**
24
+ * Binds the co-agent run finalizer onto the bridge engine (same module-load rationale as the factory above).
25
+ * Lets the engine finalize a session's dangling co-agent observability run when it reaps a bridge WITHOUT a
26
+ * live in-memory session (a prior-boot orphan / cross-host reap) — the one teardown path the agent layer's
27
+ * `Close()`-wrapped finalizer can't reach. Idempotent for clean same-process teardowns.
28
+ */
29
+ AIBridgeEngine.Instance.SetSessionRunFinalizer(FinalizeBridgeCoAgentRuns);
30
+
31
+ /**
32
+ * Binds the unified room-transcript sink onto the bridge engine (same module-load rationale). The engine
33
+ * elects one scribe per LiveKit room and feeds its final transcript lines here; the sink persists them as a
34
+ * single `MJ: Conversations` of `Type='Meeting Room'`, scoped `Application` so it stays OUT of the normal
35
+ * chat list (it surfaces in the Meet app's own view), with one `MJ: Conversation Detail` per utterance. The
36
+ * "Meeting Room"/scope choices live HERE (the Meet composition layer), keeping the engine generic.
37
+ */
38
+ AIBridgeEngine.Instance.SetTranscriptSink(
39
+ CreateBridgeRoomTranscriptSink({ ConversationType: 'Meeting Room', ApplicationScope: 'Application', ApplicationName: 'Meet' }),
40
+ );
41
+
42
+ /**
43
+ * Binds the room **turn moderator** onto the bridge engine — **OPT-IN, off by default**. When
44
+ * `MJ_REALTIME_MODERATOR_MODE=on`, a multi-agent room routes each turn through a fast LLM prompt that decides
45
+ * who speaks (see `RealtimeTurnModerator` in `@memberjunction/ai-agents`). By default it's OFF: agents run in
46
+ * plain auto-response, hear everything, and self-moderate (no STT-driven router in the loop) — the
47
+ * coordinator likewise skips meeting mode when the flag is off, so the two stay consistent. We keep the
48
+ * moderator wired-but-toggleable for controlled scenarios (webinars, large rooms, weaker models).
49
+ */
50
+ if (process.env.MJ_REALTIME_MODERATOR_MODE === 'on') {
51
+ AIBridgeEngine.Instance.SetTurnModerator(RealtimeTurnModeratorDecision);
52
+ console.log('[RealtimeBridge] turn MODERATOR mode is ON (MJ_REALTIME_MODERATOR_MODE=on) — multi-agent rooms use the LLM router.');
53
+ } else {
54
+ // Default mode — only surface this at startup when verbose (MJ_VERBOSE) is on; it's the expected state and otherwise just noise.
55
+ LogStatusEx({ message: '[RealtimeBridge] turn moderator mode is OFF (default) — multi-agent rooms run free-for-all: all agents auto-respond + hear everything.', verboseOnly: true });
56
+ }
57
+
58
+ /**
59
+ * GraphQL surface for the MJ-native LiveKit room: mints scoped client access tokens and starts an
60
+ * agent's presence in a room. The thin resolver delegates to `@memberjunction/livekit-room-server`
61
+ * (token service + session-start coordinator) per the Transport-Layer Architecture — no LiveKit logic
62
+ * lives here.
63
+ *
64
+ * `MintLiveKitClientToken` is fully functional given LiveKit credentials. `StartLiveKitAgentRoomSession`
65
+ * opens a real agent model session via the realtime-session factory bound above (the agent must have an
66
+ * Active `Realtime` model + a vendor with a resolvable API key), then bridges it into the room through the
67
+ * coordinator. The native room media client (`@memberjunction/ai-bridge-livekit-native` /
68
+ * `@livekit/rtc-node`) must be installed on the agent host for the bot's audio to flow.
69
+ */
70
+
71
+ @InputType()
72
+ export class MintLiveKitClientTokenInput {
73
+ @Field(() => String)
74
+ RoomName: string;
75
+
76
+ @Field(() => String, { nullable: true })
77
+ DisplayName?: string;
78
+ }
79
+
80
+ @ObjectType()
81
+ export class LiveKitClientTokenResult {
82
+ @Field(() => Boolean)
83
+ Success: boolean;
84
+
85
+ @Field(() => String, { nullable: true })
86
+ ErrorMessage?: string;
87
+
88
+ @Field(() => String)
89
+ ServerUrl: string;
90
+
91
+ @Field(() => String)
92
+ Token: string;
93
+
94
+ @Field(() => String)
95
+ Identity: string;
96
+
97
+ @Field(() => String)
98
+ RoomName: string;
99
+ }
100
+
101
+ @InputType()
102
+ export class StartLiveKitAgentRoomSessionInput {
103
+ @Field(() => String, { nullable: true })
104
+ AgentID?: string;
105
+
106
+ @Field(() => String, { nullable: true })
107
+ AgentName?: string;
108
+
109
+ /** The TARGET agent the co-agent voices (the one being "called") — the Realtime Co-Agent delegates to it. */
110
+ @Field(() => String, { nullable: true })
111
+ TargetAgentID?: string;
112
+
113
+ /** Optional per-session Realtime MODEL override (Name or ID) — a dev choosing the model for this agent. */
114
+ @Field(() => String, { nullable: true })
115
+ RealtimeModelID?: string;
116
+
117
+ /** Optional per-session VOICE override (provider-native voice id) — gives this agent a distinct voice. */
118
+ @Field(() => String, { nullable: true })
119
+ RealtimeVoice?: string;
120
+
121
+ @Field(() => String, { nullable: true })
122
+ RoomName?: string;
123
+
124
+ @Field(() => String, { nullable: true })
125
+ AgentSessionID?: string;
126
+
127
+ @Field(() => String, { nullable: true })
128
+ TurnMode?: string;
129
+ }
130
+
131
+ @ObjectType()
132
+ export class LiveKitAgentRoomSessionResult {
133
+ @Field(() => Boolean)
134
+ Success: boolean;
135
+
136
+ @Field(() => String, { nullable: true })
137
+ ErrorMessage?: string;
138
+
139
+ @Field(() => String)
140
+ SessionBridgeID: string;
141
+
142
+ @Field(() => String)
143
+ RoomName: string;
144
+
145
+ @Field(() => String)
146
+ ServerUrl: string;
147
+
148
+ @Field(() => String)
149
+ ClientToken: string;
150
+
151
+ @Field(() => String)
152
+ Identity: string;
153
+ }
154
+
155
+ @InputType()
156
+ export class LiveKitRecordingInput {
157
+ @Field(() => String)
158
+ RoomName: string;
159
+
160
+ @Field(() => String, { nullable: true })
161
+ Layout?: string;
162
+ }
163
+
164
+ @ObjectType()
165
+ export class LiveKitRecordingResult {
166
+ @Field(() => Boolean)
167
+ Success: boolean;
168
+
169
+ @Field(() => String, { nullable: true })
170
+ ErrorMessage?: string;
171
+
172
+ @Field(() => String)
173
+ EgressID: string;
174
+
175
+ @Field(() => String)
176
+ Status: string;
177
+ }
178
+
179
+ /** A selectable provider-native voice for the dev voice picker. */
180
+ @ObjectType()
181
+ export class RealtimeVoiceOptionResult {
182
+ @Field(() => String)
183
+ ID: string;
184
+
185
+ @Field(() => String)
186
+ Name: string;
187
+ }
188
+
189
+ /** An active Realtime model with the voices its driver supports — feeds the dev model/voice picker. */
190
+ @ObjectType()
191
+ export class RealtimeModelVoicesResult {
192
+ @Field(() => String)
193
+ ModelID: string;
194
+
195
+ @Field(() => String)
196
+ ModelName: string;
197
+
198
+ @Field(() => [RealtimeVoiceOptionResult])
199
+ Voices: RealtimeVoiceOptionResult[];
200
+ }
201
+
202
+ @Resolver()
203
+ export class RealtimeBridgeResolver extends ResolverBase {
204
+ /** Durable `AIAgentSession` record manager — creates the session row the bridge FK-references. */
205
+ private readonly sessionManager = new SessionManager();
206
+
207
+ /**
208
+ * Mints a scoped LiveKit access token for the current user to join the given room. The participant
209
+ * identity is derived server-side from the authenticated user (never trusted from the client).
210
+ */
211
+ @Mutation(() => LiveKitClientTokenResult)
212
+ async MintLiveKitClientToken(
213
+ @Arg('input', () => MintLiveKitClientTokenInput) input: MintLiveKitClientTokenInput,
214
+ @Ctx() context: AppContext = {} as AppContext,
215
+ ): Promise<LiveKitClientTokenResult> {
216
+ const failure = (msg: string): LiveKitClientTokenResult => ({
217
+ Success: false,
218
+ ErrorMessage: msg,
219
+ ServerUrl: '',
220
+ Token: '',
221
+ Identity: '',
222
+ RoomName: input.RoomName,
223
+ });
224
+ try {
225
+ const user = this.GetUserFromPayload(context.userPayload);
226
+ if (!user) {
227
+ return failure('Unable to determine current user.');
228
+ }
229
+ const tokenService = new LiveKitTokenService();
230
+ const minted = await tokenService.MintClientToken(input.RoomName, this.participantIdentity(user), input.DisplayName ?? user.Name ?? user.Email);
231
+ return { Success: true, ...minted };
232
+ } catch (error) {
233
+ const msg = error instanceof Error ? error.message : String(error);
234
+ LogError(`MintLiveKitClientToken failed: ${msg}`);
235
+ return failure(msg);
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Starts (or reuses) an agent's presence in a LiveKit room and returns a client token so the calling
241
+ * user can immediately join the same room.
242
+ */
243
+ @Mutation(() => LiveKitAgentRoomSessionResult)
244
+ async StartLiveKitAgentRoomSession(
245
+ @Arg('input', () => StartLiveKitAgentRoomSessionInput) input: StartLiveKitAgentRoomSessionInput,
246
+ @Ctx() context: AppContext = {} as AppContext,
247
+ ): Promise<LiveKitAgentRoomSessionResult> {
248
+ const failure = (msg: string, roomName = ''): LiveKitAgentRoomSessionResult => ({
249
+ Success: false,
250
+ ErrorMessage: msg,
251
+ SessionBridgeID: '',
252
+ RoomName: roomName,
253
+ ServerUrl: '',
254
+ ClientToken: '',
255
+ Identity: '',
256
+ });
257
+ try {
258
+ const user = this.GetUserFromPayload(context.userPayload);
259
+ if (!user) {
260
+ return failure('Unable to determine current user.');
261
+ }
262
+ const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
263
+ const roomName = input.RoomName?.trim() || `mj-${randomUUID()}`;
264
+
265
+ // Resolve the AIAgentSession the bridge will reference. The bridge row FK-references
266
+ // AIAgentSession(ID), so we must use an EXISTING session — either one the caller supplied, or a
267
+ // freshly-created one. Previously this minted a bare random UUID with no backing row, so the
268
+ // bridge INSERT failed the FK_AIAgentSessionBridge_Session constraint.
269
+ let agentSessionID = input.AgentSessionID?.trim();
270
+ if (!agentSessionID) {
271
+ if (!input.AgentID?.trim()) {
272
+ return failure('An AgentID is required to start an agent room session.', roomName);
273
+ }
274
+ const createdSession = await this.sessionManager.CreateSession(
275
+ { agentID: input.AgentID.trim(), userID: user.ID },
276
+ user,
277
+ provider,
278
+ );
279
+ agentSessionID = createdSession.ID;
280
+ }
281
+
282
+ const session = await LiveKitAgentRoomCoordinator.Instance.StartAgentRoomSession({
283
+ AgentSessionID: agentSessionID,
284
+ RoomName: roomName,
285
+ AgentID: input.AgentID,
286
+ AgentName: input.AgentName,
287
+ TargetAgentID: input.TargetAgentID,
288
+ RealtimeModelID: input.RealtimeModelID,
289
+ RealtimeVoice: input.RealtimeVoice,
290
+ TurnMode: this.normalizeTurnMode(input.TurnMode),
291
+ ContextUser: user,
292
+ MetadataProvider: provider,
293
+ });
294
+
295
+ const tokenService = new LiveKitTokenService();
296
+ const clientToken = await tokenService.MintClientToken(roomName, this.participantIdentity(user), user.Name ?? user.Email);
297
+
298
+ return {
299
+ Success: true,
300
+ SessionBridgeID: session.SessionBridgeID,
301
+ RoomName: session.RoomName,
302
+ ServerUrl: session.ServerUrl,
303
+ ClientToken: clientToken.Token,
304
+ Identity: clientToken.Identity,
305
+ };
306
+ } catch (error) {
307
+ const msg = error instanceof Error ? error.message : String(error);
308
+ LogError(`StartLiveKitAgentRoomSession failed: ${msg}`);
309
+ return failure(msg, input.RoomName ?? '');
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Stops one agent's presence in a room (the bot leaves) — the remove half of in-room agent management.
315
+ * Identified by the `SessionBridgeID` returned from {@link StartLiveKitAgentRoomSession}. Returns `true`
316
+ * when the bridge was stopped. Best-effort: a missing/already-stopped bridge or any error resolves `false`.
317
+ *
318
+ * @param sessionBridgeID The `MJ: AI Agent Session Bridges` row id of the agent to remove.
319
+ */
320
+ @Mutation(() => Boolean)
321
+ async StopLiveKitAgentRoomSession(
322
+ @Arg('sessionBridgeID', () => String) sessionBridgeID: string,
323
+ @Ctx() context: AppContext = {} as AppContext,
324
+ ): Promise<boolean> {
325
+ try {
326
+ const user = this.GetUserFromPayload(context.userPayload);
327
+ if (!user) {
328
+ return false;
329
+ }
330
+ const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
331
+ return await LiveKitAgentRoomCoordinator.Instance.StopAgentRoomSession(sessionBridgeID, 'Explicit', user, provider);
332
+ } catch (error) {
333
+ LogError(`StopLiveKitAgentRoomSession failed: ${error instanceof Error ? error.message : String(error)}`);
334
+ return false;
335
+ }
336
+ }
337
+
338
+ /**
339
+ * **Ends the meeting for everyone**: stops EVERY agent bot bridged into a room (by room name, via the
340
+ * coordinator's server-side roster). This is the "End meeting" half of the Zoom-style leave control —
341
+ * usable by any participant, including one who only *joined* the room and never tracked the bridge ids.
342
+ * Returns `true` when the teardown ran (even if the room held zero agents). Best-effort: any error → `false`.
343
+ *
344
+ * @param roomName The LiveKit room to end.
345
+ */
346
+ @Mutation(() => Boolean)
347
+ async EndLiveKitRoom(
348
+ @Arg('roomName', () => String) roomName: string,
349
+ @Ctx() context: AppContext = {} as AppContext,
350
+ ): Promise<boolean> {
351
+ try {
352
+ const user = this.GetUserFromPayload(context.userPayload);
353
+ if (!user) {
354
+ return false;
355
+ }
356
+ const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
357
+ await LiveKitAgentRoomCoordinator.Instance.StopAllAgentsInRoom(roomName, 'Explicit', user, provider);
358
+ return true;
359
+ } catch (error) {
360
+ LogError(`EndLiveKitRoom failed: ${error instanceof Error ? error.message : String(error)}`);
361
+ return false;
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Lists active Realtime models with the voices each driver supports — the source for the dev model/voice
367
+ * picker (gated client-side by the `Realtime: Advanced Session Controls` authorization). Read-only; returns
368
+ * an empty list on any error so the picker degrades gracefully to "no overrides".
369
+ */
370
+ @Query(() => [RealtimeModelVoicesResult])
371
+ async GetRealtimeModelVoices(
372
+ @Ctx() context: AppContext = {} as AppContext,
373
+ ): Promise<RealtimeModelVoicesResult[]> {
374
+ try {
375
+ const user = this.GetUserFromPayload(context.userPayload);
376
+ if (!user) {
377
+ return [];
378
+ }
379
+ const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
380
+ return await GetRealtimeModelVoices(user, provider);
381
+ } catch (error) {
382
+ LogError(`GetRealtimeModelVoices failed: ${error instanceof Error ? error.message : String(error)}`);
383
+ return [];
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Invites MJ users to a live room: for each user, sends a **"Live Room Invite"** notification via the
389
+ * unified {@link NotificationEngine} — which writes the in-app notification (clickable → joins the room
390
+ * via the `meet-room` ResourceConfiguration) and ALSO delivers over MJ Comms (email/SMS) when the type's
391
+ * channels + a provider are configured. Best-effort: Comms not being set up never blocks the in-app
392
+ * notification, and a missing "Live Room Invite" type (seed not yet pushed) is caught and returns false.
393
+ *
394
+ * @param roomName The LiveKit room the invitees should join.
395
+ * @param userIDs The `MJ: Users` ids to invite.
396
+ * @returns `true` when at least one invite was delivered.
397
+ */
398
+ @Mutation(() => Boolean)
399
+ async InviteUsersToLiveKitRoom(
400
+ @Arg('roomName', () => String) roomName: string,
401
+ @Arg('userIDs', () => [String]) userIDs: string[],
402
+ @Ctx() context: AppContext = {} as AppContext,
403
+ ): Promise<boolean> {
404
+ try {
405
+ const user = this.GetUserFromPayload(context.userPayload);
406
+ if (!user) {
407
+ return false;
408
+ }
409
+ const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
410
+ await NotificationEngine.Instance.Config(false, user, provider);
411
+
412
+ const inviter = user.Name?.trim() || user.Email || 'Someone';
413
+ let anyDelivered = false;
414
+ for (const userId of userIDs ?? []) {
415
+ if (!userId?.trim()) {
416
+ continue;
417
+ }
418
+ const result = await NotificationEngine.Instance.SendNotification(
419
+ {
420
+ userId,
421
+ typeNameOrId: 'Live Room Invite',
422
+ title: `${inviter} invited you to a live room`,
423
+ message: `${inviter} is inviting you to join a live Meet room. Open this notification to join.`,
424
+ resourceConfiguration: { type: 'meet-room', room: roomName },
425
+ },
426
+ user,
427
+ );
428
+ anyDelivered = anyDelivered || result.success;
429
+ }
430
+ return anyDelivered;
431
+ } catch (error) {
432
+ LogError(`InviteUsersToLiveKitRoom failed: ${error instanceof Error ? error.message : String(error)}`);
433
+ return false;
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Starts recording (composite egress) of a room. Server-authorized — the browser never holds egress
439
+ * credentials.
440
+ */
441
+ @Mutation(() => LiveKitRecordingResult)
442
+ async StartLiveKitRecording(
443
+ @Arg('input', () => LiveKitRecordingInput) input: LiveKitRecordingInput,
444
+ @Ctx() context: AppContext = {} as AppContext,
445
+ ): Promise<LiveKitRecordingResult> {
446
+ try {
447
+ if (!this.GetUserFromPayload(context.userPayload)) {
448
+ return { Success: false, ErrorMessage: 'Unable to determine current user.', EgressID: '', Status: '' };
449
+ }
450
+ const info = await new LiveKitEgressService().StartRoomRecording({ RoomName: input.RoomName, Layout: input.Layout });
451
+ return { Success: true, EgressID: info.EgressID, Status: info.Status };
452
+ } catch (error) {
453
+ const msg = error instanceof Error ? error.message : String(error);
454
+ LogError(`StartLiveKitRecording failed: ${msg}`);
455
+ return { Success: false, ErrorMessage: msg, EgressID: '', Status: '' };
456
+ }
457
+ }
458
+
459
+ /** Stops a recording by egress id. */
460
+ @Mutation(() => LiveKitRecordingResult)
461
+ async StopLiveKitRecording(@Arg('egressID', () => String) egressID: string, @Ctx() context: AppContext = {} as AppContext): Promise<LiveKitRecordingResult> {
462
+ try {
463
+ if (!this.GetUserFromPayload(context.userPayload)) {
464
+ return { Success: false, ErrorMessage: 'Unable to determine current user.', EgressID: egressID, Status: '' };
465
+ }
466
+ const info = await new LiveKitEgressService().StopRecording(egressID);
467
+ return { Success: true, EgressID: info.EgressID, Status: info.Status };
468
+ } catch (error) {
469
+ const msg = error instanceof Error ? error.message : String(error);
470
+ LogError(`StopLiveKitRecording failed: ${msg}`);
471
+ return { Success: false, ErrorMessage: msg, EgressID: egressID, Status: '' };
472
+ }
473
+ }
474
+
475
+ /** Builds a stable, lowercased participant identity from the authenticated user. */
476
+ private participantIdentity(user: UserInfo): string {
477
+ return `user-${user.ID}`.toLowerCase();
478
+ }
479
+
480
+ /** Normalizes a turn-mode string to the bridge's accepted values. */
481
+ private normalizeTurnMode(mode?: string): 'Passive' | 'Active' | 'Hybrid' | undefined {
482
+ switch ((mode ?? '').toLowerCase()) {
483
+ case 'active':
484
+ return 'Active';
485
+ case 'hybrid':
486
+ return 'Hybrid';
487
+ case 'passive':
488
+ return 'Passive';
489
+ default:
490
+ return undefined;
491
+ }
492
+ }
493
+ }