feat: enable group calls via gift wraps to each group member
Extends the NIP-AC P2P call signaling to support group calls by sending individual gift-wrapped offers to each member of the group, all sharing the same call-id. - CallOfferEvent: add multi-pubkey build overload, groupMembers(), isGroupCall(), and recipientPubKeys() helpers - WebRtcCallFactory: add GroupResult type, createGroupCallOffer(), createGroupHangup(), and createGroupReject() methods - CallState: change peerPubKey to peerPubKeys (Set<HexKey>) across Offering, Connecting, Connected, and Ended states; add groupMembers field to IncomingCall - CallManager: add initiateGroupCall() that creates a single signed offer with p tags for every callee and gift-wraps it individually; hangup sends to all peers in a group call - CallController: add initiateGroupCall() entry point - Tests: add group call offer tests for p tags, call-id, call-type, and expiration https://claude.ai/code/session_01WafqMofFQfWCQA5TcLvHRb
This commit is contained in:
+70
@@ -33,15 +33,24 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
|
||||
|
||||
class WebRtcCallFactory {
|
||||
/** Result for a single-recipient signaling message (P2P). */
|
||||
data class Result(
|
||||
val msg: Event,
|
||||
val wrap: GiftWrapEvent,
|
||||
)
|
||||
|
||||
/** Result for a signaling message gift-wrapped to multiple recipients (group calls). */
|
||||
data class GroupResult(
|
||||
val msg: Event,
|
||||
val wraps: List<GiftWrapEvent>,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val WRAP_EXPIRATION_SECONDS = 20L
|
||||
}
|
||||
|
||||
// ---- P2P (single recipient) methods ----
|
||||
|
||||
suspend fun createCallOffer(
|
||||
sdpOffer: String,
|
||||
calleePubKey: HexKey,
|
||||
@@ -114,4 +123,65 @@ class WebRtcCallFactory {
|
||||
val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
|
||||
return Result(signed, wrap)
|
||||
}
|
||||
|
||||
// ---- Group call methods (multiple recipients) ----
|
||||
|
||||
/**
|
||||
* Creates a call offer for a group call. The signed inner event contains
|
||||
* `p` tags for **every** callee so each recipient knows the full group.
|
||||
* A separate [GiftWrapEvent] is produced for each callee.
|
||||
*/
|
||||
suspend fun createGroupCallOffer(
|
||||
sdpOffer: String,
|
||||
calleePubKeys: Set<HexKey>,
|
||||
callId: String,
|
||||
callType: CallType,
|
||||
signer: NostrSigner,
|
||||
): GroupResult {
|
||||
val template = CallOfferEvent.build(sdpOffer, calleePubKeys, callId, callType)
|
||||
val signed = signer.sign(template)
|
||||
val wraps =
|
||||
calleePubKeys.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. Each peer receives its
|
||||
* own gift-wrapped hangup event.
|
||||
*/
|
||||
suspend fun createGroupHangup(
|
||||
peerPubKeys: Set<HexKey>,
|
||||
callId: String,
|
||||
reason: String = "",
|
||||
signer: NostrSigner,
|
||||
): GroupResult {
|
||||
val template = CallHangupEvent.build(peerPubKeys.first(), callId, reason)
|
||||
val signed = signer.sign(template)
|
||||
val wraps =
|
||||
peerPubKeys.map { pubKey ->
|
||||
GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
|
||||
}
|
||||
return GroupResult(signed, wraps)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a group call offer. Sends the rejection to the caller and
|
||||
* notifies self (for multi-device support).
|
||||
*/
|
||||
suspend fun createGroupReject(
|
||||
callerPubKey: HexKey,
|
||||
callId: String,
|
||||
reason: String = "",
|
||||
signer: NostrSigner,
|
||||
): GroupResult {
|
||||
val template = CallRejectEvent.build(callerPubKey, callId, reason)
|
||||
val signed = signer.sign(template)
|
||||
val wraps =
|
||||
listOf(callerPubKey, signer.pubKey).distinct().map { pubKey ->
|
||||
GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
|
||||
}
|
||||
return GroupResult(signed, wraps)
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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
|
||||
@@ -50,6 +52,18 @@ class CallOfferEvent(
|
||||
|
||||
fun sdpOffer() = content
|
||||
|
||||
/** All pubkeys referenced by `p` tags in this offer. */
|
||||
fun recipientPubKeys(): Set<HexKey> = tags.mapNotNull(PTag::parseKey).toSet()
|
||||
|
||||
/**
|
||||
* All group members for this call: the `p`-tagged recipients plus the
|
||||
* event author (caller). For 1-to-1 calls the set has two elements.
|
||||
*/
|
||||
fun groupMembers(): Set<HexKey> = recipientPubKeys().plus(pubKey)
|
||||
|
||||
/** True when this offer targets more than one callee. */
|
||||
fun isGroupCall(): Boolean = recipientPubKeys().size > 1
|
||||
|
||||
companion object {
|
||||
const val KIND = 25050
|
||||
const val ALT_DESCRIPTION = "WebRTC call offer"
|
||||
@@ -70,5 +84,21 @@ class CallOfferEvent(
|
||||
expiration(createdAt + EXPIRATION_SECONDS)
|
||||
initializer()
|
||||
}
|
||||
|
||||
fun build(
|
||||
sdpOffer: String,
|
||||
calleePubKeys: Set<HexKey>,
|
||||
callId: String,
|
||||
type: CallType,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<CallOfferEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, sdpOffer, createdAt) {
|
||||
alt(ALT_DESCRIPTION)
|
||||
pTagIds(calleePubKeys)
|
||||
callId(callId)
|
||||
callType(type)
|
||||
expiration(createdAt + EXPIRATION_SECONDS)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -188,4 +188,77 @@ class CallEventsTest {
|
||||
assertEquals("new-sdp-offer", template.content)
|
||||
assertEquals(CallRenegotiateEvent.KIND, template.kind)
|
||||
}
|
||||
|
||||
// ---- Group call offer tests ----
|
||||
|
||||
@Test
|
||||
fun groupCallOfferBuildIncludesAllPTags() {
|
||||
val callees = setOf("alice", "bob", "carol")
|
||||
val template =
|
||||
CallOfferEvent.build(
|
||||
sdpOffer = "group-sdp",
|
||||
calleePubKeys = callees,
|
||||
callId = "group-call-1",
|
||||
type = CallType.VIDEO,
|
||||
)
|
||||
val pTagValues =
|
||||
template.tags
|
||||
.filter { it[0] == "p" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
assertEquals(callees, pTagValues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupCallOfferBuildIncludesCallIdTag() {
|
||||
val template =
|
||||
CallOfferEvent.build(
|
||||
sdpOffer = "sdp",
|
||||
calleePubKeys = setOf("alice", "bob"),
|
||||
callId = "group-call-id",
|
||||
type = CallType.VOICE,
|
||||
)
|
||||
val callIdTag = template.tags.firstOrNull { it[0] == "call-id" }
|
||||
assertEquals("group-call-id", callIdTag?.get(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupCallOfferBuildIncludesCallTypeTag() {
|
||||
val template =
|
||||
CallOfferEvent.build(
|
||||
sdpOffer = "sdp",
|
||||
calleePubKeys = setOf("alice", "bob"),
|
||||
callId = "id",
|
||||
type = CallType.VIDEO,
|
||||
)
|
||||
val callTypeTag = template.tags.firstOrNull { it[0] == "call-type" }
|
||||
assertEquals("video", callTypeTag?.get(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupCallOfferBuildIncludesExpirationTag() {
|
||||
val template =
|
||||
CallOfferEvent.build(
|
||||
sdpOffer = "sdp",
|
||||
calleePubKeys = setOf("alice", "bob"),
|
||||
callId = "id",
|
||||
type = CallType.VOICE,
|
||||
createdAt = 2000L,
|
||||
)
|
||||
val expirationTag = template.tags.firstOrNull { it[0] == "expiration" }
|
||||
assertEquals((2000L + CallOfferEvent.EXPIRATION_SECONDS).toString(), expirationTag?.get(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleCalleeOfferIsNotGroupCall() {
|
||||
val template =
|
||||
CallOfferEvent.build(
|
||||
sdpOffer = "sdp",
|
||||
calleePubKey = "alice",
|
||||
callId = "id",
|
||||
type = CallType.VOICE,
|
||||
)
|
||||
val pTags = template.tags.filter { it[0] == "p" }
|
||||
assertEquals(1, pTags.size)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user