From e389115217261a7b1f58a7cf249f8e6605f35870 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Thu, 7 Nov 2024 22:22:53 -0600 Subject: [PATCH] Support for room configuration inside tokens (#324) * Support for room configuration inside tokens * changeset * add dispatch APIs via AgentDispatch client * some updates * docs * some docs changes --- .changeset/wicked-shrimps-brake.md | 5 + examples/agent-dispatch/index.ts | 54 ++++++++ examples/agent-dispatch/package.json | 23 ++++ examples/agent-dispatch/tsconfig.json | 11 ++ examples/receive-audio/package.json | 1 + examples/receive-audio/tsconfig.json | 11 ++ packages/livekit-server-sdk/package.json | 2 +- .../src/AccessToken.test.ts | 52 ++++++++ .../livekit-server-sdk/src/AccessToken.ts | 25 ++++ .../src/AgentDispatchClient.ts | 125 ++++++++++++++++++ .../livekit-server-sdk/src/EgressClient.ts | 2 +- .../livekit-server-sdk/src/IngressClient.ts | 2 +- .../src/RoomServiceClient.ts | 2 +- .../livekit-server-sdk/src/ServiceBase.ts | 4 +- packages/livekit-server-sdk/src/SipClient.ts | 2 +- packages/livekit-server-sdk/src/grants.ts | 3 + packages/livekit-server-sdk/src/index.ts | 1 + pnpm-lock.yaml | 29 +++- 18 files changed, 343 insertions(+), 11 deletions(-) create mode 100644 .changeset/wicked-shrimps-brake.md create mode 100644 examples/agent-dispatch/index.ts create mode 100644 examples/agent-dispatch/package.json create mode 100644 examples/agent-dispatch/tsconfig.json create mode 100644 examples/receive-audio/tsconfig.json create mode 100644 packages/livekit-server-sdk/src/AgentDispatchClient.ts diff --git a/.changeset/wicked-shrimps-brake.md b/.changeset/wicked-shrimps-brake.md new file mode 100644 index 00000000..9824b33d --- /dev/null +++ b/.changeset/wicked-shrimps-brake.md @@ -0,0 +1,5 @@ +--- +'livekit-server-sdk': minor +--- + +Explicit agent dispatch via API and token diff --git a/examples/agent-dispatch/index.ts b/examples/agent-dispatch/index.ts new file mode 100644 index 00000000..93998aeb --- /dev/null +++ b/examples/agent-dispatch/index.ts @@ -0,0 +1,54 @@ +import { RoomAgentDispatch, RoomConfiguration } from '@livekit/protocol'; +import { AccessToken, AgentDispatchClient } from 'livekit-server-sdk'; + +const roomName = 'my-room'; +const agentName = 'test-agent'; + +/** + * This example demonstrates how to have an agent join a room without using + * the automatic dispatch. + * In order to use this feature, you must have an agent running with `agentName` set + * when defining your WorkerOptions. + * + * A dispatch requests the agent to enter a specific room with optional metadata. + */ +async function createExplicitDispatch() { + const agentDispatchClient = new AgentDispatchClient(process.env.LIVEKIT_URL); + + // this will create invoke an agent with agentName: test-agent to join `my-room` + const dispatch = await agentDispatchClient.createDispatch(roomName, agentName, { + metadata: '{"mydata": "myvalue"}', + }); + console.log('created dispatch', dispatch); + + const dispatches = await agentDispatchClient.listDispatches(roomName); + console.log(`there are ${dispatches.length} dispatches in ${roomName}`); +} + +/** + * When agent name is set, the agent will no longer be automatically dispatched + * to new rooms. If you want that agent to be dispatched to a new room as soon as + * the participant connects, you can set the roomConfig with the agent + * definition in the access token. + */ +async function createTokenWithAgentDispatch(): Promise { + const at = new AccessToken(); + at.identity = 'my-participant'; + at.addGrant({ roomJoin: true, room: roomName }); + at.roomConfig = new RoomConfiguration({ + agents: [ + new RoomAgentDispatch({ + agentName: agentName, + metadata: '{"mydata": "myvalue"}', + }), + ], + }); + return await at.toJwt(); +} + +createTokenWithAgentDispatch().then((token) => { + console.log('created participant token', token); +}); + +console.log('creating explicit dispatch'); +createExplicitDispatch(); diff --git a/examples/agent-dispatch/package.json b/examples/agent-dispatch/package.json new file mode 100644 index 00000000..438d6a8e --- /dev/null +++ b/examples/agent-dispatch/package.json @@ -0,0 +1,23 @@ +{ + "name": "agent-dispatch", + "version": "0.0.1", + "description": "An example demonstrating dispatching agents with an API call", + "private": "true", + "author": "LiveKit", + "license": "Apache-2.0", + "type": "module", + "main": "index.ts", + "scripts": { + "build": "tsc --incremental", + "lint": "eslint -f unix \"**/*.ts\"" + }, + "dependencies": { + "dotenv": "^16.4.5", + "livekit-server-sdk": "workspace:*", + "@livekit/protocol": "^1.27.1" + }, + "devDependencies": { + "@types/node": "^20.10.4", + "tsx": "^4.7.1" + } +} diff --git a/examples/agent-dispatch/tsconfig.json b/examples/agent-dispatch/tsconfig.json new file mode 100644 index 00000000..740f5c77 --- /dev/null +++ b/examples/agent-dispatch/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "ES2022", + "esModuleInterop": true, + "target": "es2022", + "moduleResolution": "node", + "sourceMap": true, + "outDir": "dist" + }, + "lib": ["es2022"] +} diff --git a/examples/receive-audio/package.json b/examples/receive-audio/package.json index b8680e3e..cdce53d3 100644 --- a/examples/receive-audio/package.json +++ b/examples/receive-audio/package.json @@ -6,6 +6,7 @@ "type": "module", "main": "index.ts", "scripts": { + "build": "tsc --incremental", "lint": "eslint -f unix \"**/*.ts\"" }, "keywords": [], diff --git a/examples/receive-audio/tsconfig.json b/examples/receive-audio/tsconfig.json new file mode 100644 index 00000000..740f5c77 --- /dev/null +++ b/examples/receive-audio/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "ES2022", + "esModuleInterop": true, + "target": "es2022", + "moduleResolution": "node", + "sourceMap": true, + "outDir": "dist" + }, + "lib": ["es2022"] +} diff --git a/packages/livekit-server-sdk/package.json b/packages/livekit-server-sdk/package.json index d772e761..fb9a7b80 100644 --- a/packages/livekit-server-sdk/package.json +++ b/packages/livekit-server-sdk/package.json @@ -26,7 +26,7 @@ "test:edge": "vitest --environment edge-runtime run" }, "dependencies": { - "@livekit/protocol": "^1.27.0", + "@livekit/protocol": "^1.27.1", "camelcase-keys": "^9.0.0", "jose": "^5.1.2" }, diff --git a/packages/livekit-server-sdk/src/AccessToken.test.ts b/packages/livekit-server-sdk/src/AccessToken.test.ts index 102fae20..ba1e353f 100644 --- a/packages/livekit-server-sdk/src/AccessToken.test.ts +++ b/packages/livekit-server-sdk/src/AccessToken.test.ts @@ -1,6 +1,12 @@ // SPDX-FileCopyrightText: 2024 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 +import { + RoomAgentDispatch, + RoomCompositeEgressRequest, + RoomConfiguration, + RoomEgress, +} from '@livekit/protocol'; import * as jose from 'jose'; import { describe, expect, it } from 'vitest'; import { AccessToken, TokenVerifier } from './AccessToken'; @@ -98,3 +104,49 @@ describe('adding grants should not overwrite existing grants', () => { expect(payload.video?.roomJoin).toBeTruthy(); }); }); + +describe('room configuration with agents and egress', () => { + it('should set agents and egress in room configuration', async () => { + const t = new AccessToken(testApiKey, testSecret, { + identity: 'test-identity', + }); + + const roomConfig = new RoomConfiguration({ + name: 'test-room', + maxParticipants: 10, + }); + + const agents: RoomAgentDispatch[] = [ + new RoomAgentDispatch({ + agentName: 'agent1', + metadata: 'metadata-1', + }), + new RoomAgentDispatch({ + agentName: 'agent2', + metadata: 'metadata-2', + }), + ]; + + const egress = new RoomEgress({ + room: new RoomCompositeEgressRequest({ roomName: 'test-room' }), + }); + + roomConfig.agents = agents; + roomConfig.egress = egress; + + t.roomConfig = roomConfig; + + const v = new TokenVerifier(testApiKey, testSecret); + const decoded = await v.verify(await t.toJwt()); + + expect(decoded.roomConfig).toBeDefined(); + expect(decoded.roomConfig?.name).toEqual('test-room'); + expect(decoded.roomConfig?.maxParticipants).toEqual(10); + expect(decoded.roomConfig?.agents).toHaveLength(2); + expect(decoded.roomConfig?.agents?.[0].agentName).toEqual('agent1'); + expect(decoded.roomConfig?.agents?.[0].metadata).toEqual('metadata-1'); + expect(decoded.roomConfig?.agents?.[1].agentName).toEqual('agent2'); + expect(decoded.roomConfig?.agents?.[1].metadata).toEqual('metadata-2'); + expect(decoded.roomConfig?.egress?.room?.roomName).toEqual('test-room'); + }); +}); diff --git a/packages/livekit-server-sdk/src/AccessToken.ts b/packages/livekit-server-sdk/src/AccessToken.ts index e338df4b..3dab9f9d 100644 --- a/packages/livekit-server-sdk/src/AccessToken.ts +++ b/packages/livekit-server-sdk/src/AccessToken.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2024 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 +import type { RoomConfiguration } from '@livekit/protocol'; import * as jose from 'jose'; import type { ClaimGrants, SIPGrant, VideoGrant } from './grants.js'; import { claimsToJwtPayload } from './grants.js'; @@ -30,6 +31,11 @@ export interface AccessTokenOptions { * custom metadata to be passed to participants */ metadata?: string; + + /** + * custom attributes to be passed to participants + */ + attributes?: Record; } export class AccessToken { @@ -76,6 +82,9 @@ export class AccessToken { if (options?.metadata) { this.metadata = options.metadata; } + if (options?.attributes) { + this.attributes = options.attributes; + } if (options?.name) { this.name = options.name; } @@ -140,6 +149,22 @@ export class AccessToken { this.grants.sha256 = sha; } + get roomPreset(): string | undefined { + return this.grants.roomPreset; + } + + set roomPreset(preset: string | undefined) { + this.grants.roomPreset = preset; + } + + get roomConfig(): RoomConfiguration | undefined { + return this.grants.roomConfig; + } + + set roomConfig(config: RoomConfiguration | undefined) { + this.grants.roomConfig = config; + } + /** * @returns JWT encoded token */ diff --git a/packages/livekit-server-sdk/src/AgentDispatchClient.ts b/packages/livekit-server-sdk/src/AgentDispatchClient.ts new file mode 100644 index 00000000..7a31dca1 --- /dev/null +++ b/packages/livekit-server-sdk/src/AgentDispatchClient.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2024 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +import { + AgentDispatch, + CreateAgentDispatchRequest, + DeleteAgentDispatchRequest, + ListAgentDispatchRequest, + ListAgentDispatchResponse, +} from '@livekit/protocol'; +import ServiceBase from './ServiceBase'; +import { type Rpc, TwirpRpc, livekitPackage } from './TwirpRPC'; + +interface CreateDispatchOptions { + // any custom data to send along with the job. + // note: this is different from room and participant metadata + metadata?: string; +} + +const svc = 'AgentDispatchService'; + +/** + * Client to access Agent APIs + */ +export class AgentDispatchClient extends ServiceBase { + private readonly rpc: Rpc; + + /** + * @param host - hostname including protocol. i.e. 'https://.livekit.cloud' + * @param apiKey - API Key, can be set in env var LIVEKIT_API_KEY + * @param secret - API Secret, can be set in env var LIVEKIT_API_SECRET + */ + constructor(host: string, apiKey?: string, secret?: string) { + super(apiKey, secret); + this.rpc = new TwirpRpc(host, livekitPackage); + } + + /** + * Create an explicit dispatch for an agent to join a room. To use explicit + * dispatch, your agent must be registered with an `agentName`. + * @param roomName - name of the room to dispatch to + * @param agentName - name of the agent to dispatch + * @param options - optional metadata to send along with the dispatch + * @returns the dispatch that was created + */ + async createDispatch( + roomName: string, + agentName: string, + options?: CreateDispatchOptions, + ): Promise { + const req = new CreateAgentDispatchRequest({ + room: roomName, + agentName, + metadata: options?.metadata, + }).toJson(); + const data = await this.rpc.request( + svc, + 'CreateDispatch', + req, + await this.authHeader({ roomAdmin: true, room: roomName }), + ); + return AgentDispatch.fromJson(data, { ignoreUnknownFields: true }); + } + + /** + * Delete an explicit dispatch for an agent in a room. + * @param dispatchId - id of the dispatch to delete + * @param roomName - name of the room the dispatch is for + */ + async deleteDispatch(dispatchId: string, roomName: string): Promise { + const req = new DeleteAgentDispatchRequest({ + dispatchId, + room: roomName, + }).toJson(); + await this.rpc.request( + svc, + 'DeleteDispatch', + req, + await this.authHeader({ roomAdmin: true, room: roomName }), + ); + } + + /** + * Get an Agent dispatch by ID + * @param dispatchId - id of the dispatch to get + * @param roomName - name of the room the dispatch is for + * @returns the dispatch that was found, or undefined if not found + */ + async getDispatch(dispatchId: string, roomName: string): Promise { + const req = new ListAgentDispatchRequest({ + dispatchId, + room: roomName, + }).toJson(); + const data = await this.rpc.request( + svc, + 'ListDispatch', + req, + await this.authHeader({ roomAdmin: true, room: roomName }), + ); + const res = ListAgentDispatchResponse.fromJson(data, { ignoreUnknownFields: true }); + if (res.agentDispatches.length === 0) { + return undefined; + } + return res.agentDispatches[0]; + } + + /** + * List all agent dispatches for a room + * @param roomName - name of the room to list dispatches for + * @returns the list of dispatches + */ + async listDispatch(roomName: string): Promise { + const req = new ListAgentDispatchRequest({ + room: roomName, + }).toJson(); + const data = await this.rpc.request( + svc, + 'ListDispatch', + req, + await this.authHeader({ roomAdmin: true, room: roomName }), + ); + const res = ListAgentDispatchResponse.fromJson(data, { ignoreUnknownFields: true }); + return res.agentDispatches; + } +} diff --git a/packages/livekit-server-sdk/src/EgressClient.ts b/packages/livekit-server-sdk/src/EgressClient.ts index 819ca314..71d09849 100644 --- a/packages/livekit-server-sdk/src/EgressClient.ts +++ b/packages/livekit-server-sdk/src/EgressClient.ts @@ -121,7 +121,7 @@ export class EgressClient extends ServiceBase { private readonly rpc: Rpc; /** - * @param host - hostname including protocol. i.e. 'https://cluster.livekit.io' + * @param host - hostname including protocol. i.e. 'https://.livekit.cloud' * @param apiKey - API Key, can be set in env var LIVEKIT_API_KEY * @param secret - API Secret, can be set in env var LIVEKIT_API_SECRET */ diff --git a/packages/livekit-server-sdk/src/IngressClient.ts b/packages/livekit-server-sdk/src/IngressClient.ts index dd8933fd..b0f411a7 100644 --- a/packages/livekit-server-sdk/src/IngressClient.ts +++ b/packages/livekit-server-sdk/src/IngressClient.ts @@ -121,7 +121,7 @@ export class IngressClient extends ServiceBase { private readonly rpc: Rpc; /** - * @param host - hostname including protocol. i.e. 'https://cluster.livekit.io' + * @param host - hostname including protocol. i.e. 'https://.livekit.cloud' * @param apiKey - API Key, can be set in env var LIVEKIT_API_KEY * @param secret - API Secret, can be set in env var LIVEKIT_API_SECRET */ diff --git a/packages/livekit-server-sdk/src/RoomServiceClient.ts b/packages/livekit-server-sdk/src/RoomServiceClient.ts index f7b82c55..fe616bfb 100644 --- a/packages/livekit-server-sdk/src/RoomServiceClient.ts +++ b/packages/livekit-server-sdk/src/RoomServiceClient.ts @@ -109,7 +109,7 @@ export class RoomServiceClient extends ServiceBase { /** * - * @param host - hostname including protocol. i.e. 'https://cluster.livekit.io' + * @param host - hostname including protocol. i.e. 'https://.livekit.cloud' * @param apiKey - API Key, can be set in env var LIVEKIT_API_KEY * @param secret - API Secret, can be set in env var LIVEKIT_API_SECRET */ diff --git a/packages/livekit-server-sdk/src/ServiceBase.ts b/packages/livekit-server-sdk/src/ServiceBase.ts index 99e8931d..9503239b 100644 --- a/packages/livekit-server-sdk/src/ServiceBase.ts +++ b/packages/livekit-server-sdk/src/ServiceBase.ts @@ -27,7 +27,9 @@ export default class ServiceBase { async authHeader(grant: VideoGrant, sip?: SIPGrant): Promise> { const at = new AccessToken(this.apiKey, this.secret, { ttl: this.ttl }); - at.addGrant(grant); + if (grant) { + at.addGrant(grant); + } if (sip) { at.addSIPGrant(sip); } diff --git a/packages/livekit-server-sdk/src/SipClient.ts b/packages/livekit-server-sdk/src/SipClient.ts index 98dd8e0a..0056c973 100644 --- a/packages/livekit-server-sdk/src/SipClient.ts +++ b/packages/livekit-server-sdk/src/SipClient.ts @@ -103,7 +103,7 @@ export class SipClient extends ServiceBase { private readonly rpc: Rpc; /** - * @param host - hostname including protocol. i.e. 'https://cluster.livekit.io' + * @param host - hostname including protocol. i.e. 'https://.livekit.cloud' * @param apiKey - API Key, can be set in env var LIVEKIT_API_KEY * @param secret - API Secret, can be set in env var LIVEKIT_API_SECRET */ diff --git a/packages/livekit-server-sdk/src/grants.ts b/packages/livekit-server-sdk/src/grants.ts index b1ccf9f7..a04bcfac 100644 --- a/packages/livekit-server-sdk/src/grants.ts +++ b/packages/livekit-server-sdk/src/grants.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2024 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 +import type { RoomConfiguration } from '@livekit/protocol'; import { TrackSource } from '@livekit/protocol'; import type { JWTPayload } from 'jose'; @@ -108,4 +109,6 @@ export interface ClaimGrants extends JWTPayload { metadata?: string; attributes?: Record; sha256?: string; + roomPreset?: string; + roomConfig?: RoomConfiguration; } diff --git a/packages/livekit-server-sdk/src/index.ts b/packages/livekit-server-sdk/src/index.ts index e0a06595..6054e298 100644 --- a/packages/livekit-server-sdk/src/index.ts +++ b/packages/livekit-server-sdk/src/index.ts @@ -51,6 +51,7 @@ export { WebEgressRequest, } from '@livekit/protocol'; export * from './AccessToken.js'; +export * from './AgentDispatchClient.js'; export * from './EgressClient.js'; export * from './grants.js'; export * from './IngressClient.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd565a5a..20e7bb1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,25 @@ importers: specifier: ^2.0.0 version: 2.1.2(@edge-runtime/vm@4.0.3)(@types/node@20.16.11)(happy-dom@15.7.4) + examples/agent-dispatch: + dependencies: + '@livekit/protocol': + specifier: ^1.27.1 + version: 1.27.1 + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + livekit-server-sdk: + specifier: workspace:* + version: link:../../packages/livekit-server-sdk + devDependencies: + '@types/node': + specifier: ^20.10.4 + version: 20.16.11 + tsx: + specifier: ^4.7.1 + version: 4.17.0 + examples/publish-wav: dependencies: '@livekit/rtc-node': @@ -220,8 +239,8 @@ importers: packages/livekit-server-sdk: dependencies: '@livekit/protocol': - specifier: ^1.27.0 - version: 1.27.0 + specifier: ^1.27.1 + version: 1.27.1 camelcase-keys: specifier: ^9.0.0 version: 9.1.3 @@ -759,8 +778,8 @@ packages: '@livekit/mutex@1.0.0': resolution: {integrity: sha512-aiUhoThBNF9UyGTxEURFzJLhhPLIVTnQiEVMjRhPnfHNKLfo2JY9xovHKIus7B78UD5hsP6DlgpmAsjrz4U0Iw==} - '@livekit/protocol@1.27.0': - resolution: {integrity: sha512-jVb4zljNaYKoLiL5MBjGiO1+QKVsxMqXT/c0dwcKUW7NCLjAZXucoQVV1Y79FCbKwVnOCOtI6wwteEntbfk/Qw==} + '@livekit/protocol@1.27.1': + resolution: {integrity: sha512-ISEp7uWdV82mtCR1eyHFTzdRZTVbe2+ZztjmjiMPzR/KPrI1Ma/u5kLh87NNuY3Rn8wv1VlEvGHHsFjQ+dKVUw==} '@livekit/typed-emitter@3.0.0': resolution: {integrity: sha512-9bl0k4MgBPZu3Qu3R3xy12rmbW17e3bE9yf4YY85gJIQ3ezLEj/uzpKHWBsLaDoL5Mozz8QCgggwIBudYQWeQg==} @@ -3573,7 +3592,7 @@ snapshots: '@livekit/mutex@1.0.0': {} - '@livekit/protocol@1.27.0': + '@livekit/protocol@1.27.1': dependencies: '@bufbuild/protobuf': 1.10.0