diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 0542dfc199..3d21adc1a1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -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 = 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 })) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt index 09e5c3ae85..6f395de18e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt @@ -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. diff --git a/quartz/plans/2026-07-11-concord-event-classes.md b/quartz/plans/2026-07-11-concord-event-classes.md new file mode 100644 index 0000000000..fcf20e784f --- /dev/null +++ b/quartz/plans/2026-07-11-concord-event-classes.md @@ -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///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`. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index 10f2feae47..dc337250d1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -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", ]` and - * `["epoch", ]` 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", ]` + `["epoch", ]` 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> = 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> = 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( 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) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..d1f517ad72 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt @@ -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 TagArrayBuilder.channel(channelId: HexKey) = addUnique(ChannelTag.assemble(channelId)) + +fun TagArrayBuilder.epoch(epoch: Long) = addUnique(EpochTag.assemble(epoch)) + +/** Binds an event to [channelId] at [epoch] — both tags every Chat Plane rumor carries. */ +fun TagArrayBuilder.channelBinding( + channelId: HexKey, + epoch: Long, +) = apply { + channel(channelId) + epoch(epoch) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt new file mode 100644 index 0000000000..7158224348 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/ChannelTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/ChannelTag.kt new file mode 100644 index 0000000000..29897734c9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/ChannelTag.kt @@ -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", ]` 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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt new file mode 100644 index 0000000000..804697e196 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt @@ -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", ]` 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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt index 9a0eb88365..9b3376b4dc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt @@ -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>() - 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt index 801b755c1f..d2eaf17a23 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt @@ -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