feat(frame): additive threading — ThreadID, ReplyTo + REACT type
Chat bots need replies, threads and reactions. Add two optional, omitempty envelope fields (ThreadID, ReplyTo) plus a REACT frame type. The fields ride the cleartext envelope (message-id references, not secret content) and are omitted when unset, so non-threaded frames are byte-for-byte identical on the wire and their signatures unchanged — a non-breaking, additive change. Client gains PublishReply (threaded reply) and React (emoji reaction). The reaction content travels in the payload, so it is sealed like any message and stays confidential in E2E rooms; receivers dispatch on Frame.Type == REACT and read Frame.ReplyTo for the target. Publish is refactored to share one publishFrame path with the new helpers; its behavior is unchanged. Tests: frame round-trip of a threaded REACT frame (golden), non-threaded wire/sig back-compat asserting thr/re keys are absent (edge), Unmarshal of garbage errors (error path), and an end-to-end reply+reaction round-trip in an encrypted ModeMatrix room. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+45
-9
@@ -392,20 +392,31 @@ func (c *Client) signerPub(roomID, sender string) ([]byte, error) {
|
||||
|
||||
// ---- data plane: publish/subscribe ---------------------------------------
|
||||
|
||||
// Publish sends plaintext to a room. For encrypted rooms it seals the payload
|
||||
// with the current K using the subject as AEAD additional-authenticated-data;
|
||||
// for signed rooms it attaches an Ed25519 signature.
|
||||
func (c *Client) Publish(roomID string, plaintext []byte) error {
|
||||
// threadMeta carries the optional threading/reaction routing of a published
|
||||
// frame. The zero value yields a plain top-level message whose wire bytes are
|
||||
// identical to a pre-threading frame (the fields are omitempty).
|
||||
type threadMeta struct {
|
||||
threadID string // thread root message id
|
||||
replyTo string // message id being replied to / reacted to
|
||||
}
|
||||
|
||||
// publishFrame is the single publish path shared by Publish, PublishReply and
|
||||
// React. It builds the envelope, seals+signs per the room policy, and routes
|
||||
// through JetStream (persisted rooms) or core NATS (ephemeral rooms). The only
|
||||
// thing the callers vary is the frame type and the threading metadata.
|
||||
func (c *Client) publishFrame(roomID string, ftype frame.FrameType, plaintext []byte, tm threadMeta) error {
|
||||
info, err := c.fetchRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f := frame.Frame{
|
||||
Type: frame.PUB,
|
||||
Subject: info.Subject,
|
||||
Sender: c.endpoint,
|
||||
MsgID: newULID(),
|
||||
Epoch: info.Epoch,
|
||||
Type: ftype,
|
||||
Subject: info.Subject,
|
||||
Sender: c.endpoint,
|
||||
MsgID: newULID(),
|
||||
Epoch: info.Epoch,
|
||||
ThreadID: tm.threadID,
|
||||
ReplyTo: tm.replyTo,
|
||||
}
|
||||
if info.Policy.Encrypt {
|
||||
k, ep, err := c.fetchKey(roomID, info.Epoch)
|
||||
@@ -435,6 +446,31 @@ func (c *Client) Publish(roomID string, plaintext []byte) error {
|
||||
return c.nc.Publish(info.Subject, b)
|
||||
}
|
||||
|
||||
// Publish sends plaintext to a room. For encrypted rooms it seals the payload
|
||||
// with the current K using the subject as AEAD additional-authenticated-data;
|
||||
// for signed rooms it attaches an Ed25519 signature.
|
||||
func (c *Client) Publish(roomID string, plaintext []byte) error {
|
||||
return c.publishFrame(roomID, frame.PUB, plaintext, threadMeta{})
|
||||
}
|
||||
|
||||
// PublishReply sends plaintext as a reply inside a thread. replyTo is the id of
|
||||
// the message being replied to; threadID is the thread root — pass replyTo when
|
||||
// you are starting a new thread off a top-level message, or the existing
|
||||
// ThreadID to keep replying within one. Encryption and signing are identical to
|
||||
// Publish; the threading metadata rides the cleartext envelope. Receivers read
|
||||
// Frame.ReplyTo / Frame.ThreadID to render the conversation tree.
|
||||
func (c *Client) PublishReply(roomID string, plaintext []byte, replyTo, threadID string) error {
|
||||
return c.publishFrame(roomID, frame.PUB, plaintext, threadMeta{threadID: threadID, replyTo: replyTo})
|
||||
}
|
||||
|
||||
// React publishes a reaction (emoji/shortcode) to a target message. The reaction
|
||||
// content travels in the payload, so it is sealed exactly like a normal message
|
||||
// and stays confidential in E2E rooms. Receivers dispatch on Frame.Type ==
|
||||
// frame.REACT and read Frame.ReplyTo for the message being reacted to.
|
||||
func (c *Client) React(roomID, targetMsgID, emoji string) error {
|
||||
return c.publishFrame(roomID, frame.REACT, []byte(emoji), threadMeta{replyTo: targetMsgID})
|
||||
}
|
||||
|
||||
// Sub is a transport-agnostic handle to an active room subscription. It wraps
|
||||
// either a core NATS subscription (ephemeral rooms) or a JetStream durable
|
||||
// consumer (persisted rooms) behind a single Unsubscribe() method, so callers
|
||||
|
||||
Reference in New Issue
Block a user