chat

package
v1.31.0 Latest Latest
Warning

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

Go to latest
Published: Sep 17, 2026 License: MIT Imports: 37 Imported by: 0

Documentation

Index

Constants

View Source
const DefaultListenerAdmissionTTL = 30 * time.Second

DefaultListenerAdmissionTTL is how long Access remembers that a non-member satisfied a group's listener rules (see Access.CanListen). It bounds how long a reader whose balance has since dropped, or whose staff flag was since revoked, keeps reading before the rules are asked again.

View Source
const DmChatIDSize = 32

DmChatIDSize is the length, in bytes, of a DM chat ID: a SHA-256 digest over the DM's type and members (see MustDeriveDmChatID).

View Source
const GroupChatIDSize = 16

GroupChatIDSize is the length, in bytes, of a group chat ID: the leading bytes of a SHA-256 digest over the creator and the idempotency key of the request that created it (see MustDeriveGroupChatID). Group membership is mutable, so a group's ID cannot be member-derived; it is an opaque value fixed at creation, and nothing parses it as anything else.

The two sizes never overlap, so a chat ID's length is its type family's discriminator: 32 bytes is a DM, 16 bytes is a group. Every DM path must reject 16-byte IDs and every group path must reject 32-byte IDs — that enforcement is what keeps the discriminator sound (an ID can never be claimed as both a derived DM and a group).

View Source
const IdempotencyKeySize = 16

IdempotencyKeySize is the length, in bytes, of a client's IdempotencyKey: a UUID's worth of nonce, which is what a client typically mints for one.

View Source
const MaxGroupChatCreationMembers = 50

MaxGroupChatCreationMembers is the largest initial member set a group chat may be created with; a larger set is rejected with ErrTooManyMembers and the caller grows the group with AddGroupMembers instead.

Creation is all-or-nothing, so the bound is what a single atomic write can cover: a DynamoDB transaction caps at 100 items, and creation spends two on the group's own records (the canonical record and its member count). The value is held well under that ceiling so the membership records and the group's records always commit together — there is no partial-creation state for a caller to reconcile.

Variables

View Source
var (
	// ErrChatNotFound indicates that no chat exists for the given chat ID.
	ErrChatNotFound = errors.New("chat not found")

	// ErrChatExists indicates that a chat with the given ID already exists.
	ErrChatExists = errors.New("chat already exists")

	// ErrTooManyMembers indicates that a group chat was created with more
	// initial members than MaxGroupChatCreationMembers allows.
	ErrTooManyMembers = errors.New("too many initial members")

	// ErrNoMembers indicates that a chat was created with an empty member set.
	// A chat nobody belongs to is unreachable: no one can read it, send to it,
	// or be added to it, since every such path gates on membership.
	ErrNoMembers = errors.New("chat must have at least one member")
)
View Source
var ErrGroupFeedTooLarge = errors.New("group feed exceeds the token window")

ErrGroupFeedTooLarge indicates that a user is in more group chats than maxGroupFeedChats, so their feed cannot be carried in a paging token.

View Source
var ErrInvalidRules = errors.New("invalid chat rules")

ErrInvalidRules is returned by RulesFromProto for a rule set a group cannot carry (see RulesFromProto for what one can).

Functions

func DeriveDmChatType added in v1.23.0

func DeriveDmChatType(chatID *commonpb.ChatId, members []*commonpb.UserId) chatpb.ChatType

DeriveDmChatType reports which DM type's canonical derivation over the members produces chatID, letting callers that already hold a chat's members recover its type without a store read. It returns UNKNOWN when no DM type matches — including any malformed input — so callers must treat UNKNOWN as "not a derivable DM", not an error.

This works because every DM's ID commits to its type via the derivation domain. A future chat type whose ID is not member-derived (e.g. group chats) will return UNKNOWN here and needs its own discriminator.

func IsDmChatType added in v1.24.0

func IsDmChatType(chatType chatpb.ChatType) bool

IsDmChatType reports whether chatType is a direct-message chat type — one with two participants and a canonical, member-derived ID — as opposed to a group or unknown chat.

func IsGroupChatID added in v1.26.0

func IsGroupChatID(chatID *commonpb.ChatId) bool

IsGroupChatID reports whether chatID is a group chat ID, by length (see GroupChatIDSize).

func MustDeriveDmChatID

func MustDeriveDmChatID(chatType chatpb.ChatType, a, b *commonpb.UserId) *commonpb.ChatId

MustDeriveDmChatID returns the deterministic chat ID for a DM of the given type between two users.

The ID is derived purely from the DM type and the participants, so it is stable across calls and independent of who initiates the chat: MustDeriveDmChatID(t, a, b) always equals MustDeriveDmChatID(t, b, a). This lets either user open the canonical DM without a prior lookup, and makes creation idempotent.

Derivation hashes the byte-sorted, de-duplicated set of user IDs (a DM with oneself collapses to a single member) under a domain-separation prefix that encodes the DM type. Contact DMs use the bare prefix because they predate typed derivation, and their chat IDs must not change; the domains cannot alias each other because member sets are fixed-width, so the two encodings never produce equal-length hash inputs. Since the input is a sorted set, member ordering and duplicates do not affect the result. The SHA-256 digest is DmChatIDSize bytes wide by construction.

It panics on an unspecified chat type, or if either user ID is not the expected fixed width, which would be a programming error: all user IDs in the system are UUIDs. Fixed-width members also make the sorted concatenation unambiguous without length prefixing.

func MustDeriveGroupChatID added in v1.31.0

func MustDeriveGroupChatID(creatorID *commonpb.UserId, key *chatpb.IdempotencyKey) *commonpb.ChatId

MustDeriveGroupChatID returns the ID of the group chat that a StartChat from creatorID carrying key creates.

A group's ID is derived from its creator and the request's idempotency key rather than minted at random, so that the ID is itself the idempotency record: a retried request derives the same ID, and the store's uniqueness condition on creation turns the duplicate into a read of the original (see Server.StartChat). Nothing else is stored, and the mapping never expires.

The creator is part of the input so that no client can derive another user's chat ID: the key is a nonce the client chooses, and two users who choose the same one derive two distinct groups. A client can predict the ID of its own group, which is harmless — group IDs are not secrets (see Server.GetChat). Group chat IDs remain server-derived: a client-supplied ID is never trusted as a chat's identity.

The digest is truncated to GroupChatIDSize bytes, which is what makes the result a group ID by length, and then stamped as a version 8 UUID (RFC 9562 section 5.8, the custom version, which the spec offers precisely for a truncated hash like this one). Group IDs are opaque and nothing parses them, but every group ID minted before this derivation was a random UUID, and the stamp keeps that shape true of all of them, at a cost of six bits nobody will miss. It panics if either input is not its fixed width, which would be a programming error: all user IDs in the system are UUIDs, and a request's key is checked to width before it reaches here. Fixed-width inputs also make the concatenation unambiguous without length prefixing.

func MustGenerateGroupChatID added in v1.26.0

func MustGenerateGroupChatID() *commonpb.ChatId

MustGenerateGroupChatID mints a random group chat ID. StartChat does not use it — a group created by a client request derives its ID from the request (see MustDeriveGroupChatID) — so it is for a group that has no request behind it, which today means tests. Group chat IDs are always produced server-side either way; a client-supplied ID is never trusted as a chat's identity.

Types

type Access added in v1.30.0

type Access struct {
	// contains filtered or unexported fields
}

Access answers the questions every chat and messaging RPC asks before acting on behalf of a user, so the chat and messaging services — and any other domain that gates on a chat, such as a blob granted to a chat's audience — enforce one definition of who may do what in a chat:

  • IsMember: is the user on the chat's roster. The gate on every write that is not a send but still belongs to a member alone — a pointer advance, a reaction — and on anything that would leave a record in the chat on the user's behalf.
  • CanListen: may the user see the chat — its metadata, its messages, its pointers and reactions. A member always may. A non-member may read a group, and only a group, if they satisfy its listener rules right now.
  • CanSpeak: may the user produce something other members see — a message, an edit, a deletion, a typing notification. Members only, and only those who satisfy the chat's listener and speaker rules.

Membership is always checked first: it is the cheaper check (a keyed read, cached for a DM), and it answers for a member without evaluating a rule — a member whose balance has since dipped under the group's requirement is still a member and still reads (see messaging/access.go for why membership stands for the rules on a member's reads). Rules are evaluated only where they decide the answer: for a non-member's read of a group, and for any send.

Evaluating a group's listener rules on behalf of a non-member is what lets someone who satisfies them preview the group before joining. It is also a cost the RuleEvaluator was designed to avoid: a minimum balance is a valuation by the OCP server, and a non-member browsing a group would pay it on every page. So a non-member's admission is remembered for listenerAdmissionTTL, and only an admission — a refusal is never cached, so a user who tops up their balance is admitted on their very next read. The window is the same kind of lag a member already has indefinitely: for its duration a reader who no longer satisfies the rules keeps reading. A hit does not extend the window, so a reader who keeps reading is re-evaluated once per window, not never.

A DM admits its two members and no one else; its rules are nil, and nil rules admit everyone, so a DM must never reach the rules fallback. A group without listener rules admits no non-member either: the rules are the only thing that can admit a non-member, and an empty set is not taken as admitting everyone, even though that is what the RuleEvaluator says of it. Every group created through StartChat carries a minimum listener balance (see RulesFromProto), but the store does not require one, and a group written before that rule existed, or by an operator, may carry none; such a group stays its members' alone rather than becoming readable by every registered user. If an open group is ever wanted, it is an explicit rule to add here, not the absence of one.

A non-member of a group whose listener rules they do not satisfy is not nothing to the group: they may read it redacted (see Standing.CanPreview and redact.Message) — that the messages exist and their shape, never what they say — provided the group carries a listener rule at all, on the same basis as above: a rule is what opens a group to non-members, and its absence keeps the group its members' alone in every form. What a read is answered with — full, redacted, or nothing — is the viewer's standing combined with what the client asked for (see Standing.Reading and messagingpb.ViewMode): a client that wants a group blurred asks for REDACTED, and is answered from membership and the rules' existence without the rules being evaluated.

A chat that does not exist admits no one: IsMember, CanListen and CanSpeak all report false, not ErrChatNotFound, for a chat ID nothing is stored under. A caller that must tell NOT_FOUND from DENIED reads the canonical record itself and asks with CanListenWithRules or StandingWithRules.

func NewAccess added in v1.30.0

func NewAccess(chats Store, rules *RuleEvaluator, opts ...AccessOption) *Access

NewAccess constructs an Access over the chat store the servers read membership from and the RuleEvaluator they evaluate rules with. The store should be the caching store in production, so a DM's membership and a group's rules cost no read in steady state.

One Access is meant to serve every server that gates on a chat: the chat and messaging servers take it at construction rather than building their own, so a non-member's admission is evaluated and remembered once, in one window, whichever service they read through — and so the window is configured in one place.

func (*Access) CanListen added in v1.30.0

func (a *Access) CanListen(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)

CanListen reports whether userID may read chatID in full: they are a member, or chatID is a group whose listener rules they satisfy (see Access). It is false, not an error, for a chat that does not exist.

func (*Access) CanListenWithRules added in v1.30.0

func (a *Access) CanListenWithRules(ctx context.Context, chatID *commonpb.ChatId, rules *chatpb.Rules, userID *commonpb.UserId) (bool, error)

CanListenWithRules is CanListen for a caller already holding the chat's rules (see StandingWithRules).

func (*Access) CanSpeak added in v1.30.0

func (a *Access) CanSpeak(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)

CanSpeak reports whether userID may send in chatID: they are a member, and they satisfy the chat's listener and speaker rules. Membership is checked first, so a chat's rules are never evaluated for a send on behalf of a non-member. It is false, not an error, for a chat that does not exist.

func (*Access) IsMember added in v1.30.0

func (a *Access) IsMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)

IsMember reports whether userID is on chatID's roster. It is false, not an error, for a chat that does not exist and for a group member since removed.

func (*Access) Rules added in v1.30.0

func (a *Access) Rules() *RuleEvaluator

Rules is the RuleEvaluator the Access evaluates rules with, for a caller that must evaluate rules outside of a standing — a join, which the rules admit to membership, or a creation, whose rules are not stored yet.

func (*Access) Standing added in v1.31.0

func (a *Access) Standing(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, mode messagingpb.ViewMode) (Standing, error)

Standing is userID's standing in chatID (see Standing) as needed to answer a read under mode: membership, then for a non-member of a group only, the group's listener rules — whether it has any, and unless mode is REDACTED, whether userID satisfies them. Under REDACTED the rules are not evaluated, since a placeholder is the answer either way, so a client that wants a group blurred never pays a valuation for it. The standing is zero, not an error, for a chat that does not exist.

func (*Access) StandingWithRules added in v1.30.0

func (a *Access) StandingWithRules(ctx context.Context, chatID *commonpb.ChatId, rules *chatpb.Rules, userID *commonpb.UserId, mode messagingpb.ViewMode) (Standing, error)

StandingWithRules is Standing for a caller already holding the chat's rules (read off a canonical record it loaded for its own purposes, see Chat.Rules), so they are not read a second time. The chat is taken as existing: a caller that has its rules has already told NOT_FOUND from everything else. A nil rules is a chat with none, which admits no non-member in any form (see Access). On error the standing is zero.

type AccessOption added in v1.30.0

type AccessOption func(*Access)

AccessOption configures an Access at construction.

func WithListenerAdmissionTTL added in v1.30.0

func WithListenerAdmissionTTL(ttl time.Duration) AccessOption

WithListenerAdmissionTTL overrides DefaultListenerAdmissionTTL: how long a non-member's satisfied listener rules stand before they are evaluated again. A zero or negative TTL remembers nothing, so every non-member read evaluates the rules — for tests, or for a deployment that would rather pay the valuation than tolerate the window.

type BlocklistReader added in v1.24.0

type BlocklistReader interface {
	// GetBlocked returns which of candidateIDs the owner has blocked, as a set
	// keyed by string(userID.Value). Candidates the owner has not blocked are
	// absent from the map.
	GetBlocked(ctx context.Context, ownerID *commonpb.UserId, candidateIDs []*commonpb.UserId) (map[string]bool, error)
}

BlocklistReader is the read slice of the blocklist domain the Chat service needs to compute per-viewer hidden state. Like the other readers it is declared here (consumer side) so the chat package need not import blocklist; the blocklist package supplies the concrete adapter.

type Chat

type Chat struct {
	ID                     *commonpb.ChatId
	Type                   chatpb.ChatType
	Members                []*commonpb.UserId
	RosterSummary          RosterSummary
	Title                  string
	IsStaffOnly            bool
	MinimumListenerBalance *MinimumBalance
	CreatorID              *commonpb.UserId
	PictureBlobID          *blobpb.BlobId
	LastActivity           time.Time
	LastMessageID          *messagingpb.MessageId
}

Chat is the stored metadata for a chat.

It deliberately holds only the state owned by the chat domain: the chat's identity, type, membership, title, and the last-activity timestamp used to order a user's chat list. The richer fields of chatpb.Metadata — member profiles, per-member message pointers, and the last message — live in other domains (profile, messaging) and are hydrated by the server layer.

Members is the full, immutable member set for a DM, and is always empty for a group chat: group membership is mutable and lives in its own store records, which no path that reads the canonical record touches. A caller that needs a group's members reads them explicitly via Store.GetMembers. Title, IsStaffOnly, CreatorID and PictureBlobID are group-only and zero for DMs.

RosterSummary describes the member list without containing it. Like Members, it is complete for a DM on any read and left zero for a group by the canonical record's reads: a group's summary is maintained alongside its membership records (see Store.GetGroupRosterSummary), filled in by the caller alongside its Members. It is derived state, ignored on PutChat.

IsStaffOnly marks a group whose membership is restricted to staff users, and MinimumListenerBalance a group whose members must hold a balance (nil when the group asks for none). Both are stored state set at creation, surfaced to clients as listener rules (see Rules) and enforced by the messaging service through a RuleEvaluator on sends. Reads, of chat metadata and of messages alike, gate on membership alone, so a member a rule excludes can still see the chat and what it requires of them; enforcing rules on membership changes is the job of whatever path mutates membership.

CreatorID is the user who created the group, or nil when unknown (a DM has none, and so does any group written before the field existed). It is fixed at creation and records provenance only: creating a group does not by itself make the creator a member, and whether they are is answered by the membership records, never by this field.

PictureBlobID is the blob holding the ORIGINAL rendition of the group's picture, or nil when the group has none. The chat domain stores only that handle: the full rendition set (and its download URLs) is resolved from blob storage by the server layer on read, exactly as a profile picture is. Read access is a blob-domain grant to the chat's members, made when the picture is set (see blob.Integration.SetAsChatPicture), so the record here carries no authorization of its own.

func (*Chat) Clone

func (c *Chat) Clone() *Chat

Clone returns a deep copy of the chat.

func (*Chat) Rules added in v1.30.0

func (c *Chat) Rules() *chatpb.Rules

Rules projects the chat's stored participation requirements onto a chatpb.Rules, or nil when the chat has none — which is every DM, and every group not created with a requirement. The proto is the single vocabulary for rules: what the client is shown (Metadata.rules) is exactly what the server evaluates (RuleEvaluator), so the two can never disagree about what a chat requires.

Every requirement a group carries is a listener rule: a StaffRequirement for a staff-only group, a MinimumBalanceRequirement for a group with a minimum listener balance, or both. Listener rules gate reading and joining, and a member must be able to listen before they can speak, so restricting the audience restricts the speakers too without repeating a rule in the speaker class — which no group carries yet. The rules are listed cheapest to evaluate first, since an evaluator stops at the first one a user fails: a staff check is a flag read, a balance check a valuation.

func (*Chat) ToProto

func (c *Chat) ToProto() *chatpb.Metadata

ToProto projects the stored chat onto a chatpb.Metadata. Only the fields owned by the chat domain are populated: chat_id, type, title, last_activity, roster_summary, rules, a Member entry per member with just user_id set, and — for a group with a picture — a picture carrying only its ORIGINAL rendition's blob id. The caller is responsible for hydrating member profiles, pointers, the last message, and the picture's resolved rendition set.

type ChatEventPublisher added in v1.30.0

type ChatEventPublisher interface {
	OnEvent(chatID *commonpb.ChatId, e *eventpb.ChatEvent)
}

ChatEventPublisher is the write slice of the event domain the Chat service needs to notify every stream subscribed to a group chat's topic — the chat-keyed event bus. See UserEventPublisher for why it is declared here.

type DmFeedCursor

type DmFeedCursor struct {
	LastActivity time.Time
	ChatID       *commonpb.ChatId
}

DmFeedCursor marks a position within a DM feed snapshot read. The next page resumes at the chat immediately after (LastActivity, ChatID) in the feed's descending (last_activity, chat_id) order.

type GroupMembership added in v1.30.0

type GroupMembership struct {
	ChatID  *commonpb.ChatId
	Joined  bool
	Version uint64
}

GroupMembership is one of a user's group membership records: the chat, whether the record currently has them joined or departed, and the roster version the record was last moved at — the version of the user's own last transition on that group (see RosterSummary), or zero for a membership written at the group's creation, which no transition has touched. Only the user's own transitions move their record, so the version is a per-user watermark on the group: a transition of theirs at or below it is one the record already reflects, whichever way it went. A departed record carries the version of the departure for exactly that reason — without it, a stale copy of the join it superseded would be indistinguishable from news.

type Media added in v1.30.0

type Media interface {
	// ResolveRenditions returns each original's full rendition set — the
	// ORIGINAL plus every derived rendition, each with a freshly minted,
	// short-lived download URL — keyed by string(BlobId.Value). Originals that
	// are unknown or not yet servable are absent from the map. It performs no
	// authorization: the caller passes only ids it has already established the
	// reader may see.
	ResolveRenditions(ctx context.Context, ids []*blobpb.BlobId) (map[string][]*blobpb.Rendition, error)

	// SetAsChatPicture attaches the blob holding a picture's ORIGINAL to chatID
	// as its picture: it verifies that ownerID owns the blob and that it is a
	// READY image original, then grants read access to it on the surfaces the
	// picture is shown from. It is idempotent. It returns one of
	// blob.ErrBlobNotFound, blob.ErrBlobNotReady, blob.ErrBlobRejected, or
	// blob.ErrBlobInvalid when the blob cannot back a picture, having granted
	// nothing; any other error is a failure to attach.
	//
	// It touches only blob-domain state, so it may be called for a chat that
	// does not exist yet — which is how a group is created with its picture in
	// place, rather than briefly without one.
	SetAsChatPicture(ctx context.Context, ownerID *commonpb.UserId, chatID *commonpb.ChatId, blobID *blobpb.BlobId) error
}

Media is the slice of the blob domain the Chat service needs: hydrating group pictures on read, and attaching a picture to a group on write. Like the readers it is declared here (consumer side) so the service can be tested against a canned implementation; blob.Integration satisfies it directly.

type MessageRef

type MessageRef struct {
	ChatID    *commonpb.ChatId
	MessageID *messagingpb.MessageId
}

MessageRef identifies a chat's message to hydrate. The feed builds one ref per chat (its last message) to batch the lookup across the page.

type MessagingReader

type MessagingReader interface {
	// LastMessages returns the message for each ref that exists, keyed by
	// string(chatID.Value). Refs without a message are absent from the map.
	LastMessages(ctx context.Context, refs []MessageRef) (map[string]*messagingpb.Message, error)

	// Pointers returns the delivered/read pointers for the members named in each
	// ref, keyed by string(chatID.Value). Chats with no matching pointers are
	// absent from the map.
	Pointers(ctx context.Context, refs []PointerRef) (map[string][]*messagingpb.Pointer, error)

	// LatestEventSequences returns the head event sequence of each given chat,
	// keyed by string(chatID.Value). Chats at head 0 (no messages) are absent from
	// the map, so a missing key means 0.
	LatestEventSequences(ctx context.Context, chatIDs []*commonpb.ChatId) (map[string]uint64, error)
}

MessagingReader is the read slice of the messaging domain the Chat service needs to hydrate feed metadata. It is declared here (consumer side) so the chat package need not import messaging, keeping the messaging→chat dependency one-way; the messaging package supplies the concrete adapter.

type MinimumBalance added in v1.30.0

type MinimumBalance struct {
	Currency     string
	NativeAmount float64
	Mints        []*commonpb.PublicKey
}

MinimumBalance is a balance a user must hold to satisfy a chat rule: at least NativeAmount of Currency (an ISO 4217 alpha-3 code, lowercase) worth of tokens, valued at the time the rule is evaluated. Mints restricts which mints the balance may be held in; empty means any mint counts. The proto it projects onto allows at most one mint today, and the model mirrors the proto's list so that lifting the cap is not a storage change.

It is stored as given: what the amount and mints mean is the proto's contract (see chatpb.MinimumBalanceRequirement), and validating a requirement is the job of the boundary that accepts one, not the record.

func RulesFromProto added in v1.30.0

func RulesFromProto(rules *chatpb.Rules) (isStaffOnly bool, minimumListenerBalance *MinimumBalance, err error)

RulesFromProto validates a rule set a client asked a new group to carry and projects it onto the stored fields Rules projects back from, so that a group created with rules shows exactly the rules it was asked for.

It accepts what a group can store today, and nothing more, so that a rule is never accepted and then silently dropped: listener rules only, since no group carries a speaker rule yet; each kind at most once, since the record holds one of each; and a minimum balance in USD only, since that is the only balance the evaluator can answer (see satisfiesMinimumBalance), of at least the currency's minimum transfer value — one unit at its last decimal place, a penny for USD, the smallest amount OCP lets anyone hold or move in that currency (see minimumTransferValue). A requirement below it asks for a balance no one can distinguish from nothing, so the rule would admit everyone, or no one, on rounding alone. The requirement's mints are taken as given: the proto bounds how many, and validation bounds their shape. Anything else is ErrInvalidRules — a rule the server cannot enforce is refused up front rather than stored and failed on every evaluation.

It also requires what every group must carry today: a minimum listener balance. A set without one — nil, empty, or staff-only — is ErrInvalidRules, so no group is created that a holder of nothing could join.

func (*MinimumBalance) Clone added in v1.30.0

func (m *MinimumBalance) Clone() *MinimumBalance

Clone returns a deep copy of the requirement.

func (*MinimumBalance) ToProto added in v1.30.0

ToProto projects the requirement onto a chatpb.MinimumBalanceRequirement.

type PointerRef added in v1.14.0

type PointerRef struct {
	ChatID  *commonpb.ChatId
	Members []*commonpb.UserId
}

PointerRef names a chat and the members whose pointers to hydrate. The feed builds one ref per chat — a DM's members, or the viewer alone in a group (see hydrate) — to batch the pointer lookup across the page.

type ProfileReader added in v1.16.0

type ProfileReader interface {
	// GetPhoneNumbers returns the linked phone number for each of the given
	// users that has one, keyed by string(userID.Value). Users without a linked
	// phone number are absent from the map.
	GetPhoneNumbers(ctx context.Context, userIDs []*commonpb.UserId) (map[string]*commonpb.PhoneNumber, error)

	// GetPublicProfiles returns the public profile of each of the given users the
	// profile domain knows, keyed by string(userID.Value). Unknown users are
	// absent from the map. Every public field a member row shows — display name,
	// profile picture, join timestamp — comes back in this one call.
	//
	// A member who has set neither a name nor a picture still gets an entry,
	// carrying just the join timestamp. Every chat member is a user the profile
	// domain knows, so hydration treats a missing entry as an error rather than
	// standing in a profile of its own.
	//
	// Profile pictures come back with their blob metadata already resolved —
	// including a short-lived download URL — so a client can render member avatars
	// without a follow-up call. The URL expires; to re-mint one the client calls
	// GetBlobs with an AccessContext naming that user's profile, which authorizes
	// it because a profile picture is public.
	//
	// There is one proto per user, so a caller that fills in per-member fields
	// must copy before mutating: the same user can be a member of several chats.
	GetPublicProfiles(ctx context.Context, userIDs []*commonpb.UserId) (map[string]*profilepb.UserProfile, error)
}

ProfileReader is the read slice of the profile domain the Chat service needs to hydrate member profiles. Like MessagingReader it is declared here (consumer side) so the chat package need not import profile; the profile package supplies the concrete adapter.

type Reading added in v1.31.0

type Reading uint8

Reading is what a read of a chat is answered with: nothing, the messages in full, or the messages redacted (see redact.Message). It is the viewer's standing combined with the client's ViewMode (see Standing.Reading).

const (
	// ReadingDenied: the viewer may not read the chat under the mode asked.
	ReadingDenied Reading = iota
	// ReadingFull: the messages as sent.
	ReadingFull
	// ReadingRedacted: the messages redacted, with Message.redacted set.
	ReadingRedacted
)

type RosterSummary added in v1.30.0

type RosterSummary struct {
	MemberCount uint64
	Version     uint64
}

RosterSummary summarizes a chat's roster — its member list — without enumerating it: how many members there are, and a version that moves whenever the membership records do.

A DM's roster is fixed at creation, so its summary is the inline member count at version zero, forever. A group's is maintained by the store: every membership transition — a join, a departure, and in future any change to what a membership record holds about its member — moves Version by exactly one, and MemberCount by the transition's effect on the joined set. Idempotent no-ops (re-adding a joined member, removing a departed one) move neither.

Version is state, not a sequence of deltas: a client compares it against the value it last saw and refetches members when they differ, and on a stream applies the greater value and drops the rest, so delivery order does not matter. It says nothing about member profiles, which live in their own domain and are hydrated afresh onto every response that carries them.

func (RosterSummary) ToProto added in v1.30.0

func (r RosterSummary) ToProto() *chatpb.RosterSummary

ToProto projects the summary onto a chatpb.RosterSummary.

type RuleEvaluator added in v1.30.0

type RuleEvaluator struct {
	// contains filtered or unexported fields
}

RuleEvaluator decides whether a user satisfies a chat's participation rules (see Chat.Rules). It evaluates rules only: membership is a separate, cheaper check the caller makes first, so a chat's rules are evaluated on behalf of a non-member only where the rules are what admits them — a join, or a qualifying non-member's read of a group (see Access). Only a group can carry rules; a DM's evaluation never touches the store.

Rules are read through Store.GetGroupRules — in production the caching store, which holds every group's rules after its first read. A StaffRequirement is answered by the account store's staff flag, and a MinimumBalanceRequirement by the balance client's valuation of the user's holdings (see satisfiesMinimumBalance).

Rules are evaluated against the current state of their subject, not the state at join time: a member who no longer satisfies a listener rule (a staff member whose flag was revoked, a holder whose balance dropped) keeps their membership record but is denied on every path that evaluates the rules until they satisfy it again. Not every path does: a member's read is gated on membership alone, so that it never pays for an evaluation; rules are evaluated on sends, and on a non-member's read of a group (see Access). The intended design is for membership itself to track the listener rules — a member who stops satisfying one is removed — at which point the membership record is the rules' answer everywhere. Enforcing rules on membership changes, a join gated by listener rules included, is the job of whatever path mutates membership.

func NewRuleEvaluator added in v1.30.0

func NewRuleEvaluator(accounts account.Store, balances *balance.Client, chats Store) *RuleEvaluator

func (*RuleEvaluator) CanListen added in v1.30.0

func (e *RuleEvaluator) CanListen(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)

CanListen reports whether userID satisfies every listener rule of chatID — the requirements to read (and join) the chat. A chat with no listener rules admits everyone. It returns ErrChatNotFound if a group chat does not exist.

func (*RuleEvaluator) CanListenWithRules added in v1.30.0

func (e *RuleEvaluator) CanListenWithRules(ctx context.Context, rules *chatpb.Rules, userID *commonpb.UserId) (bool, error)

CanListenWithRules is CanListen for a caller that already holds the chat's rules — read off a canonical record it loaded for its own purposes, or taken from a request for a chat that does not exist yet — so the rules are not read a second time. A nil rules admits everyone, as a chat with none does.

func (*RuleEvaluator) CanSpeak added in v1.30.0

func (e *RuleEvaluator) CanSpeak(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)

CanSpeak reports whether userID satisfies every listener and speaker rule of chatID — the requirements to send messages in the chat. Speaker rules apply on top of listener rules: a user who cannot listen cannot speak, whatever the speaker rules say. It returns ErrChatNotFound if a group chat does not exist.

func (*RuleEvaluator) CanSpeakWithRules added in v1.30.0

func (e *RuleEvaluator) CanSpeakWithRules(ctx context.Context, rules *chatpb.Rules, userID *commonpb.UserId) (bool, error)

CanSpeakWithRules is CanSpeak for a caller that already holds the chat's rules (see CanListenWithRules). A nil rules admits everyone.

type Server

type Server struct {
	chatpb.UnimplementedChatServer
	// contains filtered or unexported fields
}

func NewServer

func NewServer(
	log *zap.Logger,

	authz auth.Authorizer,

	accounts account.Store,
	blocklist BlocklistReader,
	chats Store,
	media Media,
	messaging MessagingReader,
	moderator moderation.Client,
	profiles ProfileReader,

	access *Access,

	userEventBus UserEventPublisher,
	chatEventBus ChatEventPublisher,

	requireStaffForGroupManagement bool,
) *Server

func (*Server) GetChat

GetChat returns one chat's metadata as the caller may see it.

A DM is its two members' alone: anyone else is DENIED. A group's record is returned to every registered user, member or not — its title, picture, rules and roster summary are what a user weighs before joining, and what a client renders for a group it was pointed at (see Access for the rules). What the caller's standing decides, combined with the view mode they asked for (see Standing.Reading), is how much of the group comes with it:

  • A member sees everything, as before: the record, themselves as the hydrated member with their pointers, and the group's messaging state — its last message and head event sequence.
  • A non-member who satisfies the group's listener rules sees the record and its messaging state, so a group they may read previews like one they are in. They are not on the roster, so no member is hydrated: an empty Members is how the metadata says the viewer is not a member.
  • A non-member who does not satisfy the rules sees the record alone under FULL, the mode a client that does not know redaction asks for. Under FULL_OR_REDACTED they see the messaging state redacted: the last message as a placeholder (see redact.Message) and the head event sequence, so a client can show that the group is alive and how much, without what was said. The rules are carried in every case so the client can show what would admit them.
  • Under REDACTED anyone who may read the group at all — a member too — sees its last message redacted, and the rules are not evaluated for a non-member (see Access.Standing).

The group's picture is returned in every case: it is part of the record, as the title is, and the two are what identify a group — a group's picture is readable by anyone. Its download URLs are resolved here without a blob ACL check on that basis. The blob domain itself still resolves a chat-scoped grant against membership, so a non-member's GetBlobs on the same picture, or on media in a message they previewed, is denied; that is accepted, since a non-member's read access is short-lived by nature and the URLs hydrated here and on the messages are what a previewing client renders from.

func (*Server) GetDmChatFeed

func (*Server) GetGroupChatFeed added in v1.30.0

func (*Server) JoinChat added in v1.30.0

func (*Server) LeaveChat added in v1.30.0

func (*Server) StartChat added in v1.30.0

StartChat creates a group chat with the caller as its first and only member.

The request is checked in the order a client can act on: the rules it asks for must be ones a group can carry — which today means they must include a minimum listener balance (see RulesFromProto) — and ones the caller satisfies (RULES_NOT_SATISFIED); the title must pass moderation (TITLE_MODERATED); and the picture, if any, must be a READY image the caller owns (PICTURE_BLOB_NOT_ACCEPTED). Only then is anything written. Rule checks come first because they are local reads, moderation next because it is a call out, and the picture last because attaching it grants read access — against a chat ID minted for the purpose — and that grant, though harmless against a chat that is never created, is best made only once everything else has passed.

The picture is attached before the record is written, so the group never exists without its picture readable: a client that read the blob id from the metadata but could not fetch the blob would render a broken image. The creation itself is a single PutChat; it does not by itself make the creator anything other than a member (see Chat.CreatorID).

Like JoinChat, a creation is a join, and is announced to the creator's other devices as one — a MemberJoined on their user topic carrying the metadata — so they insert the new chat without a refetch. There is no one else to tell.

The RPC is retry-safe. The group's ID is derived from the caller and the request's idempotency key (see MustDeriveGroupChatID), so a retry names the same group, and one that already exists is answered from its record before any check runs: a title the moderator has since learned to flag, a balance that has since fallen below the minimum, or a staff gate since closed are facts about a new group, not this one. Which parameters the retry carries does not matter either; the key is the request's identity. A retry that loses a race with its twin — both pass the read, one write lands — is caught by the store's uniqueness condition and answered the same way. Nothing is published for a retry: the creation was announced when it happened.

The RPC shares the membership RPCs' staff gate (see requireStaffForGroupManagementRPC).

type Standing added in v1.30.0

type Standing struct {
	IsMember   bool
	CanListen  bool
	CanPreview bool
}

Standing is a user's relation to a chat as Access sees it: whether they are on its roster, whether they may read it in full (see Access.CanListen), and whether they may read it redacted (see Access). A member may always read; a non-member with CanListen is a group's qualifying non-member, admitted by its listener rules; a non-member with CanPreview alone is a non-member of a group that carries a listener rule, who may see that it has messages and their shape. CanListen implies CanPreview: whoever may read in full may read redacted (see Reading).

A standing found under ViewMode REDACTED (see Access.Standing) never evaluates the rules, so its CanListen is false for a non-member whether or not they satisfy them. Such a standing answers only the question it was asked; a caller that needs CanListen asks under a mode that evaluates it.

func (Standing) Reading added in v1.31.0

func (s Standing) Reading(mode messagingpb.ViewMode) Reading

Reading resolves the standing against what the client asked for (see messagingpb.ViewMode for the contract): FULL is full content or nothing, FULL_OR_REDACTED the most the standing allows, REDACTED a placeholder for anyone who may read the chat at all — including a member. A mode this version does not know denies, on the same footing as an unknown rule: what the client wants is not understood, so nothing is shown. The mode never widens the standing: a redacted reader is never answered in full.

type Store

type Store interface {
	// PutChat persists a new chat and its membership. It returns ErrChatExists
	// if a chat with the same ID already exists, ErrNoMembers if the member set
	// is empty, and an error when the chat ID's length does not match the chat's
	// type family.
	//
	// For a group chat (16-byte ID, type GROUP), Members become group membership
	// records, equivalent to AddGroupMembers, and are written atomically with the
	// canonical record: creation either fully succeeds or leaves nothing behind.
	// Duplicate members collapse. A set larger than MaxGroupChatCreationMembers
	// is rejected with ErrTooManyMembers rather than written non-atomically; a
	// caller wanting a larger group creates it at the cap and grows it with
	// AddGroupMembers. RosterSummary is derived from Members and ignored.
	PutChat(ctx context.Context, chat *Chat) error

	// AddGroupMembers adds users as joined members of a group chat. It is
	// idempotent: adding an already-joined member is a no-op that preserves
	// their original join time, and re-adding a departed member rejoins them
	// fresh. Each member that actually joins is one membership transition,
	// moving the group's RosterSummary atomically with their record. It
	// reports whether any member actually joined, and the summary as of the
	// last write — which may already reflect a concurrent transition by
	// another writer, and so is what a caller should publish as current. It
	// returns ErrChatNotFound if the chat does not exist, and an error if chatID
	// is not a group chat ID.
	AddGroupMembers(ctx context.Context, chatID *commonpb.ChatId, userIDs []*commonpb.UserId) (changed bool, roster RosterSummary, err error)

	// RemoveGroupMember ends a user's membership in a group chat. Departure is
	// a tombstone, not a deletion: the user stops being a member (IsMember
	// false, excluded from GetMembers) but the record of their former
	// membership is kept, and they can be re-added later. Removing a non-member
	// or unknown user is a no-op. A departure that actually happens is one
	// membership transition, moving the group's RosterSummary atomically with
	// the record; changed and roster are as for AddGroupMembers. It returns
	// ErrChatNotFound if the chat does not exist, and an error if chatID is not
	// a group chat ID.
	RemoveGroupMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (changed bool, roster RosterSummary, err error)

	// SetGroupPicture sets a group chat's picture to the blob holding its
	// ORIGINAL rendition, replacing any picture already set; a nil blobID clears
	// it. It touches only the canonical record and performs no validation of the
	// blob or granting of read access — that is the blob domain's job, done
	// before this is called (see blob.Integration.SetAsChatPicture). It returns
	// ErrChatNotFound if the chat does not exist, and an error if chatID is not
	// a group chat ID.
	SetGroupPicture(ctx context.Context, chatID *commonpb.ChatId, blobID *blobpb.BlobId) error

	// GetChatByID returns the canonical record for the chat with the given ID,
	// or ErrChatNotFound. It reads only that record: Members carries a DM's
	// inline participants and is always empty for a group chat, whose mutable
	// membership lives in its own records. A caller that needs a group's members
	// reads them explicitly via GetMembers, so the cost of enumerating a large
	// group is never paid implicitly by a metadata read. Likewise RosterSummary
	// is a DM's inline summary and zero for a group, whose summary is its own
	// read (GetGroupRosterSummary).
	GetChatByID(ctx context.Context, chatID *commonpb.ChatId) (*Chat, error)

	// GetDmFeedPage returns one page of userID's DM feed for a single chat type,
	// pinned to a snapshot: the DMs of chatType userID is a member of whose
	// last_activity is at or before snapshot, ordered by (last_activity, chat_id)
	// descending (most recent first), at most limit chats (limit <= 0 means
	// unbounded). When cursor is nil the page starts at the most recent chat in
	// the snapshot; otherwise it resumes strictly after cursor. An empty result
	// (no error) is returned when no chats remain.
	//
	// Pinning to a fixed watermark makes a multi-page read internally consistent.
	// last_activity only ever advances to a wall-clock send time, so any chat that
	// becomes active after the snapshot moves strictly above the watermark and
	// leaves the window — it can be neither duplicated onto nor skipped within a
	// later page. Those freshly-active chats are surfaced through the live
	// MetadataUpdate event stream instead (see the Chat service's GetDmChatFeed).
	//
	// It is scoped to a single DM type because each type is its own feed (see
	// GetDmChatFeedRequest.dm_chat_type). Group chats will have a parallel
	// accessor, and the server merges the descending streams into one feed.
	GetDmFeedPage(ctx context.Context, userID *commonpb.UserId, chatType chatpb.ChatType, snapshot time.Time, cursor *DmFeedCursor, limit int) ([]*Chat, error)

	// GetMembers returns the member user IDs of a chat, or ErrChatNotFound. For
	// a DM this is the canonical inline member list; for a group chat it is the
	// currently joined members.
	GetMembers(ctx context.Context, chatID *commonpb.ChatId) ([]*commonpb.UserId, error)

	// GetGroupRosterSummary returns a group chat's RosterSummary, maintained
	// alongside its membership records rather than computed by enumerating
	// them — so a group's size and version are known without paying for its
	// member list, and stay known once GetMembers returns only a subset.
	//
	// A caller that hands both the summary and a member list to a client reads
	// the summary first: any transition that lands during the enumeration is
	// then above the version handed out, so the client sees it as stale and
	// refetches. Read the other way round, a client could hold a version newer
	// than its list. It returns ErrChatNotFound if the chat does not exist, and
	// an error if chatID is not a group chat ID.
	GetGroupRosterSummary(ctx context.Context, chatID *commonpb.ChatId) (RosterSummary, error)

	// GetGroupRosterSummaries is the cross-chat batch counterpart to
	// GetGroupRosterSummary: the summary of each given group chat, keyed by
	// string(chatID.Value), read as a batch rather than one read per chat. A
	// chat that does not exist is absent from the map rather than reported —
	// which callers passing chats they hold never hit. Duplicate IDs collapse.
	// It returns an error if any ID is not a group chat ID, and an empty map
	// (no error) when chatIDs is empty.
	GetGroupRosterSummaries(ctx context.Context, chatIDs []*commonpb.ChatId) (map[string]RosterSummary, error)

	// GetGroupRules returns a group chat's participation rules (see
	// Chat.Rules), or nil when it has none. It reads only what the rules are
	// projected from, never the full canonical record — and rules are fixed at
	// creation, so an implementation is free to cache them indefinitely. It
	// returns ErrChatNotFound if the chat does not exist, and an error if chatID
	// is not a group chat ID.
	GetGroupRules(ctx context.Context, chatID *commonpb.ChatId) (*chatpb.Rules, error)

	// IsMember reports whether userID is a member of chatID. It returns false
	// (no error) when the chat does not exist, or when a group member has been
	// removed. The read is strongly consistent: it reflects every join, leave
	// and creation that completed before it. It is the authority behind every
	// access gate (see Access), and the first thing a user does after joining
	// or creating a chat is act in it, so a read that could lag the write
	// would deny exactly that action.
	IsMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)

	// GetGroupMembershipsForUser returns every group chat userID has a
	// membership record on — joined or departed — in no particular order, each
	// with its state and the version of the user's last transition there (see
	// GroupMembership). A user with no records gets an empty result, not an
	// error. It is the inverse of GetMembers over the membership records alone
	// — no canonical chat metadata is read or returned; a caller that needs it
	// follows up with GetChatByID, and one that wants only current memberships
	// filters on Joined (see GetGroupChatsForUser).
	GetGroupMembershipsForUser(ctx context.Context, userID *commonpb.UserId) ([]GroupMembership, error)

	// GetGroupChatsForUser returns the canonical record of every group chat
	// userID is currently a joined member of, in no particular order. It is
	// GetGroupMembershipsForUser, less the departed records, followed by the
	// canonical read of each ID, and returns records exactly as GetChatByID
	// does: Members empty and RosterSummary zero. A user with no group
	// memberships gets an empty result, not an error.
	//
	// It is the group feed's source: with no per-member activity index, a user's
	// groups are ordered by reading every one of them and sorting — so this is
	// paid once per feed snapshot, and the order is carried forward from there
	// (see Server.GetGroupChatFeed).
	GetGroupChatsForUser(ctx context.Context, userID *commonpb.UserId) ([]*Chat, error)

	// GetGroupChatsForUserByIDs is GetGroupChatsForUser restricted to chatIDs:
	// the canonical record of each given group chat that exists and that userID
	// is currently a joined member of, in no particular order. IDs the user is
	// not a member of (never, or no longer) and IDs of chats that do not exist
	// are omitted rather than reported; duplicate IDs collapse. It returns an
	// error if any ID is not a group chat ID, and an empty result (no error) when
	// chatIDs is empty.
	//
	// Membership is checked here, per ID, against the membership records — the
	// IDs are a caller's hint of what to read, never its authority to read it.
	// The group feed resumes from IDs a client echoed back in a paging token,
	// and this is what keeps a tampered token, or one that outlived the caller's
	// membership, from surfacing a chat they cannot see.
	GetGroupChatsForUserByIDs(ctx context.Context, userID *commonpb.UserId, chatIDs []*commonpb.ChatId) ([]*Chat, error)

	// AdvanceLastMessage records messageID as the chat's most recent message,
	// moving last_activity forward to ts and last_message_id to messageID, and
	// reports whether it advanced. The two fields are two views of the same event
	// (the newest message) and are updated together. If the stored last_activity
	// is already at or after ts, it is a no-op and reports advanced=false. It
	// returns ErrChatNotFound if the chat does not exist.
	//
	// For a DM it also returns the chat's members — the set the new activity is
	// fanned out to, which rides on the canonical record it must load regardless.
	// A caller that goes on to broadcast the same activity can reuse this set
	// instead of issuing a separate GetMembers. Members are returned on both the
	// advanced and no-op paths; they are nil on error (including
	// ErrChatNotFound). A group chat's membership lives in its own records, so
	// members is empty and the caller reads GetMembers itself.
	AdvanceLastMessage(ctx context.Context, chatID *commonpb.ChatId, messageID *messagingpb.MessageId, ts time.Time) (advanced bool, members []*commonpb.UserId, err error)
}

Store persists chats and their membership.

DM membership is fixed at creation time (the two participants) and is never mutated afterward. Group chat membership is mutable via AddGroupMembers and RemoveGroupMember. last_activity is advanced as new activity (typically messages) occurs and is the sort key for a user's chat list.

A chat ID's length discriminates its family (see DmChatIDSize and GroupChatIDSize): implementations dispatch on it, and each method rejects or misses IDs of the wrong family for its semantics.

type UserEventPublisher added in v1.30.0

type UserEventPublisher interface {
	OnEvent(userID *commonpb.UserId, e *eventpb.Event)
}

UserEventPublisher is the write slice of the event domain the Chat service needs to notify one user's streams — the user-keyed event bus. Like the readers it is declared here (consumer side) because the event package imports chat for stream registration, so chat cannot import it back; the event package's Bus satisfies it directly.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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