chat

package
v0.2.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 25, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package chat defines the built-in `chat` type — a CRDT-backed stream of messages stored as one record per message on a per-object `chat_messages` dataset. Mirrors the markdown package's shape: a registered handler.Type plus aggregating helpers in api.go.

Wire / storage shape of one message record:

{
  "id":               "<auto-derived from changeId>",
  "_ver": { "id": "<VersionId of creating change>", ... },  // SDK-managed
  "creator":          "<accountId>",                     // server-stamped
  "createdAt":        {"$date": "<RFC 3339>"},           // server-stamped
  "modifiedAt":       {"$date": "<RFC 3339>"},           // bumped on edit
  "replyToMessageId": "<msgId>",                         // optional
  "agent": {                                             // optional, create-only
    "name": "<display label>", "debugLink": "<any://…>", "done": <bool>
  },
  "text":             "<markdown>",                      // ≤ MaxTextBytes
  "mentions":         ["<accountId>", ...],              // server-derived
  "attachments":      {                                  // optional, create-only
    "<id>": { "type": "<link|image|…>", "link": "<url>" }
  },
  "reactions":        { "<emoji>": { "<accountId>": <changeTimestamp> } }
}

`attachments` is a UI-side affordance — a map keyed by short opaque ids (≤ MaxAttachmentIdBytes, [A-Za-z0-9_-]+) carrying {type, link}. Create-only — the handler rejects $set on the attachments path post-create — so once a message lands, its attachments are immutable (matches reactions' append-only stance, simpler to reason about across peers).

`agent` marks the message as authored by an agent acting on the signer's behalf (vs the signer typing it directly). It is NOT cryptographically verified — `creator` is still the change signer; the group is a UI hint, useful e.g. as a "human said this, please respond" trigger for an agent subscribed to agent-less messages. Fields: `name` (required display label), `debugLink` (optional drill-down into the run's debug page, by convention `any://<spaceId>/<objectId>[#turn_<n>]` — opaque to the server) and `done` (required liveness bool: false while the producing run is still going, true on terminal messages; clients key their typing indicator on it). Create-only and immutable as a group, like attachments.

Chronological order is `_ver.id` — the SDK's creation-version marker, set once when the record is created (newRecord) and never updated by subsequent modifies. Same role heart's `_o.id` plays. The chat handler does NOT stamp a parallel order field; sort by `_ver.id` and pagination cursors translate to message-id → _ver.id lookups at the API layer.

Reactions are stored emoji-keyed at the first segment, identity- keyed at the second: `reactions.<emoji>.<accountId> = <ts>`. The timestamp is the triggering change's clock, server-derived (the client's payload value is overwritten via sink.Derive). Two invariants follow:

  • Authorization on react ops is a single path-segment compare: path[2] (the accountId leaf) must equal ctx.Change.Creator. Anyone can toggle their own slot; nobody can toggle someone else's.
  • The emoji and accountId are both first-class storage keys, so a single CRDT $set/$unset on the leaf path adds or removes one identity's reaction with one emoji — no array shuffling.

Reads return the storage shape verbatim: `{emoji: {accountId: ts}}`.

`mentions` is server-DERIVED, never client-supplied (spoofable both ways otherwise: silent-ping griefing and notification suppression — the text is the source of truth). At change materialization the handler parses the message text for mention links (`any://m/<spaceId>/<identity>`, anyuri.ExtractMentions) and stamps the deduped identity array; when the message is a reply (`replyToMessageId`), the replied-to message's creator is folded in — a reply IS a ping to the original author, and folding it in at write time keeps every consumer (badge counter, push, "mentions of me" query) a single indexed field check. Edits re-derive the array from the new text (the reply fold-in re-reads via ctx.Get; the replied-to creator is immutable so this is replica-deterministic). Absent when the message mentions nobody — the sparse idx_mentions index stays proportional to mentioning messages. v1 does not distinguish reply-derived entries from text mentions: a ping is a ping. Self-mentions are not filtered — the array is an objective fact of the message; "not my own messages" is a client-side creator != me filter (and read-tracking never badges self-authored changes anyway).

Server-stamped fields land via sink.Derive in BeforeCreate / BeforeModify; they are intentionally invisible to client payloads. The handler rejects payloads that try to set them directly.

Index

Constants

View Source
const (
	FieldCreator          = "creator"
	FieldCreatedAt        = "createdAt"
	FieldModifiedAt       = "modifiedAt"
	FieldReplyToMessageId = "replyToMessageId"
	FieldText             = "text"
	FieldMentions         = "mentions"
	FieldReactions        = "reactions"
	FieldAgent            = "agent"
	FieldAttachments      = "attachments"
	FieldContext          = "context"
	FieldControl          = "control"
)

Field keys on a message record. Literal strings — no content-addressable propIds — to keep the registration hand-readable, matching nav and blocks.

FieldAgent is an optional, create-only group clients use to mark a message as "written by an agent acting on behalf of the signer" rather than the signer typing it themselves. The handler does NOT verify the identity (signature still comes from the signer wallet); it's a UI-only hint, useful e.g. to subscribe to messages without `agent` and have an agent respond. See the package doc for the {name, debugLink, done} sub-fields.

View Source
const (
	FieldAgentName      = "name"
	FieldAgentDebugLink = "debugLink"
	FieldAgentDone      = "done"
	FieldAgentOutcome   = "outcome"
)

Agent sub-record keys.

View Source
const (
	FieldAttachmentType = "type"
	FieldAttachmentLink = "link"
)

Attachment sub-record keys. Each attachment in the attachments map is itself a small object; ids in the outer map are opaque short strings the client chooses.

View Source
const (
	FieldControlKind = "kind"
	FieldControlHard = "hard"
)

Sub-keys of the `control` group — a client-side signal to the agent serving the chat ({kind, hard?}; api.ChatMessageControl). `break` asks the run in flight to stop.

View Source
const (
	FieldContextSpaceId  = "spaceId"
	FieldContextObjectId = "objectId"
	FieldContextView     = "view"
)

Sub-keys of the `context` group — the sender's view at send time ({spaceId, objectId?, view?}; api.ChatMessageContext).

View Source
const (
	MaxTextBytes         = 32 * 1024 // ~heart's 8000 utf-16 cps × 4
	MaxReplyIdBytes      = 256
	MaxEmojiBytes        = 64
	MaxAgentNameBytes    = 256
	MaxDebugLinkBytes    = 2 * 1024
	MaxAgentOutcomeBytes = 64

	MaxAttachments         = 32
	MaxAttachmentIdBytes   = 64
	MaxAttachmentTypeBytes = 64
	MaxAttachmentLinkBytes = 2 * 1024

	MaxContextIdBytes   = 256
	MaxContextViewBytes = 64
	MaxControlKindBytes = 64

	// MaxMentions caps the derived mentions array (post-dedup,
	// first-occurrence order wins). MaxTextBytes already bounds real
	// mentions far below this; the cap is a defense against
	// pathological link-stuffing, not a product limit.
	MaxMentions = 64
)

Validation limits. Conservative; revisit if real usage hits them.

View Source
const (
	TagMessage  = "message"
	TagMention  = "mention"
	TagReaction = "reaction"
)

Read-tracking tags (SDK unread-entry labels).

View Source
const (
	FieldUnread          = "unread"
	FieldUnreadMention   = "unreadMention"
	FieldUnreadReactions = "unreadReactions"
)

Per-message local flag fields (declared ScopeLocal in the schema).

View Source
const (
	PropUnreadCount          = "unreadCount"
	PropUnreadMentions       = "unreadMentions"
	PropUnreadReactionsCount = "unreadReactionsCount"
)

Per-chat counter properties on the object's row (declared ScopeLocal in NewType().Properties).

View Source
const Dataset = "chat_messages"

Dataset is the per-object dataset that holds the message records.

View Source
const Module = "chat"

Module is the module slug a type names in a part's dataset declaration (`{"module": "chat", "shared": true}`). Chat is shared-only: an object carries at most one chat collection — the canonical Dataset — which is what keeps a single read frontier and a single push group per object.

View Source
const PropNotifyMode = "notifyMode"

PropNotifyMode is the per-chat push-notification preference — an ACCOUNT-scoped string property on the chat object (declared ScopeAccount in NewType().Properties): synced across the account's own devices through the tech space, invisible to other members, and mirrored inline onto the chat object's row, so it reads with the same addressing as the unread counters (`chat.notifyMode` next to `chat.unreadCount` — the docs/16-chat.md row-property convention; badges and prefs ride the same objects /query row).

Values: "all" | "mentions" | "none". Deliberately NOT enum-enforced server-side — writes go through the generic properties surface (POST /v1/spaces/:s/properties/:chatObjectId/set/chat), which validates kind, not vocabulary. Consumers (internal/push's desired-topic computation, UIs) treat an absent, non-string, or out-of-vocabulary value as "inherit the space-level mode" (settings.notifyMode on the tech-space row, default "all").

Variables

View Source
var ErrNotAuthor = errors.New("chat: not the message author")

ErrNotAuthor signals an edit / delete / react attempt by someone other than the original message's creator. Maps to 403 chat.not_author at the HTTP layer.

View Source
var ErrNotFound = errors.New("chat: message not found")

ErrNotFound signals that a referenced messageId does not exist on the chat object's `chat_messages` dataset. Distinct from space.ErrNotFound so callers can map cleanly to 404 chat.not_found.

Functions

func Delete

func Delete(ctx context.Context, sp space.Space, objectId, msgId, callerId string) (space.ModifyResult, error)

Delete tombstones a message and returns the space.ModifyResult. Author check runs here for the clean 403 path; the handler's BeforeDelete provides peer-side enforcement.

func Edit

func Edit(ctx context.Context, sp space.Space, objectId, msgId, callerId, text string) (space.ModifyResult, error)

Edit updates the text of an existing message and returns the space.ModifyResult. The author check runs both here (clean 403 for local callers) and in the handler (defense-in-depth for peer changes).

func MessageLinks(spaceId, objectId, collection, msgId string, rec *anyenc.Value) []index.LinkEntry

MessageLinks extracts one message's edges: every any:// reference in its text (mentions as mention, the rest as link), each attachment's `link` and the agent group's `debugLink` (kind link; a non-any:// value — an http attachment — is skipped).

func NewChunker

func NewChunker() *index.ModuleChunker

NewChunker constructs the chat module chunker: chat_messages records as index entries under scope "chat", one entry per message, on every chat collection the space declares (the canonical one, chat being shared-only). Data is the message `text` only — creator, reactions, and attachments are deliberately excluded. Deleted messages (and empty-text messages) yield Data "".

func NewModule

func NewModule() handler.Module

NewModule returns the handler.Module to add to config.Config.Modules so the SDK serves the canonical chat_messages collection with the message handler on every controller. SharedOnly: the one declaration shape is `{"module": "chat", "shared": true}`; namespaced chat instances are refused. Reserved: only the server's own catalog install (`system:general-chat/v1`, docs/16-chat.md) declares it — a client part, dataset or bundle naming the module is refused, and the install root is the type's only carrier.

cfg := config.Config{
    Modules: []handler.Module{ editor.NewModule(), chat.NewModule() },
    ...
}

func Read

func Read(ctx context.Context, sp space.Space, objectId, msgId string) error

Read marks msgId's message and everything ordered before it read — "read up to here" in the chat's display order (`_ver.id`). A later unread change targeting an older message (a fresh reaction on a message above the line) stays unread: the user hasn't seen it.

func ReadAll

func ReadAll(ctx context.Context, sp space.Space, objectId string) error

ReadAll marks every unread change in the chat read (messages, mentions, reactions) and publishes the account's read position to its other devices.

func ReadReactions

func ReadReactions(ctx context.Context, sp space.Space, objectId, msgId string) error

ReadReactions marks the unread REACTION changes on msgId read. A reaction is a separate change written after its target message, so Read(msgId) — which cuts at the message's own _ver.id — never covers it; a client that has shown the reaction to the user clears it here via MarkRead on the reaction change ids.

SCOPE (important): MarkRead covers the given changes AND their causal ancestry, so this also marks read any unread MESSAGE the reactor had already seen when they reacted — everything causally before the reaction, not just the reaction itself. Messages that arrived AFTER the reaction stay unread (they aren't ancestors), which is what still separates this from ReadAll. In the target case — a reaction on an already-read message — the ancestry holds no unread rows, so only the reaction clears; when unread messages coexist, the client is expected to have marked the visible ones read (viewport /read) first. The SDK exposes no "mark exactly these rows, no ancestry walk" primitive (MarkRead is ancestry-covering, MarkReadUpTo is range-covering). Idempotent: a message with no unread reactions is a no-op (204, not 404).

func Send

func Send(ctx context.Context, sp space.Space, objectId string, opts SendOpts) (space.ModifyResult, error)

Send writes one new message and returns the raw space.ModifyResult. The message id is derived from the change CID via the SDK's empty-id sugar — base58(xxh3-64(changeId)) — surfaced as res.RecordIds[0]; the versionId/changeId let the caller correlate the write with the live event it'll receive over /query/subscribe. The full record is read back through /query, never re-rendered here.

func ToggleReaction

func ToggleReaction(ctx context.Context, sp space.Space, objectId, msgId, callerId, emoji string) (space.ModifyResult, error)

ToggleReaction adds or removes the caller's emoji from the message — read current state, decide $set vs $unset — and returns the space.ModifyResult. Storage is `reactions.<emoji>.<accountId> = <changeTimestamp>` (emoji first, identity at the leaf), so the handler's authorization is a single path-segment compare on the leaf; the value is server-derived from the change timestamp, so the placeholder we pass here is overwritten before it lands.

func ValidAttachmentId

func ValidAttachmentId(id string) bool

ValidAttachmentId reports whether id is a legal attachment key: non-empty, ≤ MaxAttachmentIdBytes, alphabet [A-Za-z0-9_-]. Shared by the handler-side validation and the HTTP layer's pre-flight check.

Types

type SendOpts

type SendOpts struct {
	Text             string
	ReplyToMessageId string
	Agent            *api.ChatAgentMeta
	Attachments      map[string]api.ChatAttachment
	// Context is the sender's view at send time — optional, create-only.
	Context *api.ChatMessageContext
	// Control is a signal to the agent — optional, create-only.
	Control *api.ChatMessageControl
}

SendOpts is the input to Send. Text is required only when Attachments is empty (an attachment-only message is valid) and is validated by the handler; ReplyToMessageId is an opaque soft reference; Agent is an optional group marking the message as agent-authored (UI hint, not verified — see chat.go package doc).

Attachments is an optional create-only client hint; each entry is {type, link}.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL