refactor(concord): chat plane reuses standard events + typed binding tags

First slice of the nip88Polls-structure refactor (plan in quartz/plans/
2026-07-11-concord-event-classes.md). A Concord chat rumor IS a standard Nostr
event plus a channel/epoch binding, so stop re-deriving it by kind number:

- New cord03Channels/tags/ChannelTag + EpochTag (typed, parse/assemble), plus
  TagArrayBuilderExt (channel/epoch/channelBinding) and TagArrayExt
  (concordChannel/concordEpoch/isConcordBoundTo) — the poll-package shape.
- ChannelChat.message/reply now build via ChatEvent.build{ channelBinding(...) }
  and assemble the template into a rumor; reaction reuses ReactionEvent.KIND. The
  minimal e/p/k reaction tags and q/p reply tags are kept byte-identical for Armada
  interop (guarded by the Concord round-trip tests, which pass).
- Drop the ConcordKinds.MESSAGE/COMMENT/REACTION/DELETE aliases (they shadowed
  ChatEvent/CommentEvent/ReactionEvent/DeletionEvent KINDs); callers use the real
  event KINDs. VoicePresence uses the new tag classes instead of bindingTags.

Remaining Concord-specific kinds (control 3308, invites, guestbook, rekey, voice,
seals/wrap) get their own per-kind packages in later slices per the plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-11 04:05:07 +00:00
parent f18ba03a48
commit c2cbd602bc
10 changed files with 316 additions and 52 deletions
@@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/** One decrypted, verified Concord channel message projected for display. */
data class ConcordChatMessage(
@@ -173,7 +174,7 @@ object ConcordActions {
): List<ConcordChatMessage> =
wraps
.mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor }
.filter { it.kind == ConcordKinds.MESSAGE && ChannelChat.isBoundTo(it, channelId, epoch) }
.filter { it.kind == ChatEvent.KIND && ChannelChat.isBoundTo(it, channelId, epoch) }
.map { ConcordChatMessage(it.id, it.pubKey, it.content, it.createdAt, channelId, epoch) }
.sortedWith(compareBy({ it.createdAt }, { it.id }))
@@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.events.ConcordKinds
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -73,7 +73,7 @@ class ConcordPlaneRegistryTest {
assertNotNull(routedMsg)
assertEquals(ConcordPlaneKind.CHANNEL, routedMsg.plane.kind)
assertEquals(community.generalChannelIdHex, routedMsg.plane.channelId?.channelId)
assertEquals(ConcordKinds.MESSAGE, routedMsg.opened.rumor.kind)
assertEquals(ChatEvent.KIND, routedMsg.opened.rumor.kind)
assertEquals("gm", routedMsg.opened.rumor.content)
// A wrap from an unrelated plane (different community) is not ours.
@@ -0,0 +1,82 @@
# Concord event layer → per-kind Event classes (nip88Polls structure)
## Why
The Concord quartz layer currently centralizes wire kinds in a single
`concord/events/ConcordKinds.kt` constant object and hand-rolls rumors with raw
string tags (see `cord03Channels/ChannelChat.kt`:
`RumorAssembler.assembleRumor(kind = ConcordKinds.MESSAGE, tags = arrayOf(arrayOf("q", …)))`).
Two problems:
1. **Duplicates standard Nostr kinds.** `ConcordKinds.MESSAGE=9`, `REACTION=7`,
`DELETE=5`, `COMMENT=1111`, `EDIT=3302` shadow `ChatEvent.KIND`,
`ReactionEvent.KIND`, `DeletionEvent.KIND`, `CommentEvent.KIND`. Concord chat
rumors *are* those standard events (they already parse back as `ChatEvent`
etc. on read) — the build side should reuse the classes, not re-derive by
number.
2. **No per-event structure.** Every other protocol in quartz gives each event
kind a package: `XxxEvent.kt` (the `Event` subclass + `build`), plus
`TagArrayBuilderExt.kt` / `TagArrayExt.kt` and a `tags/` folder of typed tag
classes (`nip88Polls/poll/…` is the reference). Concord instead has loose
builders (`ChannelChat`, `ControlEditionBuilder`) and stringly-typed tags.
Target: match the `nip88Polls` shape. Reuse standard events where the protocol
uses standard kinds; give each genuinely-Concord kind its own package.
## Reuse standard events (chat plane, CORD-03)
A Concord chat rumor is a standard event + a `["channel", id]` + `["epoch", n]`
binding. Introduce a binding-tag package and reuse the standard builders:
- `cord03Channels/tags/ChannelTag.kt`, `tags/EpochTag.kt` — typed tags.
- `cord03Channels/TagArrayBuilderExt.kt` — `channel(id)`, `epoch(n)` on the DSL.
- `cord03Channels/TagArrayExt.kt` — `channelId()`, `epoch()`, `isBoundTo(...)`.
| rumor | reuse | binding |
|---|---|---|
| message (9) | `ChatEvent.build` | `channel` + `epoch` |
| reply (9 + q) | `ChatEvent.build` + `q`/`p` | `channel` + `epoch` |
| reaction (7) | `ReactionEvent.build` | `channel` + `epoch` |
| delete (5) | `DeletionEvent.build` | `channel` + `epoch` |
`ChannelChat` keeps its public API (returns unsigned rumors via `RumorAssembler`)
but builds tags from the standard event's DSL + the binding ext. Delete
`ConcordKinds.MESSAGE/REACTION/DELETE/COMMENT/EDIT`.
**Interop guard:** the exact on-wire tags must stay byte-identical to today's
output (Armada compat). Keep `["q", parentId]` + `["p", parentAuthor]` for
replies and `["e"/"p"/"k"]` for reactions — do not switch to `QEventTag`'s
3-element form. Verify with `ConcordPlaneRegistryTest` + an amy↔Armada round-trip.
## New per-kind Event classes (genuinely Concord)
Each gets `concord/<cordXX>/<name>/XxxEvent.kt` + `TagArrayBuilderExt` +
`TagArrayExt` + `tags/`:
| kind | event | CORD |
|---|---|---|
| 3308 | `ControlEditionEvent` (from `ControlEdition`/`ControlEditionBuilder`) | 02/04/06 |
| 3303 | `RekeyEvent` | 06 |
| 3306 / 3309 / 3312 | `GuestbookJoinLeaveEvent` / `KickEvent` / `SnapshotEvent` | 02 |
| 3313 | `DirectInviteEvent` | 05 |
| 23311 / 23313 | `TypingEvent` / `VoicePresenceEvent` | 03/07 |
| 3310 | `WebxdcEvent` | 03 |
| 33301 | `InviteBundleEvent` (from `ConcordInviteBundle`) | 05 |
| 13303 | `InviteListEvent` | 05 |
| 20013 / 20014 / 1059 / 21059 | envelope seals + inverted wrap | 01 |
`ConcordCommunityListEvent` (13302) is already an `Event` class (now a
`BaseReplaceableEvent`) — keep, just move under a per-kind package if desired.
After the move, `ConcordKinds` retains only the truly-Concord kinds (envelope,
control, rekey, guestbook, invites, voice), not the standard-Nostr aliases.
## Sequencing (incremental, compile + interop-test each)
1. Chat plane reuse (this doc's first section) — smallest blast radius (4 refs),
highest clarity. **← start here.**
2. Control plane 3308 → `ControlEditionEvent` package.
3. Invites 33301 / 3313 / 13303.
4. Guestbook + voice + rekey.
5. Envelope seals/wrap.
6. Shrink `ConcordKinds`, delete dead constants, update `EventFactory`.
@@ -20,36 +20,31 @@
*/
package com.vitorpamplona.quartz.concord.cord03Channels
import com.vitorpamplona.quartz.concord.events.ConcordKinds
import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/**
* Chat Plane message binding (CORD-03).
*
* Every Chat Plane rumor — a message, reply, reaction, edit, or delete — commits
* to the channel and epoch it belongs to via `["channel", <id>]` and
* `["epoch", <n>]` tags inside the author-signed rumor. Recipients enforce this
* binding ([isBoundTo]) so an event lifted from one channel/epoch can't be
* replayed into another.
* A Concord chat rumor **is** a standard Nostr event — a kind-9 [ChatEvent]
* message/reply or a kind-7 [ReactionEvent] — that additionally commits to the
* channel and epoch it belongs to via `["channel", <id>]` + `["epoch", <n>]` tags
* (see [channel]/[epoch] and [ChannelTag]/[EpochTag]). This object reuses the
* standard event builders and only adds the binding, so the same event classes
* that render everywhere else in the app render Concord messages too. Recipients
* enforce the binding ([TagArray.isConcordBoundTo]) so an event lifted from one
* channel/epoch can't be replayed into another.
*/
object ChannelChat {
const val TAG_CHANNEL = "channel"
const val TAG_EPOCH = "epoch"
/** Builds the channel/epoch binding tags shared by every Chat Plane rumor. */
fun bindingTags(
channelId: HexKey,
epoch: Long,
): Array<Array<String>> = arrayOf(arrayOf(TAG_CHANNEL, channelId), arrayOf(TAG_EPOCH, epoch.toString()))
/**
* Builds an unsigned kind-9 chat message rumor bound to [channelId]/[epoch].
* Builds an unsigned kind-9 [ChatEvent] rumor bound to [channelId]/[epoch].
* Wrap it for the channel plane with
* [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope] (encrypted
* seal) to publish.
* [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope] to publish.
*/
fun message(
authorPubKey: HexKey,
@@ -60,11 +55,11 @@ object ChannelChat {
extraTags: Array<Array<String>> = emptyArray(),
): Event =
RumorAssembler.assembleRumor(
pubKey = authorPubKey,
createdAt = createdAt,
kind = ConcordKinds.MESSAGE,
tags = bindingTags(channelId, epoch) + extraTags,
content = text,
authorPubKey,
ChatEvent.build(text, createdAt) {
channelBinding(channelId, epoch)
extraTags.forEach { addUnique(it) }
},
)
/**
@@ -91,10 +86,12 @@ object ChannelChat {
)
/**
* Builds an unsigned kind-7 reaction rumor bound to [channelId]/[epoch] against
* the target message ([targetId]/[targetAuthor]/[targetKind]). [content] is the
* reaction (e.g. `"+"`, `"🤙"`). On the receiving side this decrypts to a normal
* kind-7 that wires to its target Note by the `e` tag through the shared cache.
* Builds an unsigned kind-7 [ReactionEvent] rumor bound to [channelId]/[epoch]
* against the target message ([targetId]/[targetAuthor]/[targetKind]). [content]
* is the reaction (e.g. `"+"`, `"🤙"`). Kept to the minimal `e`/`p`/`k` tag form
* (no relay hints) so it stays wire-identical across clients. On the receiving
* side it decrypts to a normal kind-7 that wires to its target Note by the `e`
* tag through the shared cache.
*/
fun reaction(
authorPubKey: HexKey,
@@ -106,34 +103,34 @@ object ChannelChat {
content: String,
createdAt: Long,
): Event =
RumorAssembler.assembleRumor(
RumorAssembler.assembleRumor<ReactionEvent>(
pubKey = authorPubKey,
createdAt = createdAt,
kind = ConcordKinds.REACTION,
kind = ReactionEvent.KIND,
tags =
bindingTags(channelId, epoch) +
arrayOf(
arrayOf("e", targetId),
arrayOf("p", targetAuthor),
arrayOf("k", targetKind.toString()),
),
arrayOf(
ChannelTag.assemble(channelId),
EpochTag.assemble(epoch),
arrayOf("e", targetId),
arrayOf("p", targetAuthor),
arrayOf("k", targetKind.toString()),
),
content = content,
)
/** The channel id a Chat Plane [rumor] is bound to, or null if unbound. */
fun channelOf(rumor: Event): HexKey? = rumor.tags.firstTagValue(TAG_CHANNEL)
fun channelOf(rumor: Event): HexKey? = rumor.tags.concordChannel()
/** The epoch a Chat Plane [rumor] is bound to, or null if unbound/malformed. */
fun epochOf(rumor: Event): Long? = rumor.tags.firstTagValue(TAG_EPOCH)?.toLongOrNull()
fun epochOf(rumor: Event): Long? = rumor.tags.concordEpoch()
/**
* True when [rumor] is bound to exactly [channelId] and [epoch]. Recipients
* must reject any Chat Plane event whose binding does not match the plane it
* arrived on.
* True when [rumor] is bound to exactly [channelId] and [epoch]. Recipients must
* reject any Chat Plane event whose binding does not match the plane it arrived on.
*/
fun isBoundTo(
rumor: Event,
channelId: HexKey,
epoch: Long,
): Boolean = channelOf(rumor) == channelId && epochOf(rumor) == epoch
): Boolean = rumor.tags.isConcordBoundTo(channelId, epoch)
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord03Channels
import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
/**
* The Concord Chat Plane binding, added to any standard event (kind 9 chat, 7
* reaction, 5 delete, …) that is published on a channel plane. Because the binding
* layers onto reused standard events, these extensions are generic over the event
* type instead of pinned to a Concord-specific one.
*/
fun <T : Event> TagArrayBuilder<T>.channel(channelId: HexKey) = addUnique(ChannelTag.assemble(channelId))
fun <T : Event> TagArrayBuilder<T>.epoch(epoch: Long) = addUnique(EpochTag.assemble(epoch))
/** Binds an event to [channelId] at [epoch] — both tags every Chat Plane rumor carries. */
fun <T : Event> TagArrayBuilder<T>.channelBinding(
channelId: HexKey,
epoch: Long,
) = apply {
channel(channelId)
epoch(epoch)
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord03Channels
import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
/** The channel id this Chat Plane rumor is bound to, or null if unbound. */
fun TagArray.concordChannel(): HexKey? = firstNotNullOfOrNull(ChannelTag::parse)
/** The epoch this Chat Plane rumor is bound to, or null if unbound/malformed. */
fun TagArray.concordEpoch(): Long? = firstNotNullOfOrNull(EpochTag::parse)
/**
* True when these tags bind to exactly [channelId] and [epoch]. Recipients must
* reject any Chat Plane event whose binding does not match the plane it arrived on.
*/
fun TagArray.isConcordBoundTo(
channelId: HexKey,
epoch: Long,
): Boolean = concordChannel() == channelId && concordEpoch() == epoch
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord03Channels.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The `["channel", <id>]` tag that binds a Concord Chat Plane rumor to the channel
* it belongs to (CORD-03). Present on every message/reply/reaction/edit/delete so a
* recipient can reject an event lifted from another channel.
*/
class ChannelTag {
companion object {
const val TAG_NAME = "channel"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(channelId: HexKey) = arrayOf(TAG_NAME, channelId)
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord03Channels.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The `["epoch", <n>]` tag that binds a Concord Chat Plane rumor to the community
* epoch it was authored under (CORD-03). Paired with [ChannelTag]; an event whose
* epoch does not match the plane it arrived on is rejected, so a message can't be
* replayed across a rekey.
*/
class EpochTag {
companion object {
const val TAG_NAME = "epoch"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
}
fun assemble(epoch: Long) = arrayOf(TAG_NAME, epoch.toString())
}
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.quartz.concord.cord07Voice
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
import com.vitorpamplona.quartz.concord.events.ConcordKinds
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -69,7 +71,8 @@ object VoicePresence {
subMs: Int? = null,
): Event {
val tags = ArrayList<Array<String>>()
tags.addAll(ChannelChat.bindingTags(channelId, epoch))
tags.add(ChannelTag.assemble(channelId))
tags.add(EpochTag.assemble(epoch))
tags.add(arrayOf(TAG_IDENTITY, identity))
if (broker != null) tags.add(arrayOf(TAG_BROKER, broker))
if (subMs != null) tags.add(arrayOf("ms", subMs.toString()))
@@ -82,7 +85,7 @@ object VoicePresence {
channelId: HexKey,
epoch: Long,
createdAt: Long,
): Event = RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, ChannelChat.bindingTags(channelId, epoch), CONTENT_LEFT)
): Event = RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch)), CONTENT_LEFT)
fun parse(rumor: Event): VoicePresenceInfo? {
if (rumor.kind != KIND) return null
@@ -37,11 +37,11 @@ object ConcordKinds {
const val SEAL_ENCRYPTED = 20013
const val SEAL_PLAINTEXT = 20014
// Chat Plane rumors (CORD-03)
const val MESSAGE = 9
const val COMMENT = 1111
const val REACTION = 7
const val DELETE = 5
// Chat Plane rumors (CORD-03).
// Messages (kind 9), replies (9 + q), reactions (7), and deletes (5) are standard
// Nostr events — Concord reuses ChatEvent / ReactionEvent / DeletionEvent and only
// adds the channel/epoch binding (see cord03Channels/ChannelChat + tags/), so they
// are NOT aliased here. Only the Concord-specific chat kinds remain.
const val EDIT = 3302
const val WEBXDC = 3310
const val TYPING = 23311