package domain import ( "crypto/sha256" "encoding/json" "errors" "fmt" "math" "strings" "time" ) // officialUpdatesChannelMention is the public @username of the updates channel // linked from the welcome message when the resolved body includes it. It is // carried in a MessageEntityMention wherever it appears in body so clients // render it as a tappable link; the mention itself lives in the default // templates in login_welcome_template.go (or an admin-panel override), not // hardcoded here. const officialUpdatesChannelMention = "@ziodotsh" // OfficialWelcomeMessage builds the account-visible incoming message sent // from the official system account on every completed sign-in (SignUp and // every subsequent SignIn/SignInWithEmail), regardless of delivery channel. // Unlike OfficialLoginCodeMessage this never embeds a secret, so it is safe // to send unconditionally — it exists to give the account owner (and, on a // self-hosted single-admin server, that's usually also "the admin") a // visible record of every session start. // // body is the already-resolved, already-{{server_name}}-substituted message // text (see ResolveWelcomeMessageTemplate / RenderWelcomeMessageTemplate in // login_welcome_template.go) -- resolving it requires the identity store and // config, both of which live above this package, so callers (internal/app/auth) // do that and pass the final text in here. func OfficialWelcomeMessage(userID int64, body string, date int) (Message, error) { body = strings.TrimSpace(body) if userID <= 0 || IsSystemUserID(userID) || body == "" || date < 0 || date > math.MaxInt32 { return Message{}, fmt.Errorf("%w: user=%d date=%d", ErrLoginCodeDeliveryInvalid, userID, date) } msg := Message{ OwnerUserID: userID, Peer: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID}, From: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID}, Date: date, Body: body, } if ent, ok := usernameMentionEntity(body, officialUpdatesChannelMention); ok { msg.Entities = []MessageEntity{ent} } return msg, nil } // usernameMentionEntity locates mention ("@name") in text and returns a // MessageEntityMention spanning it, with the UTF-16 offset/length clients expect. func usernameMentionEntity(text, mention string) (MessageEntity, bool) { before, _, found := strings.Cut(text, mention) if !found { return MessageEntity{}, false } return MessageEntity{ Type: MessageEntityMention, Offset: utf16Len(before), Length: utf16Len(mention), }, true } func utf16Len(s string) int { n := 0 for _, r := range s { n++ if r > 0xffff { n++ } } return n } // SignInMethodLabel returns the human-readable method name embedded in // OfficialWelcomeMessage. Email-signup accounts are identified by // SignupEmail (see NewEmailSignupDisplayPhone) rather than by their stored // Phone, which — once assigned — is an ordinary-looking short number that // carries no information about the signup method. func SignInMethodLabel(u User) string { if u.SignupEmail != "" { return "email" } return "phone number" } const ( MaxWelcomeMessagesPerPeer = 5 MaxWelcomeMessageContentBytes = 4 << 20 InitialWelcomeRevision = int64(1) WelcomeMessageDeliveryTTL = 24 * time.Hour ) var ( ErrWelcomeMessageInvalid = errors.New("welcome message invalid") ErrWelcomeMessagePeerInvalid = errors.New("welcome message peer invalid") ErrWelcomeMessageForbidden = errors.New("welcome message forbidden") ErrWelcomeMessageNotFound = errors.New("welcome message not found") ErrWelcomeMessageNotModified = errors.New("welcome message not modified") ErrWelcomeMessageLimit = errors.New("welcome message limit exceeded") ErrWelcomeMessageRandomIDConflict = errors.New("welcome message random id conflict") ErrWelcomeMessageRevisionOverflow = errors.New("welcome message revision overflow") ) // WelcomeMessageContent is a durable peer template. It intentionally does not // contain transient receiver, device, callback, report, TTL or reply fields. type WelcomeMessageContent struct { Message string Entities []MessageEntity Media *MessageMedia ReplyMarkup *MessageReplyMarkup RichMessage *MessageRichMessage InvertMedia bool NoForwards bool } func (c WelcomeMessageContent) Validate() error { if err := ValidateEphemeralContent(EphemeralContent{ Message: c.Message, Entities: c.Entities, Media: c.Media, ReplyMarkup: c.ReplyMarkup, RichMessage: c.RichMessage, }); err != nil { return ErrWelcomeMessageInvalid } if c.InvertMedia && c.Media == nil { return ErrWelcomeMessageInvalid } raw, err := json.Marshal(c) if err != nil || len(raw) > MaxWelcomeMessageContentBytes { return ErrWelcomeMessageInvalid } return nil } type WelcomeMessage struct { ID int Peer Peer CreatorUserID int64 Date int EditDate int RandomID int64 Content WelcomeMessageContent CreateFingerprint [32]byte Version uint64 } func (m WelcomeMessage) ValidateStored() error { if m.ID <= 0 || m.ID > MaxMessageBoxID || m.Peer.Type != PeerTypeChannel || m.Peer.ID <= 0 || m.CreatorUserID <= 0 || m.Date <= 0 || m.RandomID == 0 || m.Version == 0 || (m.EditDate != 0 && m.EditDate < m.Date) || m.CreateFingerprint == ([32]byte{}) { return ErrWelcomeMessageInvalid } return m.Content.Validate() } type CreateWelcomeMessageRequest struct { Peer Peer CreatorUserID int64 Date int RandomID int64 Content WelcomeMessageContent CreateFingerprint [32]byte } func (r CreateWelcomeMessageRequest) Validate() error { if r.Peer.Type != PeerTypeChannel || r.Peer.ID <= 0 || r.CreatorUserID <= 0 || r.Date <= 0 || r.RandomID == 0 || r.CreateFingerprint == ([32]byte{}) { return ErrWelcomeMessageInvalid } return r.Content.Validate() } type WelcomeMessageEditFields struct { SetMessage bool Message string SetEntities bool Entities []MessageEntity SetMedia bool Media *MessageMedia SetReplyMarkup bool ReplyMarkup *MessageReplyMarkup SetRichMessage bool RichMessage *MessageRichMessage SetInvertMedia bool InvertMedia bool } func (f WelcomeMessageEditFields) Empty() bool { return !f.SetMessage && !f.SetEntities && !f.SetMedia && !f.SetReplyMarkup && !f.SetRichMessage && !f.SetInvertMedia } func (f WelcomeMessageEditFields) Apply(current WelcomeMessageContent) (WelcomeMessageContent, error) { if f.Empty() { return WelcomeMessageContent{}, ErrWelcomeMessageInvalid } if f.SetMessage { current.Message = f.Message } if f.SetEntities { current.Entities = f.Entities } if f.SetMedia { current.Media = f.Media } if f.SetReplyMarkup { current.ReplyMarkup = f.ReplyMarkup } if f.SetRichMessage { current.RichMessage = f.RichMessage } if f.SetInvertMedia { current.InvertMedia = f.InvertMedia } if err := current.Validate(); err != nil { return WelcomeMessageContent{}, err } return current, nil } type EditWelcomeMessageRequest struct { Peer Peer ID int EditDate int Fields WelcomeMessageEditFields } func (r EditWelcomeMessageRequest) Validate() error { if r.Peer.Type != PeerTypeChannel || r.Peer.ID <= 0 || r.ID <= 0 || r.ID > MaxMessageBoxID || r.EditDate <= 0 || r.Fields.Empty() { return ErrWelcomeMessageInvalid } return nil } type WelcomeMessageList struct { Hash int64 Messages []WelcomeMessage NotModified bool } // WelcomeMessageDelivery is a short-lived, non-PTS snapshot created in the // same transaction as one inactive->active membership transition. It is not a // message-history row and must be physically removed no later than ExpiresAt. type WelcomeMessageDelivery struct { ID int64 JoinEventID int64 ChannelID int64 TargetUserID int64 TemplateID int EphemeralID int JoinedAt int Content WelcomeMessageContent AttemptCount int ExpiresAt time.Time } func (d WelcomeMessageDelivery) ValidateStored(now time.Time) error { if d.ID <= 0 || d.JoinEventID <= 0 || d.ChannelID <= 0 || d.TargetUserID <= 0 || d.TemplateID <= 0 || d.EphemeralID <= 0 || d.EphemeralID > MaxMessageBoxID || d.JoinedAt <= 0 || d.AttemptCount <= 0 || d.ExpiresAt.IsZero() || !d.ExpiresAt.After(now) { return ErrWelcomeMessageInvalid } return d.Content.Validate() } func WelcomeCreateFingerprint(peer Peer, creatorUserID, randomID int64, content WelcomeMessageContent) ([32]byte, error) { raw, err := json.Marshal(struct { Peer Peer CreatorUserID int64 RandomID int64 Content WelcomeMessageContent }{peer, creatorUserID, randomID, content}) if err != nil { return [32]byte{}, err } return sha256.Sum256(raw), nil } func NextWelcomeRevision(current int64) (int64, error) { if current < InitialWelcomeRevision || current == math.MaxInt64 { return 0, ErrWelcomeMessageRevisionOverflow } return current + 1, nil }