@volter/twin-livekit 0.1.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.
- package/LICENSE +202 -0
- package/README.md +106 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +23 -0
- package/dist/src/egress-service-cli.d.ts +2 -0
- package/dist/src/egress-service-cli.js +101 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +53 -0
- package/dist/src/livekit-budget.d.ts +57 -0
- package/dist/src/livekit-budget.js +131 -0
- package/dist/src/livekit-capabilities.d.ts +3 -0
- package/dist/src/livekit-capabilities.js +728 -0
- package/dist/src/livekit-conformance.d.ts +8 -0
- package/dist/src/livekit-conformance.js +13 -0
- package/dist/src/livekit-connector.d.ts +130 -0
- package/dist/src/livekit-connector.js +382 -0
- package/dist/src/livekit-data.d.ts +86 -0
- package/dist/src/livekit-data.js +246 -0
- package/dist/src/livekit-perform-harness.d.ts +5 -0
- package/dist/src/livekit-perform-harness.js +17 -0
- package/dist/src/livekit-server.d.ts +23 -0
- package/dist/src/livekit-server.js +45 -0
- package/dist/src/livekit-service-cli.d.ts +2 -0
- package/dist/src/livekit-service-cli.js +88 -0
- package/dist/src/livekit-token.d.ts +68 -0
- package/dist/src/livekit-token.js +76 -0
- package/dist/src/livekit-twin.d.ts +4 -0
- package/dist/src/livekit-twin.js +1161 -0
- package/dist/src/livekit-types.d.ts +203 -0
- package/dist/src/livekit-types.js +1 -0
- package/dist/src/livekit-webhook.d.ts +26 -0
- package/dist/src/livekit-webhook.js +79 -0
- package/dist/src/redis-service-cli.d.ts +2 -0
- package/dist/src/redis-service-cli.js +35 -0
- package/dist/test-fixtures/livekit-openapi-operations.SOURCE.md +88 -0
- package/dist/test-fixtures/livekit-openapi-operations.json +442 -0
- package/dist/test-fixtures/protobufs/cloud_replay.proto +82 -0
- package/dist/test-fixtures/protobufs/livekit_agent.proto +185 -0
- package/dist/test-fixtures/protobufs/livekit_agent_dispatch.proto +103 -0
- package/dist/test-fixtures/protobufs/livekit_agent_simulation.proto +422 -0
- package/dist/test-fixtures/protobufs/livekit_agent_worker.proto +29 -0
- package/dist/test-fixtures/protobufs/livekit_agentdb.proto +226 -0
- package/dist/test-fixtures/protobufs/livekit_analytics.proto +312 -0
- package/dist/test-fixtures/protobufs/livekit_cloud_agent.proto +376 -0
- package/dist/test-fixtures/protobufs/livekit_connector.proto +41 -0
- package/dist/test-fixtures/protobufs/livekit_connector_twilio.proto +65 -0
- package/dist/test-fixtures/protobufs/livekit_connector_whatsapp.proto +171 -0
- package/dist/test-fixtures/protobufs/livekit_egress.proto +641 -0
- package/dist/test-fixtures/protobufs/livekit_ingress.proto +222 -0
- package/dist/test-fixtures/protobufs/livekit_internal.proto +228 -0
- package/dist/test-fixtures/protobufs/livekit_metrics.proto +103 -0
- package/dist/test-fixtures/protobufs/livekit_models.proto +992 -0
- package/dist/test-fixtures/protobufs/livekit_phone_number.proto +151 -0
- package/dist/test-fixtures/protobufs/livekit_room.proto +312 -0
- package/dist/test-fixtures/protobufs/livekit_rtc.proto +673 -0
- package/dist/test-fixtures/protobufs/livekit_sip.proto +1000 -0
- package/dist/test-fixtures/protobufs/livekit_token_source.proto +49 -0
- package/dist/test-fixtures/protobufs/livekit_webhook.proto +62 -0
- package/package.json +58 -0
- package/src/cli.ts +22 -0
- package/src/egress-service-cli.ts +111 -0
- package/src/index.ts +103 -0
- package/src/livekit-budget.ts +158 -0
- package/src/livekit-capabilities.ts +789 -0
- package/src/livekit-conformance.ts +15 -0
- package/src/livekit-connector.ts +406 -0
- package/src/livekit-data.ts +278 -0
- package/src/livekit-perform-harness.ts +17 -0
- package/src/livekit-server.ts +67 -0
- package/src/livekit-service-cli.ts +93 -0
- package/src/livekit-token.ts +133 -0
- package/src/livekit-twin.ts +1162 -0
- package/src/livekit-types.ts +211 -0
- package/src/livekit-webhook.ts +91 -0
- package/src/redis-service-cli.ts +39 -0
- package/test-fixtures/livekit-openapi-operations.SOURCE.md +88 -0
- package/test-fixtures/livekit-openapi-operations.json +442 -0
- package/test-fixtures/protobufs/cloud_replay.proto +82 -0
- package/test-fixtures/protobufs/livekit_agent.proto +185 -0
- package/test-fixtures/protobufs/livekit_agent_dispatch.proto +103 -0
- package/test-fixtures/protobufs/livekit_agent_simulation.proto +422 -0
- package/test-fixtures/protobufs/livekit_agent_worker.proto +29 -0
- package/test-fixtures/protobufs/livekit_agentdb.proto +226 -0
- package/test-fixtures/protobufs/livekit_analytics.proto +312 -0
- package/test-fixtures/protobufs/livekit_cloud_agent.proto +376 -0
- package/test-fixtures/protobufs/livekit_connector.proto +41 -0
- package/test-fixtures/protobufs/livekit_connector_twilio.proto +65 -0
- package/test-fixtures/protobufs/livekit_connector_whatsapp.proto +171 -0
- package/test-fixtures/protobufs/livekit_egress.proto +641 -0
- package/test-fixtures/protobufs/livekit_ingress.proto +222 -0
- package/test-fixtures/protobufs/livekit_internal.proto +228 -0
- package/test-fixtures/protobufs/livekit_metrics.proto +103 -0
- package/test-fixtures/protobufs/livekit_models.proto +992 -0
- package/test-fixtures/protobufs/livekit_phone_number.proto +151 -0
- package/test-fixtures/protobufs/livekit_room.proto +312 -0
- package/test-fixtures/protobufs/livekit_rtc.proto +673 -0
- package/test-fixtures/protobufs/livekit_sip.proto +1000 -0
- package/test-fixtures/protobufs/livekit_token_source.proto +49 -0
- package/test-fixtures/protobufs/livekit_webhook.proto +62 -0
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
import { applyTwinWrite } from '@volter/world-core';
|
|
2
|
+
import { EGRESS_STATUS, FIXED_NOW, INGRESS_INPUT, INGRESS_STATUS, SERVICE, defaultIngress, defaultParticipant, defaultRoom, egressId, egressResourceId, egresses, ingressId, ingressInput, ingresses, isActiveEgressStatus, participants, rooms, withCounts, SIP_TRANSPORT, agentDispatchId, agentDispatches, sipCallId, sipDispatchRuleId, sipDispatchRules, sipInboundTrunkId, sipInboundTrunks, sipOutboundTrunkId, sipOutboundTrunks, sipParticipantSid, sipParticipants, sipTransport, } from "./livekit-data.js";
|
|
3
|
+
import { verifyAccessToken } from "./livekit-token.js";
|
|
4
|
+
// Grant required to authorize each Twirp method (mirrors LiveKit server's auth
|
|
5
|
+
// middleware). null = no specific video grant required beyond a valid token.
|
|
6
|
+
// Exported so the connector's live-execute grant table can be BOUND to it by a test rather than
|
|
7
|
+
// hand-copied and left to drift (§9 finding, 2026-07-26). Nothing at runtime imports it from here.
|
|
8
|
+
export const GRANT_REQUIRED = {
|
|
9
|
+
'RoomService.CreateRoom': 'roomCreate',
|
|
10
|
+
'RoomService.DeleteRoom': 'roomCreate',
|
|
11
|
+
'RoomService.ListRooms': 'roomList',
|
|
12
|
+
'RoomService.UpdateRoomMetadata': 'roomAdmin',
|
|
13
|
+
'RoomService.ListParticipants': 'roomAdmin',
|
|
14
|
+
'RoomService.GetParticipant': 'roomAdmin',
|
|
15
|
+
'RoomService.RemoveParticipant': 'roomAdmin',
|
|
16
|
+
'RoomService.UpdateParticipant': 'roomAdmin',
|
|
17
|
+
'RoomService.MutePublishedTrack': 'roomAdmin',
|
|
18
|
+
'RoomService.UpdateSubscriptions': 'roomAdmin',
|
|
19
|
+
'RoomService.SendData': 'roomAdmin',
|
|
20
|
+
'Egress.StartRoomCompositeEgress': 'roomRecord',
|
|
21
|
+
'Egress.StartWebEgress': 'roomRecord',
|
|
22
|
+
'Egress.StartParticipantEgress': 'roomRecord',
|
|
23
|
+
'Egress.StartTrackCompositeEgress': 'roomRecord',
|
|
24
|
+
'Egress.StartTrackEgress': 'roomRecord',
|
|
25
|
+
'Egress.UpdateLayout': 'roomRecord',
|
|
26
|
+
'Egress.UpdateStream': 'roomRecord',
|
|
27
|
+
'Egress.StopEgress': 'roomRecord',
|
|
28
|
+
'Egress.ListEgress': 'roomRecord',
|
|
29
|
+
'Ingress.CreateIngress': 'ingressAdmin',
|
|
30
|
+
'Ingress.UpdateIngress': 'ingressAdmin',
|
|
31
|
+
'Ingress.ListIngress': 'ingressAdmin',
|
|
32
|
+
'Ingress.DeleteIngress': 'ingressAdmin',
|
|
33
|
+
// RoomService participant moves require room admin on the source room.
|
|
34
|
+
'RoomService.ForwardParticipant': 'roomAdmin',
|
|
35
|
+
'RoomService.MoveParticipant': 'roomAdmin',
|
|
36
|
+
// Agent dispatch is authorized via the roomAdmin grant (SDK signs with roomAdmin).
|
|
37
|
+
'AgentDispatchService.CreateDispatch': 'roomAdmin',
|
|
38
|
+
'AgentDispatchService.DeleteDispatch': 'roomAdmin',
|
|
39
|
+
'AgentDispatchService.ListDispatch': 'roomAdmin',
|
|
40
|
+
// SIP services authorize via the SIP grant (admin/call), not a video grant, so
|
|
41
|
+
// they are intentionally absent here — a valid token is required but no video
|
|
42
|
+
// grant is checked. (The vendor enforces the SIP grant; the twin's opt-in auth
|
|
43
|
+
// layer only models video-grant enforcement.)
|
|
44
|
+
};
|
|
45
|
+
function parseBody(body) {
|
|
46
|
+
if (!body?.trim())
|
|
47
|
+
return {};
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(body);
|
|
50
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function twirpError(status, code, msg) {
|
|
57
|
+
return { status, body: { code, msg, meta: {} } };
|
|
58
|
+
}
|
|
59
|
+
function route(path) {
|
|
60
|
+
const match = path.match(/^\/twirp\/livekit\.([^/]+)\/([^/?]+)$/);
|
|
61
|
+
return match ? { service: match[1], method: match[2] } : null;
|
|
62
|
+
}
|
|
63
|
+
function roomJson(room) {
|
|
64
|
+
return {
|
|
65
|
+
sid: room.sid,
|
|
66
|
+
name: room.name,
|
|
67
|
+
emptyTimeout: room.emptyTimeout,
|
|
68
|
+
departureTimeout: room.departureTimeout,
|
|
69
|
+
maxParticipants: room.maxParticipants,
|
|
70
|
+
creationTime: room.creationTime,
|
|
71
|
+
creationTimeMs: room.creationTimeMs,
|
|
72
|
+
metadata: room.metadata,
|
|
73
|
+
numParticipants: room.numParticipants,
|
|
74
|
+
activeRecording: room.activeRecording,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function participantJson(p) {
|
|
78
|
+
return {
|
|
79
|
+
sid: p.sid,
|
|
80
|
+
identity: p.identity,
|
|
81
|
+
state: p.state,
|
|
82
|
+
tracks: p.tracks,
|
|
83
|
+
metadata: p.metadata,
|
|
84
|
+
joinedAt: p.joinedAt,
|
|
85
|
+
joinedAtMs: p.joinedAtMs,
|
|
86
|
+
name: p.name,
|
|
87
|
+
version: p.version,
|
|
88
|
+
isPublisher: p.isPublisher,
|
|
89
|
+
kind: p.kind,
|
|
90
|
+
attributes: p.attributes,
|
|
91
|
+
permission: p.permission,
|
|
92
|
+
subscribedTracks: p.subscribedTracks,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function egressJson(e) {
|
|
96
|
+
// EgressInfo.request is a protobuf oneof whose JSON encoding is a single
|
|
97
|
+
// top-level field named after the active case (roomComposite|web|participant|
|
|
98
|
+
// trackComposite|track). Emitting it that way lets the vendor SDK's
|
|
99
|
+
// EgressInfo.fromJson() populate `request.case`/`request.value` correctly.
|
|
100
|
+
return {
|
|
101
|
+
egressId: e.egressId,
|
|
102
|
+
roomId: e.roomId,
|
|
103
|
+
roomName: e.roomName,
|
|
104
|
+
sourceType: e.sourceType,
|
|
105
|
+
status: e.status,
|
|
106
|
+
startedAt: e.startedAt,
|
|
107
|
+
endedAt: e.endedAt,
|
|
108
|
+
updatedAt: e.updatedAt,
|
|
109
|
+
details: e.details,
|
|
110
|
+
error: e.error,
|
|
111
|
+
errorCode: e.errorCode,
|
|
112
|
+
[e.requestCase]: e.request,
|
|
113
|
+
streamResults: (e.streamUrls ?? []).map((url) => ({ url, status: isActiveEgressStatus(e.status) ? 0 : 1 })),
|
|
114
|
+
fileResults: [],
|
|
115
|
+
segmentResults: [],
|
|
116
|
+
imageResults: [],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function ingressJson(i) {
|
|
120
|
+
return {
|
|
121
|
+
ingressId: i.ingressId,
|
|
122
|
+
name: i.name,
|
|
123
|
+
streamKey: i.streamKey,
|
|
124
|
+
url: i.url,
|
|
125
|
+
inputType: i.inputType,
|
|
126
|
+
bypassTranscoding: i.bypassTranscoding,
|
|
127
|
+
enableTranscoding: i.enableTranscoding,
|
|
128
|
+
audio: i.audio,
|
|
129
|
+
video: i.video,
|
|
130
|
+
roomName: i.roomName,
|
|
131
|
+
participantIdentity: i.participantIdentity,
|
|
132
|
+
participantName: i.participantName,
|
|
133
|
+
participantMetadata: i.participantMetadata,
|
|
134
|
+
reusable: i.reusable,
|
|
135
|
+
enabled: i.enabled,
|
|
136
|
+
state: i.state,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function sipInboundTrunkJson(t) {
|
|
140
|
+
return {
|
|
141
|
+
sipTrunkId: t.sipTrunkId,
|
|
142
|
+
name: t.name,
|
|
143
|
+
metadata: t.metadata,
|
|
144
|
+
numbers: t.numbers,
|
|
145
|
+
allowedAddresses: t.allowedAddresses,
|
|
146
|
+
allowedNumbers: t.allowedNumbers,
|
|
147
|
+
authUsername: t.authUsername,
|
|
148
|
+
authPassword: t.authPassword,
|
|
149
|
+
headers: t.headers,
|
|
150
|
+
headersToAttributes: t.headersToAttributes,
|
|
151
|
+
krispEnabled: t.krispEnabled,
|
|
152
|
+
includeHeaders: t.includeHeaders,
|
|
153
|
+
mediaEncryption: t.mediaEncryption,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function sipOutboundTrunkJson(t) {
|
|
157
|
+
return {
|
|
158
|
+
sipTrunkId: t.sipTrunkId,
|
|
159
|
+
name: t.name,
|
|
160
|
+
metadata: t.metadata,
|
|
161
|
+
address: t.address,
|
|
162
|
+
transport: t.transport,
|
|
163
|
+
numbers: t.numbers,
|
|
164
|
+
authUsername: t.authUsername,
|
|
165
|
+
authPassword: t.authPassword,
|
|
166
|
+
headers: t.headers,
|
|
167
|
+
headersToAttributes: t.headersToAttributes,
|
|
168
|
+
includeHeaders: t.includeHeaders,
|
|
169
|
+
mediaEncryption: t.mediaEncryption,
|
|
170
|
+
destinationCountry: t.destinationCountry,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function sipDispatchRuleJson(r) {
|
|
174
|
+
// The protobuf `rule` oneof JSON-encodes as a single field named after the case.
|
|
175
|
+
return {
|
|
176
|
+
sipDispatchRuleId: r.sipDispatchRuleId,
|
|
177
|
+
rule: { [r.rule.case]: r.rule.value },
|
|
178
|
+
trunkIds: r.trunkIds,
|
|
179
|
+
inboundNumbers: r.inboundNumbers,
|
|
180
|
+
hidePhoneNumber: r.hidePhoneNumber,
|
|
181
|
+
name: r.name,
|
|
182
|
+
metadata: r.metadata,
|
|
183
|
+
attributes: r.attributes,
|
|
184
|
+
roomPreset: r.roomPreset,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
// Applies a livekit.ListUpdate to a string list. The oneof-like fields are
|
|
188
|
+
// honored in vendor order: `set` (or `clear`) wholly replaces; otherwise
|
|
189
|
+
// `remove` then `add` (add de-duplicates, matching the vendor comment).
|
|
190
|
+
function applyListUpdate(current, update) {
|
|
191
|
+
if (!update || typeof update !== 'object')
|
|
192
|
+
return current;
|
|
193
|
+
const u = update;
|
|
194
|
+
if (u.clear === true)
|
|
195
|
+
return [];
|
|
196
|
+
if (Array.isArray(u.set))
|
|
197
|
+
return u.set.map(String);
|
|
198
|
+
let next = [...current];
|
|
199
|
+
if (Array.isArray(u.remove)) {
|
|
200
|
+
const drop = new Set(u.remove.map(String));
|
|
201
|
+
next = next.filter((n) => !drop.has(n));
|
|
202
|
+
}
|
|
203
|
+
if (Array.isArray(u.add)) {
|
|
204
|
+
for (const item of u.add.map(String))
|
|
205
|
+
if (!next.includes(item))
|
|
206
|
+
next.push(item);
|
|
207
|
+
}
|
|
208
|
+
return next;
|
|
209
|
+
}
|
|
210
|
+
// Field-merge a scalar: only overwrite when the update field is present
|
|
211
|
+
// (protobuf `optional` semantics — absence means "leave unchanged").
|
|
212
|
+
function mergeScalar(current, update, key, coerce) {
|
|
213
|
+
return key in update && update[key] !== undefined ? coerce(update[key]) : current;
|
|
214
|
+
}
|
|
215
|
+
function sipParticipantJson(p) {
|
|
216
|
+
return {
|
|
217
|
+
participantId: p.participantId,
|
|
218
|
+
participantIdentity: p.participantIdentity,
|
|
219
|
+
roomName: p.roomName,
|
|
220
|
+
sipCallId: p.sipCallId,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function agentDispatchJson(a) {
|
|
224
|
+
return {
|
|
225
|
+
id: a.dispatchId,
|
|
226
|
+
agentName: a.agentName,
|
|
227
|
+
room: a.room,
|
|
228
|
+
metadata: a.metadata,
|
|
229
|
+
state: { jobs: [], createdAt: a.createdAt, deletedAt: '0' },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
async function writeResource(type, id, operation, fields, occurredAt, root) {
|
|
233
|
+
await applyTwinWrite(SERVICE, {
|
|
234
|
+
operation,
|
|
235
|
+
subjectType: type,
|
|
236
|
+
subjectId: id,
|
|
237
|
+
fields,
|
|
238
|
+
occurredAt,
|
|
239
|
+
actor: { kind: 'system' },
|
|
240
|
+
}, root);
|
|
241
|
+
}
|
|
242
|
+
async function writeRoom(room, occurredAt, root) {
|
|
243
|
+
await applyTwinWrite(SERVICE, {
|
|
244
|
+
operation: 'room.upsert',
|
|
245
|
+
subjectType: 'room',
|
|
246
|
+
subjectId: room.id,
|
|
247
|
+
fields: room,
|
|
248
|
+
occurredAt,
|
|
249
|
+
actor: { kind: 'system' },
|
|
250
|
+
}, root);
|
|
251
|
+
}
|
|
252
|
+
async function writeParticipant(participant, occurredAt, root) {
|
|
253
|
+
await applyTwinWrite(SERVICE, {
|
|
254
|
+
operation: 'participant.upsert',
|
|
255
|
+
subjectType: 'participant',
|
|
256
|
+
subjectId: participant.id,
|
|
257
|
+
fields: participant,
|
|
258
|
+
occurredAt,
|
|
259
|
+
actor: { kind: 'system' },
|
|
260
|
+
}, root);
|
|
261
|
+
}
|
|
262
|
+
async function writeEgress(egress, operation, occurredAt, root) {
|
|
263
|
+
await applyTwinWrite(SERVICE, {
|
|
264
|
+
operation,
|
|
265
|
+
subjectType: 'egress',
|
|
266
|
+
subjectId: egress.id,
|
|
267
|
+
fields: egress,
|
|
268
|
+
occurredAt,
|
|
269
|
+
actor: { kind: 'system' },
|
|
270
|
+
}, root);
|
|
271
|
+
}
|
|
272
|
+
async function writeIngress(ingress, operation, occurredAt, root) {
|
|
273
|
+
await applyTwinWrite(SERVICE, {
|
|
274
|
+
operation,
|
|
275
|
+
subjectType: 'ingress',
|
|
276
|
+
subjectId: ingress.id,
|
|
277
|
+
fields: ingress,
|
|
278
|
+
occurredAt,
|
|
279
|
+
actor: { kind: 'system' },
|
|
280
|
+
}, root);
|
|
281
|
+
}
|
|
282
|
+
function requireRoom(name, root) {
|
|
283
|
+
return rooms(root).find((r) => r.name === name && !r.deleted) ?? null;
|
|
284
|
+
}
|
|
285
|
+
function latestRoom(name, root) {
|
|
286
|
+
const matches = rooms(root).filter((r) => r.name === name);
|
|
287
|
+
return matches[matches.length - 1] ?? null;
|
|
288
|
+
}
|
|
289
|
+
function requireParticipant(roomName, identity, root) {
|
|
290
|
+
return participants(root).find((p) => p.roomName === roomName && p.identity === identity) ?? null;
|
|
291
|
+
}
|
|
292
|
+
export async function handleLiveKitTwinRequest(req) {
|
|
293
|
+
const method = req.method.toUpperCase();
|
|
294
|
+
const url = new URL(req.path, 'http://twin.local');
|
|
295
|
+
const matched = route(url.pathname);
|
|
296
|
+
const body = parseBody(req.body);
|
|
297
|
+
const occurredAt = req.occurredAt ?? FIXED_NOW;
|
|
298
|
+
if (method !== 'POST' || !matched)
|
|
299
|
+
return twirpError(404, 'not_found', `LiveKit operation not modeled: ${method} ${url.pathname}`);
|
|
300
|
+
// Vendor-faithful access-token auth (opt-in: only enforced when apiKeys supplied).
|
|
301
|
+
// Internal Twin.* seeding endpoints are exempt — they are not part of the vendor surface.
|
|
302
|
+
if (req.apiKeys && matched.service !== 'Twin') {
|
|
303
|
+
const now = req.nowSeconds ?? secondsNum(occurredAt);
|
|
304
|
+
if (!req.authToken)
|
|
305
|
+
return twirpError(401, 'unauthenticated', 'authorization is required');
|
|
306
|
+
const result = verifyAccessToken(req.authToken, (k) => req.apiKeys[k], { nowSeconds: now });
|
|
307
|
+
if (!result.ok) {
|
|
308
|
+
const msg = result.reason === 'expired' ? 'token expired'
|
|
309
|
+
: result.reason === 'not_yet_valid' ? 'token not yet valid'
|
|
310
|
+
: result.reason === 'unknown_key' ? 'unknown API key'
|
|
311
|
+
: 'invalid authorization token';
|
|
312
|
+
return twirpError(401, 'unauthenticated', msg);
|
|
313
|
+
}
|
|
314
|
+
const required = GRANT_REQUIRED[`${matched.service}.${matched.method}`];
|
|
315
|
+
if (required) {
|
|
316
|
+
const video = (result.grants.video ?? {});
|
|
317
|
+
if (!video[required])
|
|
318
|
+
return twirpError(403, 'permission_denied', `missing required grant: ${required}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (matched.service === 'Twin' && matched.method === 'UpsertParticipant') {
|
|
322
|
+
if (req.readOnly)
|
|
323
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
324
|
+
const roomName = String(body.roomName ?? body.room ?? '');
|
|
325
|
+
const identity = String(body.identity ?? '');
|
|
326
|
+
if (!roomName || !identity)
|
|
327
|
+
return twirpError(400, 'invalid_argument', 'roomName and identity are required');
|
|
328
|
+
if (!requireRoom(roomName, req.root))
|
|
329
|
+
await writeRoom(defaultRoom(roomName, occurredAt), occurredAt, req.root);
|
|
330
|
+
const participant = defaultParticipant(roomName, identity, occurredAt, body);
|
|
331
|
+
await writeParticipant(participant, occurredAt, req.root);
|
|
332
|
+
return { status: 200, body: participantJson(participant) };
|
|
333
|
+
}
|
|
334
|
+
if (matched.service === 'RoomService') {
|
|
335
|
+
if (matched.method === 'CreateRoom') {
|
|
336
|
+
if (req.readOnly)
|
|
337
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
338
|
+
const name = String(body.name ?? '');
|
|
339
|
+
if (!name)
|
|
340
|
+
return twirpError(400, 'invalid_argument', 'name is required');
|
|
341
|
+
const existing = requireRoom(name, req.root);
|
|
342
|
+
const prior = latestRoom(name, req.root);
|
|
343
|
+
const generation = existing ? existing.generation : prior?.deleted ? (prior.generation ?? 0) + 1 : 0;
|
|
344
|
+
const room = existing
|
|
345
|
+
? { ...existing, ...defaultRoom(name, existing.creationTimeMs ? new Date(Number(existing.creationTimeMs)).toISOString() : occurredAt, { ...body, generation }), id: existing.id, sid: existing.sid }
|
|
346
|
+
: defaultRoom(name, occurredAt, { ...body, generation });
|
|
347
|
+
await writeRoom(room, occurredAt, req.root);
|
|
348
|
+
return { status: 200, body: roomJson(withCounts(room, req.root)) };
|
|
349
|
+
}
|
|
350
|
+
if (matched.method === 'ListRooms') {
|
|
351
|
+
const names = Array.isArray(body.names) ? body.names.map(String) : [];
|
|
352
|
+
const listed = rooms(req.root).filter((r) => !r.deleted && (names.length === 0 || names.includes(r.name))).map((r) => roomJson(withCounts(r, req.root)));
|
|
353
|
+
return { status: 200, body: { rooms: listed } };
|
|
354
|
+
}
|
|
355
|
+
if (matched.method === 'DeleteRoom') {
|
|
356
|
+
if (req.readOnly)
|
|
357
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
358
|
+
const room = String(body.room ?? '');
|
|
359
|
+
if (!room)
|
|
360
|
+
return twirpError(400, 'invalid_argument', 'room is required');
|
|
361
|
+
const existing = requireRoom(room, req.root);
|
|
362
|
+
if (!existing)
|
|
363
|
+
return twirpError(404, 'not_found', 'room not found');
|
|
364
|
+
for (const participant of participants(req.root).filter((p) => p.roomName === room && p.state !== 3)) {
|
|
365
|
+
await writeParticipant({ ...participant, state: 3 }, occurredAt, req.root);
|
|
366
|
+
}
|
|
367
|
+
for (const egress of egresses(req.root).filter((e) => e.roomName === room && (e.status === EGRESS_STATUS.STARTING || e.status === EGRESS_STATUS.ACTIVE || e.status === EGRESS_STATUS.ENDING))) {
|
|
368
|
+
await writeEgress({ ...egress, status: EGRESS_STATUS.COMPLETE, endedAt: secondsOrZero(occurredAt), updatedAt: secondsOrZero(occurredAt) }, 'egress.room_deleted', occurredAt, req.root);
|
|
369
|
+
}
|
|
370
|
+
await writeRoom({ ...existing, deleted: true }, occurredAt, req.root);
|
|
371
|
+
return { status: 200, body: {} };
|
|
372
|
+
}
|
|
373
|
+
if (matched.method === 'UpdateRoomMetadata') {
|
|
374
|
+
if (req.readOnly)
|
|
375
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
376
|
+
const name = String(body.room ?? '');
|
|
377
|
+
const existing = requireRoom(name, req.root);
|
|
378
|
+
if (!existing)
|
|
379
|
+
return twirpError(404, 'not_found', 'room not found');
|
|
380
|
+
const room = { ...existing, metadata: String(body.metadata ?? '') };
|
|
381
|
+
await writeRoom(room, occurredAt, req.root);
|
|
382
|
+
return { status: 200, body: roomJson(withCounts(room, req.root)) };
|
|
383
|
+
}
|
|
384
|
+
if (matched.method === 'ListParticipants') {
|
|
385
|
+
const roomName = String(body.room ?? '');
|
|
386
|
+
if (!roomName)
|
|
387
|
+
return twirpError(400, 'invalid_argument', 'room is required');
|
|
388
|
+
if (!requireRoom(roomName, req.root))
|
|
389
|
+
return twirpError(404, 'not_found', 'room not found');
|
|
390
|
+
return { status: 200, body: { participants: participants(req.root).filter((p) => p.roomName === roomName && p.state !== 3).map(participantJson) } };
|
|
391
|
+
}
|
|
392
|
+
if (matched.method === 'GetParticipant') {
|
|
393
|
+
const participant = requireParticipant(String(body.room ?? ''), String(body.identity ?? ''), req.root);
|
|
394
|
+
return participant ? { status: 200, body: participantJson(participant) } : twirpError(404, 'not_found', 'participant not found');
|
|
395
|
+
}
|
|
396
|
+
if (matched.method === 'RemoveParticipant') {
|
|
397
|
+
if (req.readOnly)
|
|
398
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
399
|
+
const participant = requireParticipant(String(body.room ?? ''), String(body.identity ?? ''), req.root);
|
|
400
|
+
if (!participant)
|
|
401
|
+
return twirpError(404, 'not_found', 'participant not found');
|
|
402
|
+
await writeParticipant({ ...participant, state: 3 }, occurredAt, req.root);
|
|
403
|
+
return { status: 200, body: {} };
|
|
404
|
+
}
|
|
405
|
+
if (matched.method === 'UpdateParticipant') {
|
|
406
|
+
if (req.readOnly)
|
|
407
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
408
|
+
const participant = requireParticipant(String(body.room ?? ''), String(body.identity ?? ''), req.root);
|
|
409
|
+
if (!participant)
|
|
410
|
+
return twirpError(404, 'not_found', 'participant not found');
|
|
411
|
+
// permissions are updated atomically — the supplied object fully replaces the prior grant set.
|
|
412
|
+
const permission = body.permission && typeof body.permission === 'object'
|
|
413
|
+
? {
|
|
414
|
+
canSubscribe: Boolean(body.permission.canSubscribe ?? false),
|
|
415
|
+
canPublish: Boolean(body.permission.canPublish ?? false),
|
|
416
|
+
canPublishData: Boolean(body.permission.canPublishData ?? false),
|
|
417
|
+
canUpdateMetadata: Boolean(body.permission.canUpdateMetadata ?? false),
|
|
418
|
+
hidden: Boolean(body.permission.hidden ?? false),
|
|
419
|
+
canSubscribeMetrics: Boolean(body.permission.canSubscribeMetrics ?? false),
|
|
420
|
+
}
|
|
421
|
+
: participant.permission;
|
|
422
|
+
const updated = {
|
|
423
|
+
...participant,
|
|
424
|
+
metadata: body.metadata !== undefined ? String(body.metadata) : participant.metadata,
|
|
425
|
+
name: body.name !== undefined ? String(body.name) : participant.name,
|
|
426
|
+
attributes: body.attributes && typeof body.attributes === 'object' ? body.attributes : participant.attributes,
|
|
427
|
+
permission,
|
|
428
|
+
isPublisher: permission.canPublish,
|
|
429
|
+
version: participant.version + 1,
|
|
430
|
+
};
|
|
431
|
+
await writeParticipant(updated, occurredAt, req.root);
|
|
432
|
+
return { status: 200, body: participantJson(updated) };
|
|
433
|
+
}
|
|
434
|
+
if (matched.method === 'UpdateSubscriptions') {
|
|
435
|
+
if (req.readOnly)
|
|
436
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
437
|
+
const participant = requireParticipant(String(body.room ?? ''), String(body.identity ?? ''), req.root);
|
|
438
|
+
if (!participant)
|
|
439
|
+
return twirpError(404, 'not_found', 'participant not found');
|
|
440
|
+
const trackSids = Array.isArray(body.trackSids) ? body.trackSids.map(String) : [];
|
|
441
|
+
const subscribe = Boolean(body.subscribe);
|
|
442
|
+
const current = new Set(participant.subscribedTracks);
|
|
443
|
+
for (const sid of trackSids) {
|
|
444
|
+
if (subscribe)
|
|
445
|
+
current.add(sid);
|
|
446
|
+
else
|
|
447
|
+
current.delete(sid);
|
|
448
|
+
}
|
|
449
|
+
const updated = { ...participant, subscribedTracks: [...current], version: participant.version + 1 };
|
|
450
|
+
await writeParticipant(updated, occurredAt, req.root);
|
|
451
|
+
return { status: 200, body: {} };
|
|
452
|
+
}
|
|
453
|
+
if (matched.method === 'SendData') {
|
|
454
|
+
if (req.readOnly)
|
|
455
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
456
|
+
const roomName = String(body.room ?? '');
|
|
457
|
+
const room = requireRoom(roomName, req.root);
|
|
458
|
+
if (!room)
|
|
459
|
+
return twirpError(404, 'not_found', 'room not found');
|
|
460
|
+
if (body.data === undefined || body.data === null)
|
|
461
|
+
return twirpError(400, 'invalid_argument', 'data is required');
|
|
462
|
+
// SendData is fire-and-forget media-plane signalling; the control-plane twin
|
|
463
|
+
// validates the request and acknowledges (the real server returns an empty body).
|
|
464
|
+
return { status: 200, body: {} };
|
|
465
|
+
}
|
|
466
|
+
if (matched.method === 'MutePublishedTrack') {
|
|
467
|
+
if (req.readOnly)
|
|
468
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
469
|
+
const participant = requireParticipant(String(body.room ?? ''), String(body.identity ?? ''), req.root);
|
|
470
|
+
if (!participant)
|
|
471
|
+
return twirpError(404, 'not_found', 'participant not found');
|
|
472
|
+
const tracks = participant.tracks.map((track) => track.sid === body.trackSid ? { ...track, muted: Boolean(body.muted) } : track);
|
|
473
|
+
const track = tracks.find((t) => t.sid === body.trackSid);
|
|
474
|
+
if (!track)
|
|
475
|
+
return twirpError(404, 'not_found', 'track not found');
|
|
476
|
+
await writeParticipant({ ...participant, tracks, version: participant.version + 1 }, occurredAt, req.root);
|
|
477
|
+
return { status: 200, body: { track } };
|
|
478
|
+
}
|
|
479
|
+
// ForwardParticipant: mirror a participant's published tracks into a destination
|
|
480
|
+
// room. The original participant stays in the source room; a copy appears in the
|
|
481
|
+
// destination (the real server marks it a forwarded/non-publishing mirror).
|
|
482
|
+
if (matched.method === 'ForwardParticipant') {
|
|
483
|
+
if (req.readOnly)
|
|
484
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
485
|
+
const sourceRoom = String(body.room ?? '');
|
|
486
|
+
const identity = String(body.identity ?? '');
|
|
487
|
+
const destinationRoom = String(body.destinationRoom ?? '');
|
|
488
|
+
if (!destinationRoom)
|
|
489
|
+
return twirpError(400, 'invalid_argument', 'destinationRoom is required');
|
|
490
|
+
const participant = requireParticipant(sourceRoom, identity, req.root);
|
|
491
|
+
if (!participant)
|
|
492
|
+
return twirpError(404, 'not_found', 'participant not found');
|
|
493
|
+
if (!requireRoom(destinationRoom, req.root))
|
|
494
|
+
await writeRoom(defaultRoom(destinationRoom, occurredAt), occurredAt, req.root);
|
|
495
|
+
if (requireParticipant(destinationRoom, identity, req.root))
|
|
496
|
+
return twirpError(409, 'already_exists', 'participant already present in destination room');
|
|
497
|
+
const mirrored = defaultParticipant(destinationRoom, identity, occurredAt, {
|
|
498
|
+
...participant,
|
|
499
|
+
// A forwarded mirror cannot itself publish into the destination.
|
|
500
|
+
isPublisher: false,
|
|
501
|
+
permission: { ...participant.permission, canPublish: false },
|
|
502
|
+
});
|
|
503
|
+
await writeParticipant(mirrored, occurredAt, req.root);
|
|
504
|
+
return { status: 200, body: {} };
|
|
505
|
+
}
|
|
506
|
+
// MoveParticipant: relocate a participant to a destination room — they leave the
|
|
507
|
+
// source room (disconnected) and appear in the destination room.
|
|
508
|
+
if (matched.method === 'MoveParticipant') {
|
|
509
|
+
if (req.readOnly)
|
|
510
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
511
|
+
const sourceRoom = String(body.room ?? '');
|
|
512
|
+
const identity = String(body.identity ?? '');
|
|
513
|
+
const destinationRoom = String(body.destinationRoom ?? '');
|
|
514
|
+
if (!destinationRoom)
|
|
515
|
+
return twirpError(400, 'invalid_argument', 'destinationRoom is required');
|
|
516
|
+
const participant = requireParticipant(sourceRoom, identity, req.root);
|
|
517
|
+
if (!participant || participant.state === 3)
|
|
518
|
+
return twirpError(404, 'not_found', 'participant not found');
|
|
519
|
+
if (!requireRoom(destinationRoom, req.root))
|
|
520
|
+
await writeRoom(defaultRoom(destinationRoom, occurredAt), occurredAt, req.root);
|
|
521
|
+
if (requireParticipant(destinationRoom, identity, req.root))
|
|
522
|
+
return twirpError(409, 'already_exists', 'participant already present in destination room');
|
|
523
|
+
// disconnect from source
|
|
524
|
+
await writeParticipant({ ...participant, state: 3 }, occurredAt, req.root);
|
|
525
|
+
// appear in destination, preserving identity/name/tracks/permissions
|
|
526
|
+
const moved = defaultParticipant(destinationRoom, identity, occurredAt, participant);
|
|
527
|
+
await writeParticipant(moved, occurredAt, req.root);
|
|
528
|
+
return { status: 200, body: {} };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (matched.service === 'Egress') {
|
|
532
|
+
const startMap = {
|
|
533
|
+
StartRoomCompositeEgress: { case: 'roomComposite', sourceType: 0, op: 'egress.start_room_composite' },
|
|
534
|
+
StartWebEgress: { case: 'web', sourceType: 1, op: 'egress.start_web' },
|
|
535
|
+
StartParticipantEgress: { case: 'participant', sourceType: 2, op: 'egress.start_participant' },
|
|
536
|
+
StartTrackCompositeEgress: { case: 'trackComposite', sourceType: 3, op: 'egress.start_track_composite' },
|
|
537
|
+
StartTrackEgress: { case: 'track', sourceType: 4, op: 'egress.start_track' },
|
|
538
|
+
};
|
|
539
|
+
const start = startMap[matched.method];
|
|
540
|
+
if (start) {
|
|
541
|
+
if (req.readOnly)
|
|
542
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
543
|
+
if (req.maxActiveEgress !== undefined) {
|
|
544
|
+
const activeCount = egresses(req.root).filter((e) => isActiveEgressStatus(e.status)).length;
|
|
545
|
+
if (activeCount >= req.maxActiveEgress)
|
|
546
|
+
return twirpError(429, 'resource_exhausted', 'egress capacity limit reached');
|
|
547
|
+
}
|
|
548
|
+
// Web egress is keyed on a URL and has no backing room; the others require a known room.
|
|
549
|
+
const roomName = String(body.roomName ?? '');
|
|
550
|
+
let roomSid = '';
|
|
551
|
+
if (start.case !== 'web') {
|
|
552
|
+
const room = requireRoom(roomName, req.root);
|
|
553
|
+
if (!room)
|
|
554
|
+
return twirpError(404, 'not_found', 'room not found');
|
|
555
|
+
roomSid = room.sid;
|
|
556
|
+
if (start.case === 'participant') {
|
|
557
|
+
if (!body.identity)
|
|
558
|
+
return twirpError(400, 'invalid_argument', 'identity is required');
|
|
559
|
+
}
|
|
560
|
+
if (start.case === 'trackComposite' && !body.audioTrackId && !body.videoTrackId) {
|
|
561
|
+
return twirpError(400, 'invalid_argument', 'audioTrackId or videoTrackId is required');
|
|
562
|
+
}
|
|
563
|
+
if (start.case === 'track' && !body.trackId) {
|
|
564
|
+
return twirpError(400, 'invalid_argument', 'trackId is required');
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
else if (!body.url) {
|
|
568
|
+
return twirpError(400, 'invalid_argument', 'url is required');
|
|
569
|
+
}
|
|
570
|
+
const sequence = egresses(req.root).length + 1;
|
|
571
|
+
const id = egressId(roomName || String(body.url ?? ''), body, occurredAt, sequence);
|
|
572
|
+
const streamUrls = collectStreamUrls(body);
|
|
573
|
+
const egress = {
|
|
574
|
+
id: egressResourceId(id),
|
|
575
|
+
egressId: id,
|
|
576
|
+
roomId: roomSid,
|
|
577
|
+
roomName,
|
|
578
|
+
sourceType: start.sourceType,
|
|
579
|
+
status: EGRESS_STATUS.ACTIVE,
|
|
580
|
+
startedAt: secondsOrZero(occurredAt),
|
|
581
|
+
endedAt: '0',
|
|
582
|
+
updatedAt: secondsOrZero(occurredAt),
|
|
583
|
+
details: '',
|
|
584
|
+
error: '',
|
|
585
|
+
errorCode: 0,
|
|
586
|
+
layout: String(body.layout ?? ''),
|
|
587
|
+
requestCase: start.case,
|
|
588
|
+
streamUrls,
|
|
589
|
+
fileOutputs: Array.isArray(body.fileOutputs) ? body.fileOutputs : [],
|
|
590
|
+
streamOutputs: Array.isArray(body.streamOutputs) ? body.streamOutputs : [],
|
|
591
|
+
segmentOutputs: Array.isArray(body.segmentOutputs) ? body.segmentOutputs : [],
|
|
592
|
+
imageOutputs: Array.isArray(body.imageOutputs) ? body.imageOutputs : [],
|
|
593
|
+
request: body,
|
|
594
|
+
};
|
|
595
|
+
await writeEgress(egress, start.op, occurredAt, req.root);
|
|
596
|
+
return { status: 200, body: egressJson(egress) };
|
|
597
|
+
}
|
|
598
|
+
if (matched.method === 'ListEgress') {
|
|
599
|
+
const roomName = body.roomName === undefined ? '' : String(body.roomName);
|
|
600
|
+
const id = body.egressId === undefined ? '' : String(body.egressId);
|
|
601
|
+
const active = body.active === undefined ? null : Boolean(body.active);
|
|
602
|
+
const rows = egresses(req.root).filter((e) => {
|
|
603
|
+
if (roomName && e.roomName !== roomName)
|
|
604
|
+
return false;
|
|
605
|
+
if (id && e.egressId !== id)
|
|
606
|
+
return false;
|
|
607
|
+
if (active !== null && active !== isActiveEgressStatus(e.status))
|
|
608
|
+
return false;
|
|
609
|
+
return true;
|
|
610
|
+
});
|
|
611
|
+
return { status: 200, body: { items: rows.map(egressJson) } };
|
|
612
|
+
}
|
|
613
|
+
if (matched.method === 'StopEgress') {
|
|
614
|
+
if (req.readOnly)
|
|
615
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
616
|
+
const existing = egresses(req.root).find((e) => e.egressId === String(body.egressId ?? ''));
|
|
617
|
+
if (!existing)
|
|
618
|
+
return twirpError(404, 'not_found', 'egress not found');
|
|
619
|
+
if (!isActiveEgressStatus(existing.status))
|
|
620
|
+
return twirpError(412, 'failed_precondition', 'egress is not active');
|
|
621
|
+
const updated = { ...existing, status: EGRESS_STATUS.COMPLETE, endedAt: secondsOrZero(occurredAt), updatedAt: secondsOrZero(occurredAt) };
|
|
622
|
+
await writeEgress(updated, 'egress.stop', occurredAt, req.root);
|
|
623
|
+
return { status: 200, body: egressJson(updated) };
|
|
624
|
+
}
|
|
625
|
+
if (matched.method === 'UpdateLayout') {
|
|
626
|
+
if (req.readOnly)
|
|
627
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
628
|
+
const existing = egresses(req.root).find((e) => e.egressId === String(body.egressId ?? ''));
|
|
629
|
+
if (!existing)
|
|
630
|
+
return twirpError(404, 'not_found', 'egress not found');
|
|
631
|
+
const updated = { ...existing, layout: String(body.layout ?? ''), updatedAt: secondsOrZero(occurredAt), request: { ...existing.request, layout: String(body.layout ?? '') } };
|
|
632
|
+
await writeEgress(updated, 'egress.update_layout', occurredAt, req.root);
|
|
633
|
+
return { status: 200, body: egressJson(updated) };
|
|
634
|
+
}
|
|
635
|
+
if (matched.method === 'UpdateStream') {
|
|
636
|
+
if (req.readOnly)
|
|
637
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
638
|
+
const existing = egresses(req.root).find((e) => e.egressId === String(body.egressId ?? ''));
|
|
639
|
+
if (!existing)
|
|
640
|
+
return twirpError(404, 'not_found', 'egress not found');
|
|
641
|
+
if (!isActiveEgressStatus(existing.status))
|
|
642
|
+
return twirpError(412, 'failed_precondition', 'egress is not active');
|
|
643
|
+
const add = Array.isArray(body.addOutputUrls) ? body.addOutputUrls.map(String) : [];
|
|
644
|
+
const remove = new Set(Array.isArray(body.removeOutputUrls) ? body.removeOutputUrls.map(String) : []);
|
|
645
|
+
const streamUrls = [...existing.streamUrls.filter((u) => !remove.has(u)), ...add.filter((u) => !existing.streamUrls.includes(u))];
|
|
646
|
+
const updated = { ...existing, streamUrls, updatedAt: secondsOrZero(occurredAt) };
|
|
647
|
+
await writeEgress(updated, 'egress.update_stream', occurredAt, req.root);
|
|
648
|
+
return { status: 200, body: egressJson(updated) };
|
|
649
|
+
}
|
|
650
|
+
// Twin-only: force an egress into a terminal failure state for failure-path testing.
|
|
651
|
+
if (matched.method === 'FailEgress') {
|
|
652
|
+
if (req.readOnly)
|
|
653
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
654
|
+
const existing = egresses(req.root).find((e) => e.egressId === String(body.egressId ?? ''));
|
|
655
|
+
if (!existing)
|
|
656
|
+
return twirpError(404, 'not_found', 'egress not found');
|
|
657
|
+
const status = Number(body.status ?? EGRESS_STATUS.FAILED);
|
|
658
|
+
if (status !== EGRESS_STATUS.FAILED && status !== EGRESS_STATUS.ABORTED && status !== EGRESS_STATUS.LIMIT_REACHED) {
|
|
659
|
+
return twirpError(400, 'invalid_argument', 'status must be a terminal failure state');
|
|
660
|
+
}
|
|
661
|
+
const updated = {
|
|
662
|
+
...existing,
|
|
663
|
+
status,
|
|
664
|
+
error: String(body.error ?? 'egress failed'),
|
|
665
|
+
errorCode: Number(body.errorCode ?? 1),
|
|
666
|
+
endedAt: secondsOrZero(occurredAt),
|
|
667
|
+
updatedAt: secondsOrZero(occurredAt),
|
|
668
|
+
};
|
|
669
|
+
await writeEgress(updated, 'egress.fail', occurredAt, req.root);
|
|
670
|
+
return { status: 200, body: egressJson(updated) };
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (matched.service === 'Ingress') {
|
|
674
|
+
if (matched.method === 'CreateIngress') {
|
|
675
|
+
if (req.readOnly)
|
|
676
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
677
|
+
const inputType = ingressInput(body.inputType ?? body.input_type);
|
|
678
|
+
const identity = String(body.participantIdentity ?? '');
|
|
679
|
+
if (!identity)
|
|
680
|
+
return twirpError(400, 'invalid_argument', 'participantIdentity is required');
|
|
681
|
+
if (inputType === INGRESS_INPUT.URL_INPUT && !body.url)
|
|
682
|
+
return twirpError(400, 'invalid_argument', 'url is required for URL input');
|
|
683
|
+
const sequence = ingresses(req.root).length + 1;
|
|
684
|
+
const id = ingressId(String(body.roomName ?? ''), identity, body, sequence);
|
|
685
|
+
const ingress = defaultIngress(id, inputType, occurredAt, body);
|
|
686
|
+
await writeIngress(ingress, 'ingress.create', occurredAt, req.root);
|
|
687
|
+
return { status: 200, body: ingressJson(ingress) };
|
|
688
|
+
}
|
|
689
|
+
if (matched.method === 'UpdateIngress') {
|
|
690
|
+
if (req.readOnly)
|
|
691
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
692
|
+
const existing = ingresses(req.root).find((i) => i.ingressId === String(body.ingressId ?? '') && !i.deleted);
|
|
693
|
+
if (!existing)
|
|
694
|
+
return twirpError(404, 'not_found', 'ingress not found');
|
|
695
|
+
const enableTranscoding = body.enableTranscoding !== undefined ? Boolean(body.enableTranscoding) : existing.enableTranscoding;
|
|
696
|
+
const updated = {
|
|
697
|
+
...existing,
|
|
698
|
+
name: body.name !== undefined ? String(body.name) : existing.name,
|
|
699
|
+
roomName: body.roomName !== undefined ? String(body.roomName) : existing.roomName,
|
|
700
|
+
participantIdentity: body.participantIdentity !== undefined ? String(body.participantIdentity) : existing.participantIdentity,
|
|
701
|
+
participantName: body.participantName !== undefined ? String(body.participantName) : existing.participantName,
|
|
702
|
+
participantMetadata: body.participantMetadata !== undefined ? String(body.participantMetadata) : existing.participantMetadata,
|
|
703
|
+
enableTranscoding,
|
|
704
|
+
bypassTranscoding: !enableTranscoding,
|
|
705
|
+
enabled: body.enabled !== undefined ? Boolean(body.enabled) : existing.enabled,
|
|
706
|
+
audio: body.audio && typeof body.audio === 'object' ? body.audio : existing.audio,
|
|
707
|
+
video: body.video && typeof body.video === 'object' ? body.video : existing.video,
|
|
708
|
+
};
|
|
709
|
+
await writeIngress(updated, 'ingress.update', occurredAt, req.root);
|
|
710
|
+
return { status: 200, body: ingressJson(updated) };
|
|
711
|
+
}
|
|
712
|
+
if (matched.method === 'ListIngress') {
|
|
713
|
+
const roomName = body.roomName === undefined ? '' : String(body.roomName);
|
|
714
|
+
const id = body.ingressId === undefined ? '' : String(body.ingressId);
|
|
715
|
+
const rows = ingresses(req.root).filter((i) => {
|
|
716
|
+
if (i.deleted)
|
|
717
|
+
return false;
|
|
718
|
+
if (roomName && i.roomName !== roomName)
|
|
719
|
+
return false;
|
|
720
|
+
if (id && i.ingressId !== id)
|
|
721
|
+
return false;
|
|
722
|
+
return true;
|
|
723
|
+
});
|
|
724
|
+
return { status: 200, body: { items: rows.map(ingressJson) } };
|
|
725
|
+
}
|
|
726
|
+
if (matched.method === 'DeleteIngress') {
|
|
727
|
+
if (req.readOnly)
|
|
728
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
729
|
+
const existing = ingresses(req.root).find((i) => i.ingressId === String(body.ingressId ?? '') && !i.deleted);
|
|
730
|
+
if (!existing)
|
|
731
|
+
return twirpError(404, 'not_found', 'ingress not found');
|
|
732
|
+
const updated = { ...existing, deleted: true, state: { ...existing.state, status: INGRESS_STATUS.ENDPOINT_COMPLETE, endedAt: secondsOrZero(occurredAt), updatedAt: secondsOrZero(occurredAt) } };
|
|
733
|
+
await writeIngress(updated, 'ingress.delete', occurredAt, req.root);
|
|
734
|
+
return { status: 200, body: ingressJson(updated) };
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
// --- SIP (SIPService Twirp surface) -------------------------------------
|
|
738
|
+
if (matched.service === 'SIP') {
|
|
739
|
+
if (matched.method === 'CreateSIPInboundTrunk') {
|
|
740
|
+
if (req.readOnly)
|
|
741
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
742
|
+
const trunk = (body.trunk && typeof body.trunk === 'object' ? body.trunk : body);
|
|
743
|
+
const numbers = Array.isArray(trunk.numbers) ? trunk.numbers.map(String) : [];
|
|
744
|
+
if (numbers.length === 0)
|
|
745
|
+
return twirpError(400, 'invalid_argument', 'inbound trunk requires at least one number');
|
|
746
|
+
const sequence = sipInboundTrunks(req.root).length + 1;
|
|
747
|
+
const id = sipInboundTrunkId(String(trunk.name ?? ''), numbers, sequence);
|
|
748
|
+
const row = {
|
|
749
|
+
id: `sip_inbound_trunk_${id}`,
|
|
750
|
+
sipTrunkId: id,
|
|
751
|
+
name: String(trunk.name ?? ''),
|
|
752
|
+
metadata: String(trunk.metadata ?? ''),
|
|
753
|
+
numbers,
|
|
754
|
+
allowedAddresses: Array.isArray(trunk.allowedAddresses) ? trunk.allowedAddresses.map(String) : [],
|
|
755
|
+
allowedNumbers: Array.isArray(trunk.allowedNumbers) ? trunk.allowedNumbers.map(String) : [],
|
|
756
|
+
authUsername: String(trunk.authUsername ?? ''),
|
|
757
|
+
authPassword: String(trunk.authPassword ?? ''),
|
|
758
|
+
headers: trunk.headers && typeof trunk.headers === 'object' ? trunk.headers : {},
|
|
759
|
+
headersToAttributes: trunk.headersToAttributes && typeof trunk.headersToAttributes === 'object' ? trunk.headersToAttributes : {},
|
|
760
|
+
krispEnabled: Boolean(trunk.krispEnabled ?? false),
|
|
761
|
+
includeHeaders: String(trunk.includeHeaders ?? 'SIP_NO_HEADERS'),
|
|
762
|
+
mediaEncryption: String(trunk.mediaEncryption ?? 'SIP_MEDIA_ENCRYPT_DISABLE'),
|
|
763
|
+
};
|
|
764
|
+
await writeResource('sip_inbound_trunk', row.id, 'sip.inbound_trunk.create', row, occurredAt, req.root);
|
|
765
|
+
return { status: 200, body: sipInboundTrunkJson(row) };
|
|
766
|
+
}
|
|
767
|
+
if (matched.method === 'CreateSIPOutboundTrunk') {
|
|
768
|
+
if (req.readOnly)
|
|
769
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
770
|
+
const trunk = (body.trunk && typeof body.trunk === 'object' ? body.trunk : body);
|
|
771
|
+
const address = String(trunk.address ?? '');
|
|
772
|
+
if (!address)
|
|
773
|
+
return twirpError(400, 'invalid_argument', 'outbound trunk requires an address');
|
|
774
|
+
const sequence = sipOutboundTrunks(req.root).length + 1;
|
|
775
|
+
const id = sipOutboundTrunkId(String(trunk.name ?? ''), address, sequence);
|
|
776
|
+
const row = {
|
|
777
|
+
id: `sip_outbound_trunk_${id}`,
|
|
778
|
+
sipTrunkId: id,
|
|
779
|
+
name: String(trunk.name ?? ''),
|
|
780
|
+
metadata: String(trunk.metadata ?? ''),
|
|
781
|
+
address,
|
|
782
|
+
transport: sipTransport(trunk.transport ?? SIP_TRANSPORT.SIP_TRANSPORT_AUTO),
|
|
783
|
+
numbers: Array.isArray(trunk.numbers) ? trunk.numbers.map(String) : [],
|
|
784
|
+
authUsername: String(trunk.authUsername ?? ''),
|
|
785
|
+
authPassword: String(trunk.authPassword ?? ''),
|
|
786
|
+
headers: trunk.headers && typeof trunk.headers === 'object' ? trunk.headers : {},
|
|
787
|
+
headersToAttributes: trunk.headersToAttributes && typeof trunk.headersToAttributes === 'object' ? trunk.headersToAttributes : {},
|
|
788
|
+
includeHeaders: String(trunk.includeHeaders ?? 'SIP_NO_HEADERS'),
|
|
789
|
+
mediaEncryption: String(trunk.mediaEncryption ?? 'SIP_MEDIA_ENCRYPT_DISABLE'),
|
|
790
|
+
destinationCountry: String(trunk.destinationCountry ?? ''),
|
|
791
|
+
};
|
|
792
|
+
await writeResource('sip_outbound_trunk', row.id, 'sip.outbound_trunk.create', row, occurredAt, req.root);
|
|
793
|
+
return { status: 200, body: sipOutboundTrunkJson(row) };
|
|
794
|
+
}
|
|
795
|
+
if (matched.method === 'UpdateSIPInboundTrunk') {
|
|
796
|
+
if (req.readOnly)
|
|
797
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
798
|
+
const id = String(body.sipTrunkId ?? '');
|
|
799
|
+
const existing = sipInboundTrunks(req.root).find((t) => t.sipTrunkId === id && !t.deleted);
|
|
800
|
+
if (!existing)
|
|
801
|
+
return twirpError(404, 'not_found', 'sip inbound trunk not found');
|
|
802
|
+
let next;
|
|
803
|
+
if (body.replace && typeof body.replace === 'object') {
|
|
804
|
+
// Full replace: a complete SIPInboundTrunkInfo overwrites everything but identity.
|
|
805
|
+
const r = body.replace;
|
|
806
|
+
next = {
|
|
807
|
+
...existing,
|
|
808
|
+
name: String(r.name ?? ''),
|
|
809
|
+
metadata: String(r.metadata ?? ''),
|
|
810
|
+
numbers: Array.isArray(r.numbers) ? r.numbers.map(String) : [],
|
|
811
|
+
allowedAddresses: Array.isArray(r.allowedAddresses) ? r.allowedAddresses.map(String) : [],
|
|
812
|
+
allowedNumbers: Array.isArray(r.allowedNumbers) ? r.allowedNumbers.map(String) : [],
|
|
813
|
+
authUsername: String(r.authUsername ?? ''),
|
|
814
|
+
authPassword: String(r.authPassword ?? ''),
|
|
815
|
+
headers: r.headers && typeof r.headers === 'object' ? r.headers : {},
|
|
816
|
+
headersToAttributes: r.headersToAttributes && typeof r.headersToAttributes === 'object' ? r.headersToAttributes : {},
|
|
817
|
+
krispEnabled: Boolean(r.krispEnabled ?? false),
|
|
818
|
+
includeHeaders: String(r.includeHeaders ?? 'SIP_NO_HEADERS'),
|
|
819
|
+
mediaEncryption: String(r.mediaEncryption ?? 'SIP_MEDIA_ENCRYPT_DISABLE'),
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
else if (body.update && typeof body.update === 'object') {
|
|
823
|
+
// Field merge: scalars overwrite only when present; lists use ListUpdate.
|
|
824
|
+
const u = body.update;
|
|
825
|
+
next = {
|
|
826
|
+
...existing,
|
|
827
|
+
numbers: applyListUpdate(existing.numbers, u.numbers),
|
|
828
|
+
allowedAddresses: applyListUpdate(existing.allowedAddresses, u.allowedAddresses),
|
|
829
|
+
allowedNumbers: applyListUpdate(existing.allowedNumbers, u.allowedNumbers),
|
|
830
|
+
authUsername: mergeScalar(existing.authUsername, u, 'authUsername', String),
|
|
831
|
+
authPassword: mergeScalar(existing.authPassword, u, 'authPassword', String),
|
|
832
|
+
name: mergeScalar(existing.name, u, 'name', String),
|
|
833
|
+
metadata: mergeScalar(existing.metadata, u, 'metadata', String),
|
|
834
|
+
mediaEncryption: mergeScalar(existing.mediaEncryption, u, 'mediaEncryption', String),
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
return twirpError(400, 'invalid_argument', 'update requires a replace or update action');
|
|
839
|
+
}
|
|
840
|
+
await writeResource('sip_inbound_trunk', existing.id, 'sip.inbound_trunk.update', next, occurredAt, req.root);
|
|
841
|
+
return { status: 200, body: sipInboundTrunkJson(next) };
|
|
842
|
+
}
|
|
843
|
+
if (matched.method === 'UpdateSIPOutboundTrunk') {
|
|
844
|
+
if (req.readOnly)
|
|
845
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
846
|
+
const id = String(body.sipTrunkId ?? '');
|
|
847
|
+
const existing = sipOutboundTrunks(req.root).find((t) => t.sipTrunkId === id && !t.deleted);
|
|
848
|
+
if (!existing)
|
|
849
|
+
return twirpError(404, 'not_found', 'sip outbound trunk not found');
|
|
850
|
+
let next;
|
|
851
|
+
if (body.replace && typeof body.replace === 'object') {
|
|
852
|
+
const r = body.replace;
|
|
853
|
+
next = {
|
|
854
|
+
...existing,
|
|
855
|
+
name: String(r.name ?? ''),
|
|
856
|
+
metadata: String(r.metadata ?? ''),
|
|
857
|
+
address: String(r.address ?? ''),
|
|
858
|
+
transport: sipTransport(r.transport ?? SIP_TRANSPORT.SIP_TRANSPORT_AUTO),
|
|
859
|
+
numbers: Array.isArray(r.numbers) ? r.numbers.map(String) : [],
|
|
860
|
+
authUsername: String(r.authUsername ?? ''),
|
|
861
|
+
authPassword: String(r.authPassword ?? ''),
|
|
862
|
+
headers: r.headers && typeof r.headers === 'object' ? r.headers : {},
|
|
863
|
+
headersToAttributes: r.headersToAttributes && typeof r.headersToAttributes === 'object' ? r.headersToAttributes : {},
|
|
864
|
+
includeHeaders: String(r.includeHeaders ?? 'SIP_NO_HEADERS'),
|
|
865
|
+
mediaEncryption: String(r.mediaEncryption ?? 'SIP_MEDIA_ENCRYPT_DISABLE'),
|
|
866
|
+
destinationCountry: String(r.destinationCountry ?? ''),
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
else if (body.update && typeof body.update === 'object') {
|
|
870
|
+
const u = body.update;
|
|
871
|
+
next = {
|
|
872
|
+
...existing,
|
|
873
|
+
numbers: applyListUpdate(existing.numbers, u.numbers),
|
|
874
|
+
address: mergeScalar(existing.address, u, 'address', String),
|
|
875
|
+
transport: 'transport' in u && u.transport !== undefined ? sipTransport(u.transport) : existing.transport,
|
|
876
|
+
destinationCountry: mergeScalar(existing.destinationCountry, u, 'destinationCountry', String),
|
|
877
|
+
authUsername: mergeScalar(existing.authUsername, u, 'authUsername', String),
|
|
878
|
+
authPassword: mergeScalar(existing.authPassword, u, 'authPassword', String),
|
|
879
|
+
name: mergeScalar(existing.name, u, 'name', String),
|
|
880
|
+
metadata: mergeScalar(existing.metadata, u, 'metadata', String),
|
|
881
|
+
mediaEncryption: mergeScalar(existing.mediaEncryption, u, 'mediaEncryption', String),
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
return twirpError(400, 'invalid_argument', 'update requires a replace or update action');
|
|
886
|
+
}
|
|
887
|
+
await writeResource('sip_outbound_trunk', existing.id, 'sip.outbound_trunk.update', next, occurredAt, req.root);
|
|
888
|
+
return { status: 200, body: sipOutboundTrunkJson(next) };
|
|
889
|
+
}
|
|
890
|
+
if (matched.method === 'ListSIPInboundTrunk') {
|
|
891
|
+
const ids = Array.isArray(body.trunkIds) ? body.trunkIds.map(String) : [];
|
|
892
|
+
const numbers = Array.isArray(body.numbers) ? body.numbers.map(String) : [];
|
|
893
|
+
const items = sipInboundTrunks(req.root).filter((t) => !t.deleted
|
|
894
|
+
&& (ids.length === 0 || ids.includes(t.sipTrunkId))
|
|
895
|
+
&& (numbers.length === 0 || t.numbers.some((n) => numbers.includes(n))));
|
|
896
|
+
return { status: 200, body: { items: items.map(sipInboundTrunkJson) } };
|
|
897
|
+
}
|
|
898
|
+
if (matched.method === 'ListSIPOutboundTrunk') {
|
|
899
|
+
const ids = Array.isArray(body.trunkIds) ? body.trunkIds.map(String) : [];
|
|
900
|
+
const numbers = Array.isArray(body.numbers) ? body.numbers.map(String) : [];
|
|
901
|
+
const items = sipOutboundTrunks(req.root).filter((t) => !t.deleted
|
|
902
|
+
&& (ids.length === 0 || ids.includes(t.sipTrunkId))
|
|
903
|
+
&& (numbers.length === 0 || t.numbers.some((n) => numbers.includes(n))));
|
|
904
|
+
return { status: 200, body: { items: items.map(sipOutboundTrunkJson) } };
|
|
905
|
+
}
|
|
906
|
+
if (matched.method === 'DeleteSIPTrunk') {
|
|
907
|
+
if (req.readOnly)
|
|
908
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
909
|
+
const id = String(body.sipTrunkId ?? '');
|
|
910
|
+
const inbound = sipInboundTrunks(req.root).find((t) => t.sipTrunkId === id && !t.deleted);
|
|
911
|
+
if (inbound) {
|
|
912
|
+
await writeResource('sip_inbound_trunk', inbound.id, 'sip.inbound_trunk.delete', { ...inbound, deleted: true }, occurredAt, req.root);
|
|
913
|
+
return { status: 200, body: sipInboundTrunkJson(inbound) };
|
|
914
|
+
}
|
|
915
|
+
const outbound = sipOutboundTrunks(req.root).find((t) => t.sipTrunkId === id && !t.deleted);
|
|
916
|
+
if (outbound) {
|
|
917
|
+
await writeResource('sip_outbound_trunk', outbound.id, 'sip.outbound_trunk.delete', { ...outbound, deleted: true }, occurredAt, req.root);
|
|
918
|
+
return { status: 200, body: sipOutboundTrunkJson(outbound) };
|
|
919
|
+
}
|
|
920
|
+
return twirpError(404, 'not_found', 'sip trunk not found');
|
|
921
|
+
}
|
|
922
|
+
if (matched.method === 'CreateSIPDispatchRule') {
|
|
923
|
+
if (req.readOnly)
|
|
924
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
925
|
+
// `rule` is a oneof JSON-encoded as { dispatchRuleDirect: {...} } or { dispatchRuleIndividual: {...} }.
|
|
926
|
+
const ruleObj = body.rule && typeof body.rule === 'object' ? body.rule : {};
|
|
927
|
+
let ruleCase = null;
|
|
928
|
+
let ruleValue = {};
|
|
929
|
+
if (ruleObj.dispatchRuleDirect && typeof ruleObj.dispatchRuleDirect === 'object') {
|
|
930
|
+
ruleCase = 'dispatchRuleDirect';
|
|
931
|
+
ruleValue = { roomName: String(ruleObj.dispatchRuleDirect.roomName ?? ''), pin: String(ruleObj.dispatchRuleDirect.pin ?? '') };
|
|
932
|
+
if (!ruleValue.roomName)
|
|
933
|
+
return twirpError(400, 'invalid_argument', 'direct dispatch rule requires roomName');
|
|
934
|
+
}
|
|
935
|
+
else if (ruleObj.dispatchRuleIndividual && typeof ruleObj.dispatchRuleIndividual === 'object') {
|
|
936
|
+
ruleCase = 'dispatchRuleIndividual';
|
|
937
|
+
ruleValue = { roomPrefix: String(ruleObj.dispatchRuleIndividual.roomPrefix ?? ''), pin: String(ruleObj.dispatchRuleIndividual.pin ?? '') };
|
|
938
|
+
}
|
|
939
|
+
if (!ruleCase)
|
|
940
|
+
return twirpError(400, 'invalid_argument', 'dispatch rule requires a direct or individual rule');
|
|
941
|
+
const sequence = sipDispatchRules(req.root).length + 1;
|
|
942
|
+
const id = sipDispatchRuleId(body, sequence);
|
|
943
|
+
const row = {
|
|
944
|
+
id: `sip_dispatch_rule_${id}`,
|
|
945
|
+
sipDispatchRuleId: id,
|
|
946
|
+
name: String(body.name ?? ''),
|
|
947
|
+
metadata: String(body.metadata ?? ''),
|
|
948
|
+
trunkIds: Array.isArray(body.trunkIds) ? body.trunkIds.map(String) : [],
|
|
949
|
+
hidePhoneNumber: Boolean(body.hidePhoneNumber ?? false),
|
|
950
|
+
attributes: body.attributes && typeof body.attributes === 'object' ? body.attributes : {},
|
|
951
|
+
roomPreset: String(body.roomPreset ?? ''),
|
|
952
|
+
inboundNumbers: Array.isArray(body.inboundNumbers) ? body.inboundNumbers.map(String) : [],
|
|
953
|
+
rule: { case: ruleCase, value: ruleValue },
|
|
954
|
+
};
|
|
955
|
+
await writeResource('sip_dispatch_rule', row.id, 'sip.dispatch_rule.create', row, occurredAt, req.root);
|
|
956
|
+
return { status: 200, body: sipDispatchRuleJson(row) };
|
|
957
|
+
}
|
|
958
|
+
if (matched.method === 'ListSIPDispatchRule') {
|
|
959
|
+
const ids = Array.isArray(body.dispatchRuleIds) ? body.dispatchRuleIds.map(String) : [];
|
|
960
|
+
const trunkIds = Array.isArray(body.trunkIds) ? body.trunkIds.map(String) : [];
|
|
961
|
+
const items = sipDispatchRules(req.root).filter((r) => !r.deleted
|
|
962
|
+
&& (ids.length === 0 || ids.includes(r.sipDispatchRuleId))
|
|
963
|
+
// wildcard rules (no trunkIds) match any trunk filter
|
|
964
|
+
&& (trunkIds.length === 0 || r.trunkIds.length === 0 || r.trunkIds.some((t) => trunkIds.includes(t))));
|
|
965
|
+
return { status: 200, body: { items: items.map(sipDispatchRuleJson) } };
|
|
966
|
+
}
|
|
967
|
+
if (matched.method === 'UpdateSIPDispatchRule') {
|
|
968
|
+
if (req.readOnly)
|
|
969
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
970
|
+
const id = String(body.sipDispatchRuleId ?? '');
|
|
971
|
+
const existing = sipDispatchRules(req.root).find((r) => r.sipDispatchRuleId === id && !r.deleted);
|
|
972
|
+
if (!existing)
|
|
973
|
+
return twirpError(404, 'not_found', 'sip dispatch rule not found');
|
|
974
|
+
// Parse a `rule` oneof ({ dispatchRuleDirect } | { dispatchRuleIndividual }).
|
|
975
|
+
const parseRule = (ruleObj) => {
|
|
976
|
+
if (ruleObj.dispatchRuleDirect && typeof ruleObj.dispatchRuleDirect === 'object') {
|
|
977
|
+
const d = ruleObj.dispatchRuleDirect;
|
|
978
|
+
if (!d.roomName)
|
|
979
|
+
return null;
|
|
980
|
+
return { case: 'dispatchRuleDirect', value: { roomName: String(d.roomName), pin: String(d.pin ?? '') } };
|
|
981
|
+
}
|
|
982
|
+
if (ruleObj.dispatchRuleIndividual && typeof ruleObj.dispatchRuleIndividual === 'object') {
|
|
983
|
+
const i = ruleObj.dispatchRuleIndividual;
|
|
984
|
+
return { case: 'dispatchRuleIndividual', value: { roomPrefix: String(i.roomPrefix ?? ''), pin: String(i.pin ?? '') } };
|
|
985
|
+
}
|
|
986
|
+
return null;
|
|
987
|
+
};
|
|
988
|
+
let next;
|
|
989
|
+
if (body.replace && typeof body.replace === 'object') {
|
|
990
|
+
const r = body.replace;
|
|
991
|
+
const rule = r.rule && typeof r.rule === 'object' ? parseRule(r.rule) : null;
|
|
992
|
+
if (!rule)
|
|
993
|
+
return twirpError(400, 'invalid_argument', 'dispatch rule requires a direct or individual rule');
|
|
994
|
+
next = {
|
|
995
|
+
...existing,
|
|
996
|
+
name: String(r.name ?? ''),
|
|
997
|
+
metadata: String(r.metadata ?? ''),
|
|
998
|
+
trunkIds: Array.isArray(r.trunkIds) ? r.trunkIds.map(String) : [],
|
|
999
|
+
hidePhoneNumber: Boolean(r.hidePhoneNumber ?? false),
|
|
1000
|
+
attributes: r.attributes && typeof r.attributes === 'object' ? r.attributes : {},
|
|
1001
|
+
roomPreset: String(r.roomPreset ?? ''),
|
|
1002
|
+
inboundNumbers: Array.isArray(r.inboundNumbers) ? r.inboundNumbers.map(String) : [],
|
|
1003
|
+
rule,
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
else if (body.update && typeof body.update === 'object') {
|
|
1007
|
+
const u = body.update;
|
|
1008
|
+
let rule = existing.rule;
|
|
1009
|
+
if (u.rule && typeof u.rule === 'object') {
|
|
1010
|
+
const parsed = parseRule(u.rule);
|
|
1011
|
+
if (!parsed)
|
|
1012
|
+
return twirpError(400, 'invalid_argument', 'dispatch rule requires a direct or individual rule');
|
|
1013
|
+
rule = parsed;
|
|
1014
|
+
}
|
|
1015
|
+
next = {
|
|
1016
|
+
...existing,
|
|
1017
|
+
trunkIds: applyListUpdate(existing.trunkIds, u.trunkIds),
|
|
1018
|
+
name: mergeScalar(existing.name, u, 'name', String),
|
|
1019
|
+
metadata: mergeScalar(existing.metadata, u, 'metadata', String),
|
|
1020
|
+
attributes: u.attributes && typeof u.attributes === 'object' ? u.attributes : existing.attributes,
|
|
1021
|
+
rule,
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
return twirpError(400, 'invalid_argument', 'update requires a replace or update action');
|
|
1026
|
+
}
|
|
1027
|
+
await writeResource('sip_dispatch_rule', existing.id, 'sip.dispatch_rule.update', next, occurredAt, req.root);
|
|
1028
|
+
return { status: 200, body: sipDispatchRuleJson(next) };
|
|
1029
|
+
}
|
|
1030
|
+
if (matched.method === 'DeleteSIPDispatchRule') {
|
|
1031
|
+
if (req.readOnly)
|
|
1032
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
1033
|
+
const existing = sipDispatchRules(req.root).find((r) => r.sipDispatchRuleId === String(body.sipDispatchRuleId ?? '') && !r.deleted);
|
|
1034
|
+
if (!existing)
|
|
1035
|
+
return twirpError(404, 'not_found', 'sip dispatch rule not found');
|
|
1036
|
+
await writeResource('sip_dispatch_rule', existing.id, 'sip.dispatch_rule.delete', { ...existing, deleted: true }, occurredAt, req.root);
|
|
1037
|
+
return { status: 200, body: sipDispatchRuleJson(existing) };
|
|
1038
|
+
}
|
|
1039
|
+
if (matched.method === 'CreateSIPParticipant') {
|
|
1040
|
+
if (req.readOnly)
|
|
1041
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
1042
|
+
const trunkId = String(body.sipTrunkId ?? '');
|
|
1043
|
+
const roomName = String(body.roomName ?? '');
|
|
1044
|
+
const sipCallTo = String(body.sipCallTo ?? '');
|
|
1045
|
+
if (!roomName)
|
|
1046
|
+
return twirpError(400, 'invalid_argument', 'roomName is required');
|
|
1047
|
+
if (!sipCallTo)
|
|
1048
|
+
return twirpError(400, 'invalid_argument', 'sipCallTo (number to dial) is required');
|
|
1049
|
+
// Dialing requires a known outbound trunk (unless a trunk config is inlined).
|
|
1050
|
+
const hasTrunk = trunkId
|
|
1051
|
+
? sipOutboundTrunks(req.root).some((t) => t.sipTrunkId === trunkId && !t.deleted)
|
|
1052
|
+
: (body.trunk && typeof body.trunk === 'object');
|
|
1053
|
+
if (!hasTrunk)
|
|
1054
|
+
return twirpError(404, 'not_found', 'sip outbound trunk not found');
|
|
1055
|
+
if (!requireRoom(roomName, req.root))
|
|
1056
|
+
await writeRoom(defaultRoom(roomName, occurredAt), occurredAt, req.root);
|
|
1057
|
+
const identity = String(body.participantIdentity ?? 'sip-participant');
|
|
1058
|
+
const sequence = sipParticipants(req.root).length + 1;
|
|
1059
|
+
const callId = sipCallId(roomName, sipCallTo, sequence);
|
|
1060
|
+
const row = {
|
|
1061
|
+
id: `sip_participant_${callId}`,
|
|
1062
|
+
participantId: sipParticipantSid(roomName, identity),
|
|
1063
|
+
participantIdentity: identity,
|
|
1064
|
+
roomName,
|
|
1065
|
+
sipCallId: callId,
|
|
1066
|
+
sipTrunkId: trunkId,
|
|
1067
|
+
sipCallTo,
|
|
1068
|
+
fromNumber: String(body.sipNumber ?? body.fromNumber ?? ''),
|
|
1069
|
+
};
|
|
1070
|
+
await writeResource('sip_participant', row.id, 'sip.participant.create', row, occurredAt, req.root);
|
|
1071
|
+
// The dialed SIP caller also appears as a room participant (kind=SIP).
|
|
1072
|
+
const roomParticipant = defaultParticipant(roomName, identity, occurredAt, {
|
|
1073
|
+
name: String(body.participantName ?? identity),
|
|
1074
|
+
kind: 3, // ParticipantInfo.Kind.SIP
|
|
1075
|
+
attributes: body.participantAttributes && typeof body.participantAttributes === 'object' ? body.participantAttributes : {},
|
|
1076
|
+
metadata: String(body.participantMetadata ?? ''),
|
|
1077
|
+
});
|
|
1078
|
+
await writeParticipant({ ...roomParticipant, sid: row.participantId }, occurredAt, req.root);
|
|
1079
|
+
return { status: 200, body: sipParticipantJson(row) };
|
|
1080
|
+
}
|
|
1081
|
+
if (matched.method === 'TransferSIPParticipant') {
|
|
1082
|
+
if (req.readOnly)
|
|
1083
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
1084
|
+
const roomName = String(body.roomName ?? '');
|
|
1085
|
+
const identity = String(body.participantIdentity ?? '');
|
|
1086
|
+
const transferTo = String(body.transferTo ?? '');
|
|
1087
|
+
if (!transferTo)
|
|
1088
|
+
return twirpError(400, 'invalid_argument', 'transferTo is required');
|
|
1089
|
+
const participant = requireParticipant(roomName, identity, req.root);
|
|
1090
|
+
if (!participant || participant.state === 3)
|
|
1091
|
+
return twirpError(404, 'not_found', 'sip participant not found');
|
|
1092
|
+
// Transfer is a media-plane operation; the control-plane twin records the
|
|
1093
|
+
// transfer destination on the participant attributes and acks (empty body).
|
|
1094
|
+
const attributes = { ...participant.attributes, 'sip.transferTo': transferTo };
|
|
1095
|
+
await writeParticipant({ ...participant, attributes, version: participant.version + 1 }, occurredAt, req.root);
|
|
1096
|
+
return { status: 200, body: {} };
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
// --- Agent dispatch (AgentDispatchService Twirp surface) ----------------
|
|
1100
|
+
if (matched.service === 'AgentDispatchService') {
|
|
1101
|
+
if (matched.method === 'CreateDispatch') {
|
|
1102
|
+
if (req.readOnly)
|
|
1103
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
1104
|
+
const room = String(body.room ?? '');
|
|
1105
|
+
const agentName = String(body.agentName ?? '');
|
|
1106
|
+
if (!room)
|
|
1107
|
+
return twirpError(400, 'invalid_argument', 'room is required');
|
|
1108
|
+
if (!agentName)
|
|
1109
|
+
return twirpError(400, 'invalid_argument', 'agentName is required');
|
|
1110
|
+
const sequence = agentDispatches(req.root).length + 1;
|
|
1111
|
+
const id = agentDispatchId(room, agentName, sequence);
|
|
1112
|
+
const row = {
|
|
1113
|
+
id: `agent_dispatch_${id}`,
|
|
1114
|
+
dispatchId: id,
|
|
1115
|
+
agentName,
|
|
1116
|
+
room,
|
|
1117
|
+
metadata: String(body.metadata ?? ''),
|
|
1118
|
+
createdAt: secondsOrZero(occurredAt),
|
|
1119
|
+
};
|
|
1120
|
+
await writeResource('agent_dispatch', row.id, 'agent.dispatch.create', row, occurredAt, req.root);
|
|
1121
|
+
return { status: 200, body: agentDispatchJson(row) };
|
|
1122
|
+
}
|
|
1123
|
+
if (matched.method === 'ListDispatch') {
|
|
1124
|
+
const room = body.room === undefined ? '' : String(body.room);
|
|
1125
|
+
const dispatchId = body.dispatchId === undefined ? '' : String(body.dispatchId);
|
|
1126
|
+
const items = agentDispatches(req.root).filter((a) => !a.deleted
|
|
1127
|
+
&& (room === '' || a.room === room)
|
|
1128
|
+
&& (dispatchId === '' || a.dispatchId === dispatchId));
|
|
1129
|
+
return { status: 200, body: { agentDispatches: items.map(agentDispatchJson) } };
|
|
1130
|
+
}
|
|
1131
|
+
if (matched.method === 'DeleteDispatch') {
|
|
1132
|
+
if (req.readOnly)
|
|
1133
|
+
return twirpError(405, 'permission_denied', 'LiveKit twin is read-only');
|
|
1134
|
+
const existing = agentDispatches(req.root).find((a) => a.dispatchId === String(body.dispatchId ?? '') && !a.deleted);
|
|
1135
|
+
if (!existing)
|
|
1136
|
+
return twirpError(404, 'not_found', 'agent dispatch not found');
|
|
1137
|
+
await writeResource('agent_dispatch', existing.id, 'agent.dispatch.delete', { ...existing, deleted: true }, occurredAt, req.root);
|
|
1138
|
+
return { status: 200, body: agentDispatchJson(existing) };
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
return twirpError(404, 'not_found', `LiveKit operation not modeled: ${matched.service}.${matched.method}`);
|
|
1142
|
+
}
|
|
1143
|
+
function collectStreamUrls(body) {
|
|
1144
|
+
const urls = [];
|
|
1145
|
+
const outputs = Array.isArray(body.streamOutputs) ? body.streamOutputs : [];
|
|
1146
|
+
for (const out of outputs) {
|
|
1147
|
+
if (out && typeof out === 'object' && Array.isArray(out.urls))
|
|
1148
|
+
urls.push(...out.urls.map(String));
|
|
1149
|
+
}
|
|
1150
|
+
if (body.stream && typeof body.stream === 'object' && Array.isArray(body.stream.urls))
|
|
1151
|
+
urls.push(...body.stream.urls.map(String));
|
|
1152
|
+
return urls;
|
|
1153
|
+
}
|
|
1154
|
+
function secondsOrZero(iso) {
|
|
1155
|
+
const value = Math.floor(new Date(iso).getTime() / 1000);
|
|
1156
|
+
return Number.isFinite(value) ? String(value) : '0';
|
|
1157
|
+
}
|
|
1158
|
+
function secondsNum(iso) {
|
|
1159
|
+
const value = Math.floor(new Date(iso).getTime() / 1000);
|
|
1160
|
+
return Number.isFinite(value) ? value : 0;
|
|
1161
|
+
}
|