Move livekit auth to relay
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
---
|
||||
name: Voice Rooms Production
|
||||
overview: Implement production voice rooms by moving LiveKit token generation to the zooid relay (NIP-29 extension), making voice rooms conditional on group metadata tags, and updating flotilla to fetch tokens from the relay via NIP-98 authenticated HTTP requests.
|
||||
todos:
|
||||
- id: zooid-config
|
||||
content: "Zooid: Add [livekit] config section (server_url, api_key, api_secret) to config.go"
|
||||
status: pending
|
||||
- id: zooid-livekit-go
|
||||
content: "Zooid: Create livekit.go with token generation (livekit/protocol/auth), room creation (Twirp API), and response type"
|
||||
status: pending
|
||||
- id: zooid-endpoint
|
||||
content: "Zooid: Register GET /.well-known/nip29/livekit/{groupId} endpoint on instance router with NIP-98 auth, membership check, livekit tag check"
|
||||
status: pending
|
||||
- id: zooid-nip98-util
|
||||
content: "Zooid: Extract/reuse NIP-98 auth validation for the livekit endpoint (from api.go pattern)"
|
||||
status: pending
|
||||
- id: zooid-notext
|
||||
content: "Zooid: Add no-text enforcement in CheckWrite -- reject non-moderation events for groups with no-text tag"
|
||||
status: pending
|
||||
- id: zooid-test-cp1
|
||||
content: "Zooid: Test checkpoint 1+2 -- curl the token endpoint, verify livekit/no-text tag behavior"
|
||||
status: pending
|
||||
- id: flotilla-remove-poc
|
||||
content: "Flotilla: Remove jose dependency, VITE_LIVEKIT_* env vars, client-side JWT generation, live activity (kind:30311) publishing"
|
||||
status: completed
|
||||
- id: flotilla-nip98-fetch
|
||||
content: "Flotilla: Implement NIP-98 auth header construction and relay token HTTP fetch in voice.ts"
|
||||
status: completed
|
||||
- id: flotilla-update-join
|
||||
content: "Flotilla: Update joinVoiceRoom/leaveVoiceRoom to use relay-provided token and server_url, use hex pubkey identity"
|
||||
status: completed
|
||||
- id: flotilla-presence-fix
|
||||
content: "Flotilla: Simplify presence events to use h tag directly (drop a tag referencing live activity)"
|
||||
status: completed
|
||||
- id: flotilla-conditional-ui
|
||||
content: "Flotilla: Add roomHasLivekit/roomIsNoText helpers; update SpaceMenu to conditionally render voice/text rooms"
|
||||
status: completed
|
||||
- id: flotilla-test-e2e
|
||||
content: "Flotilla: End-to-end test -- join voice room via relay token, verify audio, verify conditional display"
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Voice Rooms Production Implementation
|
||||
|
||||
This plan implements the proposed [NIP-29 LiveKit extension](https://github.com/nostr-protocol/nips/pull/2238) across two repositories: **zooid** (relay) and **flotilla** (client). Changes are organized into checkpoints so each can be tested independently. Reference implementation: [pyramid commit 4ac5418](https://github.com/fiatjaf/pyramid/commit/4ac5418175c7ea7b53e81858aa69be6f8f56c691).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant F as Flotilla Client
|
||||
participant Z as Zooid Relay
|
||||
participant LK as LiveKit Server
|
||||
|
||||
F->>Z: Subscribe kind:39000 (room metadata)
|
||||
Z-->>F: Room with ["livekit"] tag
|
||||
|
||||
F->>F: Show voice room in sidebar
|
||||
|
||||
Note over F: User clicks voice room
|
||||
|
||||
F->>F: Build NIP-98 auth event (kind 27235)
|
||||
F->>Z: GET /.well-known/nip29/livekit/{groupId}<br/>Authorization: Nostr base64(event)
|
||||
Z->>Z: Verify signature, membership, livekit tag
|
||||
Z->>LK: Create room if needed (Twirp API)
|
||||
Z-->>F: { server_url, participant_token }
|
||||
F->>LK: Connect with JWT token
|
||||
F->>Z: Publish kind:10312 presence event
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zooid Changes (relay)
|
||||
|
||||
### Checkpoint 1: LiveKit config and token endpoint
|
||||
|
||||
**Goal**: Zooid serves LiveKit JWTs to authenticated group members.
|
||||
|
||||
**Config changes** ([zooid/config.go](zooid/config.go)):
|
||||
|
||||
- Add a `[livekit]` section to the TOML config struct:
|
||||
|
||||
```go
|
||||
Livekit struct {
|
||||
ServerURL string `toml:"server_url" json:"server_url"`
|
||||
APIKey string `toml:"api_key" json:"api_key"`
|
||||
APISecret string `toml:"api_secret" json:"api_secret"`
|
||||
} `toml:"livekit" json:"livekit"`
|
||||
|
||||
|
||||
```
|
||||
|
||||
**New file** `zooid/livekit.go`:
|
||||
|
||||
- `generateLivekitToken(apiKey, apiSecret, room string, pubkey nostr.PubKey) string` -- Uses `github.com/livekit/protocol/auth` to create a JWT with `RoomJoin` grant and `SetIdentity(pubkey.Hex())` (hex pubkey per the NIP).
|
||||
- `ensureLivekitRoom(apiKey, apiSecret, serverURL, roomName string)` -- Calls LiveKit's Twirp API to lazily create rooms. Cache known rooms in a `map[string]bool` with a mutex (same pattern as pyramid).
|
||||
- `TokenEndpointResponse` struct: `{ ServerURL string, ParticipantToken string }`.
|
||||
|
||||
**HTTP endpoint** ([zooid/instance.go](zooid/instance.go)):
|
||||
|
||||
- In the router setup (after the existing `/static/` handler), register:
|
||||
|
||||
```go
|
||||
router.HandleFunc("GET /.well-known/nip29/livekit/{groupId}", instance.livekitTokenHandler)
|
||||
|
||||
|
||||
```
|
||||
|
||||
- The handler (`livekitTokenHandler` on `*Instance`):
|
||||
1. Check `instance.Config.Livekit.APIKey` is configured (return 404 if not).
|
||||
2. Parse `{groupId}` from the path.
|
||||
3. Validate NIP-98 auth header: `Authorization: Nostr <base64-encoded-kind-27235-event>`. Reuse the same pattern from `api.go`'s `authenticateNIP98`. Verify: signature, kind == 27235, `u` tag matches the request URL, timestamp is recent.
|
||||
4. Check the group exists: call `instance.Groups.GetMetadata(groupId)`.
|
||||
5. Check the group's kind:39000 event has a `livekit` tag (use `HasTag(meta.Tags, "livekit")`).
|
||||
6. Check the user is a group member: `instance.Groups.HasAccess(groupId, pubkey)`.
|
||||
7. Ensure the LiveKit room exists.
|
||||
8. Generate a token and return JSON `{ "server_url": "...", "participant_token": "..." }`.
|
||||
|
||||
**NIP-98 validation**: Extract from `api.go`'s `authenticateNIP98` into a shared utility in `zooid/util.go` (or call it directly), since both the REST API and the livekit endpoint need it. The key difference: the API validates `method` tag too; the livekit endpoint should also validate method is "GET".
|
||||
|
||||
**Dependency**: Add `github.com/livekit/protocol` to `go.mod`.
|
||||
|
||||
**Test**: With a zooid instance running with `[livekit]` config and a group that has a `livekit` tag on its kind:39000 event:
|
||||
|
||||
```bash
|
||||
# Manually test with a NIP-98 auth header (use nak or a script to create the auth event)
|
||||
curl -H "Authorization: Nostr <base64-event>" \
|
||||
https://your-relay/.well-known/nip29/livekit/<group-id>
|
||||
# Expect: { "server_url": "wss://...", "participant_token": "eyJ..." }
|
||||
```
|
||||
|
||||
### Checkpoint 2: livekit and no-text tag support
|
||||
|
||||
**Goal**: Admins can set `livekit` and `no-text` tags on groups; `no-text` groups reject text events.
|
||||
|
||||
**Tag passthrough** -- Zooid's `UpdateMetadata` in [zooid/groups.go](zooid/groups.go) already copies all tags from edit events to the kind:39000 metadata event (converting `h` to `d`). The `livekit` and `no-text` tags will pass through naturally when an admin sends a `KindSimpleGroupEditMetadata` event containing them. No code change needed for this.
|
||||
|
||||
**no-text enforcement** -- In `CheckWrite` in [zooid/groups.go](zooid/groups.go), after existing access checks, add:
|
||||
|
||||
```go
|
||||
if HasTag(meta.Tags, "no-text") &&
|
||||
!slices.Contains(nip29.ModerationEventKinds, event.Kind) &&
|
||||
event.Kind != nostr.KindSimpleGroupJoinRequest &&
|
||||
event.Kind != nostr.KindSimpleGroupLeaveRequest {
|
||||
return "blocked: this group does not allow text events"
|
||||
}
|
||||
```
|
||||
|
||||
This mirrors pyramid's `reject-event.go` logic.
|
||||
|
||||
**Test**:
|
||||
|
||||
- Send a `KindSimpleGroupEditMetadata` event with `["livekit"]` and `["no-text"]` tags. Query kind:39000 and verify the tags appear.
|
||||
- Try sending a regular message to a `no-text` group; expect rejection.
|
||||
- Try the token endpoint for a group with vs without the `livekit` tag; expect 403 for non-livekit groups.
|
||||
|
||||
---
|
||||
|
||||
## Flotilla Changes (client)
|
||||
|
||||
### Checkpoint 3: NIP-98 token fetching from relay
|
||||
|
||||
**Goal**: Replace client-side JWT generation with relay HTTP request.
|
||||
|
||||
**Remove POC artifacts** from [src/app/voice.ts](src/app/voice.ts):
|
||||
|
||||
- Remove the `jose` import and the `generateToken` function.
|
||||
- Remove `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` constants.
|
||||
- Remove `LIVE_ACTIVITY` constant and `publishLiveActivity` function (dropping kind:30311).
|
||||
- Keep `ROOM_PRESENCE` and presence-related code (kind:10312).
|
||||
|
||||
**Remove env vars** from [.env.template](.env.template):
|
||||
|
||||
- Remove `VITE_LIVEKIT_URL`, `VITE_LIVEKIT_API_KEY`, `VITE_LIVEKIT_API_SECRET`.
|
||||
|
||||
**Remove dependency**: Run `pnpm remove jose`.
|
||||
|
||||
**Implement NIP-98 auth + token fetch** in [src/app/voice.ts](src/app/voice.ts):
|
||||
|
||||
```typescript
|
||||
const fetchLivekitToken = async (
|
||||
url: string,
|
||||
groupId: string,
|
||||
): Promise<{server_url: string; participant_token: string}> => {
|
||||
const httpUrl = url.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://")
|
||||
const endpoint = `${httpUrl}/.well-known/nip29/livekit/${groupId}`
|
||||
|
||||
const authEvent = await buildNip98AuthEvent(endpoint, "GET")
|
||||
const encoded = btoa(JSON.stringify(authEvent))
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {Authorization: `Nostr ${encoded}`},
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error(`Token request failed: ${response.status}`)
|
||||
return response.json()
|
||||
}
|
||||
```
|
||||
|
||||
**Build NIP-98 auth event**: Create a helper that uses the welshman signer to sign a kind 27235 event:
|
||||
|
||||
```typescript
|
||||
const buildNip98AuthEvent = async (url: string, method: string) => {
|
||||
const event = makeEvent(27235, {
|
||||
tags: [
|
||||
["u", url],
|
||||
["method", method],
|
||||
],
|
||||
})
|
||||
// Sign using welshman's signer
|
||||
return signEvent(event)
|
||||
}
|
||||
```
|
||||
|
||||
Look at how `publishThunk` signs events for the pattern. The signer is available via `@welshman/app`. The event must be a complete signed event (with id, pubkey, sig).
|
||||
|
||||
**Update `joinVoiceRoom`**:
|
||||
|
||||
- Replace `generateToken(roomName, identity)` with `fetchLivekitToken(url, h)`.
|
||||
- Use the returned `server_url` for `room.connect()` instead of the hardcoded `LIVEKIT_URL`.
|
||||
- Change identity from `nip19.npubEncode(pk)` to just `pk` (hex pubkey) since the relay now sets it in the JWT. The `nip19` import can be removed.
|
||||
- Remove the `publishLiveActivity` call; keep `startPresenceHeartbeat`.
|
||||
- Remove `makeLivekitRoomName` since the relay now determines the room name.
|
||||
|
||||
**Update `leaveVoiceRoom`**:
|
||||
|
||||
- Remove `publishLiveActivity(session.url, session.h, "ended")` call.
|
||||
|
||||
**Update sync** in [src/app/core/sync.ts](src/app/core/sync.ts):
|
||||
|
||||
- Remove `LIVE_ACTIVITY` from the import and the subscription filter. Change `[LIVE_ACTIVITY, ROOM_PRESENCE]` to just `[ROOM_PRESENCE]`.
|
||||
|
||||
**Update presence derivation** in [src/app/voice.ts](src/app/voice.ts):
|
||||
|
||||
- Remove the reference to `LIVE_ACTIVITY` in the `a` tag matching inside `deriveVoiceParticipants`. The presence event currently references an `a` tag containing the live activity address. Since we're dropping live activities, switch to using the `h` tag directly on the presence event:
|
||||
|
||||
```typescript
|
||||
const event = makeEvent(ROOM_PRESENCE, {
|
||||
tags: [["h", h]],
|
||||
})
|
||||
```
|
||||
|
||||
And in deriveVoiceParticipants, match on the `h` tag instead of the `a` tag.
|
||||
|
||||
**Test**: With zooid running the checkpoint 1+2 changes, and a group with the `livekit` tag:
|
||||
|
||||
- Click a voice room in flotilla.
|
||||
- Verify the NIP-98 HTTP request is sent to the relay.
|
||||
- Verify the token is received and the LiveKit connection succeeds.
|
||||
- Verify audio works between two clients.
|
||||
|
||||
### Checkpoint 4: Conditional voice room display
|
||||
|
||||
**Goal**: Only show voice rooms for groups with the `livekit` tag; show `no-text` groups as voice-only.
|
||||
|
||||
**Read tags from room metadata** -- The `Room` type in [src/app/core/state.ts](src/app/core/state.ts) extends `PublishedRoomMeta`, which has an `event: TrustedEvent` field. The raw kind:39000 event tags are accessible via `room.event.tags`. Check for the `livekit` tag:
|
||||
|
||||
```typescript
|
||||
export const roomHasLivekit = (room: Room) =>
|
||||
room.event?.tags?.some(t => t[0] === "livekit") ?? false
|
||||
|
||||
export const roomIsNoText = (room: Room) => room.event?.tags?.some(t => t[0] === "no-text") ?? false
|
||||
```
|
||||
|
||||
Add these helpers to [src/app/core/state.ts](src/app/core/state.ts).
|
||||
|
||||
**Update SpaceMenu** ([src/app/components/SpaceMenu.svelte](src/app/components/SpaceMenu.svelte)):
|
||||
|
||||
- Currently, every room gets a `<VoiceRoomItem>` unconditionally. Change this to only render `<VoiceRoomItem>` when the room has the `livekit` tag.
|
||||
- For `no-text` rooms, do NOT render `<SpaceMenuRoomItem>` (the text channel). Only render `<VoiceRoomItem>`.
|
||||
- This requires deriving the room metadata for each `h` value. Use `getRoom(makeRoomId(url, h))` to access the room data and check tags:
|
||||
|
||||
```svelte
|
||||
{#each $userRooms as h (h)}
|
||||
{@const room = getRoom(makeRoomId(url, h))}
|
||||
{#if !roomIsNoText(room)}
|
||||
<SpaceMenuRoomItem {replaceState} {url} {h} />
|
||||
{/if}
|
||||
{#if roomHasLivekit(room)}
|
||||
<VoiceRoomItem {url} {h} />
|
||||
{/if}
|
||||
{/each}
|
||||
```
|
||||
|
||||
Apply the same pattern to the `$roomSearch.searchValues(term)` loop.
|
||||
|
||||
**Test**:
|
||||
|
||||
- Create a group WITHOUT `livekit` tag -- verify no voice room appears.
|
||||
- Add `livekit` tag to a group -- verify voice room appears alongside text channel.
|
||||
- Create a group with both `livekit` and `no-text` tags -- verify only voice room appears (no text channel).
|
||||
|
||||
---
|
||||
|
||||
## Summary of changes by repo
|
||||
|
||||
### Zooid (relay)
|
||||
|
||||
| File | Change |
|
||||
| ------------------------ | -------------------------------------------------------- |
|
||||
| `zooid/config.go` | Add `[livekit]` config section |
|
||||
| `zooid/livekit.go` (new) | Token generation, room creation, response type |
|
||||
| `zooid/instance.go` | Register HTTP endpoint, add handler method |
|
||||
| `zooid/util.go` | Extract NIP-98 validation from api.go (or add alongside) |
|
||||
| `zooid/groups.go` | Add `no-text` enforcement in `CheckWrite` |
|
||||
| `go.mod` | Add `github.com/livekit/protocol` dependency |
|
||||
|
||||
### Flotilla (client)
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `src/app/voice.ts` | Replace client-side JWT with relay HTTP fetch; add NIP-98 auth; drop live activity; simplify presence |
|
||||
| `src/app/core/state.ts` | Add `roomHasLivekit()` and `roomIsNoText()` helpers |
|
||||
| `src/app/core/sync.ts` | Remove `LIVE_ACTIVITY` from sync filters |
|
||||
| `src/app/components/SpaceMenu.svelte` | Conditionally show voice/text rooms based on tags |
|
||||
| `.env.template` | Remove `VITE_LIVEKIT_` vars |
|
||||
| `package.json` | Remove `jose` dependency |
|
||||
@@ -18,8 +18,5 @@ VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
|
||||
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,nostr-01.uid.ovh,relay.keychat.io,relay.0xchat.com
|
||||
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
|
||||
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
|
||||
VITE_LIVEKIT_URL=
|
||||
VITE_LIVEKIT_API_KEY=
|
||||
VITE_LIVEKIT_API_SECRET=
|
||||
VITE_GLITCHTIP_API_KEY=
|
||||
GLITCHTIP_AUTH_TOKEN=
|
||||
|
||||
@@ -83,7 +83,6 @@
|
||||
"fuse.js": "^7.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"idb": "^8.0.3",
|
||||
"jose": "^6.1.3",
|
||||
"livekit-client": "^2.17.2",
|
||||
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
|
||||
"nostr-tools": "^2.19.4",
|
||||
|
||||
Generated
-3
@@ -134,9 +134,6 @@ importers:
|
||||
idb:
|
||||
specifier: ^8.0.3
|
||||
version: 8.0.3
|
||||
jose:
|
||||
specifier: ^6.1.3
|
||||
version: 6.1.3
|
||||
livekit-client:
|
||||
specifier: ^2.17.2
|
||||
version: 2.17.2(@types/dom-mediacapture-record@1.0.22)
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
notificationSettings,
|
||||
deriveShouldNotify,
|
||||
displayRoom,
|
||||
roomHasLivekit,
|
||||
roomIsNoText,
|
||||
} from "@app/core/state"
|
||||
import {setSpaceNotifications} from "@app/core/commands"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
@@ -257,9 +259,13 @@
|
||||
<div class="h-2"></div>
|
||||
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
|
||||
{/if}
|
||||
{#each $userRooms as h, i (h)}
|
||||
<SpaceMenuRoomItem notify {replaceState} {url} {h} />
|
||||
<VoiceRoomItem {url} {h} />
|
||||
{#each $userRooms as h (h)}
|
||||
{#if !roomIsNoText(url, h)}
|
||||
<SpaceMenuRoomItem notify {replaceState} {url} {h} />
|
||||
{/if}
|
||||
{#if roomHasLivekit(url, h)}
|
||||
<VoiceRoomItem {url} {h} />
|
||||
{/if}
|
||||
{/each}
|
||||
{#if $otherRooms.length > 0}
|
||||
<div class="h-2"></div>
|
||||
@@ -277,9 +283,13 @@
|
||||
<input bind:value={term} onblur={clearTerm} class="grow" />
|
||||
</label>
|
||||
{/if}
|
||||
{#each $roomSearch.searchValues(term) as h, i (h)}
|
||||
<SpaceMenuRoomItem {replaceState} {url} {h} />
|
||||
<VoiceRoomItem {url} {h} />
|
||||
{#each $roomSearch.searchValues(term) as h (h)}
|
||||
{#if !roomIsNoText(url, h)}
|
||||
<SpaceMenuRoomItem {replaceState} {url} {h} />
|
||||
{/if}
|
||||
{#if roomHasLivekit(url, h)}
|
||||
<VoiceRoomItem {url} {h} />
|
||||
{/if}
|
||||
{/each}
|
||||
{#if $canCreateRoom}
|
||||
<SecondaryNavItem {replaceState} onclick={addRoom}>
|
||||
|
||||
@@ -663,6 +663,16 @@ export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h
|
||||
|
||||
export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase()
|
||||
|
||||
export const roomHasLivekit = (url: string, h: string) => {
|
||||
const room = getRoom(makeRoomId(url, h))
|
||||
return room?.event?.tags?.some(t => t[0] === "livekit") ?? false
|
||||
}
|
||||
|
||||
export const roomIsNoText = (url: string, h: string) => {
|
||||
const room = getRoom(makeRoomId(url, h))
|
||||
return room?.event?.tags?.some(t => t[0] === "no-text") ?? false
|
||||
}
|
||||
|
||||
// User space/room lists
|
||||
|
||||
export const groupListsByPubkey = deriveItemsByKey({
|
||||
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
loadFeedsForPubkey,
|
||||
} from "@app/core/state"
|
||||
import {hasBlossomSupport} from "@app/core/commands"
|
||||
import {LIVE_ACTIVITY, ROOM_PRESENCE} from "@app/voice"
|
||||
import {ROOM_PRESENCE} from "@app/voice"
|
||||
|
||||
// Utils
|
||||
|
||||
@@ -320,7 +320,7 @@ const syncSpace = (url: string, rooms: string[]) => {
|
||||
pullAndListen({
|
||||
url,
|
||||
signal: controller.signal,
|
||||
filters: [{kinds: [LIVE_ACTIVITY, ROOM_PRESENCE]}],
|
||||
filters: [{kinds: [ROOM_PRESENCE]}],
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
|
||||
+38
-67
@@ -1,18 +1,12 @@
|
||||
import {derived, get, writable} from "svelte/store"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {SignJWT} from "jose"
|
||||
import {Room, RoomEvent, Track} from "livekit-client"
|
||||
import {now} from "@welshman/lib"
|
||||
import {makeEvent, normalizeRelayUrl, getTag} from "@welshman/util"
|
||||
import {pubkey, publishThunk} from "@welshman/app"
|
||||
import {deriveEventsForUrl, displayRoom} from "@app/core/state"
|
||||
import {makeEvent, getTagValue} from "@welshman/util"
|
||||
import {signer, publishThunk} from "@welshman/app"
|
||||
import {deriveEventsForUrl} from "@app/core/state"
|
||||
|
||||
export const LIVE_ACTIVITY = 30311
|
||||
export const ROOM_PRESENCE = 10312
|
||||
|
||||
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || ""
|
||||
const LIVEKIT_API_KEY = import.meta.env.VITE_LIVEKIT_API_KEY || ""
|
||||
const LIVEKIT_API_SECRET = import.meta.env.VITE_LIVEKIT_API_SECRET || ""
|
||||
const PRESENCE_INTERVAL_MS = 60_000
|
||||
const PRESENCE_EXPIRY_S = 300
|
||||
|
||||
@@ -25,23 +19,40 @@ export type VoiceSession = {
|
||||
|
||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||
|
||||
const makeLivekitRoomName = (url: string, h: string) =>
|
||||
`${normalizeRelayUrl(url)}:${h}`.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
const buildNip98AuthEvent = async (url: string, method: string) => {
|
||||
const $signer = signer.get()
|
||||
if (!$signer) throw new Error("No signer available")
|
||||
|
||||
const generateToken = async (roomName: string, identity: string) => {
|
||||
const secret = new TextEncoder().encode(LIVEKIT_API_SECRET)
|
||||
const jwt = await new SignJWT({
|
||||
video: {roomJoin: true, room: roomName, canPublish: true, canSubscribe: true},
|
||||
sub: identity,
|
||||
iss: LIVEKIT_API_KEY,
|
||||
jti: identity,
|
||||
const event = makeEvent(27235, {
|
||||
tags: [
|
||||
["u", url],
|
||||
["method", method],
|
||||
],
|
||||
})
|
||||
.setProtectedHeader({alg: "HS256"})
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("6h")
|
||||
.sign(secret)
|
||||
|
||||
return jwt
|
||||
return $signer.sign(event)
|
||||
}
|
||||
|
||||
const fetchLivekitToken = async (
|
||||
url: string,
|
||||
groupId: string,
|
||||
): Promise<{server_url: string; participant_token: string}> => {
|
||||
const httpUrl = url.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://")
|
||||
const endpoint = `${httpUrl}/.well-known/nip29/livekit/${groupId}`
|
||||
|
||||
const authEvent = await buildNip98AuthEvent(endpoint, "GET")
|
||||
const encoded = btoa(JSON.stringify(authEvent))
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {Authorization: `Nostr ${encoded}`},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(`Token request failed (${response.status}): ${text}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const deriveVoiceParticipants = (url: string, h: string) =>
|
||||
@@ -52,11 +63,7 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
|
||||
for (const event of $events) {
|
||||
if (event.created_at < cutoff) continue
|
||||
|
||||
const aTag = getTag("a", event.tags)
|
||||
if (!aTag) continue
|
||||
|
||||
const [, , dTag] = aTag[1].split(":")
|
||||
if (dTag === h) {
|
||||
if (getTagValue("h", event.tags) === h) {
|
||||
pubkeys.push(event.pubkey)
|
||||
}
|
||||
}
|
||||
@@ -64,29 +71,9 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
|
||||
return pubkeys
|
||||
})
|
||||
|
||||
const publishLiveActivity = (url: string, h: string, status: "live" | "ended") => {
|
||||
const pk = get(pubkey)!
|
||||
const title = displayRoom(url, h)
|
||||
const event = makeEvent(LIVE_ACTIVITY, {
|
||||
tags: [
|
||||
["d", h],
|
||||
["h", h],
|
||||
["title", title],
|
||||
["service", LIVEKIT_URL],
|
||||
["status", status],
|
||||
["starts", String(now())],
|
||||
["p", pk, "", "Host"],
|
||||
],
|
||||
})
|
||||
|
||||
return publishThunk({event, relays: [url]})
|
||||
}
|
||||
|
||||
const publishPresence = (url: string, h: string) => {
|
||||
const pk = get(pubkey)!
|
||||
const aTag = `${LIVE_ACTIVITY}:${pk}:${h}`
|
||||
const event = makeEvent(ROOM_PRESENCE, {
|
||||
tags: [["a", aTag, url, "root"]],
|
||||
tags: [["h", h]],
|
||||
})
|
||||
|
||||
return publishThunk({event, relays: [url]})
|
||||
@@ -121,10 +108,7 @@ export const joinVoiceRoom = async (url: string, h: string) => {
|
||||
await leaveVoiceRoom()
|
||||
}
|
||||
|
||||
const pk = get(pubkey)!
|
||||
const identity = nip19.npubEncode(pk)
|
||||
const roomName = makeLivekitRoomName(url, h)
|
||||
const token = await generateToken(roomName, identity)
|
||||
const {server_url, participant_token} = await fetchLivekitToken(url, h)
|
||||
|
||||
const room = new Room({
|
||||
adaptiveStream: true,
|
||||
@@ -149,12 +133,11 @@ export const joinVoiceRoom = async (url: string, h: string) => {
|
||||
track.detach().forEach(el => el.remove())
|
||||
})
|
||||
|
||||
await room.connect(LIVEKIT_URL, token)
|
||||
await room.connect(server_url, participant_token)
|
||||
await room.localParticipant.setMicrophoneEnabled(true)
|
||||
|
||||
currentVoiceSession.set({url, h, room, muted: false})
|
||||
|
||||
publishLiveActivity(url, h, "live")
|
||||
startPresenceHeartbeat(url, h)
|
||||
}
|
||||
|
||||
@@ -165,7 +148,6 @@ export const leaveVoiceRoom = async () => {
|
||||
stopPresenceHeartbeat()
|
||||
session.room.disconnect()
|
||||
deletePresence(session.url)
|
||||
publishLiveActivity(session.url, session.h, "ended")
|
||||
currentVoiceSession.set(undefined)
|
||||
}
|
||||
|
||||
@@ -177,14 +159,3 @@ export const toggleMute = () => {
|
||||
session.room.localParticipant.setMicrophoneEnabled(!muted)
|
||||
currentVoiceSession.set({...session, muted})
|
||||
}
|
||||
|
||||
export const toggleDeafen = () => {
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
|
||||
for (const participant of session.room.remoteParticipants.values()) {
|
||||
for (const pub of participant.audioTrackPublications.values()) {
|
||||
pub.setEnabled(!pub.isEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user