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:
Claude
2026-04-03 04:53:17 +00:00
parent 2fa045370d
commit b2584e6705
8 changed files with 268 additions and 31 deletions
@@ -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:
@@ -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)
@@ -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()
}
}
}
@@ -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()
}
}
}
@@ -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()
}
}
}
@@ -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()
}
}
}
@@ -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 =