Merge pull request #2091 from vitorpamplona/claude/add-members-to-ptags-O8lvL
Add group call support with multi-member p-tags and per-peer SDP
This commit is contained in:
@@ -362,13 +362,14 @@ class CallController(
|
||||
private fun onRenegotiationOfferReceived(event: CallRenegotiateEvent) {
|
||||
val session = webRtcSession ?: return
|
||||
val sdpOffer = event.sdpOffer()
|
||||
val peerPubKey = event.pubKey
|
||||
Log.d(TAG) { "Renegotiation offer received, SDP length=${sdpOffer.length}" }
|
||||
|
||||
scope.launch {
|
||||
session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
|
||||
session.createAnswer { sdp ->
|
||||
scope.launch {
|
||||
callManager.sendRenegotiationAnswer(sdp.description)
|
||||
callManager.sendRenegotiationAnswer(sdp.description, peerPubKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,11 +379,12 @@ class CallController(
|
||||
val session = webRtcSession ?: return
|
||||
val state = callManager.state.value
|
||||
if (state !is CallState.Connected && state !is CallState.Connecting) return
|
||||
val peerPubKey = callManager.currentPeerPubKey() ?: return
|
||||
|
||||
Log.d(TAG) { "Starting renegotiation" }
|
||||
session.createOffer { sdp ->
|
||||
scope.launch {
|
||||
callManager.sendRenegotiation(sdp.description)
|
||||
callManager.sendRenegotiation(sdp.description, peerPubKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -50,6 +50,7 @@ fun ChatroomScreen(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isGroupChat = roomId.users.size > 1
|
||||
val isCallSupported = roomId.users.size <= 5
|
||||
val startVoiceCall =
|
||||
rememberCallWithPermission(context) {
|
||||
ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
|
||||
@@ -80,8 +81,8 @@ fun ChatroomScreen(
|
||||
room = roomId,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onCallClick = { _ -> startVoiceCall() },
|
||||
onVideoCallClick = { _ -> startVideoCall() },
|
||||
onCallClick = if (isCallSupported) ({ _ -> startVoiceCall() }) else null,
|
||||
onVideoCallClick = if (isCallSupported) ({ _ -> startVideoCall() }) else null,
|
||||
)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
+43
-25
@@ -137,27 +137,25 @@ class CallManager(
|
||||
val current = _state.value
|
||||
if (current !is CallState.IncomingCall) return
|
||||
|
||||
val result = factory.createCallAnswer(sdpAnswer, current.callerPubKey, current.callId, signer)
|
||||
_state.value = CallState.Connecting(current.callId, current.peerPubKeys(), current.callType)
|
||||
cancelTimeout()
|
||||
publishEvent(result.wrap)
|
||||
|
||||
// Notify other devices of this user that the call was answered here.
|
||||
val selfNotify = factory.createCallAnswer(sdpAnswer, signer.pubKey, current.callId, signer)
|
||||
publishEvent(selfNotify.wrap)
|
||||
// Include all group members + self so other devices get notified too.
|
||||
val allRecipients = current.groupMembers + signer.pubKey
|
||||
val result = factory.createGroupCallAnswer(sdpAnswer, allRecipients, current.callId, signer)
|
||||
result.wraps.forEach { publishEvent(it) }
|
||||
}
|
||||
|
||||
suspend fun rejectCall() {
|
||||
val current = _state.value
|
||||
if (current !is CallState.IncomingCall) return
|
||||
|
||||
val result = factory.createReject(current.callerPubKey, current.callId, signer = signer)
|
||||
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED)
|
||||
publishEvent(result.wrap)
|
||||
|
||||
// Notify other devices of this user that the call was rejected here.
|
||||
val selfNotify = factory.createReject(signer.pubKey, current.callId, signer = signer)
|
||||
publishEvent(selfNotify.wrap)
|
||||
// Include all group members + self so other devices get notified too.
|
||||
val allRecipients = current.groupMembers + signer.pubKey
|
||||
val result = factory.createGroupReject(allRecipients, current.callId, signer = signer)
|
||||
result.wraps.forEach { publishEvent(it) }
|
||||
}
|
||||
|
||||
fun onCallAnswered(event: CallAnswerEvent) {
|
||||
@@ -281,17 +279,33 @@ class CallManager(
|
||||
onRenegotiationOfferReceived?.invoke(event)
|
||||
}
|
||||
|
||||
suspend fun sendRenegotiation(sdpOffer: String) {
|
||||
/**
|
||||
* Sends a renegotiation offer to a specific peer. SDP is per-PeerConnection
|
||||
* so this is always addressed to a single peer. In group calls the inner
|
||||
* event includes `p` tags for all members for group context.
|
||||
*/
|
||||
suspend fun sendRenegotiation(
|
||||
sdpOffer: String,
|
||||
peerPubKey: HexKey,
|
||||
) {
|
||||
val callId = currentCallId() ?: return
|
||||
val peerPubKey = currentPeerPubKey() ?: return
|
||||
val result = factory.createRenegotiate(sdpOffer, peerPubKey, callId, signer)
|
||||
val peerPubKeys = currentPeerPubKeys() ?: return
|
||||
val result = factory.createRenegotiate(sdpOffer, peerPubKey, peerPubKeys, callId, signer)
|
||||
publishEvent(result.wrap)
|
||||
}
|
||||
|
||||
suspend fun sendRenegotiationAnswer(sdpAnswer: String) {
|
||||
/**
|
||||
* Sends a renegotiation answer to a specific peer. SDP is per-PeerConnection
|
||||
* so this is always addressed to a single peer. The inner event includes
|
||||
* `p` tags for all members for group context.
|
||||
*/
|
||||
suspend fun sendRenegotiationAnswer(
|
||||
sdpAnswer: String,
|
||||
peerPubKey: HexKey,
|
||||
) {
|
||||
val callId = currentCallId() ?: return
|
||||
val peerPubKey = currentPeerPubKey() ?: return
|
||||
val result = factory.createCallAnswer(sdpAnswer, peerPubKey, callId, signer)
|
||||
val peerPubKeys = currentPeerPubKeys() ?: return
|
||||
val result = factory.createCallAnswer(sdpAnswer, peerPubKey, peerPubKeys, callId, signer)
|
||||
publishEvent(result.wrap)
|
||||
}
|
||||
|
||||
@@ -309,7 +323,11 @@ class CallManager(
|
||||
)
|
||||
}
|
||||
|
||||
/** Invites a new peer into the current call by sending them an offer. */
|
||||
/**
|
||||
* Invites a new peer into the current call by sending them an offer.
|
||||
* The inner event includes `p` tags for all existing group members plus
|
||||
* the new invitee so they can see the full group composition.
|
||||
*/
|
||||
suspend fun invitePeer(
|
||||
peerPubKey: HexKey,
|
||||
sdpOffer: String,
|
||||
@@ -317,16 +335,19 @@ class CallManager(
|
||||
val current = _state.value
|
||||
val callId: String
|
||||
val callType: CallType
|
||||
val existingMembers: Set<HexKey>
|
||||
when (current) {
|
||||
is CallState.Connecting -> {
|
||||
callId = current.callId
|
||||
callType = current.callType
|
||||
existingMembers = current.peerPubKeys + current.pendingPeerPubKeys
|
||||
_state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys + peerPubKey)
|
||||
}
|
||||
|
||||
is CallState.Connected -> {
|
||||
callId = current.callId
|
||||
callType = current.callType
|
||||
existingMembers = current.allPeerPubKeys
|
||||
_state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys + peerPubKey)
|
||||
}
|
||||
|
||||
@@ -335,7 +356,9 @@ class CallManager(
|
||||
}
|
||||
}
|
||||
|
||||
val result = factory.createCallOffer(sdpOffer, peerPubKey, callId, callType, signer)
|
||||
// All group members: existing peers + the new invitee + ourselves
|
||||
val allMembers = existingMembers + peerPubKey + signer.pubKey
|
||||
val result = factory.createCallOffer(sdpOffer, peerPubKey, allMembers, callId, callType, signer)
|
||||
publishEvent(result.wrap)
|
||||
}
|
||||
|
||||
@@ -363,13 +386,8 @@ class CallManager(
|
||||
}
|
||||
}
|
||||
|
||||
if (peerPubKeys.size == 1) {
|
||||
val result = factory.createHangup(peerPubKeys.first(), callId, signer = signer)
|
||||
publishEvent(result.wrap)
|
||||
} else {
|
||||
val result = factory.createGroupHangup(peerPubKeys, callId, signer = signer)
|
||||
result.wraps.forEach { publishEvent(it) }
|
||||
}
|
||||
val result = factory.createGroupHangup(peerPubKeys, callId, signer = signer)
|
||||
result.wraps.forEach { publishEvent(it) }
|
||||
transitionToEnded(callId, peerPubKeys, EndReason.HANGUP)
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ All signaling events MUST include:
|
||||
|
||||
| Tag | Description | Required |
|
||||
|---------------|-------------------------------------------------------|----------|
|
||||
| `p` | Hex pubkey of the recipient | YES |
|
||||
| `p` | Hex pubkey of the recipient (group calls: one per member) | YES |
|
||||
| `call-id` | UUID identifying the call session | YES |
|
||||
| `expiration` | Unix timestamp ([NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md)), SHOULD be `created_at + 20` seconds | YES |
|
||||
| `alt` | Human-readable description ([NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md)) | YES |
|
||||
@@ -251,6 +251,65 @@ Either party may send a `CallHangup` (kind 25053) at any time. The recipient SHO
|
||||
|
||||
The callee may send a `CallReject` (kind 25054) instead of a `CallAnswer`. The caller SHOULD stop ringing and display a "call rejected" state.
|
||||
|
||||
## Group Calls
|
||||
|
||||
Group calls (calls with more than two participants) use the same event kinds but differ in how `p` tags and gift wraps are structured.
|
||||
|
||||
### P-Tag Convention
|
||||
|
||||
In a group call, all signaling events (except ICE candidates, kind 25052) MUST include a `p` tag for **every** group member. This allows each recipient to know the full group composition from any signaling event.
|
||||
|
||||
ICE candidates (kind 25052) remain addressed to a single peer because WebRTC connections are peer-to-peer — each ICE candidate is relevant only to the specific connection it belongs to.
|
||||
|
||||
### Sign Once, Wrap Per Recipient
|
||||
|
||||
For events whose content is identical for all recipients (hangup, reject), the event is **signed once** and then gift-wrapped individually for each recipient:
|
||||
|
||||
1. **Build** the signaling event with `p` tags for all group members
|
||||
2. **Sign** the event once with the sender's key
|
||||
3. **Gift-wrap** the same signed event separately for each member (each wrap encrypted to that member's pubkey)
|
||||
4. **Publish** each gift wrap to the corresponding member's relay list
|
||||
|
||||
This is more efficient than signing a separate event per recipient and ensures cryptographic consistency — every member receives the exact same signed inner event.
|
||||
|
||||
### Per-Peer SDP with Group P-Tags
|
||||
|
||||
Events carrying SDP payloads (offer, answer, renegotiate) contain session descriptions that are specific to a single `PeerConnection`. In a full-mesh group call, each participant maintains a separate `PeerConnection` per peer, so SDP content differs per connection.
|
||||
|
||||
For these events, the inner event still includes `p` tags for **all** group members (so any recipient can see the full group), but:
|
||||
|
||||
1. **Build** the event with `p` tags for all group members and the per-peer SDP content
|
||||
2. **Sign** the event (signed per peer, since the SDP content differs)
|
||||
3. **Gift-wrap** and send **only to the specific peer** the SDP is intended for
|
||||
|
||||
This means offer, answer, and renegotiate events in group calls are signed per-peer but still carry the full group membership in their `p` tags.
|
||||
|
||||
### Group Call Offer
|
||||
|
||||
The Call Offer (kind 25050) initiating a group call contains multiple `p` tags:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 25050,
|
||||
"pubkey": "<caller-hex-pubkey>",
|
||||
"tags": [
|
||||
["p", "<callee-1-hex-pubkey>"],
|
||||
["p", "<callee-2-hex-pubkey>"],
|
||||
["p", "<callee-3-hex-pubkey>"],
|
||||
["call-id", "550e8400-e29b-41d4-a716-446655440000"],
|
||||
["call-type", "video"],
|
||||
["expiration", "1234567910"],
|
||||
["alt", "WebRTC call offer"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Recipients detect a group call by the presence of multiple `p` tags. The full group is the union of all `p`-tagged pubkeys plus the event's `pubkey` (the caller).
|
||||
|
||||
### Inviting New Peers
|
||||
|
||||
To invite a new peer into an active group call, send a Call Offer (kind 25050) with `p` tags listing **all** existing group members plus the new invitee. This allows the invitee to immediately see the full group composition. The SDP in the offer is specific to the new PeerConnection being established, so the wrap is addressed only to the invitee.
|
||||
|
||||
## Spam Prevention
|
||||
|
||||
Clients SHOULD implement call filtering:
|
||||
@@ -294,6 +353,8 @@ When a user is logged in on multiple devices, all devices will receive and ring
|
||||
|
||||
These self-notification events use the same `call-id` as the original call and follow the same gift-wrapping rules. Clients receiving a self-addressed answer or reject MUST verify the `call-id` matches the currently ringing call before acting on it.
|
||||
|
||||
**Group calls**: In group calls, the sender's own pubkey SHOULD be included in the set of recipients when gift-wrapping answer and reject events. This means the self-notification is implicit — no separate self-addressed event is needed. The same signed inner event (with all group member `p` tags) is simply wrapped to the sender's own pubkey along with all other members.
|
||||
|
||||
### Audio and Media
|
||||
|
||||
- Clients SHOULD switch `AudioManager` to `MODE_IN_COMMUNICATION` when a call connects and restore to `MODE_NORMAL` when the call ends.
|
||||
|
||||
+90
-14
@@ -64,6 +64,26 @@ class WebRtcCallFactory {
|
||||
return Result(signed, wrap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer with group context. The inner event includes `p` tags for
|
||||
* **all** group members so the invitee knows the full group, but the
|
||||
* SDP is specific to one [PeerConnection][org.webrtc.PeerConnection]
|
||||
* so the wrap is addressed only to [calleePubKey].
|
||||
*/
|
||||
suspend fun createCallOffer(
|
||||
sdpOffer: String,
|
||||
calleePubKey: HexKey,
|
||||
memberPubKeys: Set<HexKey>,
|
||||
callId: String,
|
||||
callType: CallType,
|
||||
signer: NostrSigner,
|
||||
): Result {
|
||||
val template = CallOfferEvent.build(sdpOffer, memberPubKeys, callId, callType)
|
||||
val signed = signer.sign(template)
|
||||
val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = calleePubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
|
||||
return Result(signed, wrap)
|
||||
}
|
||||
|
||||
suspend fun createCallAnswer(
|
||||
sdpAnswer: String,
|
||||
callerPubKey: HexKey,
|
||||
@@ -76,6 +96,25 @@ class WebRtcCallFactory {
|
||||
return Result(signed, wrap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer with group context. The inner event includes `p` tags for
|
||||
* **all** group members, but the SDP is specific to one
|
||||
* [PeerConnection][org.webrtc.PeerConnection] so the wrap is addressed
|
||||
* only to [peerPubKey].
|
||||
*/
|
||||
suspend fun createCallAnswer(
|
||||
sdpAnswer: String,
|
||||
peerPubKey: HexKey,
|
||||
memberPubKeys: Set<HexKey>,
|
||||
callId: String,
|
||||
signer: NostrSigner,
|
||||
): Result {
|
||||
val template = CallAnswerEvent.build(sdpAnswer, memberPubKeys, callId)
|
||||
val signed = signer.sign(template)
|
||||
val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
|
||||
return Result(signed, wrap)
|
||||
}
|
||||
|
||||
suspend fun createIceCandidate(
|
||||
candidateJson: String,
|
||||
peerPubKey: HexKey,
|
||||
@@ -124,6 +163,25 @@ class WebRtcCallFactory {
|
||||
return Result(signed, wrap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renegotiation with group context. The inner event includes `p` tags
|
||||
* for **all** group members, but the SDP is specific to one
|
||||
* [PeerConnection][org.webrtc.PeerConnection] so the wrap is addressed
|
||||
* only to [peerPubKey].
|
||||
*/
|
||||
suspend fun createRenegotiate(
|
||||
sdpOffer: String,
|
||||
peerPubKey: HexKey,
|
||||
memberPubKeys: Set<HexKey>,
|
||||
callId: String,
|
||||
signer: NostrSigner,
|
||||
): Result {
|
||||
val template = CallRenegotiateEvent.build(sdpOffer, memberPubKeys, callId)
|
||||
val signed = signer.sign(template)
|
||||
val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
|
||||
return Result(signed, wrap)
|
||||
}
|
||||
|
||||
// ---- Group call methods (multiple recipients) ----
|
||||
|
||||
/**
|
||||
@@ -148,8 +206,29 @@ class WebRtcCallFactory {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a hangup to every peer in a group call. Each peer receives its
|
||||
* own gift-wrapped hangup event.
|
||||
* Creates a call answer for a group call. The signed inner event contains
|
||||
* `p` tags for **every** member so each recipient knows the full group.
|
||||
* A separate [GiftWrapEvent] is produced for each member.
|
||||
*/
|
||||
suspend fun createGroupCallAnswer(
|
||||
sdpAnswer: String,
|
||||
memberPubKeys: Set<HexKey>,
|
||||
callId: String,
|
||||
signer: NostrSigner,
|
||||
): GroupResult {
|
||||
val template = CallAnswerEvent.build(sdpAnswer, memberPubKeys, callId)
|
||||
val signed = signer.sign(template)
|
||||
val wraps =
|
||||
memberPubKeys.map { pubKey ->
|
||||
GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
|
||||
}
|
||||
return GroupResult(signed, wraps)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a hangup to every peer in a group call. The signed inner event
|
||||
* contains `p` tags for **every** member so each recipient knows the
|
||||
* full group. A separate [GiftWrapEvent] is produced for each member.
|
||||
*/
|
||||
suspend fun createGroupHangup(
|
||||
peerPubKeys: Set<HexKey>,
|
||||
@@ -157,33 +236,30 @@ class WebRtcCallFactory {
|
||||
reason: String = "",
|
||||
signer: NostrSigner,
|
||||
): GroupResult {
|
||||
// Each peer gets its own signed hangup with the correct `p` tag
|
||||
// targeting that specific recipient.
|
||||
var firstSigned: Event? = null
|
||||
val template = CallHangupEvent.build(peerPubKeys, callId, reason)
|
||||
val signed = signer.sign(template)
|
||||
val wraps =
|
||||
peerPubKeys.map { pubKey ->
|
||||
val template = CallHangupEvent.build(pubKey, callId, reason)
|
||||
val signed = signer.sign(template)
|
||||
if (firstSigned == null) firstSigned = signed
|
||||
GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
|
||||
}
|
||||
return GroupResult(firstSigned!!, wraps)
|
||||
return GroupResult(signed, wraps)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a group call offer. Sends the rejection to the caller and
|
||||
* notifies self (for multi-device support).
|
||||
* Rejects a group call offer. The signed inner event contains `p` tags
|
||||
* for **every** member so each recipient knows the full group.
|
||||
* A separate [GiftWrapEvent] is produced for each member.
|
||||
*/
|
||||
suspend fun createGroupReject(
|
||||
callerPubKey: HexKey,
|
||||
memberPubKeys: Set<HexKey>,
|
||||
callId: String,
|
||||
reason: String = "",
|
||||
signer: NostrSigner,
|
||||
): GroupResult {
|
||||
val template = CallRejectEvent.build(callerPubKey, callId, reason)
|
||||
val template = CallRejectEvent.build(memberPubKeys, callId, reason)
|
||||
val signed = signer.sign(template)
|
||||
val wraps =
|
||||
listOf(callerPubKey, signer.pubKey).distinct().map { pubKey ->
|
||||
memberPubKeys.map { pubKey ->
|
||||
GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
|
||||
}
|
||||
return GroupResult(signed, wraps)
|
||||
|
||||
+22
@@ -25,7 +25,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTagIds
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
|
||||
@@ -45,6 +47,12 @@ class CallAnswerEvent(
|
||||
|
||||
fun sdpAnswer() = content
|
||||
|
||||
/** All pubkeys referenced by `p` tags in this event. */
|
||||
fun recipientPubKeys(): Set<HexKey> = tags.mapNotNull(PTag::parseKey).toSet()
|
||||
|
||||
/** All group members: `p`-tagged pubkeys plus the event author. */
|
||||
fun groupMembers(): Set<HexKey> = recipientPubKeys().plus(pubKey)
|
||||
|
||||
companion object {
|
||||
const val KIND = 25051
|
||||
const val ALT_DESCRIPTION = "WebRTC call answer"
|
||||
@@ -63,5 +71,19 @@ class CallAnswerEvent(
|
||||
expiration(createdAt + EXPIRATION_SECONDS)
|
||||
initializer()
|
||||
}
|
||||
|
||||
fun build(
|
||||
sdpAnswer: String,
|
||||
memberPubKeys: Set<HexKey>,
|
||||
callId: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<CallAnswerEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, sdpAnswer, createdAt) {
|
||||
alt(ALT_DESCRIPTION)
|
||||
pTagIds(memberPubKeys)
|
||||
callId(callId)
|
||||
expiration(createdAt + EXPIRATION_SECONDS)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -25,7 +25,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTagIds
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
|
||||
@@ -45,6 +47,12 @@ class CallHangupEvent(
|
||||
|
||||
fun reason() = content.ifEmpty { null }
|
||||
|
||||
/** All pubkeys referenced by `p` tags in this event. */
|
||||
fun recipientPubKeys(): Set<HexKey> = tags.mapNotNull(PTag::parseKey).toSet()
|
||||
|
||||
/** All group members: `p`-tagged pubkeys plus the event author. */
|
||||
fun groupMembers(): Set<HexKey> = recipientPubKeys().plus(pubKey)
|
||||
|
||||
companion object {
|
||||
const val KIND = 25053
|
||||
const val ALT_DESCRIPTION = "WebRTC call hangup"
|
||||
@@ -63,5 +71,19 @@ class CallHangupEvent(
|
||||
expiration(createdAt + EXPIRATION_SECONDS)
|
||||
initializer()
|
||||
}
|
||||
|
||||
fun build(
|
||||
memberPubKeys: Set<HexKey>,
|
||||
callId: String,
|
||||
reason: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<CallHangupEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, reason, createdAt) {
|
||||
alt(ALT_DESCRIPTION)
|
||||
pTagIds(memberPubKeys)
|
||||
callId(callId)
|
||||
expiration(createdAt + EXPIRATION_SECONDS)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -25,7 +25,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTagIds
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
|
||||
@@ -45,6 +47,12 @@ class CallRejectEvent(
|
||||
|
||||
fun reason() = content.ifEmpty { null }
|
||||
|
||||
/** All pubkeys referenced by `p` tags in this event. */
|
||||
fun recipientPubKeys(): Set<HexKey> = tags.mapNotNull(PTag::parseKey).toSet()
|
||||
|
||||
/** All group members: `p`-tagged pubkeys plus the event author. */
|
||||
fun groupMembers(): Set<HexKey> = recipientPubKeys().plus(pubKey)
|
||||
|
||||
companion object {
|
||||
const val KIND = 25054
|
||||
const val ALT_DESCRIPTION = "WebRTC call rejection"
|
||||
@@ -63,5 +71,19 @@ class CallRejectEvent(
|
||||
expiration(createdAt + EXPIRATION_SECONDS)
|
||||
initializer()
|
||||
}
|
||||
|
||||
fun build(
|
||||
memberPubKeys: Set<HexKey>,
|
||||
callId: String,
|
||||
reason: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<CallRejectEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, reason, createdAt) {
|
||||
alt(ALT_DESCRIPTION)
|
||||
pTagIds(memberPubKeys)
|
||||
callId(callId)
|
||||
expiration(createdAt + EXPIRATION_SECONDS)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -25,7 +25,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTagIds
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
|
||||
@@ -45,6 +47,12 @@ class CallRenegotiateEvent(
|
||||
|
||||
fun sdpOffer() = content
|
||||
|
||||
/** All pubkeys referenced by `p` tags in this event. */
|
||||
fun recipientPubKeys(): Set<HexKey> = tags.mapNotNull(PTag::parseKey).toSet()
|
||||
|
||||
/** All group members: `p`-tagged pubkeys plus the event author. */
|
||||
fun groupMembers(): Set<HexKey> = recipientPubKeys().plus(pubKey)
|
||||
|
||||
companion object {
|
||||
const val KIND = 25055
|
||||
const val ALT_DESCRIPTION = "WebRTC call renegotiation"
|
||||
@@ -63,5 +71,19 @@ class CallRenegotiateEvent(
|
||||
expiration(createdAt + EXPIRATION_SECONDS)
|
||||
initializer()
|
||||
}
|
||||
|
||||
fun build(
|
||||
sdpOffer: String,
|
||||
memberPubKeys: Set<HexKey>,
|
||||
callId: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<CallRenegotiateEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, sdpOffer, createdAt) {
|
||||
alt(ALT_DESCRIPTION)
|
||||
pTagIds(memberPubKeys)
|
||||
callId(callId)
|
||||
expiration(createdAt + EXPIRATION_SECONDS)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+66
@@ -249,6 +249,72 @@ class CallEventsTest {
|
||||
assertEquals((2000L + CallOfferEvent.EXPIRATION_SECONDS).toString(), expirationTag?.get(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupCallAnswerBuildIncludesAllPTags() {
|
||||
val members = setOf("alice", "bob", "carol")
|
||||
val template =
|
||||
CallAnswerEvent.build(
|
||||
sdpAnswer = "answer-sdp",
|
||||
memberPubKeys = members,
|
||||
callId = "group-call-1",
|
||||
)
|
||||
val pTagValues =
|
||||
template.tags
|
||||
.filter { it[0] == "p" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
assertEquals(members, pTagValues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupCallHangupBuildIncludesAllPTags() {
|
||||
val members = setOf("alice", "bob", "carol")
|
||||
val template =
|
||||
CallHangupEvent.build(
|
||||
memberPubKeys = members,
|
||||
callId = "group-call-1",
|
||||
)
|
||||
val pTagValues =
|
||||
template.tags
|
||||
.filter { it[0] == "p" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
assertEquals(members, pTagValues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupCallRejectBuildIncludesAllPTags() {
|
||||
val members = setOf("alice", "bob", "carol")
|
||||
val template =
|
||||
CallRejectEvent.build(
|
||||
memberPubKeys = members,
|
||||
callId = "group-call-1",
|
||||
)
|
||||
val pTagValues =
|
||||
template.tags
|
||||
.filter { it[0] == "p" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
assertEquals(members, pTagValues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupCallRenegotiateBuildIncludesAllPTags() {
|
||||
val members = setOf("alice", "bob", "carol")
|
||||
val template =
|
||||
CallRenegotiateEvent.build(
|
||||
sdpOffer = "new-sdp-offer",
|
||||
memberPubKeys = members,
|
||||
callId = "group-call-1",
|
||||
)
|
||||
val pTagValues =
|
||||
template.tags
|
||||
.filter { it[0] == "p" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
assertEquals(members, pTagValues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleCalleeOfferIsNotGroupCall() {
|
||||
val template =
|
||||
|
||||
Reference in New Issue
Block a user