@memberjunction/server 5.44.0 → 5.45.1
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.
- package/dist/agentSessions/ReturningVisitorRecap.d.ts +39 -0
- package/dist/agentSessions/ReturningVisitorRecap.d.ts.map +1 -0
- package/dist/agentSessions/ReturningVisitorRecap.js +198 -0
- package/dist/agentSessions/ReturningVisitorRecap.js.map +1 -0
- package/dist/agentSessions/SessionJanitor.d.ts +13 -0
- package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
- package/dist/agentSessions/SessionJanitor.js +65 -7
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +37 -0
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.d.ts +10 -0
- package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.js +16 -0
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts +7 -0
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +13 -3
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/magicLink/index.d.ts +1 -1
- package/dist/auth/magicLink/index.d.ts.map +1 -1
- package/dist/auth/magicLink/index.js +1 -1
- package/dist/auth/magicLink/index.js.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.d.ts +38 -1
- package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.js +42 -19
- package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
- package/dist/auth/magicLink/types.d.ts +26 -0
- package/dist/auth/magicLink/types.d.ts.map +1 -1
- package/dist/config.d.ts +1883 -144
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +160 -36
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +45 -2
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +522 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +2860 -0
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +13 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +22 -0
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +4 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +101 -3
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
- package/dist/integration/CustomColumnPromoter.js +6 -0
- package/dist/integration/CustomColumnPromoter.js.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.d.ts +26 -0
- package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -0
- package/dist/realtimeWidget/WidgetRouter.js +182 -0
- package/dist/realtimeWidget/WidgetRouter.js.map +1 -0
- package/dist/realtimeWidget/WidgetSessionService.d.ts +265 -0
- package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -0
- package/dist/realtimeWidget/WidgetSessionService.js +477 -0
- package/dist/realtimeWidget/WidgetSessionService.js.map +1 -0
- package/dist/realtimeWidget/host-identity.d.ts +40 -0
- package/dist/realtimeWidget/host-identity.d.ts.map +1 -0
- package/dist/realtimeWidget/host-identity.js +58 -0
- package/dist/realtimeWidget/host-identity.js.map +1 -0
- package/dist/realtimeWidget/index.d.ts +10 -0
- package/dist/realtimeWidget/index.d.ts.map +1 -0
- package/dist/realtimeWidget/index.js +9 -0
- package/dist/realtimeWidget/index.js.map +1 -0
- package/dist/realtimeWidget/visitorIdentity.d.ts +76 -0
- package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -0
- package/dist/realtimeWidget/visitorIdentity.js +202 -0
- package/dist/realtimeWidget/visitorIdentity.js.map +1 -0
- package/dist/realtimeWidget/widgetCore.d.ts +106 -0
- package/dist/realtimeWidget/widgetCore.d.ts.map +1 -0
- package/dist/realtimeWidget/widgetCore.js +173 -0
- package/dist/realtimeWidget/widgetCore.js.map +1 -0
- package/dist/realtimeWidget/widgetGuestElevation.d.ts +51 -0
- package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -0
- package/dist/realtimeWidget/widgetGuestElevation.js +79 -0
- package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -0
- package/dist/resolvers/ComponentRegistryResolver.d.ts +7 -0
- package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -1
- package/dist/resolvers/ComponentRegistryResolver.js +31 -8
- package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
- package/dist/resolvers/EntityPermissionResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityPermissionResolver.js +1 -2
- package/dist/resolvers/EntityPermissionResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts +9 -6
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +15 -13
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +47 -3
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RingCentralTelephonyResolver.d.ts +27 -0
- package/dist/resolvers/RingCentralTelephonyResolver.d.ts.map +1 -0
- package/dist/resolvers/RingCentralTelephonyResolver.js +87 -0
- package/dist/resolvers/RingCentralTelephonyResolver.js.map +1 -0
- package/dist/resolvers/RunAIAgentResolver.d.ts +9 -0
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +20 -4
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +2 -0
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/TeamsMeetingsResolver.d.ts +28 -0
- package/dist/resolvers/TeamsMeetingsResolver.d.ts.map +1 -0
- package/dist/resolvers/TeamsMeetingsResolver.js +91 -0
- package/dist/resolvers/TeamsMeetingsResolver.js.map +1 -0
- package/dist/resolvers/TelephonyResolver.d.ts +26 -0
- package/dist/resolvers/TelephonyResolver.d.ts.map +1 -0
- package/dist/resolvers/TelephonyResolver.js +86 -0
- package/dist/resolvers/TelephonyResolver.js.map +1 -0
- package/dist/resolvers/TestQuerySQLResolver.d.ts +1 -1
- package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.js +2 -3
- package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
- package/dist/resolvers/VonageTelephonyResolver.d.ts +26 -0
- package/dist/resolvers/VonageTelephonyResolver.d.ts.map +1 -0
- package/dist/resolvers/VonageTelephonyResolver.js +86 -0
- package/dist/resolvers/VonageTelephonyResolver.js.map +1 -0
- package/dist/rest/MediaStreamHandler.js +4 -1
- package/dist/rest/MediaStreamHandler.js.map +1 -1
- package/dist/telephony/RingCentralTelephonyService.d.ts +115 -0
- package/dist/telephony/RingCentralTelephonyService.d.ts.map +1 -0
- package/dist/telephony/RingCentralTelephonyService.js +262 -0
- package/dist/telephony/RingCentralTelephonyService.js.map +1 -0
- package/dist/telephony/TeamsMeetingsRouter.d.ts +40 -0
- package/dist/telephony/TeamsMeetingsRouter.d.ts.map +1 -0
- package/dist/telephony/TeamsMeetingsRouter.js +96 -0
- package/dist/telephony/TeamsMeetingsRouter.js.map +1 -0
- package/dist/telephony/TeamsMeetingsService.d.ts +113 -0
- package/dist/telephony/TeamsMeetingsService.d.ts.map +1 -0
- package/dist/telephony/TeamsMeetingsService.js +196 -0
- package/dist/telephony/TeamsMeetingsService.js.map +1 -0
- package/dist/telephony/TwilioTelephonyRouter.d.ts +36 -0
- package/dist/telephony/TwilioTelephonyRouter.d.ts.map +1 -0
- package/dist/telephony/TwilioTelephonyRouter.js +143 -0
- package/dist/telephony/TwilioTelephonyRouter.js.map +1 -0
- package/dist/telephony/TwilioTelephonyService.d.ts +95 -0
- package/dist/telephony/TwilioTelephonyService.d.ts.map +1 -0
- package/dist/telephony/TwilioTelephonyService.js +193 -0
- package/dist/telephony/TwilioTelephonyService.js.map +1 -0
- package/dist/telephony/VonageTelephonyRouter.d.ts +41 -0
- package/dist/telephony/VonageTelephonyRouter.d.ts.map +1 -0
- package/dist/telephony/VonageTelephonyRouter.js +201 -0
- package/dist/telephony/VonageTelephonyRouter.js.map +1 -0
- package/dist/telephony/VonageTelephonyService.d.ts +90 -0
- package/dist/telephony/VonageTelephonyService.d.ts.map +1 -0
- package/dist/telephony/VonageTelephonyService.js +191 -0
- package/dist/telephony/VonageTelephonyService.js.map +1 -0
- package/dist/telephony/calendar-scheduler.d.ts +67 -0
- package/dist/telephony/calendar-scheduler.d.ts.map +1 -0
- package/dist/telephony/calendar-scheduler.js +133 -0
- package/dist/telephony/calendar-scheduler.js.map +1 -0
- package/dist/telephony/index.d.ts +29 -0
- package/dist/telephony/index.d.ts.map +1 -0
- package/dist/telephony/index.js +38 -0
- package/dist/telephony/index.js.map +1 -0
- package/dist/telephony/media-upgrade-router.d.ts +33 -0
- package/dist/telephony/media-upgrade-router.d.ts.map +1 -0
- package/dist/telephony/media-upgrade-router.js +56 -0
- package/dist/telephony/media-upgrade-router.js.map +1 -0
- package/dist/telephony/ringcentral-runtime.d.ts +14 -0
- package/dist/telephony/ringcentral-runtime.d.ts.map +1 -0
- package/dist/telephony/ringcentral-runtime.js +18 -0
- package/dist/telephony/ringcentral-runtime.js.map +1 -0
- package/dist/telephony/teams-meetings-runtime.d.ts +16 -0
- package/dist/telephony/teams-meetings-runtime.d.ts.map +1 -0
- package/dist/telephony/teams-meetings-runtime.js +20 -0
- package/dist/telephony/teams-meetings-runtime.js.map +1 -0
- package/dist/telephony/teamsAcsMediaRegistry.d.ts +69 -0
- package/dist/telephony/teamsAcsMediaRegistry.d.ts.map +1 -0
- package/dist/telephony/teamsAcsMediaRegistry.js +118 -0
- package/dist/telephony/teamsAcsMediaRegistry.js.map +1 -0
- package/dist/telephony/telephony-runtime.d.ts +14 -0
- package/dist/telephony/telephony-runtime.d.ts.map +1 -0
- package/dist/telephony/telephony-runtime.js +18 -0
- package/dist/telephony/telephony-runtime.js.map +1 -0
- package/dist/telephony/twilioMediaRegistry.d.ts +60 -0
- package/dist/telephony/twilioMediaRegistry.d.ts.map +1 -0
- package/dist/telephony/twilioMediaRegistry.js +106 -0
- package/dist/telephony/twilioMediaRegistry.js.map +1 -0
- package/dist/telephony/vonage-runtime.d.ts +14 -0
- package/dist/telephony/vonage-runtime.d.ts.map +1 -0
- package/dist/telephony/vonage-runtime.js +18 -0
- package/dist/telephony/vonage-runtime.js.map +1 -0
- package/dist/telephony/vonageMediaRegistry.d.ts +78 -0
- package/dist/telephony/vonageMediaRegistry.d.ts.map +1 -0
- package/dist/telephony/vonageMediaRegistry.js +158 -0
- package/dist/telephony/vonageMediaRegistry.js.map +1 -0
- package/package.json +88 -83
- package/src/__tests__/RunAIAgentResolver.streaming.test.ts +128 -0
- package/src/__tests__/magicLink.test.ts +53 -3
- package/src/__tests__/search-knowledge-tags.test.ts +9 -9
- package/src/__tests__/teams-meetings-service.test.ts +65 -0
- package/src/__tests__/teamsAcsMediaRegistry.test.ts +82 -0
- package/src/__tests__/twilio-telephony-service.test.ts +23 -0
- package/src/__tests__/twilioMediaRegistry.test.ts +103 -0
- package/src/__tests__/vonageMediaRegistry.test.ts +180 -0
- package/src/__tests__/widget.test.ts +204 -0
- package/src/__tests__/widgetGuestElevation.test.ts +57 -0
- package/src/__tests__/widgetHostIdentity.test.ts +117 -0
- package/src/agentSessions/ReturningVisitorRecap.ts +242 -0
- package/src/agentSessions/SessionJanitor.ts +66 -6
- package/src/agentSessions/SessionManager.ts +38 -0
- package/src/auth/magicLink/MagicLinkRouter.ts +17 -0
- package/src/auth/magicLink/MagicLinkService.ts +13 -3
- package/src/auth/magicLink/index.ts +1 -0
- package/src/auth/magicLink/magicLinkCore.ts +48 -20
- package/src/auth/magicLink/types.ts +26 -0
- package/src/config.ts +172 -38
- package/src/context.ts +50 -3
- package/src/generated/generated.ts +1991 -1
- package/src/generic/ResolverBase.ts +28 -0
- package/src/generic/RunViewResolver.ts +4 -0
- package/src/index.ts +109 -3
- package/src/integration/CustomColumnPromoter.ts +6 -0
- package/src/realtimeWidget/WidgetRouter.ts +204 -0
- package/src/realtimeWidget/WidgetSessionService.ts +714 -0
- package/src/realtimeWidget/host-identity.ts +84 -0
- package/src/realtimeWidget/index.ts +15 -0
- package/src/realtimeWidget/visitorIdentity.ts +258 -0
- package/src/realtimeWidget/widgetCore.ts +231 -0
- package/src/realtimeWidget/widgetGuestElevation.ts +115 -0
- package/src/resolvers/ComponentRegistryResolver.ts +36 -10
- package/src/resolvers/EntityPermissionResolver.ts +1 -2
- package/src/resolvers/QuerySystemUserResolver.ts +14 -10
- package/src/resolvers/RealtimeClientSessionResolver.ts +59 -2
- package/src/resolvers/RingCentralTelephonyResolver.ts +64 -0
- package/src/resolvers/RunAIAgentResolver.ts +32 -8
- package/src/resolvers/SearchKnowledgeResolver.ts +2 -0
- package/src/resolvers/TeamsMeetingsResolver.ts +68 -0
- package/src/resolvers/TelephonyResolver.ts +63 -0
- package/src/resolvers/TestQuerySQLResolver.ts +2 -3
- package/src/resolvers/VonageTelephonyResolver.ts +63 -0
- package/src/rest/MediaStreamHandler.ts +4 -1
- package/src/telephony/RingCentralTelephonyService.ts +342 -0
- package/src/telephony/TeamsMeetingsRouter.ts +124 -0
- package/src/telephony/TeamsMeetingsService.ts +268 -0
- package/src/telephony/TwilioTelephonyRouter.ts +184 -0
- package/src/telephony/TwilioTelephonyService.ts +261 -0
- package/src/telephony/VonageTelephonyRouter.ts +239 -0
- package/src/telephony/VonageTelephonyService.ts +259 -0
- package/src/telephony/calendar-scheduler.ts +176 -0
- package/src/telephony/index.ts +43 -0
- package/src/telephony/media-upgrade-router.ts +62 -0
- package/src/telephony/ringcentral-runtime.ts +22 -0
- package/src/telephony/teams-meetings-runtime.ts +24 -0
- package/src/telephony/teamsAcsMediaRegistry.ts +152 -0
- package/src/telephony/telephony-runtime.ts +22 -0
- package/src/telephony/twilioMediaRegistry.ts +137 -0
- package/src/telephony/vonage-runtime.ts +22 -0
- package/src/telephony/vonageMediaRegistry.ts +200 -0
- package/dist/agents/skip-agent.d.ts +0 -111
- package/dist/agents/skip-agent.d.ts.map +0 -1
- package/dist/agents/skip-agent.js +0 -1487
- package/dist/agents/skip-agent.js.map +0 -1
- package/dist/agents/skip-sdk.d.ts +0 -261
- package/dist/agents/skip-sdk.d.ts.map +0 -1
- package/dist/agents/skip-sdk.js +0 -909
- package/dist/agents/skip-sdk.js.map +0 -1
- package/src/agents/skip-agent.ts +0 -1594
- package/src/agents/skip-sdk.ts +0 -1210
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Express wiring for the Teams meetings ingress — the Microsoft Graph change-notification webhook.
|
|
3
|
+
*
|
|
4
|
+
* One public surface (Graph cannot present an MJ JWT — the subscription validation-token handshake + the
|
|
5
|
+
* per-notification `clientState` shared secret are the gate):
|
|
6
|
+
* - `POST /meetings/teams/notifications` — the Graph change-notification endpoint. Handles BOTH:
|
|
7
|
+
* 1. The **subscription-validation handshake**: when Graph creates/renews a subscription it calls this
|
|
8
|
+
* URL with `?validationToken=…` and expects the exact token echoed back as `text/plain` 200.
|
|
9
|
+
* 2. **Real call/participant notifications**: each carries a `clientState` verified (constant-time)
|
|
10
|
+
* against the secret set on subscription creation, then mapped to the meetings service's roster /
|
|
11
|
+
* call-ended drive helpers.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors the Twilio telephony public-router pattern: mount the router BEFORE the unified auth middleware. The
|
|
14
|
+
* validation + notification parsing use the offline-complete, unit-tested helpers from the Teams provider
|
|
15
|
+
* package (`validateGraphNotification` / `parseCallNotification`); this module is thin HTTP plumbing over them
|
|
16
|
+
* and the {@link TeamsMeetingsService}.
|
|
17
|
+
*
|
|
18
|
+
* The ACS application-hosted-media socket(s) (the audio plane) are owned by the server's native ACS media
|
|
19
|
+
* adapter, which attaches transports to the shared {@link TeamsAcsMediaRegistry} — there is no media WSS here
|
|
20
|
+
* (unlike Twilio Media Streams), because ACS app-hosted media is negotiated out-of-band by that adapter.
|
|
21
|
+
*
|
|
22
|
+
* @module @memberjunction/server/telephony
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Router, json, type Request, type Response } from 'express';
|
|
26
|
+
import { LogError, LogStatus } from '@memberjunction/core';
|
|
27
|
+
import {
|
|
28
|
+
validateGraphNotification,
|
|
29
|
+
parseCallNotification,
|
|
30
|
+
type GraphChangeNotification,
|
|
31
|
+
} from '@memberjunction/ai-bridge-teams';
|
|
32
|
+
import type { TeamsMeetingsConfig } from '../config.js';
|
|
33
|
+
import { TeamsAcsMediaRegistry } from './teamsAcsMediaRegistry.js';
|
|
34
|
+
import { TeamsMeetingsService } from './TeamsMeetingsService.js';
|
|
35
|
+
|
|
36
|
+
/** The mount path for the Teams meetings public router. */
|
|
37
|
+
export const TEAMS_MEETINGS_MOUNT_PATH = '/meetings/teams';
|
|
38
|
+
|
|
39
|
+
/** The minimal shape of a Graph change-notification POST body (the `value[]` batch). */
|
|
40
|
+
interface GraphNotificationBatch {
|
|
41
|
+
value?: GraphChangeNotification[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Builds the Teams meetings handler: the public Graph change-notification router + the shared ACS media
|
|
46
|
+
* registry + the meetings service (returned for observability/tests + so server boot can bind the service to
|
|
47
|
+
* the runtime holder and hand the registry to the native ACS media adapter).
|
|
48
|
+
*/
|
|
49
|
+
export function createTeamsMeetingsHandler(
|
|
50
|
+
config: TeamsMeetingsConfig,
|
|
51
|
+
): {
|
|
52
|
+
publicRouter: Router;
|
|
53
|
+
registry: TeamsAcsMediaRegistry;
|
|
54
|
+
service: TeamsMeetingsService;
|
|
55
|
+
} {
|
|
56
|
+
const registry = new TeamsAcsMediaRegistry(config.acsSampleRate);
|
|
57
|
+
const service = new TeamsMeetingsService(config, registry);
|
|
58
|
+
const publicRouter = Router();
|
|
59
|
+
|
|
60
|
+
// Graph posts JSON; it also issues the validation handshake as a query-param GET-like POST. Parse JSON but
|
|
61
|
+
// tolerate an empty body on the handshake.
|
|
62
|
+
publicRouter.post('/notifications', json({ type: () => true }), (req: Request, res: Response) => {
|
|
63
|
+
handleGraphNotification(service, config, req, res);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return { publicRouter, registry, service };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Handles the Graph webhook: echo the validation token, else verify clientState + drive the service. */
|
|
70
|
+
function handleGraphNotification(
|
|
71
|
+
service: TeamsMeetingsService,
|
|
72
|
+
config: TeamsMeetingsConfig,
|
|
73
|
+
req: Request,
|
|
74
|
+
res: Response,
|
|
75
|
+
): void {
|
|
76
|
+
const validationToken = readValidationToken(req);
|
|
77
|
+
const batch = (req.body ?? {}) as GraphNotificationBatch;
|
|
78
|
+
const notifications = Array.isArray(batch.value) ? batch.value : [];
|
|
79
|
+
const clientStates = notifications.map((n) => n.clientState);
|
|
80
|
+
|
|
81
|
+
const verdict = validateGraphNotification(validationToken, config.notificationClientState ?? '', clientStates);
|
|
82
|
+
if (verdict.Kind === 'validation') {
|
|
83
|
+
// The subscription-validation handshake: echo the token verbatim as text/plain 200.
|
|
84
|
+
res.status(200).type('text/plain').send(verdict.ValidationToken);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (verdict.Kind === 'reject') {
|
|
88
|
+
LogStatus(`[Meetings][Teams] notification rejected: ${verdict.Reason}`);
|
|
89
|
+
res.status(verdict.Reason === 'empty-validation-token' ? 400 : 403).type('text/plain').send('Rejected.');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
dispatchNotifications(service, notifications);
|
|
94
|
+
// Graph requires a fast 202 to acknowledge receipt; processing is fire-and-forget.
|
|
95
|
+
res.status(202).end();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Maps each Graph call/participant notification to the meetings service's roster / call-ended drive helpers. */
|
|
99
|
+
function dispatchNotifications(service: TeamsMeetingsService, notifications: GraphChangeNotification[]): void {
|
|
100
|
+
for (const notification of notifications) {
|
|
101
|
+
try {
|
|
102
|
+
const normalized = parseCallNotification(notification);
|
|
103
|
+
if (normalized.state === 'terminated') {
|
|
104
|
+
service.DriveCallEnded(normalized.callId);
|
|
105
|
+
} else if (normalized.participants.length > 0) {
|
|
106
|
+
service.DriveParticipantsUpdated(normalized.callId, normalized.participants);
|
|
107
|
+
}
|
|
108
|
+
} catch (e) {
|
|
109
|
+
LogError(`[Meetings][Teams] failed to process notification: ${e instanceof Error ? e.message : String(e)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Reads the `?validationToken=…` query param (Graph URL-encodes it), when present. */
|
|
115
|
+
function readValidationToken(req: Request): string | undefined {
|
|
116
|
+
const raw = req.query?.validationToken;
|
|
117
|
+
if (typeof raw === 'string') {
|
|
118
|
+
return raw;
|
|
119
|
+
}
|
|
120
|
+
if (Array.isArray(raw) && typeof raw[0] === 'string') {
|
|
121
|
+
return raw[0];
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Orchestrates Teams meeting bridge sessions (join-by-URL) for MJAPI.
|
|
3
|
+
*
|
|
4
|
+
* This is the composition layer between the Teams meetings ingress (the public Graph change-notification
|
|
5
|
+
* webhook + the server-owned ACS media plane) and the ONE unified realtime-agent pathway. It does NOT
|
|
6
|
+
* re-implement any bridge logic: it resolves the Teams provider + the agent identity, opens a server-side
|
|
7
|
+
* realtime session via the agent layer's `CreateBridgeRealtimeSession` factory, binds the REAL Teams binding
|
|
8
|
+
* (`RealTeamsBindings` over the Graph control plane + the per-call ACS media registry) onto the driver, and
|
|
9
|
+
* hands everything to `AIBridgeEngine.StartBridgeSession` — exactly the seam the Twilio telephony service +
|
|
10
|
+
* the LiveKit room coordinator use.
|
|
11
|
+
*
|
|
12
|
+
* Collaborators are injected (engine, session factory, session manager) so the resolution/branching logic is
|
|
13
|
+
* unit-testable with fakes; the actual Graph call placement + ACS media flow are live-only (real Teams/Azure
|
|
14
|
+
* credentials + a publicly reachable Graph webhook URL — see meeting-vendor-bindings-teams-slack.md M1).
|
|
15
|
+
*
|
|
16
|
+
* @module @memberjunction/server/telephony
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { RunView, UserInfo, IMetadataProvider, LogError } from '@memberjunction/core';
|
|
20
|
+
import type { MJAIBridgeAgentIdentityEntity, MJAIBridgeProviderEntity } from '@memberjunction/core-entities';
|
|
21
|
+
import { AIBridgeEngine, type StartBridgeSessionParams } from '@memberjunction/ai-bridge-server';
|
|
22
|
+
import { CreateBridgeRealtimeSession } from '@memberjunction/ai-agents';
|
|
23
|
+
import { BaseRealtimeBridge, type BridgeNativeSdkBinding } from '@memberjunction/ai-bridge-base';
|
|
24
|
+
import { RealTeamsBindings, RealGraphCallsClient, PumpBackedAcsMedia, type TeamsBridge } from '@memberjunction/ai-bridge-teams';
|
|
25
|
+
import type { TeamsMeetingsConfig } from '../config.js';
|
|
26
|
+
import { SessionManager } from '../agentSessions/SessionManager.js';
|
|
27
|
+
import { TeamsAcsMediaRegistry } from './teamsAcsMediaRegistry.js';
|
|
28
|
+
|
|
29
|
+
const TEAMS_PROVIDER_DRIVER = 'TeamsBridge';
|
|
30
|
+
const AGENT_IDENTITY_ENTITY = 'MJ: AI Bridge Agent Identities';
|
|
31
|
+
|
|
32
|
+
/** Result of starting a Teams meeting bridge session. */
|
|
33
|
+
export interface JoinMeetingResult {
|
|
34
|
+
/** Whether a bridge session was started for the meeting. */
|
|
35
|
+
accepted: boolean;
|
|
36
|
+
/** The Graph call id the bot joined (the bridge's external connection id), when accepted. */
|
|
37
|
+
callId?: string;
|
|
38
|
+
/** Why it was rejected (no agent identity, provider missing, unparseable join URL, etc.). */
|
|
39
|
+
reason?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Injectable collaborators (production defaults wired in the constructor; fakes in tests). */
|
|
43
|
+
export interface TeamsMeetingsServiceDeps {
|
|
44
|
+
engine?: Pick<AIBridgeEngine, 'ProviderByName' | 'ProviderByDriverClass' | 'StartBridgeSession' | 'Config'>;
|
|
45
|
+
sessionFactory?: typeof CreateBridgeRealtimeSession;
|
|
46
|
+
sessionManager?: Pick<SessionManager, 'CreateSession'>;
|
|
47
|
+
/** Constructs the Graph control-plane client for a session (default: the real lazy-SDK client). */
|
|
48
|
+
graphClientFactory?: (accessToken: string, tenantId?: string) => RealGraphCallsClient;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Starts Teams meeting bridge sessions. One instance per server, constructed alongside the meetings router and
|
|
53
|
+
* sharing the {@link TeamsAcsMediaRegistry} with the server's ACS media adapter.
|
|
54
|
+
*/
|
|
55
|
+
export class TeamsMeetingsService {
|
|
56
|
+
private readonly engine: Pick<AIBridgeEngine, 'ProviderByName' | 'ProviderByDriverClass' | 'StartBridgeSession' | 'Config'>;
|
|
57
|
+
private readonly sessionFactory: typeof CreateBridgeRealtimeSession;
|
|
58
|
+
private readonly sessionManager: Pick<SessionManager, 'CreateSession'>;
|
|
59
|
+
private readonly graphClientFactory: (accessToken: string, tenantId?: string) => RealGraphCallsClient;
|
|
60
|
+
/** Live Graph clients keyed by call id, so the webhook ingress can drive their roster/ended handlers. */
|
|
61
|
+
private readonly graphClientsByCall = new Map<string, RealGraphCallsClient>();
|
|
62
|
+
|
|
63
|
+
constructor(
|
|
64
|
+
private readonly config: TeamsMeetingsConfig,
|
|
65
|
+
private readonly registry: TeamsAcsMediaRegistry,
|
|
66
|
+
deps: TeamsMeetingsServiceDeps = {},
|
|
67
|
+
) {
|
|
68
|
+
this.engine = deps.engine ?? AIBridgeEngine.Instance;
|
|
69
|
+
this.sessionFactory = deps.sessionFactory ?? CreateBridgeRealtimeSession;
|
|
70
|
+
this.sessionManager = deps.sessionManager ?? new SessionManager();
|
|
71
|
+
this.graphClientFactory =
|
|
72
|
+
deps.graphClientFactory ?? ((accessToken, tenantId) => new RealGraphCallsClient({ AccessToken: accessToken, TenantId: tenantId }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Joins a Teams meeting by URL for a given agent identity and starts the bridge session. The bound
|
|
77
|
+
* `RealTeamsBindings` issues the Graph `POST /communications/calls` and accepts the ACS media transport
|
|
78
|
+
* (attached by the server's media adapter) for the joined call. Returns `{ accepted:false }` (never
|
|
79
|
+
* throws) when the agent identity is missing/inactive, so the caller can answer cleanly.
|
|
80
|
+
*
|
|
81
|
+
* @param agentIdentityId The agent identity to join as (its Teams account/email is the bot's identity).
|
|
82
|
+
* @param joinUrl The Teams meeting join URL.
|
|
83
|
+
* @param contextUser The request-scoped principal.
|
|
84
|
+
* @param provider The request-scoped metadata provider (multi-provider safe — never the global Metadata).
|
|
85
|
+
*/
|
|
86
|
+
public async JoinMeetingByUrl(
|
|
87
|
+
agentIdentityId: string,
|
|
88
|
+
joinUrl: string,
|
|
89
|
+
contextUser: UserInfo,
|
|
90
|
+
provider: IMetadataProvider,
|
|
91
|
+
): Promise<JoinMeetingResult> {
|
|
92
|
+
try {
|
|
93
|
+
const identity = await this.loadAgentIdentity(agentIdentityId, contextUser, provider);
|
|
94
|
+
if (!identity) {
|
|
95
|
+
return { accepted: false, reason: `Agent identity '${agentIdentityId}' not found or inactive.` };
|
|
96
|
+
}
|
|
97
|
+
const session = await this.startBridge({ agentID: identity.AgentID, joinUrl, contextUser, provider });
|
|
98
|
+
return { accepted: true, callId: session.CallId };
|
|
99
|
+
} catch (e) {
|
|
100
|
+
LogError(`[Meetings][Teams] join meeting failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
101
|
+
return { accepted: false, reason: e instanceof Error ? e.message : 'Internal error starting the agent.' };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Drives the retained Graph roster handler for a call from a Graph change notification (called by the
|
|
107
|
+
* webhook ingress — Graph delivers roster changes over the webhook, not a client subscription).
|
|
108
|
+
*/
|
|
109
|
+
public DriveParticipantsUpdated(callId: string, participants: { id: string; displayName?: string; role?: string }[]): void {
|
|
110
|
+
this.graphClientsByCall.get(callId)?.DriveParticipantsUpdated(callId, participants);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Drives the call-ended handler for a call from a Graph change notification, then tears the call down. */
|
|
114
|
+
public DriveCallEnded(callId: string): void {
|
|
115
|
+
this.graphClientsByCall.get(callId)?.DriveCallEnded(callId);
|
|
116
|
+
this.graphClientsByCall.delete(callId);
|
|
117
|
+
this.registry.EndCall(callId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Builds the {@link StartBridgeSessionParams} for a SCHEDULED Teams join — a bridge the
|
|
122
|
+
* {@link ScheduledBridgeRunner} owns (it created the `MJ: AI Agent Sessions` row and owns the
|
|
123
|
+
* `StartBridgeSession` call). Reuses the same provider resolution, realtime-session factory, Graph
|
|
124
|
+
* client, and bind-sdk as an on-demand join, so a scheduled Teams join is identical to a live one
|
|
125
|
+
* for audio (the ACS media registry is wired via {@link buildBindSdk}).
|
|
126
|
+
*
|
|
127
|
+
* NOTE: an on-demand join also registers the live Graph client by call id for webhook-driven
|
|
128
|
+
* roster/ended routing; the runner abstracts the post-start call id away, so for a SCHEDULED join
|
|
129
|
+
* that notification routing is a documented follow-on. Audio is fully wired here.
|
|
130
|
+
*/
|
|
131
|
+
public async BuildScheduledStartParams(args: {
|
|
132
|
+
agentID: string;
|
|
133
|
+
joinUrl: string;
|
|
134
|
+
agentSessionID: string;
|
|
135
|
+
contextUser: UserInfo;
|
|
136
|
+
provider: IMetadataProvider;
|
|
137
|
+
}): Promise<StartBridgeSessionParams> {
|
|
138
|
+
await this.engine.Config(false, args.contextUser, args.provider);
|
|
139
|
+
const teamsProvider = this.resolveProvider();
|
|
140
|
+
const realtimeSession = await this.sessionFactory({
|
|
141
|
+
AgentID: args.agentID,
|
|
142
|
+
TargetAgentID: args.agentID,
|
|
143
|
+
ContextUser: args.contextUser,
|
|
144
|
+
MetadataProvider: args.provider,
|
|
145
|
+
AgentSessionID: args.agentSessionID,
|
|
146
|
+
RoomName: args.joinUrl,
|
|
147
|
+
});
|
|
148
|
+
const graphClient = this.graphClientFactory(this.config.botAccessToken ?? '', this.config.tenantId);
|
|
149
|
+
return {
|
|
150
|
+
AgentSessionID: args.agentSessionID,
|
|
151
|
+
AgentID: args.agentID,
|
|
152
|
+
TargetAgentID: args.agentID,
|
|
153
|
+
Provider: teamsProvider,
|
|
154
|
+
RealtimeSession: realtimeSession,
|
|
155
|
+
Address: args.joinUrl,
|
|
156
|
+
Direction: 'Outbound',
|
|
157
|
+
Configuration: this.buildSessionConfiguration(),
|
|
158
|
+
BindSdk: this.buildBindSdk(graphClient),
|
|
159
|
+
ContextUser: args.contextUser,
|
|
160
|
+
MetadataProvider: args.provider,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── internals ────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/** Resolves provider, opens the realtime session, binds the real Teams SDK, and starts the bridge. */
|
|
167
|
+
private async startBridge(args: {
|
|
168
|
+
agentID: string;
|
|
169
|
+
joinUrl: string;
|
|
170
|
+
contextUser: UserInfo;
|
|
171
|
+
provider: IMetadataProvider;
|
|
172
|
+
}): Promise<{ CallId?: string }> {
|
|
173
|
+
await this.engine.Config(false, args.contextUser, args.provider);
|
|
174
|
+
const teamsProvider = this.resolveProvider();
|
|
175
|
+
|
|
176
|
+
const agentSession = await this.sessionManager.CreateSession(
|
|
177
|
+
{ agentID: args.agentID, userID: args.contextUser.ID },
|
|
178
|
+
args.contextUser,
|
|
179
|
+
args.provider,
|
|
180
|
+
);
|
|
181
|
+
const realtimeSession = await this.sessionFactory({
|
|
182
|
+
AgentID: args.agentID,
|
|
183
|
+
TargetAgentID: args.agentID,
|
|
184
|
+
ContextUser: args.contextUser,
|
|
185
|
+
MetadataProvider: args.provider,
|
|
186
|
+
AgentSessionID: agentSession.ID,
|
|
187
|
+
RoomName: args.joinUrl,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// The Graph client is created per session; its retained webhook handlers are driven by the ingress.
|
|
191
|
+
const graphClient = this.graphClientFactory(this.config.botAccessToken ?? '', this.config.tenantId);
|
|
192
|
+
|
|
193
|
+
const active = await this.engine.StartBridgeSession({
|
|
194
|
+
AgentSessionID: agentSession.ID,
|
|
195
|
+
AgentID: args.agentID,
|
|
196
|
+
TargetAgentID: args.agentID,
|
|
197
|
+
Provider: teamsProvider,
|
|
198
|
+
RealtimeSession: realtimeSession,
|
|
199
|
+
Address: args.joinUrl,
|
|
200
|
+
Direction: 'Outbound',
|
|
201
|
+
Configuration: this.buildSessionConfiguration(),
|
|
202
|
+
BindSdk: this.buildBindSdk(graphClient),
|
|
203
|
+
ContextUser: args.contextUser,
|
|
204
|
+
MetadataProvider: args.provider,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const callId = active.RoomKey;
|
|
208
|
+
if (callId) {
|
|
209
|
+
this.registry.RegisterCall(callId);
|
|
210
|
+
this.graphClientsByCall.set(callId, graphClient);
|
|
211
|
+
}
|
|
212
|
+
return { CallId: callId };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Resolves the seeded Teams provider row (by driver class, falling back to display name). */
|
|
216
|
+
private resolveProvider(): MJAIBridgeProviderEntity {
|
|
217
|
+
const provider = this.engine.ProviderByDriverClass(TEAMS_PROVIDER_DRIVER) ?? this.engine.ProviderByName('Microsoft Teams');
|
|
218
|
+
if (!provider) {
|
|
219
|
+
throw new Error(`No active 'MJ: AI Bridge Providers' row for the Teams bridge (DriverClass '${TEAMS_PROVIDER_DRIVER}').`);
|
|
220
|
+
}
|
|
221
|
+
return provider;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Builds the per-session SDK binding that wires the REAL Teams binding (`RealTeamsBindings` over the Graph
|
|
226
|
+
* control plane + the per-call ACS media registry) onto the Teams driver — overriding the package's default
|
|
227
|
+
* native loader. This is where the offline-complete seams meet the live Graph + ACS plumbing.
|
|
228
|
+
*/
|
|
229
|
+
public buildBindSdk(graphClient: RealGraphCallsClient): BridgeNativeSdkBinding {
|
|
230
|
+
return (driver: BaseRealtimeBridge) => {
|
|
231
|
+
const teams = driver as TeamsBridge;
|
|
232
|
+
// RealTeamsBindings threads the Graph call id through every media call, so PumpBackedAcsMedia
|
|
233
|
+
// routes onto the shared registry keyed by that id — no call id needed at construction.
|
|
234
|
+
teams.SetSdkFactory(
|
|
235
|
+
() =>
|
|
236
|
+
new RealTeamsBindings({
|
|
237
|
+
Graph: graphClient,
|
|
238
|
+
Media: new PumpBackedAcsMedia(this.registry),
|
|
239
|
+
ModelSampleRate: this.config.modelSampleRate,
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Assembles the per-session bridge configuration (carries the resolved tenant for diagnostics). */
|
|
246
|
+
private buildSessionConfiguration(): Record<string, unknown> {
|
|
247
|
+
const config: Record<string, unknown> = {};
|
|
248
|
+
if (this.config.tenantId) {
|
|
249
|
+
config.TenantId = this.config.tenantId;
|
|
250
|
+
}
|
|
251
|
+
return config;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Loads a specific agent identity by id (used by join-by-URL), requiring it be active. */
|
|
255
|
+
private async loadAgentIdentity(
|
|
256
|
+
agentIdentityId: string,
|
|
257
|
+
contextUser: UserInfo,
|
|
258
|
+
provider: IMetadataProvider,
|
|
259
|
+
): Promise<MJAIBridgeAgentIdentityEntity | null> {
|
|
260
|
+
// Use the request-scoped provider (multi-provider safe), not the global Metadata.
|
|
261
|
+
const entity = await provider.GetEntityObject<MJAIBridgeAgentIdentityEntity>(AGENT_IDENTITY_ENTITY, contextUser);
|
|
262
|
+
const loaded = await entity.Load(agentIdentityId);
|
|
263
|
+
if (!loaded || !entity.IsActive) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
return entity;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Express + WSS wiring for the Twilio telephony ingress.
|
|
3
|
+
*
|
|
4
|
+
* Two public surfaces (carriers cannot present an MJ JWT — the X-Twilio-Signature HMAC is the gate):
|
|
5
|
+
* - `POST /telephony/twilio/voice` — inbound voice webhook. Verifies the signature, resolves the
|
|
6
|
+
* dialed DID to a pinned agent, starts an inbound bridge session, and answers with the
|
|
7
|
+
* `<Connect><Stream>` TwiML that opens the Media-Streams socket.
|
|
8
|
+
* - `WSS /telephony/twilio/media` — the bidirectional Media-Streams socket. Frames are routed to the
|
|
9
|
+
* active call's `RealTwilioBindings` via the shared {@link TwilioCallMediaRegistry}.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors the magic-link / widget public-router pattern: mount the router BEFORE the unified auth
|
|
12
|
+
* middleware and attach the WSS to the shared HTTP server at startup. The signature-verification gate +
|
|
13
|
+
* inbound resolution use the offline-complete, unit-tested helpers from the Twilio provider package; this
|
|
14
|
+
* module is thin HTTP/WS plumbing over them and the {@link TwilioTelephonyService}.
|
|
15
|
+
*
|
|
16
|
+
* @module @memberjunction/server/telephony
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Router, urlencoded, type Request, type Response } from 'express';
|
|
20
|
+
import { WebSocketServer, type WebSocket } from 'ws';
|
|
21
|
+
import { RegisterMediaUpgradeRoute } from './media-upgrade-router.js';
|
|
22
|
+
import { LogError, LogStatus, UserInfo, IMetadataProvider, Metadata } from '@memberjunction/core';
|
|
23
|
+
import { UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
24
|
+
import {
|
|
25
|
+
verifyTwilioSignature,
|
|
26
|
+
resolveInboundCall,
|
|
27
|
+
buildInboundVoiceTwiML,
|
|
28
|
+
type TwilioMediaFrame,
|
|
29
|
+
} from '@memberjunction/ai-bridge-twilio';
|
|
30
|
+
import type { TwilioTelephonyConfig } from '../config.js';
|
|
31
|
+
import { TwilioCallMediaRegistry } from './twilioMediaRegistry.js';
|
|
32
|
+
import { TwilioTelephonyService } from './TwilioTelephonyService.js';
|
|
33
|
+
|
|
34
|
+
/** The mount path for the Twilio telephony public router. */
|
|
35
|
+
export const TWILIO_TELEPHONY_MOUNT_PATH = '/telephony/twilio';
|
|
36
|
+
|
|
37
|
+
/** The Media-Streams websocket path Twilio's `<Connect><Stream>` connects to. */
|
|
38
|
+
export const TWILIO_MEDIA_WSS_PATH = '/telephony/twilio/media';
|
|
39
|
+
|
|
40
|
+
/** A polite TwiML response played when no agent is available for the dialed number. */
|
|
41
|
+
const NO_AGENT_TWIML =
|
|
42
|
+
'<?xml version="1.0" encoding="UTF-8"?><Response><Say>Sorry, no agent is available to take this call.</Say><Hangup/></Response>';
|
|
43
|
+
|
|
44
|
+
/** The minimal shape of a Twilio Media-Streams websocket message (a superset of TwilioMediaFrame). */
|
|
45
|
+
interface TwilioWsMessage extends TwilioMediaFrame {
|
|
46
|
+
/** Present on the `start` frame: carries the Call SID + stream SID that key the call's media channel. */
|
|
47
|
+
start?: { callSid?: string; streamSid?: string };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Builds the Twilio telephony handler: the public webhook router + a function to attach the
|
|
52
|
+
* Media-Streams WSS to the shared HTTP server. Returns the registry + service for observability/tests.
|
|
53
|
+
*/
|
|
54
|
+
export function createTwilioTelephonyHandler(
|
|
55
|
+
publicUrl: string,
|
|
56
|
+
config: TwilioTelephonyConfig,
|
|
57
|
+
): {
|
|
58
|
+
publicRouter: Router;
|
|
59
|
+
attachMediaStreamServer: () => void;
|
|
60
|
+
registry: TwilioCallMediaRegistry;
|
|
61
|
+
service: TwilioTelephonyService;
|
|
62
|
+
} {
|
|
63
|
+
const registry = new TwilioCallMediaRegistry();
|
|
64
|
+
const service = new TwilioTelephonyService(config, registry);
|
|
65
|
+
const publicRouter = Router();
|
|
66
|
+
|
|
67
|
+
// Twilio signs the form-urlencoded webhook body; parse it so we can both verify + resolve.
|
|
68
|
+
publicRouter.post('/voice', urlencoded({ extended: false }), async (req: Request, res: Response) => {
|
|
69
|
+
await handleInboundVoice(service, config, publicUrl, req, res);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
publicRouter,
|
|
74
|
+
attachMediaStreamServer: () => attachMediaStreamServer(registry),
|
|
75
|
+
registry,
|
|
76
|
+
service,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Handles the inbound voice webhook: verify signature → resolve → start bridge → answer with stream TwiML. */
|
|
81
|
+
async function handleInboundVoice(
|
|
82
|
+
service: TwilioTelephonyService,
|
|
83
|
+
config: TwilioTelephonyConfig,
|
|
84
|
+
publicUrl: string,
|
|
85
|
+
req: Request,
|
|
86
|
+
res: Response,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const params = coerceParams(req.body);
|
|
89
|
+
const fullUrl = `${publicUrl.replace(/\/+$/, '')}${req.originalUrl}`;
|
|
90
|
+
if (!config.authToken || !verifyTwilioSignature(config.authToken, req.get('X-Twilio-Signature'), fullUrl, params)) {
|
|
91
|
+
res.status(403).type('text/plain').send('Invalid Twilio signature.');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { callSid, from, to } = resolveInboundCall(params);
|
|
96
|
+
const context = resolveServerContext();
|
|
97
|
+
if (!context) {
|
|
98
|
+
LogError('[Telephony][Twilio] no server context user available; cannot start inbound call.');
|
|
99
|
+
res.status(200).type('text/xml').send(NO_AGENT_TWIML);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const result = await service.HandleInboundCall({ callSid, from, to }, context.user, context.provider);
|
|
104
|
+
if (!result.accepted) {
|
|
105
|
+
LogStatus(`[Telephony][Twilio] inbound ${callSid} not accepted: ${result.reason ?? 'unknown'}`);
|
|
106
|
+
res.status(200).type('text/xml').send(NO_AGENT_TWIML);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
res.status(200).type('text/xml').send(buildInboundVoiceTwiML(config.streamPublicUrl));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Attaches the Media-Streams WSS to the shared HTTP server on {@link TWILIO_MEDIA_WSS_PATH}. Each socket
|
|
114
|
+
* is mapped to its Call SID on the first `start` frame, then inbound frames are dispatched to the call's
|
|
115
|
+
* bindings via the registry; the socket is also the registry's outbound pump for that call.
|
|
116
|
+
*/
|
|
117
|
+
function attachMediaStreamServer(registry: TwilioCallMediaRegistry): void {
|
|
118
|
+
// noServer: the shared path-routing upgrade dispatcher (media-upgrade-router) owns the HTTP
|
|
119
|
+
// 'upgrade' event — a second {server}-bound WS server would fight GraphQL's socket and 400 the
|
|
120
|
+
// handshake (Twilio error 31920). We register our path and the dispatcher routes to it.
|
|
121
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
122
|
+
wss.on('connection', (socket: WebSocket) => wireMediaSocket(socket, registry));
|
|
123
|
+
RegisterMediaUpgradeRoute(TWILIO_MEDIA_WSS_PATH, wss);
|
|
124
|
+
LogStatus(`[Telephony][Twilio] Media-Streams WSS route registered at ${TWILIO_MEDIA_WSS_PATH}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Wires one Media-Streams socket: bind on `start`, dispatch inbound frames, tear down on close. */
|
|
128
|
+
function wireMediaSocket(socket: WebSocket, registry: TwilioCallMediaRegistry): void {
|
|
129
|
+
let callSid: string | null = null;
|
|
130
|
+
const adapter = { send: (data: string) => socket.send(data), close: () => socket.close() };
|
|
131
|
+
|
|
132
|
+
socket.on('message', (raw: unknown) => {
|
|
133
|
+
const message = parseWsMessage(raw);
|
|
134
|
+
if (!message) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (message.event === 'start' && message.start?.callSid) {
|
|
138
|
+
callSid = message.start.callSid;
|
|
139
|
+
registry.AttachSocket(callSid, adapter, message.start.streamSid ?? '');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (callSid) {
|
|
143
|
+
registry.DispatchInbound(callSid, message);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
socket.on('close', () => {
|
|
147
|
+
if (callSid) {
|
|
148
|
+
registry.EndCall(callSid);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Parses a raw WS payload (Buffer/string) into a Twilio message, or null when unparseable. */
|
|
154
|
+
function parseWsMessage(raw: unknown): TwilioWsMessage | null {
|
|
155
|
+
try {
|
|
156
|
+
const text = typeof raw === 'string' ? raw : raw instanceof Buffer ? raw.toString('utf8') : String(raw);
|
|
157
|
+
return JSON.parse(text) as TwilioWsMessage;
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Coerces an Express urlencoded body to the `Record<string,string>` the signature verifier expects. */
|
|
164
|
+
function coerceParams(body: unknown): Record<string, string> {
|
|
165
|
+
const out: Record<string, string> = {};
|
|
166
|
+
if (body && typeof body === 'object') {
|
|
167
|
+
for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
|
|
168
|
+
if (typeof value === 'string') {
|
|
169
|
+
out[key] = value;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Resolves the server-side principal + provider for the public webhook (no MJ JWT on a carrier request). */
|
|
177
|
+
function resolveServerContext(): { user: UserInfo; provider: IMetadataProvider } | null {
|
|
178
|
+
const user = UserCache.Instance.GetSystemUser() ?? UserCache.Users.find((u) => u.IsActive && u.Type?.trim().toLowerCase() === 'owner') ?? null;
|
|
179
|
+
const provider = Metadata.Provider as unknown as IMetadataProvider | undefined; // global-provider-ok: public carrier webhook has no MJ JWT / per-request provider; the server's single default provider is correct
|
|
180
|
+
if (!user || !provider) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
return { user, provider };
|
|
184
|
+
}
|