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
@@ -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)
}
}