merged with fixes

This commit is contained in:
onysd 2026-09-09 02:49:30 +03:00
parent a9e758b712
commit 2f1818d656
176 changed files with 9000 additions and 907 deletions

View file

@ -4,6 +4,9 @@ import (
"errors"
"strings"
"time"
"unicode"
"github.com/nyaruka/phonenumbers"
)
var (
@ -260,38 +263,105 @@ func MaskEmail(email string) string {
return name[:1] + "***" + name[len(name)-1:] + email[at:]
}
// NormalizePhone 仅保留手机号中的数字(与 users.phone 的存储形态一致)。全部被过滤
// 掉时返回原串,便于上层做 validPhone 拒绝。auth/account 两域共用同一规则避免漂移。
//
// Email-signup 合成号码EncodeEmailPhone 生成,"888" 前缀 + 至少一个字母)是唯一例外:
// 原样保留(仅 lower+trim不剥离字母——否则 DecodeEmailPhone 会因编码内容被剥空而
// 永远解不出邮箱。真实手机号恒为纯数字,不含字母,故这个判定不会误伤任何真实号码。
func NormalizePhone(phone string) string {
if IsEmailSignupPhone(phone) {
return strings.ToLower(strings.TrimSpace(phone))
}
// virtualLoginPhoneMinDigits/virtualLoginPhoneMaxDigits bound the "888"-prefixed
// virtual login identity range NormalizePhone accepts without going through
// libphonenumber (real E.164 numbers never start with 888). This is a login
// identity concept only -- distinct from, and independent of, ownership of any
// purchasable collectible-phone asset with the same digit shape.
const (
virtualLoginPhoneMinDigits = 7
virtualLoginPhoneMaxDigits = 15
)
// PhoneDigits removes presentation punctuation from a phone number. It is
// intentionally not an identity canonicalizer: callers that select accounts,
// issue codes, or persist users must use NormalizePhone and ValidPhone.
func PhoneDigits(phone string) string {
var b strings.Builder
b.Grow(len(phone))
seenDigit := false
seenPlus := false
for _, r := range phone {
if r >= '0' && r <= '9' {
switch {
case r >= '0' && r <= '9':
b.WriteRune(r)
seenDigit = true
case r == '+':
if seenPlus || seenDigit {
return ""
}
seenPlus = true
case unicode.IsSpace(r), r == '-', r == '(', r == ')', r == '.', r == '/':
// Presentation separators accepted by official clients and contact UIs.
default:
return ""
}
}
if b.Len() == 0 {
return phone
}
return b.String()
}
// ValidPhone 校验 NormalizePhone 后的持久化形态:真实手机号是 5-200 位纯数字;
// email-signup 合成号码额外允许小写字母EncodeEmailPhone 的转义字符集)。
// 上限与 users.phone 列宽一致;当前开发登录/改号链路不强制精确 E.164 长度,
// 但拒绝空串、非法字符和会截断的超长输入。
func ValidPhone(phone string) bool {
if len(phone) < 5 || len(phone) > 200 {
return false
}
// NormalizePhone returns the one persisted login identity. Virtual +888
// identities are independent of the collectible-phone registry and accept
// 7-15 canonical digits. Ordinary international numbers use E.164 digits
// without the leading '+'. Their parsing is deliberately country-aware so a
// national trunk prefix is removed only where the numbering plan says it is a
// prefix. For example, both +98 0998 167 9461 and +98 998 167 9461 become
// 989981679461, while Italy's significant leading zero in +39 02 ... is retained.
//
// Email-signup synthetic numbers (EncodeEmailPhone, "888" prefix plus at least
// one letter) are a separate exception, kept as-is (lower+trim only, no digit
// stripping) -- otherwise DecodeEmailPhone could never recover the email from
// an already letter-stripped value. A real phone is always pure digits, so
// this check never misclassifies one.
//
// IsPossibleNumber is the structural gate rather than IsValidNumber. It keeps
// syntactically possible reserved/test ranges usable without accepting local
// numbers that omit their country calling code or numbers outside E.164's
// length/plan metadata.
func NormalizePhone(phone string) string {
if IsEmailSignupPhone(phone) {
return strings.ToLower(strings.TrimSpace(phone))
}
digits := PhoneDigits(phone)
if digits == "" {
return ""
}
// Every syntactically valid +888 virtual number is an independent login
// identity; minting or owning the same collectible-phone value is not a
// prerequisite. users.phone therefore takes lookup precedence over any
// optional collectible alias registry.
if len(digits) >= virtualLoginPhoneMinDigits &&
len(digits) <= virtualLoginPhoneMaxDigits &&
strings.HasPrefix(digits, "888") {
return digits
}
// 42777 is the reserved, non-login phone of the built-in service identity.
// It predates the ordinary E.164 user invariant and remains resolvable only
// so auth can reject it as a system account instead of treating it as free.
if digits == OfficialSystemPhone {
return digits
}
number, err := phonenumbers.Parse("+"+digits, phonenumbers.UNKNOWN_REGION)
if err != nil || !phonenumbers.IsPossibleNumber(number) {
return ""
}
canonical := strings.TrimPrefix(phonenumbers.Format(number, phonenumbers.E164), "+")
if canonical == "" || len(canonical) > 15 {
return ""
}
return canonical
}
// ValidPhone reports whether phone is already in the persisted canonical form.
// Callers accepting user input normalize first, then validate, so equivalent
// international spellings converge before lookup, rate limiting, OTP delivery,
// and uniqueness checks. Email-signup synthetic numbers keep their own
// lower+trim canonical form (see NormalizePhone).
func ValidPhone(phone string) bool {
if IsEmailSignupPhone(phone) {
if len(phone) < 5 || len(phone) > 200 {
return false
}
for _, r := range phone {
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') {
return false
@ -299,10 +369,6 @@ func ValidPhone(phone string) bool {
}
return true
}
for _, r := range phone {
if r < '0' || r > '9' {
return false
}
}
return true
canonical := NormalizePhone(phone)
return canonical != "" && canonical == phone
}

View file

@ -43,4 +43,7 @@ type AuthKeyClientInfo struct {
SystemVersion string
APIID int
AppVersion string
// IP 是最近一次会话建立的客户端对端地址host-only。只做 metadata 级别的
// 合并刷新,绝不当作登录/绑定的身份证据,也不会触碰 created_at。
IP string
}

View file

@ -104,8 +104,13 @@ func TestNewEmailSignupDisplayPhoneHonorsConfiguredPrefix(t *testing.T) {
if !strings.HasPrefix(phone, prefix) {
t.Fatalf("phone %q missing configured prefix %q", phone, prefix)
}
if !ValidPhone(phone) {
t.Fatalf("phone %q fails ValidPhone", phone)
// A display phone is cosmetic only (see assignEmailSignupDisplayPhone --
// it never goes through ValidPhone in production, only a uniqueness
// check): random digits after a real prefix essentially never form a
// libphonenumber-possible number, so the invariant worth checking here
// is "still a plain digit string", not full E.164 validity.
if PhoneDigits(phone) != phone {
t.Fatalf("phone %q is not a plain digit string", phone)
}
}
}

View file

@ -41,7 +41,7 @@ func TestRenderWelcomeMessageTemplateSubstitutesServerName(t *testing.T) {
got := RenderWelcomeMessageTemplate("Hello from {{server_name}}!")
if got != "Hello from OwpenGram!" {
t.Fatalf("expected default branding.ProductName substitution, got %q", got)
t.Fatalf("expected default branding.ProductName() substitution, got %q", got)
}
SetOfficialSystemUserDisplayName("Custom Server")

View file

@ -49,7 +49,8 @@ func (c MediaCategoryCounts) CountAny(categories []MediaCategory) int {
// MediaSearchRequest 是共享媒体标签页分页查询的入参messages.search 媒体过滤分支)。
// Categories 是该标签页映射到的基础类别并集PhotoVideo→[Photo,Video]、RoundVoice→[Voice,RoundVideo])。
// 分页对齐历史语义OffsetID 为游标(返回 id 严格小于它、AddOffset 为额外偏移、MaxID/MinID 为闭区间。
// OffsetID 定位第一条严格更旧的消息;负 AddOffset 向更新侧取数(可含游标本身)。
// MaxID/MinID、MaxDate/MinDate 均为开区间;计数忽略分页偏移。
type MediaSearchRequest struct {
Categories []MediaCategory
Query string

View file

@ -35,7 +35,7 @@ const (
// MaxMessageReplyQuoteLength matches TDesktop's quote_length_max app config default.
MaxMessageReplyQuoteLength = 1024
// MaxMessageReplyQuoteOffset bounds quote_offset, which is an offset inside message text, not a message id.
MaxMessageReplyQuoteOffset = MaxMessageTextLength
MaxMessageReplyQuoteOffset = 2 * MaxMessageTextLength // UTF-16 units, including surrogate pairs.
// MaxMessageEntityCount limits styled text entity vectors in message text and quotes.
MaxMessageEntityCount = 256
// MaxMessageBoxID 是 TL int / PostgreSQL int4 可安全表达的最大 message id。
@ -81,7 +81,7 @@ func ValidateMessageReplyBounds(reply *MessageReply) error {
return ErrReplyMessageIDInvalid
}
// story 回复StoryID>0不携带 MessageID/TopMessageID普通回复至少有其一。
if reply.MessageID == 0 && reply.TopMessageID == 0 && reply.StoryID == 0 {
if reply.MessageID == 0 && reply.TopMessageID == 0 && reply.StoryID == 0 && reply.External == nil {
return ErrReplyMessageIDInvalid
}
if reply.QuoteOffset < 0 || reply.QuoteOffset > MaxMessageReplyQuoteOffset {
@ -250,6 +250,9 @@ type MessageReply struct {
QuoteText string
QuoteEntities []MessageEntity
QuoteOffset int
// External is an immutable source snapshot resolved by the owning store.
// It is not client input and does not authorize a source message lookup.
External *MessageReplyExternal `json:",omitempty"`
// StoryID > 0 表示这是一条对 story 的回复评论MessageID 为 0Peer 为 story 作者,
// 投影为 messageReplyStoryHeader 而非普通 messageReplyHeader。
StoryID int
@ -287,6 +290,11 @@ type MessageFilter struct {
MaxID int
MinID int
Hash int64
// SenderUserID intersects all other predicates; zero means no sender filter.
SenderUserID int64
// CountOnly ignores pagination and returns the exact filtered total without
// loading message payloads or users. History's default limit is separate.
CountOnly bool
// PinnedOnly 仅返回置顶消息messages.search filterPinned 与
// userFull.pinned_msg_id 的查询路径)。
PinnedOnly bool
@ -716,11 +724,13 @@ type PinPrivateMessageRequest struct {
Pinned bool
// PmOneside 仅置顶在本侧(官方私聊置顶框"同时为对方置顶"未勾选时),
// 不向对端翻转、不生成服务消息。unpin 无此语义,恒双侧清除。
PmOneside bool
Silent bool
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
PmOneside bool
Silent bool
// RecipientBlocked applies the normal private-service delivery policy.
RecipientBlocked bool
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
// PinnedMessagesForUser 描述置顶状态变化对某个 owner 视角的影响。

View file

@ -26,6 +26,7 @@ var (
// undisclosed delivery and make a retry impossible to reconcile.
ErrLoginCodeDeliveryCommitAmbiguous = errors.New("login code delivery commit ambiguous")
ErrReplyMessageIDInvalid = errors.New("reply message id invalid")
ErrQuoteTextInvalid = errors.New("quote text invalid")
ErrChatForwardsRestricted = errors.New("chat forwards restricted")
ErrNoForwardsRequestExpired = errors.New("no forwards request expired")
// ErrPinnedSavedDialogsTooMuch 映射 PINNED_TOO_MUCH收藏夹子会话置顶

View file

@ -0,0 +1,193 @@
package domain
import (
"bytes"
"encoding/json"
"fmt"
"io"
"reflect"
"unicode/utf16"
"unicode/utf8"
)
const MaxMessageReplyExternalBytes = 1 << 20
// MessageReplyExternal retains the source at send time, independently of its
// later edit/deletion. It never contains another reply or owner-local jump IDs.
type MessageReplyExternal struct {
From MessageForward `json:"from"`
Text string `json:"text"`
Entities []MessageEntity `json:"entities,omitempty"`
Media *MessageMedia `json:"media,omitempty"`
}
// Apply the same validation when this value is nested in an immutable send
// receipt. Otherwise json.Unmarshal there would discard unknown fields or
// accept an invalid author even though the message-box decoder rejects it.
func (v *MessageReplyExternal) UnmarshalJSON(b []byte) error {
if len(b) > MaxMessageReplyExternalBytes {
return fmt.Errorf("external reply snapshot exceeds size bound")
}
type snapshot MessageReplyExternal
var out snapshot
d := json.NewDecoder(bytes.NewReader(b))
d.DisallowUnknownFields()
if err := d.Decode(&out); err != nil {
return fmt.Errorf("decode external reply: %w", err)
}
if err := d.Decode(new(any)); err != io.EOF {
return fmt.Errorf("external reply trailing JSON")
}
if _, err := EncodeMessageReplyExternal((*MessageReplyExternal)(&out)); err != nil {
return err
}
*v = MessageReplyExternal(out)
return nil
}
func EncodeMessageReplyExternal(v *MessageReplyExternal) ([]byte, error) {
if v == nil {
return []byte("{}"), nil
}
if v.From.Date <= 0 || v.From.From.ID <= 0 || (v.From.From.Type != PeerTypeUser && v.From.From.Type != PeerTypeChannel) || v.From.SavedFrom.ID != 0 || v.From.SavedFromMsgID != 0 || len(v.Entities) > MaxMessageEntityCount || !utf8.ValidString(v.Text) || utf8.RuneCountInString(v.Text) > MaxMessageTextLength {
return nil, fmt.Errorf("invalid external reply snapshot")
}
b, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("encode external reply: %w", err)
}
if len(b) > MaxMessageReplyExternalBytes {
return nil, fmt.Errorf("external reply snapshot exceeds size bound")
}
return b, nil
}
func DecodeMessageReplyExternal(b []byte) (*MessageReplyExternal, error) {
if len(b) == 0 || bytes.Equal(bytes.TrimSpace(b), []byte("{}")) {
return nil, nil
}
if len(b) > MaxMessageReplyExternalBytes {
return nil, fmt.Errorf("external reply snapshot exceeds size bound")
}
var out MessageReplyExternal
if err := out.UnmarshalJSON(b); err != nil {
return nil, err
}
return &out, nil
}
func NewMessageReplyExternal(source Message) (*MessageReplyExternal, error) {
v := &MessageReplyExternal{From: MessageForward{From: source.From, Date: source.Date}, Text: source.Body, Entities: source.Entities, Media: source.Media}
b, err := EncodeMessageReplyExternal(v)
if err != nil {
return nil, err
}
// A codec round trip detaches the source media, including nested slices.
return DecodeMessageReplyExternal(b)
}
func ValidateExternalReplyQuote(reply *MessageReply, text string) error {
if reply == nil {
return nil
}
if len(reply.QuoteText) > MaxMessageReplyQuoteLength || !utf8.ValidString(reply.QuoteText) || len(reply.QuoteEntities) > MaxMessageEntityCount {
return ErrQuoteTextInvalid
}
if reply.QuoteText == "" {
if reply.QuoteOffset != 0 || len(reply.QuoteEntities) != 0 {
return ErrQuoteTextInvalid
}
return nil
}
source, quote := utf16.Encode([]rune(text)), utf16.Encode([]rune(reply.QuoteText))
start := reply.QuoteOffset
if start < 0 || start > len(source) || len(quote) > len(source)-start {
return ErrQuoteTextInvalid
}
for i, r := range quote {
if source[start+i] != r {
return ErrQuoteTextInvalid
}
}
for _, e := range reply.QuoteEntities {
if e.Offset < 0 || e.Length <= 0 || e.Offset > len(quote) || e.Length > len(quote)-e.Offset {
return ErrQuoteTextInvalid
}
}
return nil
}
func CloneMessageReply(in *MessageReply) *MessageReply {
if in == nil {
return nil
}
out := *in
out.QuoteEntities = append([]MessageEntity(nil), in.QuoteEntities...)
if in.External != nil {
x := *in.External
x.Entities = append([]MessageEntity(nil), x.Entities...)
if x.Media != nil {
x.Media = cloneReplyData(reflect.ValueOf(x.Media)).Interface().(*MessageMedia)
}
out.External = &x
}
return &out
}
// Clone only our data model. This is not a protocol codec: it neither interprets
// TL fields nor converts bytes. Copying nested pointer/slice fields keeps media
// added to MessageMedia from silently becoming shared between owner snapshots.
func cloneReplyData(v reflect.Value) reflect.Value {
switch v.Kind() {
case reflect.Pointer:
if v.IsNil() {
return reflect.Zero(v.Type())
}
out := reflect.New(v.Type().Elem())
out.Elem().Set(cloneReplyData(v.Elem()))
return out
case reflect.Slice:
if v.IsNil() {
return reflect.Zero(v.Type())
}
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(cloneReplyData(v.Index(i)))
}
return out
case reflect.Map:
if v.IsNil() {
return reflect.Zero(v.Type())
}
out := reflect.MakeMapWithSize(v.Type(), v.Len())
iter := v.MapRange()
for iter.Next() {
out.SetMapIndex(iter.Key(), cloneReplyData(iter.Value()))
}
return out
case reflect.Interface:
if v.IsNil() {
return reflect.Zero(v.Type())
}
out := reflect.New(v.Type()).Elem()
out.Set(cloneReplyData(v.Elem()))
return out
case reflect.Struct:
out := reflect.New(v.Type()).Elem()
out.Set(v)
for i := 0; i < v.NumField(); i++ {
if v.Type().Field(i).IsExported() {
out.Field(i).Set(cloneReplyData(v.Field(i)))
}
}
return out
case reflect.Array:
out := reflect.New(v.Type()).Elem()
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(cloneReplyData(v.Index(i)))
}
return out
default:
return v
}
}

View file

@ -0,0 +1,92 @@
package domain
import (
"errors"
"reflect"
"strings"
"testing"
)
func TestExternalReplySnapshotIsolationAndInvalidPayload(t *testing.T) {
source := Message{From: Peer{Type: PeerTypeUser, ID: 42}, Date: 1700000000, Body: "🌕 quote", Media: &MessageMedia{Kind: MessageMediaKindPhoto, Photo: &Photo{ID: 7, FileReference: []byte{1, 2, 3}}}}
x, err := NewMessageReplyExternal(source)
if err != nil {
t.Fatal(err)
}
source.Media.Photo.FileReference[0] = 9
if x.Media.Photo.FileReference[0] != 1 {
t.Fatal("source can mutate persisted snapshot")
}
r := &MessageReply{External: x}
if err := ValidateMessageReplyBounds(r); err != nil {
t.Fatal("snapshot-only recipient header rejected", err)
}
copy := CloneMessageReply(r)
copy.External.Media.Photo.FileReference[0] = 8
if x.Media.Photo.FileReference[0] != 1 {
t.Fatal("owner clone aliases media snapshot")
}
b, err := EncodeMessageReplyExternal(x)
if err != nil {
t.Fatal(err)
}
restored, err := DecodeMessageReplyExternal(b)
if err != nil || !reflect.DeepEqual(restored, x) {
t.Fatalf("snapshot roundtrip: %v", err)
}
for _, bad := range []string{"null", "[]", `{"from":{}}`, string(b) + ` {}`, strings.Replace(string(b), `"text":`, `"unrecognized":1,"text":`, 1), strings.Repeat("x", MaxMessageReplyExternalBytes+1)} {
if _, err := DecodeMessageReplyExternal([]byte(bad)); err == nil {
t.Fatalf("invalid external snapshot accepted: %.50s", bad)
}
}
}
func TestExternalReplyQuoteUsesUTF16AndExactSubstring(t *testing.T) {
for _, tc := range []struct {
text, quote string
offset int
valid bool
}{
{"a🌕 quote", "quote", 4, true}, {"a🌕 quote", "quote", 3, false}, {"a🌕 quote", "🌕", 1, true}, {"a🌕 quote", "🌕", 2, false}, {"source", "invented", 0, false}, {"source", "source", 0, true}, {"source", "", 0, true}, {"source", "", 1, false},
} {
err := ValidateExternalReplyQuote(&MessageReply{QuoteText: tc.quote, QuoteOffset: tc.offset}, tc.text)
if (err == nil) != tc.valid || (err != nil && !errors.Is(err, ErrQuoteTextInvalid)) {
t.Fatalf("%+v: %v", tc, err)
}
}
text := strings.Repeat("🌕", 3000) + "quote"
if err := ValidateExternalReplyQuote(&MessageReply{QuoteText: "quote", QuoteOffset: 6000}, text); err != nil {
t.Fatal(err)
}
if err := ValidateMessageReplyBounds(&MessageReply{MessageID: 1, QuoteOffset: 6000}); err != nil {
t.Fatal(err)
}
}
func TestExternalReplyDocumentNestedSlicesRemainIndependent(t *testing.T) {
source := Message{From: Peer{Type: PeerTypeUser, ID: 42}, Date: 1700000000,
Media: &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{
ID: 7, FileReference: []byte{1, 2, 3}, MimeType: "audio/ogg",
Attributes: []DocumentAttribute{{Kind: DocAttrAudio, Voice: true, Waveform: []byte{4, 5, 6}}},
}},
}
x, err := NewMessageReplyExternal(source)
if err != nil {
t.Fatal(err)
}
source.Media.Document.FileReference[0] = 9
source.Media.Document.Attributes[0].Waveform[0] = 9
copy := CloneMessageReply(&MessageReply{External: x})
copy.External.Media.Document.Attributes[0].Waveform[1] = 9
if !reflect.DeepEqual(x.Media.Document.FileReference, []byte{1, 2, 3}) || !reflect.DeepEqual(x.Media.Document.Attributes[0].Waveform, []byte{4, 5, 6}) {
t.Fatal("source or another owner mutated the document snapshot")
}
raw, err := EncodeMessageReplyExternal(x)
if err != nil {
t.Fatal(err)
}
got, err := DecodeMessageReplyExternal(raw)
if err != nil || !reflect.DeepEqual(got, x) {
t.Fatalf("document snapshot roundtrip: %+v %v", got, err)
}
}

View file

@ -0,0 +1,59 @@
package domain
import "testing"
func TestNormalizePhoneUsesCountryAwareE164Identity(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "iran redundant national trunk", input: "+98 0998 167 9461", want: "989981679461"},
{name: "iran canonical", input: "+98 998 167 9461", want: "989981679461"},
{name: "iran wire digits redundant trunk", input: "9809981679461", want: "989981679461"},
{name: "italy significant leading zero", input: "+39 02 1234 5678", want: "390212345678"},
{name: "china presentation", input: "+86 (188) 0000-0000", want: "8618800000000"},
{name: "possible reserved NANP range", input: "+1 555 000 0001", want: "15550000001"},
{name: "local number without country", input: "09981679461", want: ""},
{name: "letters are not separators", input: "+98abc9981679461", want: ""},
{name: "international prefix is not country code", input: "00989981679461", want: ""},
{name: "reserved system identity", input: OfficialSystemPhone, want: OfficialSystemPhone},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := NormalizePhone(test.input); got != test.want {
t.Fatalf("NormalizePhone(%q) = %q, want %q", test.input, got, test.want)
}
})
}
}
func TestNormalizePhoneAcceptsVirtual888LoginIdentityRange(t *testing.T) {
for input, want := range map[string]string{
"+888 12-34": "8881234",
"8880000": "8880000",
"888123456789012": "888123456789012",
} {
if got := NormalizePhone(input); got != want {
t.Fatalf("NormalizePhone(%q) = %q, want %q", input, got, want)
}
}
for _, phone := range []string{"888123", "8881234567890123", "+888abc1234"} {
if got := NormalizePhone(phone); got != "" {
t.Fatalf("NormalizePhone(%q) = %q, want empty", phone, got)
}
}
}
func TestValidPhoneRequiresCanonicalStorageShape(t *testing.T) {
for _, phone := range []string{"989981679461", "390212345678", "8618800000000", "15550000001", "8880000", "888123456789012", OfficialSystemPhone} {
if !ValidPhone(phone) {
t.Fatalf("ValidPhone(%q) = false", phone)
}
}
for _, phone := range []string{"+989981679461", "+8881234", "888123", "8881234567890123", "9809981679461", "09981679461", "", "+98abc9981679461"} {
if ValidPhone(phone) {
t.Fatalf("ValidPhone(%q) = true", phone)
}
}
}

View file

@ -0,0 +1,89 @@
package domain
import "slices"
func EvaluatePrivacy(rules PrivacyRules, ctx PrivacyContext) bool {
if ctx.OwnerUserID != 0 && ctx.OwnerUserID == ctx.ViewerUserID {
return true
}
if len(rules.Rules) == 0 {
rules.Rules = DefaultPrivacyRules(rules.Key)
}
for _, rule := range rules.Rules {
if explicitDisallowMatches(rule, ctx) {
return false
}
}
for _, rule := range rules.Rules {
if explicitAllowMatches(rule, ctx) {
return true
}
}
for _, rule := range rules.Rules {
switch rule.Kind {
case PrivacyRuleDisallowContacts:
if ctx.ViewerIsContact {
return false
}
case PrivacyRuleAllowContacts:
if ctx.ViewerIsContact {
return true
}
}
}
for _, rule := range rules.Rules {
switch rule.Kind {
case PrivacyRuleDisallowAll:
return false
case PrivacyRuleAllowAll:
return true
}
}
return false
}
func explicitDisallowMatches(rule PrivacyRule, ctx PrivacyContext) bool {
switch rule.Kind {
case PrivacyRuleDisallowUsers:
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
case PrivacyRuleDisallowChatParticipants:
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
case PrivacyRuleDisallowBots:
return ctx.ViewerIsBot
default:
return false
}
}
func explicitAllowMatches(rule PrivacyRule, ctx PrivacyContext) bool {
switch rule.Kind {
case PrivacyRuleAllowUsers:
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
case PrivacyRuleAllowChatParticipants:
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
case PrivacyRuleAllowCloseFriends:
return ctx.ViewerCloseFriend
case PrivacyRuleAllowPremium:
return ctx.ViewerIsPremium
case PrivacyRuleAllowBots:
return ctx.ViewerIsBot
default:
return false
}
}
func intersects(a, b []int64) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
set := make(map[int64]struct{}, len(a))
for _, id := range a {
set[id] = struct{}{}
}
for _, id := range b {
if _, ok := set[id]; ok {
return true
}
}
return false
}

View file

@ -9,6 +9,9 @@ import (
const (
// OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。
OfficialSystemUserID int64 = 777000
// OfficialSystemPhone is a reserved service identity, not an ordinary E.164
// login number. Auth must recognize and reject it before account lookup.
OfficialSystemPhone = "42777"
// OfficialSystemUserPhotoID/AccessHash 是该账号头像 photo 的固定 id
// 与 files.Service.SeedOfficialSystemAvatar 种子写入的行保持一致,
// 确保跨重启后 OfficialSystemUser() 引用的 photo id 稳定不变。
@ -108,7 +111,7 @@ func SetOfficialSystemUserAvatar(dcID int, stripped []byte) {
}
// officialSystemUserDisplayName overrides OfficialSystemUser's FirstName --
// empty means "use branding.ProductName" (the compile-time default), set
// empty means "use branding.ProductName()" (the compile-time default), set
// once at startup from the operator's Server Settings -> Server identity
// name, if any. Deliberately only the display name, not Username: the
// account's @username is a stable, addressable identifier other things may
@ -118,7 +121,7 @@ var officialSystemUserDisplayName string
// SetOfficialSystemUserDisplayName records the operator's custom server
// name for the official system account (777000), read once at startup from
// Server Settings -> Server identity. Pass "" to fall back to
// branding.ProductName -- the same "unset -> default" contract the avatar
// branding.ProductName() -- the same "unset -> default" contract the avatar
// override above uses.
func SetOfficialSystemUserDisplayName(name string) {
officialSystemUserDisplayName = strings.TrimSpace(name)
@ -126,7 +129,7 @@ func SetOfficialSystemUserDisplayName(name string) {
// officialSystemDisplayName returns the official system account's current
// effective display name: the operator's custom override if set via
// SetOfficialSystemUserDisplayName, else branding.ProductName. Shared by
// SetOfficialSystemUserDisplayName, else branding.ProductName(). Shared by
// OfficialSystemUser (777000's FirstName) and the login-welcome-message
// {{server_name}} placeholder (see login_welcome_template.go) so both stay
// consistent with each other.
@ -134,7 +137,7 @@ func officialSystemDisplayName() string {
if officialSystemUserDisplayName != "" {
return officialSystemUserDisplayName
}
return branding.ProductName
return branding.ProductName()
}
// botFatherPhotoDCID/Stripped 由 files.Service.SeedBotFatherAvatar 在启动时
@ -223,11 +226,11 @@ func OfficialSystemUser() User {
// only the default snapshot; startup reconciliation and the memory backend use
// these helpers so custom deployments do not expose stale "telesrv" text.
func ChatBotDescription() string {
return "Chat with the configured " + branding.ProductName + " AI provider."
return "Chat with the configured " + branding.ProductName() + " AI provider."
}
func StickersBotDescription() string {
return "Create custom sticker and emoji packs for " + branding.ProductName + "."
return "Create custom sticker and emoji packs for " + branding.ProductName() + "."
}
// BotFatherUser 返回内置 BotFather 账号。username 不以 bot 结尾属种子例外(与官方一致)。