Guides

Conversations and messages

Direct, group, channel, and forum conversations. Mentions, aliases, and blocks are engine APIs. messages.send commits in PostgreSQL.

Conversations

  • createDirect({ creatorId, userIds: [a, b] }) is idempotent for a pair. Direct chats do not accept a retention write.
  • createGroup({ creatorId, title, userIds }) assigns owner to the creator.
  • createChannel / createForum use the same membership model. Discovery and ranking stay in the application.
  • Roles: owner, admin, moderator, member.

Public Conversation includes nested resolved .retention. Drive UI from that view. Staff who may change modes use conversations.updateRetentionPolicy. See retention.

Messages

messages.send validates, authorizes, rate-limits, then writes the message and message_outbox in one PostgreSQL transaction (plus push_intents when push is enabled and the event is a visible message.created). The call returns after commit. Centrifugo and push-provider success are not required.

Every send needs a clientMessageId. Retries with the same sender and key return the original row.

Supported types: text, image, audio, video, file, sticker, gif. system is reserved.

Mentions

Mentions target immutable identity IDs. Putting @alice in the body does not create a mention.

await gozwire.messages.send({
  conversationId,
  senderId: "user-a",
  clientMessageId: crypto.randomUUID(),
  type: "text",
  body: "Hello",
  metadata: { mentions: ["user-b"] },
});

metadata.mentions, when present, must be an array of strings. Unknown, duplicate, self, departed, blocked, and over-cap IDs are dropped, not rejected, so send cannot probe whether a user or a block exists. Cap is push.maxMentionsPerMessage (default 32).

Autocomplete is conversation-scoped: conversations.membersForMention({ conversationId, actorId }). Do not list global identities for targeting. displayName may be the viewer’s private alias.

messages.edit may pass metadata and reuses the same sanitization. Edits never insert push_intents. See push.

Aliases

Owner-scoped private labels: identities.aliases.set / list / remove. Mentions still target IDs. conversations.participants and membersForMention show the alias to that viewer only. identities.list stays canonical. Aliases never appear in outbox envelopes or push payloads.

Blocks

Directional rows. Either direction is a bilateral pair.

await gozwire.identities.blocks.block({ actorId: "user-a", userId: "user-b" });
await gozwire.identities.blocks.list({ actorId: "user-a" });
await gozwire.identities.blocks.unblock({ actorId: "user-a", userId: "user-b" });

New DMs, invites, mention targeting, and push are suppressed both ways. Shared-room ordinary messages and replyToId remain allowed. Errors match a missing identity (user not found). A mention does not override a block.

History and sync

History uses opaque cursors (before / after), not SQL OFFSET. Forums can pass rootsOnly: true. Nested replies load with messages.thread({ threadRootId }).

After reconnect, call messages.sync({ afterSequence }). Deduplicate by message id. Do not poll. Reload the current history page as well so in-place expiry and moderation transitions are not skipped.

Receipts

markDelivered and markRead are monotonic. Read implies delivered. Senders cannot receipt their own messages. Invisible (held, blocked, expired, or deleted) messages cannot be receipted. For after_read, the first recipient markRead arms expiresAt.