feat: include all group members in p-tags of inner ephemeral events
All signaling event types in group calls (answer, hangup, reject, renegotiate) now include p-tags for every group member, matching the pattern already used by CallOfferEvent. This allows signing the inner event once and gift-wrapping it separately for each recipient. - Add Set<HexKey> build() overloads to CallAnswerEvent, CallHangupEvent, CallRejectEvent, and CallRenegotiateEvent - Add createGroupCallAnswer, createGroupRenegotiate factory methods - Fix createGroupHangup to sign once instead of per-peer - Update createGroupReject to accept full member set - Update CallManager to use group methods when in group call - Document group call p-tag convention in NIP-AC spec - Add tests for all group build overloads https://claude.ai/code/session_013h2E7spwHDgSjunqumsYgp
This commit is contained in:
+46
-16
@@ -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() {
|
||||
|
||||
@@ -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": "<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).
|
||||
|
||||
## Spam Prevention
|
||||
|
||||
Clients SHOULD implement call filtering:
|
||||
|
||||
+52
-14
@@ -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<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 +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<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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HexKey>,
|
||||
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)
|
||||
|
||||
+15
@@ -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<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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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<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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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<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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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<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