diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 6e1a7969f..cf0b5db76 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -137,27 +137,45 @@ 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) + if (current.groupMembers.size > 2) { + // Group call: include all members in p-tags, sign once, wrap for each. + // Include 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) } + } else { + val result = factory.createCallAnswer(sdpAnswer, current.callerPubKey, current.callId, signer) + 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) + } } 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) + if (current.groupMembers.size > 2) { + // Group call: include all members in p-tags, sign once, wrap for each. + // Include 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) } + } else { + val result = factory.createReject(current.callerPubKey, current.callId, signer = signer) + 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) + } } fun onCallAnswered(event: CallAnswerEvent) { @@ -283,16 +301,28 @@ class CallManager( suspend fun sendRenegotiation(sdpOffer: String) { val callId = currentCallId() ?: return - val peerPubKey = currentPeerPubKey() ?: return - val result = factory.createRenegotiate(sdpOffer, peerPubKey, callId, signer) - publishEvent(result.wrap) + val peerPubKeys = currentPeerPubKeys() ?: return + + if (peerPubKeys.size > 1) { + val result = factory.createGroupRenegotiate(sdpOffer, peerPubKeys, callId, signer) + result.wraps.forEach { publishEvent(it) } + } else { + val result = factory.createRenegotiate(sdpOffer, peerPubKeys.first(), callId, signer) + publishEvent(result.wrap) + } } suspend fun sendRenegotiationAnswer(sdpAnswer: String) { val callId = currentCallId() ?: return - val peerPubKey = currentPeerPubKey() ?: return - val result = factory.createCallAnswer(sdpAnswer, peerPubKey, callId, signer) - publishEvent(result.wrap) + val peerPubKeys = currentPeerPubKeys() ?: return + + if (peerPubKeys.size > 1) { + val result = factory.createGroupCallAnswer(sdpAnswer, peerPubKeys, callId, signer) + result.wraps.forEach { publishEvent(it) } + } else { + val result = factory.createCallAnswer(sdpAnswer, peerPubKeys.first(), callId, signer) + publishEvent(result.wrap) + } } fun onPeerConnected() { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md index 8d282c807..3fe368952 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md @@ -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,49 @@ 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 + +Because all group members are listed in the inner event's `p` tags, 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. + +### Group Call Offer + +The Call Offer (kind 25050) initiating a group call contains multiple `p` tags: + +```json +{ + "kind": 25050, + "pubkey": "", + "tags": [ + ["p", ""], + ["p", ""], + ["p", ""], + ["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). + ## Spam Prevention Clients SHOULD implement call filtering: diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt index 845d397ae..e31ccf0b4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt @@ -148,8 +148,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, + 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, @@ -157,33 +178,50 @@ 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, 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) + } + + /** + * Sends a renegotiation offer 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 createGroupRenegotiate( + sdpOffer: String, + memberPubKeys: Set, + callId: String, + signer: NostrSigner, + ): GroupResult { + val template = CallRenegotiateEvent.build(sdpOffer, 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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt index e36619b45..22f74615e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt @@ -26,6 +26,7 @@ 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.pTagIds import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag @@ -63,5 +64,19 @@ class CallAnswerEvent( expiration(createdAt + EXPIRATION_SECONDS) initializer() } + + fun build( + sdpAnswer: String, + memberPubKeys: Set, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpAnswer, createdAt) { + alt(ALT_DESCRIPTION) + pTagIds(memberPubKeys) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt index 3251cbc5f..e0a902301 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt @@ -26,6 +26,7 @@ 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.pTagIds import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag @@ -63,5 +64,19 @@ class CallHangupEvent( expiration(createdAt + EXPIRATION_SECONDS) initializer() } + + fun build( + memberPubKeys: Set, + callId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + pTagIds(memberPubKeys) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt index f4e4a62cd..a8ae74018 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt @@ -26,6 +26,7 @@ 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.pTagIds import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag @@ -63,5 +64,19 @@ class CallRejectEvent( expiration(createdAt + EXPIRATION_SECONDS) initializer() } + + fun build( + memberPubKeys: Set, + callId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + pTagIds(memberPubKeys) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt index d4df637ab..e9a25024c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt @@ -26,6 +26,7 @@ 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.pTagIds import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag @@ -63,5 +64,19 @@ class CallRenegotiateEvent( expiration(createdAt + EXPIRATION_SECONDS) initializer() } + + fun build( + sdpOffer: String, + memberPubKeys: Set, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpOffer, createdAt) { + alt(ALT_DESCRIPTION) + pTagIds(memberPubKeys) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt index 584f2a3c8..7f885612d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt @@ -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 =