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:
Claude
2026-04-02 22:13:48 +00:00
parent c1ef8792e9
commit f25c1df0e2
7 changed files with 280 additions and 42 deletions
@@ -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)
}
}
@@ -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()
}
}
}
@@ -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)
}
}