Documentation
¶
Index ¶
- Constants
- Variables
- func DeriveDmChatType(chatID *commonpb.ChatId, members []*commonpb.UserId) chatpb.ChatType
- func IsDmChatType(chatType chatpb.ChatType) bool
- func IsGroupChatID(chatID *commonpb.ChatId) bool
- func MustDeriveDmChatID(chatType chatpb.ChatType, a, b *commonpb.UserId) *commonpb.ChatId
- func MustDeriveGroupChatID(creatorID *commonpb.UserId, key *chatpb.IdempotencyKey) *commonpb.ChatId
- func MustGenerateGroupChatID() *commonpb.ChatId
- func NewBlobDMMembership(store Store) blob.DMMembership
- func SortRoster(members []GroupMember)
- type Access
- func (a *Access) CanListen(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
- func (a *Access) CanListenWithRules(ctx context.Context, chatID *commonpb.ChatId, rules *chatpb.Rules, ...) (bool, error)
- func (a *Access) CanSpeak(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
- func (a *Access) IsMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
- func (a *Access) IsMemberWithChat(ctx context.Context, c *Chat, userID *commonpb.UserId) (bool, error)
- func (a *Access) PublicStanding(c *Chat) Standing
- func (a *Access) Rules() *RuleEvaluator
- func (a *Access) Standing(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, ...) (Standing, error)
- func (a *Access) StandingWithChat(ctx context.Context, c *Chat, userID *commonpb.UserId, ...) (Standing, error)
- func (a *Access) StandingWithRules(ctx context.Context, chatID *commonpb.ChatId, rules *chatpb.Rules, ...) (Standing, error)
- type AccessOption
- type BlocklistReader
- type Chat
- type ChatEventPublisher
- type DmFeedCursor
- type GroupEdit
- type GroupMember
- type GroupMembership
- type Media
- type MembersPage
- type MessageRef
- type MessagingReader
- type MinimumBalance
- type Mute
- type Permissions
- type PointerRef
- type ProfileReader
- type Reading
- type RosterPosition
- type RosterSummary
- type RuleEvaluator
- func (e *RuleEvaluator) CanListen(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
- func (e *RuleEvaluator) CanListenWithRules(ctx context.Context, rules *chatpb.Rules, userID *commonpb.UserId) (bool, error)
- func (e *RuleEvaluator) CanSpeak(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
- func (e *RuleEvaluator) CanSpeakWithRules(ctx context.Context, rules *chatpb.Rules, userID *commonpb.UserId) (bool, error)
- type Server
- func (s *Server) EditChat(ctx context.Context, req *chatpb.EditChatRequest) (*chatpb.EditChatResponse, error)
- func (s *Server) GetChat(ctx context.Context, req *chatpb.GetChatRequest) (*chatpb.GetChatResponse, error)
- func (s *Server) GetDmChatFeed(ctx context.Context, req *chatpb.GetDmChatFeedRequest) (*chatpb.GetDmChatFeedResponse, error)
- func (s *Server) GetGroupChatFeed(ctx context.Context, req *chatpb.GetGroupChatFeedRequest) (*chatpb.GetGroupChatFeedResponse, error)
- func (s *Server) GetRoster(ctx context.Context, req *chatpb.GetRosterRequest) (*chatpb.GetRosterResponse, error)
- func (s *Server) JoinChat(ctx context.Context, req *chatpb.JoinChatRequest) (*chatpb.JoinChatResponse, error)
- func (s *Server) LeaveChat(ctx context.Context, req *chatpb.LeaveChatRequest) (*chatpb.LeaveChatResponse, error)
- func (s *Server) MuteChat(ctx context.Context, req *chatpb.MuteChatRequest) (*chatpb.MuteChatResponse, error)
- func (s *Server) StartChat(ctx context.Context, req *chatpb.StartChatRequest) (*chatpb.StartChatResponse, error)
- func (s *Server) UnmuteChat(ctx context.Context, req *chatpb.UnmuteChatRequest) (*chatpb.UnmuteChatResponse, error)
- type Standing
- type Store
- type UserEventPublisher
- type ViewerState
Constants ¶
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.
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).
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).
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.
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 ¶
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") )
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.
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).
var ErrMuteUntilOutOfRange = errors.New("mute until is out of range")
ErrMuteUntilOutOfRange indicates that a timed mute ends outside the range a store can record (see Mute.Until).
var MaxMuteUntil = time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
MaxMuteUntil bounds a timed mute: Mute.Until must be strictly before it. Stores encode an indefinite mute as this instant, so a timed mute reaching it would read back as indefinite; rejecting the boundary keeps the two distinguishable. No real mute ends in the year 9999, so nothing is lost.
Functions ¶
func DeriveDmChatType ¶ added in v1.23.0
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
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
IsGroupChatID reports whether chatID is a group chat ID, by length (see GroupChatIDSize).
func MustDeriveDmChatID ¶
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
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
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.
func NewBlobDMMembership ¶ added in v1.32.0
func NewBlobDMMembership(store Store) blob.DMMembership
NewBlobDMMembership returns a blob.DMMembership backed by the given chat store, for wiring the blob service.
func SortRoster ¶ added in v1.32.0
func SortRoster(members []GroupMember)
SortRoster sorts members into roster order (see RosterPosition).
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) IsMemberWithChat ¶ added in v1.32.0
func (a *Access) IsMemberWithChat(ctx context.Context, c *Chat, userID *commonpb.UserId) (bool, error)
IsMemberWithChat is IsMember for a caller already holding the chat's canonical record: a DM is answered off the record, a group from the store (see StandingWithChat).
func (*Access) PublicStanding ¶ added in v1.32.0
PublicStanding is the standing of a viewer who is no one — an unauthenticated read of a chat's public view (see chat.Server.GetChat) — towards the chat whose canonical record the caller holds. They are on no roster and satisfy no rule, so they stand exactly where a registered non-member reading under REDACTED does: a group that carries a listener rule may be previewed, and nothing else admits them in any form. Nothing is read: the rules come off the record.
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) StandingWithChat ¶ added in v1.32.0
func (a *Access) StandingWithChat(ctx context.Context, c *Chat, userID *commonpb.UserId, mode messagingpb.ViewMode) (Standing, error)
StandingWithChat is Standing for a caller already holding the chat's canonical record, which decides all it can before the store is asked again: a DM's membership is read off the record's inline members (see Chat.HasMember), so a DM costs no membership read at all, and a group's rules come off the record as for StandingWithRules. A group's membership is not on its record and is read from the store, strongly consistent, as every gate reads it. The chat is taken as existing, as a caller holding its record has established.
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) HasMember ¶
HasMember reports whether userID is among the record's inline Members. It is a DM's membership check — a DM's members are fixed at creation and carried on its canonical record, so a caller holding the record has the answer in hand — and says nothing about a group, whose record carries no members (see Store.GetChatByID): for a group it is always false.
func (*Chat) IsCreator ¶ added in v1.32.0
IsCreator reports whether userID created the chat (see CreatorID). It is false for a DM, and for a group whose creator was never recorded.
func (*Chat) PermissionsFor ¶ added in v1.32.0
func (c *Chat) PermissionsFor(userID *commonpb.UserId, isMember bool) Permissions
PermissionsFor returns what userID may do in the chat (see Permissions), given whether they are a member — which the caller has established, since a group's membership is not on its record. A non-member may do nothing. A member may edit the chat if and only if they created it: a DM has no creator, so a DM is never editable, and a group's creator who has left it edits nothing until they rejoin.
func (*Chat) Rules ¶ added in v1.30.0
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 ¶
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, creator (a group's, when recorded), 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
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 ¶
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 GroupEdit ¶ added in v1.32.0
GroupEdit is a change to a group's editable record fields (see Store.EditGroup): the title, and the blob holding its picture's ORIGINAL. A nil field is left as it is, so an edit names only what it changes and two edits of different fields never overwrite each other. Neither field can be cleared through an edit — a picture is removed with Store.SetGroupPicture — and an edit that names nothing is invalid.
type GroupMember ¶ added in v1.32.0
GroupMember is one joined member of a group as its membership record holds them: who, when they most recently joined, and the roster version that placed them there — the version the group's RosterSummary moved to on their join, or zero for a member written at the group's creation, which no transition has touched (see GroupMembership for the same stamp read the other way round). A member who left and rejoined carries the rejoin's time and version. It is what a roster read hands a client per member, and what Member.joined_at and Member.version are projected from.
func (GroupMember) Position ¶ added in v1.32.0
func (m GroupMember) Position() RosterPosition
Position is the member's place in roster order (see RosterPosition).
type GroupMembership ¶ added in v1.30.0
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 MembersPage ¶ added in v1.32.0
MembersPage is one page of a group's joined members in user-ID order (see Store.GetGroupMembersPage): the users, and the cursor to resume after, nil once the walk is complete. Its first and last user bound the key range a caller hands Store.GetMutedUsersPage for the same page.
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
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 of at least the currency's minimum transfer value — one unit at its last decimal place, a penny for USD, a yen for JPY, 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 currency is any ISO 4217 code: the proto bounds its shape, and whether OCP can value a balance in it is OCP's to say, which it does the first time the rule is evaluated — for a new group, against its creator, before anything is written (see Server.StartChat). 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
func (m *MinimumBalance) ToProto() *chatpb.MinimumBalanceRequirement
ToProto projects the requirement onto a chatpb.MinimumBalanceRequirement.
type Mute ¶ added in v1.32.0
Mute is a mute a viewer set on a chat: until an instant, or until they lift it themselves (Forever). While a mute is active the viewer still receives the chat's pushes, flagged so the client suppresses the notification (see push.v1.ChatMetadata.muted); it never affects message delivery.
Until is meaningful only when Forever is false, and is recorded at second precision: a store truncates it on write and returns the truncated value. It must lie in [Unix epoch, MaxMuteUntil), else the write is rejected with ErrMuteUntilOutOfRange. A timed mute past its Until has lapsed: it is still recorded, and returned as stored, until replaced or cleared — nothing sweeps it, and nothing is published when it lapses — so a reader decides with Active, never from presence alone.
func MuteFromProto ¶ added in v1.32.0
MuteFromProto is the inverse of Mute.ToProto. A MuteState carrying neither duration — which validation rejects before a request reaches here — reads as a timed mute at the zero time, which no store records (see Mute).
func (Mute) Normalize ¶ added in v1.32.0
Normalize returns the mute as a store records it — Until truncated to the second, in UTC, and zeroed when Forever — or ErrMuteUntilOutOfRange when it cannot be recorded. Every store applies it on write, so equality of two normalized mutes is what "the mute already recorded" means.
type Permissions ¶ added in v1.32.0
type Permissions struct {
CanEdit bool
}
Permissions is what a user may do in a chat beyond what membership alone allows every member: today, whether they may edit its record (see Server.EditChat). It is computed from the chat's record and the user's membership (see Chat.PermissionsFor), never stored, and shown to the user alone on their ViewerState — a client shows an affordance if and only if its flag is set, so the flags are the server's word on what an RPC will admit, and every gate must agree with them.
func (Permissions) ToProto ¶ added in v1.32.0
func (p Permissions) ToProto() *chatpb.ViewerState_Permissions
ToProto projects the permissions onto a chatpb.ViewerState_Permissions.
type PointerRef ¶ added in v1.14.0
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).
type RosterPosition ¶ added in v1.32.0
RosterPosition is a place in a group's roster order: most recently joined first, ties broken by user ID descending, so the order is total and a page can resume strictly after the last member it carried. Store.GetGroupRosterPage walks in this order; a caller that reads a roster whole sorts it with SortRoster. Ties in join time are nanosecond coincidences within one group, so the tie-break exists for totality rather than for anything a client sees.
A DM's participants have no join time (see Member.joined_at): a DM's position is the zero time and the user ID alone, so a DM's roster is in user-ID order under the same rule.
func (RosterPosition) Compare ¶ added in v1.32.0
func (p RosterPosition) Compare(o RosterPosition) int
Compare orders positions in roster order: negative when p comes before o (joined later, or the same instant with the greater user ID), positive when after, zero when they name the same member.
type RosterSummary ¶ added in v1.30.0
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 (*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, disableGetRoster bool, ) *Server
func (*Server) EditChat ¶ added in v1.32.0
func (s *Server) EditChat(ctx context.Context, req *chatpb.EditChatRequest) (*chatpb.EditChatResponse, error)
EditChat changes a group's title and/or picture, atomically.
Only a group can be edited, and only by its creator while they are a member (see Chat.PermissionsFor): a DM has no editable record and no creator, a group written before creators were recorded has no one who may edit it, and a creator who left their group edits nothing until they rejoin. Anyone else is DENIED — the same answer ViewerState.Permissions.can_edit gives them, so a client never shows an affordance this refuses. Creatorship is decided off the record before membership is read, so the store is asked nothing on behalf of a caller who could never edit. The staff gate on the membership RPCs does not apply: while it holds, every creator is staff by construction, and one who predates it keeps their own group.
A field set to the value the record already holds is dropped before anything is checked, so a retried request, or a client that sends every field, costs no moderation and no attach — and a request left with nothing to change is answered OK from the record as it stands, with nothing written and nothing published. What remains is checked in StartChat's order, for its reasons: the title through moderation (TITLE_MODERATED), then the picture attached (PICTURE_BLOB_NOT_ACCEPTED), and only once every part has passed is the record written, as one update naming exactly those fields. The atomicity the proto promises is that: a refusal of either part leaves the record as it was, though a picture attached ahead of a write that then fails stays granted — harmless, as in StartChat.
A real change is announced once, on the chat topic, to every member's devices including the editor's, as one MetadataUpdate per field changed; the picture's is the resolved rendition set the response carries. The response is the record after the edit as the editor sees it. A group whose record vanished between the read and the write is NOT_FOUND.
func (*Server) GetChat ¶
func (s *Server) GetChat(ctx context.Context, req *chatpb.GetChatRequest) (*chatpb.GetChatResponse, error)
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.
Auth is optional. A request without it asks for the chat's public view (see getPublicChat).
func (*Server) GetDmChatFeed ¶
func (s *Server) GetDmChatFeed(ctx context.Context, req *chatpb.GetDmChatFeedRequest) (*chatpb.GetDmChatFeedResponse, error)
func (*Server) GetGroupChatFeed ¶ added in v1.30.0
func (s *Server) GetGroupChatFeed(ctx context.Context, req *chatpb.GetGroupChatFeedRequest) (*chatpb.GetGroupChatFeedResponse, error)
func (*Server) GetRoster ¶ added in v1.32.0
func (s *Server) GetRoster(ctx context.Context, req *chatpb.GetRosterRequest) (*chatpb.GetRosterResponse, error)
func (*Server) JoinChat ¶ added in v1.30.0
func (s *Server) JoinChat(ctx context.Context, req *chatpb.JoinChatRequest) (*chatpb.JoinChatResponse, error)
func (*Server) LeaveChat ¶ added in v1.30.0
func (s *Server) LeaveChat(ctx context.Context, req *chatpb.LeaveChatRequest) (*chatpb.LeaveChatResponse, error)
func (*Server) MuteChat ¶ added in v1.32.0
func (s *Server) MuteChat(ctx context.Context, req *chatpb.MuteChatRequest) (*chatpb.MuteChatResponse, error)
MuteChat records the caller's mute on a chat, until a time or indefinitely.
Only a member may mute: the gate is membership alone, never the rules, so a member a rule has since excluded can still quiet the chat they are in. A DM member is always a member, so DMs are muted like groups. A timed mute must end in the future as it will be recorded (see Mute.Normalize), which is checked here rather than by request validation because MuteState also appears in responses, where a mute that lapsed a moment before the read must not fail a client's validation.
The write is the store's own no-op-aware update: the mute already recorded is answered with the current state and publishes nothing, and any real change — a first mute, a different end, indefinite where timed was — reaches the caller's other devices as a MetadataUpdate.ViewerStateChanged on their user topic (see publishViewerStateChanged). The response carries the same state, so the calling device applies it at its version without waiting for the stream. A mute lasts as long as the membership it was set under: leaving the chat clears it, best effort (see clearMuteOnLeave), and a departed member cannot set or clear one until they rejoin.
func (*Server) StartChat ¶ added in v1.30.0
func (s *Server) StartChat(ctx context.Context, req *chatpb.StartChatRequest) (*chatpb.StartChatResponse, error)
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, in a currency OCP can value (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).
func (*Server) UnmuteChat ¶ added in v1.32.0
func (s *Server) UnmuteChat(ctx context.Context, req *chatpb.UnmuteChatRequest) (*chatpb.UnmuteChatResponse, error)
UnmuteChat clears the caller's mute on a chat, lapsed or not. It is gated and published exactly as MuteChat is; clearing a chat that is not muted is the no-op that returns the current state and publishes nothing.
type Standing ¶ added in v1.30.0
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
// EditGroup applies edit to a group chat's canonical record (see
// GroupEdit): each field the edit names is set, and every other field is
// left as it is, in one write. Like SetGroupPicture it touches only the
// canonical record and validates nothing — the title is moderated and the
// picture attached before this is called (see Server.EditChat). It returns
// ErrChatNotFound if the chat does not exist, and an error if chatID is
// not a group chat ID or the edit names nothing.
EditGroup(ctx context.Context, chatID *commonpb.ChatId, edit GroupEdit) 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)
// GetGroupMembersPage is GetMembers as a walk over a group's roster: the
// currently joined members in ascending user-ID (byte) order, strictly
// after the after cursor (nil starts at the beginning), at most limit of
// them (limit <= 0 means unbounded). The order is the one
// GetMutedUsersPage returns a chat's mutes in, so a fan-out can read the
// mutes between a roster page's first and last user and never hold either
// whole; that is what bounds a push to a large group by its page size
// rather than its membership. A page's cursor is the last user it
// carries whenever the limit was reached, whether or not any member
// follows, so the walk may end on an empty, final page.
//
// It reads the membership records alone, never the canonical record: a
// group that does not exist is an empty page, not ErrChatNotFound, and a
// caller that needs the distinction reads GetChatByID first — which the
// walkers do anyway, for the metadata a push carries. Like GetMembers, it
// may lag a transition by a moment. It returns an error if chatID is not a
// group chat ID.
GetGroupMembersPage(ctx context.Context, chatID *commonpb.ChatId, after *commonpb.UserId, limit int) (MembersPage, error)
// GetGroupRoster returns a group's RosterSummary and every joined member
// (see GroupMember), in no particular order, from one strongly consistent
// read: the members are exactly the roster at the summary's version, and
// the summary's count is their number — an implementation whose read is
// not a snapshot verifies that against the rows and re-reads, a bounded
// number of times, when a transition landed mid-read, so the promise holds
// short of a roster churning faster than it can be read. It is the read
// behind a small group's roster page, which promises a client exactly
// that (see Server.GetRoster); it costs the whole roster, so a caller
// sizes the group first (GetGroupRosterSummary) and pages a large one with
// GetGroupRosterPage instead. It returns ErrChatNotFound if the chat does
// not exist, and an error if chatID is not a group chat ID.
GetGroupRoster(ctx context.Context, chatID *commonpb.ChatId) (RosterSummary, []GroupMember, error)
// GetGroupRosterPage is one page of a group's joined members in roster
// order (see RosterPosition): most recently joined first, strictly after
// the after position (nil starts at the newest member), at most limit of
// them (limit <= 0 means unbounded). A position need not name a current
// member: a cursor whose member has since left resumes just as well. The
// read may lag a transition by a moment — it is served from an index of
// the joined members in join order rather than the records themselves, so
// a member who just joined may be missing and one who just left may still
// appear — which is what a large group's page tolerates (see
// Server.GetRoster). It reads the membership records alone, never the
// canonical record, so a group that does not exist is an empty page. It
// returns an error if chatID is not a group chat ID.
GetGroupRosterPage(ctx context.Context, chatID *commonpb.ChatId, after *RosterPosition, limit int) ([]GroupMember, error)
// GetGroupMemberRecords returns userID's joined membership record (see
// GroupMember) on each of chatIDs they are currently a member of, keyed by
// string(chatID.Value); a chat they are not joined to — never, or no
// longer — or that does not exist is absent rather than reported, and
// duplicate IDs collapse. The read is strongly consistent, as IsMember is
// and for the same reason: it is asked about a chat the user just joined
// or created, to announce the record the join wrote (see
// RosterUpdate.MemberJoined). It returns an error if any ID is not a group
// chat ID, and an empty map (no error) when chatIDs is empty.
GetGroupMemberRecords(ctx context.Context, userID *commonpb.UserId, chatIDs []*commonpb.ChatId) (map[string]GroupMember, 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)
// SetMute records mute as userID's mute on chatID, replacing any mute
// already set, and returns the state after the write. changed reports
// whether anything moved: recording the mute already recorded (after
// Mute.Normalize) is a no-op that returns the current state at its
// current version. It returns ErrMuteUntilOutOfRange for a timed mute
// outside the recordable range (see Mute).
SetMute(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId, mute Mute) (state ViewerState, changed bool, err error)
// ClearMute removes userID's mute on chatID, lapsed or not, and returns
// the state after the write. Clearing when no mute is recorded is a no-op
// that returns the current state — the zero state when the user has no
// record, which the no-op does not create.
ClearMute(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (state ViewerState, changed bool, err error)
// GetViewerStates returns userID's state on each of chatIDs that they have
// a record on, keyed by string(chatID.Value); a chat with no record is
// absent rather than reported, and duplicate IDs collapse. The read is
// strongly consistent: it reflects every write that completed before it,
// so the user who just muted sees the mute. An empty chatIDs is an empty
// result, not an error.
GetViewerStates(ctx context.Context, userID *commonpb.UserId, chatIDs []*commonpb.ChatId) (map[string]ViewerState, error)
// GetMutedUsers returns the users whose mute on chatID is active at now
// (see Mute.Active), at most limit of them when limit is positive; which
// users are omitted when the limit truncates is unspecified, and the
// order is arbitrary. It reads only the recorded mutes, so it is the
// cheaper read for a chat few have muted; see GetMutedCount for choosing.
// The read may lag a write by a moment — it is the fan-out's read, where
// a mute that lands during a send is tolerated, and the flag it feeds is
// best-effort by contract — and a caller that needs a user's own current
// state reads GetViewerStates instead.
GetMutedUsers(ctx context.Context, chatID *commonpb.ChatId, now time.Time, limit int) ([]*commonpb.UserId, error)
// GetMutedUsersPage is GetMutedUsers bounded to a key range: the users
// whose mute on chatID is active at now and whose ID lies in [lo, hi]
// (byte order, inclusive; a nil bound is open on that side), in ascending
// user-ID order. The order is the one GetGroupMembersPage walks a group's
// roster in, so a fan-out that holds one page of the roster asks for the
// mutes between that page's first and last user and never holds either
// whole; that is the read for a chat most have muted. It reads every
// record the chat's users hold in the range, muted or not — including
// records of users who have since left, which a caller intersects with
// the roster — and consistency is as for GetMutedUsers.
GetMutedUsersPage(ctx context.Context, chatID *commonpb.ChatId, now time.Time, lo, hi *commonpb.UserId) ([]*commonpb.UserId, error)
// GetMutedCount returns how many users have a mute recorded on chatID:
// moved with every mute first recorded or cleared, never by a replaced
// mute or a timed one lapsing, so it bounds the active mutes from above.
// It is what a fan-out compares against the roster size to choose
// between GetMutedUsers and GetMutedUsersPage, and is read for that
// alone: like them, it may lag a write by a moment, which a choice of
// shape tolerates. A chat nobody has muted reads as zero.
GetMutedCount(ctx context.Context, chatID *commonpb.ChatId) (uint64, error)
}
Store persists chats, their membership, and each user's own state on a chat.
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 — except the viewer-state methods, which accept both and do not care which.
Viewer state (see ViewerState) is one record per (user, chat), written only by that user's own actions and read back only to them — plus the chat-scoped reads GetMutedUsers, GetMutedUsersPage and GetMutedCount, which serve the push fan-out. Records are sparse: nothing is written when a chat is created or joined, a record appears on the user's first write, and nothing removes it — a cleared mute leaves the record and its version behind. The record knows nothing of membership: a departure clears a mute only because the leave handler calls ClearMute (see Server.LeaveChat), and whether the user may act on the chat is likewise the caller's gate. Every write is conditional — a single update, or one transaction where a store keeps an aggregate alongside the record — and moves Version by exactly one on a real change and not at all on a no-op, so a retried or duplicated request is harmless.
type UserEventPublisher ¶ added in v1.30.0
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.
type ViewerState ¶ added in v1.32.0
ViewerState is what a chat holds about one user, independent of whether they are a member: state the user set for themselves (today, a mute) and a version over all of it. It is private to that user and never shared with other members, and shown to the user only while they are a member (see Server.hydrate). The record outlives membership — the version never resets — but what it holds may not: a mute is cleared, best effort, when the user leaves the chat (see Server.LeaveChat), so a user who returns to a group starts unmuted unless that clear failed.
Mute is the recorded mute, or nil when none is set; see Mute for what a recorded mute may still mean. Version is state, not a delta: every real change — a mute set, replaced or cleared — moves it by exactly one and an idempotent no-op leaves it alone, so a client keeps the greater of two versions and drops the rest, whatever order they arrived in. It is not the roster version (see RosterSummary), which nothing here ever moves.
What a client is shown alongside the record is the chat's Permissions for the user (see ToProto), computed from the chat's record and the user's membership at the time of the read rather than stored, and not covered by Version: a membership transition changes what the user may do (see Chat.PermissionsFor) without moving the record, so the same version may carry different permissions before and after one. That is accepted for now — a transition of the user's own is what changes them, and it reaches the user's devices as a RosterUpdate carrying fresh metadata — rather than moving the version on every transition of a user who may hold no record.
func (ViewerState) ActiveMute ¶ added in v1.32.0
func (v ViewerState) ActiveMute(now time.Time) *Mute
ActiveMute returns the recorded mute when it is in force at now, else nil.
func (ViewerState) Clone ¶ added in v1.32.0
func (v ViewerState) Clone() ViewerState
Clone returns a deep copy of the state.
func (ViewerState) ToProto ¶ added in v1.32.0
func (v ViewerState) ToProto(permissions Permissions) *chatpb.ViewerState
ToProto projects the state onto a chatpb.ViewerState exactly as recorded: the version, under settings the mute if one is recorded, lapsed or not, and the user's permissions in the chat as the caller computed them (see Permissions). A version names one exact state, and every carrier of that version — a response, a hydrated Metadata, a ViewerStateChanged — must agree on it, which they could not if the projection depended on the clock it was made at. Whether a timed mute is still in force is the reader's call against its own clock (see Mute.Active); the server makes that call only where it acts on it, in the push fan-out. The permissions are always carried, empty for a user who may do nothing, so a carrier never leaves a client to guess whether they were withheld or are none.