chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
|
|
@ -1,6 +1,9 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPasswordHashInvalid = errors.New("password hash invalid")
|
||||
|
|
@ -76,7 +79,10 @@ type PasswordSettings struct {
|
|||
Hint string
|
||||
EmailUnconfirmedPattern string
|
||||
RecoveryEmail string
|
||||
LoginEmailPattern string
|
||||
// LoginEmail 是已确认的登录邮箱地址(服务端私有,永不直接下发;下发的是掩码后的
|
||||
// LoginEmailPattern)。它独立于 2FA 恢复邮箱 RecoveryEmail:账号可只设登录邮箱而无 2FA。
|
||||
LoginEmail string
|
||||
LoginEmailPattern string
|
||||
NewAlgo PasswordKDFAlgo
|
||||
NewSecureAlgo SecurePasswordKDFAlgo
|
||||
SecureRandom []byte
|
||||
|
|
@ -142,3 +148,79 @@ func DefaultAccountReactionSettings() AccountReactionSettings {
|
|||
PaidPrivacy: PaidReactionPrivacy{Kind: PaidReactionPrivacyDefault},
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultAccountTTLDays 是账号自毁默认期限(无显式设置时)。与历史固定回显一致。
|
||||
const DefaultAccountTTLDays = 365
|
||||
|
||||
// GlobalPrivacy 是 globalPrivacySettings 的业务层表达(账号级隐私开关)。
|
||||
// DisallowedGifts 依赖礼物资产模型(当前未实现),故不建模、保持默认。
|
||||
type GlobalPrivacy struct {
|
||||
ArchiveAndMuteNewNoncontactPeers bool
|
||||
KeepArchivedUnmuted bool
|
||||
KeepArchivedFolders bool
|
||||
HideReadMarks bool
|
||||
NewNoncontactPeersRequirePremium bool
|
||||
DisplayGiftsButton bool
|
||||
// NoncontactPeersPaidStars:非联系人给本人发消息所需 Stars 数。Stars 账本尚未实现,
|
||||
// 此处仅做忠实持久化(往返不丢值),不参与计费逻辑。
|
||||
NoncontactPeersPaidStars int64
|
||||
}
|
||||
|
||||
// AccountSettings 聚合账号级单例设置(每用户一行):全局隐私、账号自毁期限、
|
||||
// 敏感内容开关、联系人注册通知静音。对应 account.get/set GlobalPrivacySettings、
|
||||
// get/set AccountTTL、get/set ContentSettings、get/set ContactSignUpNotification。
|
||||
type AccountSettings struct {
|
||||
GlobalPrivacy GlobalPrivacy
|
||||
AccountTTLDays int
|
||||
SensitiveContentEnabled bool
|
||||
// ContactSignUpSilent 对应 account.setContactSignUpNotification 的 silent 形参:
|
||||
// true=联系人注册时不通知本人。getContactSignUpNotification 直接返回该值。
|
||||
ContactSignUpSilent bool
|
||||
}
|
||||
|
||||
// DefaultAccountSettings 是未持久化时的账号设置默认值(与历史回显 stub 行为一致:
|
||||
// 全局隐私全关、TTL 365 天、敏感内容关、联系人注册通知开启)。
|
||||
func DefaultAccountSettings() AccountSettings {
|
||||
return AccountSettings{
|
||||
AccountTTLDays: DefaultAccountTTLDays,
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizedTTLDays 返回钳制后的账号自毁期限(0/越界回落默认)。
|
||||
func (s AccountSettings) NormalizedTTLDays() int {
|
||||
if s.AccountTTLDays <= 0 {
|
||||
return DefaultAccountTTLDays
|
||||
}
|
||||
return s.AccountTTLDays
|
||||
}
|
||||
|
||||
// MaskEmail 把邮箱地址按 Telegram pattern 习惯掩码(首尾各保留一位本地名,如
|
||||
// a***z@x.com),用于 account.password.login_email_pattern / auth.sentCodeTypeEmailCode
|
||||
// 等只能暴露掩码的下发点。空串返回空串。
|
||||
func MaskEmail(email string) string {
|
||||
if email == "" {
|
||||
return ""
|
||||
}
|
||||
at := strings.Index(email, "@")
|
||||
if at <= 1 {
|
||||
return email
|
||||
}
|
||||
name := email[:at]
|
||||
return name[:1] + "***" + name[len(name)-1:] + email[at:]
|
||||
}
|
||||
|
||||
// NormalizePhone 仅保留手机号中的数字(与 users.phone 的存储形态一致)。全部被过滤
|
||||
// 掉时返回原串,便于上层做 validPhone 拒绝。auth/account 两域共用同一规则避免漂移。
|
||||
func NormalizePhone(phone string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(phone))
|
||||
for _, r := range phone {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return phone
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
|
|||
36
internal/domain/admin.go
Normal file
36
internal/domain/admin.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type AdminCommandStatus string
|
||||
|
||||
const (
|
||||
AdminCommandRunning AdminCommandStatus = "running"
|
||||
AdminCommandCompleted AdminCommandStatus = "completed"
|
||||
AdminCommandFailed AdminCommandStatus = "failed"
|
||||
)
|
||||
|
||||
type AdminCommand struct {
|
||||
CommandID string
|
||||
Actor string
|
||||
Action string
|
||||
TargetUserID int64
|
||||
TargetPeer Peer
|
||||
DryRun bool
|
||||
Reason string
|
||||
RequestJSON []byte
|
||||
ResultJSON []byte
|
||||
Status AdminCommandStatus
|
||||
Error string
|
||||
CreatedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
type AccountSendRestriction struct {
|
||||
UserID int64
|
||||
Frozen bool
|
||||
Reason string
|
||||
Actor string
|
||||
CommandID string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
|
@ -15,6 +15,9 @@ type Authorization struct {
|
|||
APIID int
|
||||
AppVersion string
|
||||
IP string
|
||||
CreatedAt time.Time
|
||||
ActiveAt time.Time
|
||||
// PasswordPending 表示该 auth_key 已通过短信验证码、但账号开启了两步验证且尚未通过
|
||||
// auth.checkPassword。此状态下业务鉴权须视其为未登录,仅允许继续完成两步验证。
|
||||
PasswordPending bool
|
||||
CreatedAt time.Time
|
||||
ActiveAt time.Time
|
||||
}
|
||||
|
|
|
|||
291
internal/domain/boost.go
Normal file
291
internal/domain/boost.go
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultPremiumBoostSlotID is the single base boost slot every active premium
|
||||
// user owns in the current small-scale implementation.
|
||||
DefaultPremiumBoostSlotID = 1
|
||||
// MaxPremiumBoostSlotsPerApply bounds premium.applyBoost slots before storage.
|
||||
MaxPremiumBoostSlotsPerApply = 16
|
||||
// MaxPremiumBoostsListLimit bounds premium.getBoostsList pages.
|
||||
MaxPremiumBoostsListLimit = 100
|
||||
// MaxPremiumBoostsOffsetBytes bounds opaque boost pagination cursors.
|
||||
MaxPremiumBoostsOffsetBytes = 128
|
||||
// MaxChannelBoostsToUnblockRestrictions matches Layer 225 TDesktop UI bounds.
|
||||
MaxChannelBoostsToUnblockRestrictions = 8
|
||||
// MaxDefaultPremiumBoostLevel bounds the built-in level table so status replies
|
||||
// never expose unbounded next-level values to clients.
|
||||
MaxDefaultPremiumBoostLevel = 100
|
||||
// DefaultPremiumBoostReassignCooldownSeconds keeps the current dev behavior of
|
||||
// allowing immediate moves while preserving a single hook for official cooldowns.
|
||||
DefaultPremiumBoostReassignCooldownSeconds = 0
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBoostNotModified = errors.New("boost not modified")
|
||||
|
||||
defaultPremiumBoostLevelPolicy = PremiumBoostLevelPolicy{
|
||||
Thresholds: linearPremiumBoostThresholds(MaxDefaultPremiumBoostLevel),
|
||||
}
|
||||
)
|
||||
|
||||
// PremiumBoostSlot describes one user-owned boost slot without TL types.
|
||||
type PremiumBoostSlot struct {
|
||||
UserID int64
|
||||
Slot int
|
||||
Peer Peer
|
||||
Date int
|
||||
Expires int
|
||||
CooldownUntil int
|
||||
Multiplier int
|
||||
Gift bool
|
||||
Giveaway bool
|
||||
Unclaimed bool
|
||||
GiveawayMsgID int
|
||||
UsedGiftSlug string
|
||||
Stars int64
|
||||
}
|
||||
|
||||
// Active reports whether the slot can currently count for a peer.
|
||||
func (s PremiumBoostSlot) Active(now int) bool {
|
||||
if s.UserID == 0 || s.Slot <= 0 {
|
||||
return false
|
||||
}
|
||||
return s.Expires == 0 || s.Expires > now
|
||||
}
|
||||
|
||||
// Assigned reports whether the active slot is currently applied to a peer.
|
||||
func (s PremiumBoostSlot) Assigned(now int) bool {
|
||||
return s.Active(now) && s.Peer.ID != 0
|
||||
}
|
||||
|
||||
// Weight returns the boost contribution of one active assigned slot.
|
||||
func (s PremiumBoostSlot) Weight(now int) int {
|
||||
if !s.Assigned(now) {
|
||||
return 0
|
||||
}
|
||||
if s.Multiplier <= 0 {
|
||||
return 1
|
||||
}
|
||||
return s.Multiplier
|
||||
}
|
||||
|
||||
// PremiumBoostStatus is the domain projection behind premium.boostsStatus.
|
||||
type PremiumBoostStatus struct {
|
||||
Peer Peer
|
||||
Level int
|
||||
CurrentLevelBoosts int
|
||||
Boosts int
|
||||
GiftBoosts int
|
||||
NextLevelBoosts int
|
||||
HasNextLevelBoosts bool
|
||||
PremiumAudiencePart int
|
||||
PremiumAudienceTotal int
|
||||
MyBoostSlots []PremiumBoostSlot
|
||||
}
|
||||
|
||||
// PremiumBoostList is a bounded admin-visible boosts page.
|
||||
type PremiumBoostList struct {
|
||||
Count int
|
||||
Boosts []PremiumBoostSlot
|
||||
Users []User
|
||||
NextOffset string
|
||||
}
|
||||
|
||||
// PremiumMyBoosts is the current user's slot inventory.
|
||||
type PremiumMyBoosts struct {
|
||||
Slots []PremiumBoostSlot
|
||||
Users []User
|
||||
Channels []Channel
|
||||
}
|
||||
|
||||
// PremiumBoostLevelPolicy maps cumulative active boosts to channel boost levels.
|
||||
// Thresholds[n-1] is the minimum boost count required for level n.
|
||||
type PremiumBoostLevelPolicy struct {
|
||||
Thresholds []int
|
||||
}
|
||||
|
||||
// DefaultPremiumBoostLevelPolicy returns the current bounded telesrv level table.
|
||||
// It is intentionally policy-backed: production deployments can replace the table
|
||||
// with official thresholds without touching store/RPC state transitions.
|
||||
func DefaultPremiumBoostLevelPolicy() PremiumBoostLevelPolicy {
|
||||
return PremiumBoostLevelPolicy{
|
||||
Thresholds: append([]int(nil), defaultPremiumBoostLevelPolicy.Thresholds...),
|
||||
}
|
||||
}
|
||||
|
||||
// NewPremiumBoostLevelPolicy creates a monotonic threshold policy from raw values.
|
||||
func NewPremiumBoostLevelPolicy(thresholds []int) PremiumBoostLevelPolicy {
|
||||
return PremiumBoostLevelPolicy{Thresholds: normalizePremiumBoostThresholds(thresholds)}
|
||||
}
|
||||
|
||||
// LevelForBoosts returns level bounds for the given active boost count.
|
||||
func (p PremiumBoostLevelPolicy) LevelForBoosts(boosts int) (level, currentLevelBoosts, nextLevelBoosts int, hasNext bool) {
|
||||
if boosts < 0 {
|
||||
boosts = 0
|
||||
}
|
||||
thresholds := p.thresholds()
|
||||
if len(thresholds) == 0 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
level = sort.Search(len(thresholds), func(i int) bool { return thresholds[i] > boosts })
|
||||
if level > 0 {
|
||||
currentLevelBoosts = thresholds[level-1]
|
||||
}
|
||||
if level < len(thresholds) {
|
||||
return level, currentLevelBoosts, thresholds[level], true
|
||||
}
|
||||
return len(thresholds), thresholds[len(thresholds)-1], 0, false
|
||||
}
|
||||
|
||||
func (p PremiumBoostLevelPolicy) thresholds() []int {
|
||||
if len(p.Thresholds) == 0 {
|
||||
return defaultPremiumBoostLevelPolicy.Thresholds
|
||||
}
|
||||
last := 0
|
||||
for _, threshold := range p.Thresholds {
|
||||
if threshold <= 0 || threshold <= last {
|
||||
return normalizePremiumBoostThresholds(p.Thresholds)
|
||||
}
|
||||
last = threshold
|
||||
}
|
||||
return p.Thresholds
|
||||
}
|
||||
|
||||
func normalizePremiumBoostThresholds(thresholds []int) []int {
|
||||
if len(thresholds) == 0 {
|
||||
return nil
|
||||
}
|
||||
sorted := make([]int, 0, len(thresholds))
|
||||
for _, threshold := range thresholds {
|
||||
if threshold > 0 {
|
||||
sorted = append(sorted, threshold)
|
||||
}
|
||||
}
|
||||
sort.Ints(sorted)
|
||||
out := sorted[:0]
|
||||
last := 0
|
||||
for _, threshold := range sorted {
|
||||
if threshold == last {
|
||||
continue
|
||||
}
|
||||
out = append(out, threshold)
|
||||
last = threshold
|
||||
}
|
||||
return append([]int(nil), out...)
|
||||
}
|
||||
|
||||
func linearPremiumBoostThresholds(maxLevel int) []int {
|
||||
if maxLevel <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]int, maxLevel)
|
||||
for i := range out {
|
||||
out[i] = i + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PremiumBoostStatusForCount returns the bounded default level projection.
|
||||
func PremiumBoostStatusForCount(peer Peer, boosts int, my []PremiumBoostSlot) PremiumBoostStatus {
|
||||
return PremiumBoostStatusForPolicy(peer, boosts, my, defaultPremiumBoostLevelPolicy)
|
||||
}
|
||||
|
||||
// PremiumBoostStatusForPolicy returns a status projection from an explicit level policy.
|
||||
func PremiumBoostStatusForPolicy(peer Peer, boosts int, my []PremiumBoostSlot, policy PremiumBoostLevelPolicy) PremiumBoostStatus {
|
||||
if boosts < 0 {
|
||||
boosts = 0
|
||||
}
|
||||
level, current, next, hasNext := policy.LevelForBoosts(boosts)
|
||||
return PremiumBoostStatus{
|
||||
Peer: peer,
|
||||
Level: level,
|
||||
CurrentLevelBoosts: current,
|
||||
Boosts: boosts,
|
||||
NextLevelBoosts: next,
|
||||
HasNextLevelBoosts: hasNext,
|
||||
MyBoostSlots: append([]PremiumBoostSlot(nil), my...),
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyPremiumBoostSlot applies one user-owned slot to a peer and enforces shared
|
||||
// lifecycle rules for all store implementations.
|
||||
func ApplyPremiumBoostSlot(slot PremiumBoostSlot, userID int64, slotID int, peer Peer, now, premiumUntil, cooldownSeconds int) (PremiumBoostSlot, bool, error) {
|
||||
if userID == 0 || slotID <= 0 || peer.Type != PeerTypeChannel || peer.ID == 0 || now < 0 {
|
||||
return PremiumBoostSlot{}, false, ErrChannelInvalid
|
||||
}
|
||||
if premiumUntil <= now {
|
||||
return PremiumBoostSlot{}, false, ErrPremiumRequired
|
||||
}
|
||||
if cooldownSeconds < 0 {
|
||||
cooldownSeconds = 0
|
||||
}
|
||||
if slot.UserID == 0 || slot.Slot == 0 || !slot.Active(now) {
|
||||
slot = PremiumBoostSlot{
|
||||
UserID: userID,
|
||||
Slot: slotID,
|
||||
Multiplier: 1,
|
||||
}
|
||||
}
|
||||
if slot.UserID != userID || slot.Slot != slotID {
|
||||
return PremiumBoostSlot{}, false, ErrChannelInvalid
|
||||
}
|
||||
if slot.Multiplier <= 0 {
|
||||
slot.Multiplier = 1
|
||||
}
|
||||
if slot.Assigned(now) && slot.Peer == peer {
|
||||
if slot.Expires != 0 && slot.Expires != premiumUntil {
|
||||
slot.Expires = premiumUntil
|
||||
return slot, true, nil
|
||||
}
|
||||
return slot, false, ErrBoostNotModified
|
||||
}
|
||||
if slot.Assigned(now) && slot.Peer != peer && slot.CooldownUntil > now {
|
||||
return slot, false, NewPremiumBoostFloodWaitError(slot.CooldownUntil - now)
|
||||
}
|
||||
wasAssigned := slot.Assigned(now)
|
||||
slot.Peer = peer
|
||||
slot.Date = now
|
||||
slot.Expires = premiumUntil
|
||||
if wasAssigned && cooldownSeconds > 0 {
|
||||
slot.CooldownUntil = now + cooldownSeconds
|
||||
} else if slot.CooldownUntil <= now {
|
||||
slot.CooldownUntil = 0
|
||||
}
|
||||
return slot, true, nil
|
||||
}
|
||||
|
||||
// PremiumBoostFloodWaitError carries remaining seconds for boost reassign cooldowns.
|
||||
type PremiumBoostFloodWaitError struct {
|
||||
Seconds int
|
||||
}
|
||||
|
||||
func (e PremiumBoostFloodWaitError) Error() string {
|
||||
if e.Seconds <= 0 {
|
||||
return "premium boost flood wait"
|
||||
}
|
||||
return fmt.Sprintf("premium boost flood wait %d seconds", e.Seconds)
|
||||
}
|
||||
|
||||
func NewPremiumBoostFloodWaitError(seconds int) error {
|
||||
if seconds <= 0 {
|
||||
seconds = 1
|
||||
}
|
||||
return PremiumBoostFloodWaitError{Seconds: seconds}
|
||||
}
|
||||
|
||||
func PremiumBoostFloodWaitSeconds(err error) (int, bool) {
|
||||
var wait PremiumBoostFloodWaitError
|
||||
if !errors.As(err, &wait) {
|
||||
return 0, false
|
||||
}
|
||||
if wait.Seconds <= 0 {
|
||||
return 1, true
|
||||
}
|
||||
return wait.Seconds, true
|
||||
}
|
||||
93
internal/domain/boost_test.go
Normal file
93
internal/domain/boost_test.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPremiumBoostStatusDefaultPolicyIsBoundedLinear(t *testing.T) {
|
||||
peer := Peer{Type: PeerTypeChannel, ID: 100}
|
||||
|
||||
empty := PremiumBoostStatusForCount(peer, 0, nil)
|
||||
if empty.Level != 0 || empty.CurrentLevelBoosts != 0 || empty.Boosts != 0 || empty.NextLevelBoosts != 1 || !empty.HasNextLevelBoosts {
|
||||
t.Fatalf("empty status = %+v, want level 0 next 1", empty)
|
||||
}
|
||||
|
||||
one := PremiumBoostStatusForCount(peer, 1, nil)
|
||||
if one.Level != 1 || one.CurrentLevelBoosts != 1 || one.Boosts != 1 || one.NextLevelBoosts != 2 || !one.HasNextLevelBoosts {
|
||||
t.Fatalf("one boost status = %+v, want level 1 next 2", one)
|
||||
}
|
||||
|
||||
max := PremiumBoostStatusForCount(peer, MaxDefaultPremiumBoostLevel, nil)
|
||||
if max.Level != MaxDefaultPremiumBoostLevel || max.CurrentLevelBoosts != MaxDefaultPremiumBoostLevel || max.HasNextLevelBoosts {
|
||||
t.Fatalf("max status = %+v, want capped max level without next", max)
|
||||
}
|
||||
|
||||
over := PremiumBoostStatusForCount(peer, MaxDefaultPremiumBoostLevel+50, nil)
|
||||
if over.Level != MaxDefaultPremiumBoostLevel || over.CurrentLevelBoosts != MaxDefaultPremiumBoostLevel || over.HasNextLevelBoosts {
|
||||
t.Fatalf("over max status = %+v, want capped max level without next", over)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPremiumBoostStatusCustomPolicy(t *testing.T) {
|
||||
policy := NewPremiumBoostLevelPolicy([]int{6, 1, 3, 3, 0})
|
||||
peer := Peer{Type: PeerTypeChannel, ID: 100}
|
||||
|
||||
status := PremiumBoostStatusForPolicy(peer, 4, nil, policy)
|
||||
if status.Level != 2 || status.CurrentLevelBoosts != 3 || status.NextLevelBoosts != 6 || !status.HasNextLevelBoosts {
|
||||
t.Fatalf("custom policy status = %+v, want level 2 current 3 next 6", status)
|
||||
}
|
||||
|
||||
status = PremiumBoostStatusForPolicy(peer, 6, nil, policy)
|
||||
if status.Level != 3 || status.CurrentLevelBoosts != 6 || status.HasNextLevelBoosts {
|
||||
t.Fatalf("custom max status = %+v, want level 3 without next", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPremiumBoostSlotLifecycle(t *testing.T) {
|
||||
peer := Peer{Type: PeerTypeChannel, ID: 100}
|
||||
slot, changed, err := ApplyPremiumBoostSlot(PremiumBoostSlot{}, 10, DefaultPremiumBoostSlotID, peer, 1000, 2000, 0)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("apply free slot changed=%v err=%v", changed, err)
|
||||
}
|
||||
if slot.UserID != 10 || slot.Slot != DefaultPremiumBoostSlotID || slot.Peer != peer || slot.Date != 1000 || slot.Expires != 2000 || slot.Multiplier != 1 {
|
||||
t.Fatalf("applied slot = %+v", slot)
|
||||
}
|
||||
|
||||
_, changed, err = ApplyPremiumBoostSlot(slot, 10, DefaultPremiumBoostSlotID, peer, 1001, 2000, 0)
|
||||
if !errors.Is(err, ErrBoostNotModified) || changed {
|
||||
t.Fatalf("reapply same peer changed=%v err=%v, want ErrBoostNotModified", changed, err)
|
||||
}
|
||||
|
||||
extended, changed, err := ApplyPremiumBoostSlot(slot, 10, DefaultPremiumBoostSlotID, peer, 1002, 3000, 0)
|
||||
if err != nil || !changed || extended.Expires != 3000 || extended.Date != slot.Date {
|
||||
t.Fatalf("extend same peer slot=%+v changed=%v err=%v", extended, changed, err)
|
||||
}
|
||||
|
||||
shortened, changed, err := ApplyPremiumBoostSlot(extended, 10, DefaultPremiumBoostSlotID, peer, 1003, 2500, 0)
|
||||
if err != nil || !changed || shortened.Expires != 2500 || shortened.Date != slot.Date {
|
||||
t.Fatalf("refresh same peer expiry slot=%+v changed=%v err=%v", shortened, changed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPremiumBoostSlotCooldown(t *testing.T) {
|
||||
first := Peer{Type: PeerTypeChannel, ID: 100}
|
||||
second := Peer{Type: PeerTypeChannel, ID: 200}
|
||||
slot := PremiumBoostSlot{
|
||||
UserID: 10,
|
||||
Slot: DefaultPremiumBoostSlotID,
|
||||
Peer: first,
|
||||
Date: 1000,
|
||||
Expires: 3000,
|
||||
CooldownUntil: 1500,
|
||||
Multiplier: 1,
|
||||
}
|
||||
|
||||
_, changed, err := ApplyPremiumBoostSlot(slot, 10, DefaultPremiumBoostSlotID, second, 1200, 3000, 0)
|
||||
if changed || err == nil {
|
||||
t.Fatalf("cooldown apply changed=%v err=%v, want flood wait", changed, err)
|
||||
}
|
||||
if seconds, ok := PremiumBoostFloodWaitSeconds(err); !ok || seconds != 300 {
|
||||
t.Fatalf("cooldown err = %v seconds=%d ok=%v, want 300", err, seconds, ok)
|
||||
}
|
||||
}
|
||||
313
internal/domain/bot.go
Normal file
313
internal/domain/bot.go
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxBotsPerOwner 是每个用户可创建的 bot 上限(对齐官方 appConfig bots_create_limit_default)。
|
||||
MaxBotsPerOwner = 20
|
||||
// BotTokenSecretLength 是 bot token 随机段长度(官方格式 <bot_id>:<35 位>)。
|
||||
BotTokenSecretLength = 35
|
||||
// BotUsernameSuffix 是 bot username 的强制后缀(大小写不敏感)。
|
||||
BotUsernameSuffix = "bot"
|
||||
// MaxBotNameLength 是 bot 显示名(first_name)上限。
|
||||
MaxBotNameLength = 64
|
||||
// MaxBotInlinePlaceholderLen 是 inline mode placeholder 上限(bots.inline_placeholder)。
|
||||
MaxBotInlinePlaceholderLen = 128
|
||||
// MaxBotAppShortNameLen 是 bot app short_name 上限。
|
||||
MaxBotAppShortNameLen = 64
|
||||
// MaxBotAppTitleLen 是 bot app title 上限。
|
||||
MaxBotAppTitleLen = 128
|
||||
// MaxBotAppDescriptionLen 是 bot app description 上限。
|
||||
MaxBotAppDescriptionLen = 512
|
||||
// MaxBotAppURLLen 是 bot app / attach menu webview URL 上限。
|
||||
MaxBotAppURLLen = 512
|
||||
// MaxBotAttachMenuPeerTypes 限制 attach menu peer_types 数量,避免无界响应。
|
||||
MaxBotAttachMenuPeerTypes = 8
|
||||
// MaxBotAttachMenuIcons 限制 attach menu icon 变体数量。
|
||||
MaxBotAttachMenuIcons = 8
|
||||
// MaxBotPreviewMedia 是单 bot main mini app preview media 上限。
|
||||
MaxBotPreviewMedia = 20
|
||||
// MaxBotRequestedPeerQuantity 限制 request peer 一次选择数量。
|
||||
MaxBotRequestedPeerQuantity = 10
|
||||
// MaxBotCustomMethodLen 限制 WebView custom method 名称长度。
|
||||
MaxBotCustomMethodLen = 128
|
||||
// MaxBotCustomMethodPayloadLen 限制 WebView custom method JSON 载荷长度。
|
||||
MaxBotCustomMethodPayloadLen = 4096
|
||||
)
|
||||
|
||||
// bot 业务错误。
|
||||
var (
|
||||
ErrBotTokenInvalid = errors.New("bot token invalid")
|
||||
ErrBotsTooMany = errors.New("bot create limit exceeded")
|
||||
ErrBotNameInvalid = errors.New("bot name invalid")
|
||||
ErrBotUsernameInvalid = errors.New("bot username invalid")
|
||||
ErrBotNotFound = errors.New("bot not found")
|
||||
// ErrBotCommandInvalid 表示命令名/描述非法(bots.setBotCommands)。
|
||||
ErrBotCommandInvalid = errors.New("bot command invalid")
|
||||
// ErrBotInfoInvalid 表示 setBotInfo 的 name/about/description 越界。
|
||||
ErrBotInfoInvalid = errors.New("bot info invalid")
|
||||
// ErrBotMenuButtonInvalid 表示 menu button 文本/URL 非法。
|
||||
ErrBotMenuButtonInvalid = errors.New("bot menu button invalid")
|
||||
// ErrBotInlinePlaceholderInvalid 表示 inline placeholder 为空以外的文本越界。
|
||||
ErrBotInlinePlaceholderInvalid = errors.New("bot inline placeholder invalid")
|
||||
// ErrBotAppInvalid 表示 bot app 元数据、URL 或 access hash 非法。
|
||||
ErrBotAppInvalid = errors.New("bot app invalid")
|
||||
// ErrBotAppShortNameInvalid 表示 bot app short_name 非法。
|
||||
ErrBotAppShortNameInvalid = errors.New("bot app short name invalid")
|
||||
// ErrBotAttachMenuInvalid 表示 attach menu catalog 或用户状态非法。
|
||||
ErrBotAttachMenuInvalid = errors.New("bot attach menu invalid")
|
||||
// ErrBotRequestedButtonInvalid 表示 request-peer button 上下文非法或已过期。
|
||||
ErrBotRequestedButtonInvalid = errors.New("bot requested button invalid")
|
||||
// ErrBotDownloadParamsInvalid 表示 mini app download 参数未通过安全策略。
|
||||
ErrBotDownloadParamsInvalid = errors.New("bot download params invalid")
|
||||
// ErrBotCustomMethodUnavailable 表示没有可完成 WebView custom method 的 bot 侧回答。
|
||||
ErrBotCustomMethodUnavailable = errors.New("bot custom method unavailable")
|
||||
// ErrBotSessionsNotRevoked 表示 token 已轮换但撤销已登录 session 失败
|
||||
//(token 不可回滚,调用方须告知用户重试以确保旧 session 终止)。
|
||||
ErrBotSessionsNotRevoked = errors.New("bot sessions not revoked")
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxBotCommands 是单个 bot 的命令上限(default scope)。
|
||||
MaxBotCommands = 100
|
||||
// MaxBotCommandLen 是命令名长度上限(不含前导 /)。
|
||||
MaxBotCommandLen = 32
|
||||
// MaxBotCommandDescriptionLen 是命令描述长度上限。
|
||||
MaxBotCommandDescriptionLen = 256
|
||||
// MaxBotAboutLen 是 bot about(users.about)长度上限。
|
||||
MaxBotAboutLen = 120
|
||||
// MaxBotDescriptionLen 是 bot description("What can this bot do?")长度上限。
|
||||
MaxBotDescriptionLen = 512
|
||||
// MaxBotMenuButtonTextLen / MaxBotMenuButtonURLLen 是菜单按钮文本/URL 上限。
|
||||
MaxBotMenuButtonTextLen = 64
|
||||
MaxBotMenuButtonURLLen = 512
|
||||
)
|
||||
|
||||
// BotCommand 是 bot 的一条命令(botInfo.commands 元素)。
|
||||
type BotCommand struct {
|
||||
Command string `json:"command"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// BotMenuButtonType 标识菜单按钮类型。
|
||||
type BotMenuButtonType int
|
||||
|
||||
const (
|
||||
// BotMenuButtonDefault 是默认(不显示特殊按钮,客户端按 commands 是否非空决定 '/' 圆钮)。
|
||||
BotMenuButtonDefault BotMenuButtonType = 0
|
||||
// BotMenuButtonCommands 显式让客户端展示命令菜单按钮。
|
||||
BotMenuButtonCommands BotMenuButtonType = 1
|
||||
// BotMenuButtonWebView 是带文本+URL 的 web view 按钮。
|
||||
BotMenuButtonWebView BotMenuButtonType = 2
|
||||
)
|
||||
|
||||
// BotMenuButton 是 bot 的菜单按钮(per-bot 全局;per-user 维度 P2 不做)。
|
||||
type BotMenuButton struct {
|
||||
Type BotMenuButtonType
|
||||
Text string
|
||||
URL string
|
||||
}
|
||||
|
||||
// BotInfoUpdate 是 bots.setBotInfo 的部分更新(name→users.first_name、
|
||||
// about→users.about、description→bots.description)。各 SetXxx 为 false 时不动该字段。
|
||||
type BotInfoUpdate struct {
|
||||
SetName bool
|
||||
Name string
|
||||
SetAbout bool
|
||||
About string
|
||||
SetDescription bool
|
||||
Description string
|
||||
}
|
||||
|
||||
// BotApp 是 mini app catalog 的一项。一个 bot 可拥有多个 app,ID/access_hash
|
||||
// 在创建后稳定,short_name 在同一 bot 下唯一。
|
||||
type BotApp struct {
|
||||
ID int64
|
||||
BotUserID int64
|
||||
ShortName string
|
||||
Title string
|
||||
Description string
|
||||
URL string
|
||||
PhotoID int64
|
||||
DocumentID int64
|
||||
AccessHash int64
|
||||
Hash int64
|
||||
Inactive bool
|
||||
RequestWriteAccess bool
|
||||
HasSettings bool
|
||||
Main bool
|
||||
}
|
||||
|
||||
// BotAppSettings 是 BotInfo.app_settings 的协议中立表示。
|
||||
type BotAppSettings struct {
|
||||
PlaceholderPath []byte
|
||||
BackgroundColor int
|
||||
BackgroundDarkColor int
|
||||
HeaderColor int
|
||||
HeaderDarkColor int
|
||||
HasBackgroundColor bool
|
||||
HasBackgroundDark bool
|
||||
HasHeaderColor bool
|
||||
HasHeaderDarkColor bool
|
||||
}
|
||||
|
||||
// BotAppPreviewMedia 是 main mini app preview media 的持久快照。
|
||||
type BotAppPreviewMedia struct {
|
||||
ID int64
|
||||
BotUserID int64
|
||||
AppID int64
|
||||
Position int
|
||||
PhotoID int64
|
||||
DocumentID int64
|
||||
}
|
||||
|
||||
// BotAttachMenuIconColor 描述 attach menu icon 的主题色覆盖。
|
||||
type BotAttachMenuIconColor struct {
|
||||
Name string
|
||||
Color int
|
||||
}
|
||||
|
||||
// BotAttachMenuIcon 描述 attach menu 的平台 icon 文档引用。
|
||||
type BotAttachMenuIcon struct {
|
||||
Name string
|
||||
DocumentID int64
|
||||
Colors []BotAttachMenuIconColor
|
||||
}
|
||||
|
||||
// BotAttachMenuBot 是全局 attach/side menu catalog 的一项。
|
||||
type BotAttachMenuBot struct {
|
||||
BotUserID int64
|
||||
AppID int64
|
||||
ShortName string
|
||||
Inactive bool
|
||||
HasSettings bool
|
||||
RequestWriteAccess bool
|
||||
ShowInAttachMenu bool
|
||||
ShowInSideMenu bool
|
||||
SideMenuDisclaimerNeeded bool
|
||||
PeerTypes []string
|
||||
Icons []BotAttachMenuIcon
|
||||
}
|
||||
|
||||
// BotAttachMenuState 是用户对某个 attach menu bot 的启用与写权限状态。
|
||||
type BotAttachMenuState struct {
|
||||
UserID int64
|
||||
BotUserID int64
|
||||
Enabled bool
|
||||
WriteAllowed bool
|
||||
}
|
||||
|
||||
// BotRequestedWebViewButton 是 bots.requestWebViewButton 创建的 request-peer 上下文。
|
||||
type BotRequestedWebViewButton struct {
|
||||
WebAppReqID string
|
||||
BotUserID int64
|
||||
UserID int64
|
||||
ButtonID int
|
||||
Text string
|
||||
PeerType string
|
||||
MaxQuantity int
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// BotWebViewCustomMethodQuery 是 custom method 的 pending 记录。没有 bot 侧回答
|
||||
// registry 时只记录查询并显式返回 blocked 错误,避免假成功。
|
||||
type BotWebViewCustomMethodQuery struct {
|
||||
ID string
|
||||
BotUserID int64
|
||||
UserID int64
|
||||
CustomMethod string
|
||||
ParamsJSON string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// ValidBotCommandName 校验命令名:1-32 位小写字母/数字/下划线(不含前导 /)。
|
||||
func ValidBotCommandName(cmd string) bool {
|
||||
if len(cmd) == 0 || len(cmd) > MaxBotCommandLen {
|
||||
return false
|
||||
}
|
||||
for _, r := range cmd {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// BotProfile 是 bots 表一行:bot 账号的元数据与 token。
|
||||
// 显示名/username/about 复用 users 行,不在此重复。
|
||||
type BotProfile struct {
|
||||
BotUserID int64
|
||||
OwnerUserID int64
|
||||
TokenSecret string
|
||||
Description string
|
||||
Commands []BotCommand
|
||||
ChatHistory bool // privacy mode 关闭 = true(bot_chat_history flag)
|
||||
Nochats bool // true = 不允许加群(bot_nochats flag)
|
||||
InlineGeo bool
|
||||
InlinePlaceholder string
|
||||
MenuButton BotMenuButton
|
||||
HasMainApp bool
|
||||
HasAttachMenu bool
|
||||
HasPreviewMedias bool
|
||||
AppSettings *BotAppSettings
|
||||
}
|
||||
|
||||
// BotChatState 是内置 bot(当前仅 BotFather)与某用户的对话状态机持久态。
|
||||
type BotChatState struct {
|
||||
BotUserID int64
|
||||
UserID int64
|
||||
// Command 是进行中的主命令(newbot/token/revoke)。
|
||||
Command string `json:"command"`
|
||||
// Step 是主命令内的当前步骤(name/username/choose)。
|
||||
Step string `json:"step"`
|
||||
// Draft 暂存跨步骤的中间输入(如 newbot 已收的 name)。
|
||||
Draft map[string]string `json:"draft,omitempty"`
|
||||
}
|
||||
|
||||
// FormatBotToken 拼装完整 bot token(<bot_user_id>:<secret>)。
|
||||
func FormatBotToken(botUserID int64, secret string) string {
|
||||
return strconv.FormatInt(botUserID, 10) + ":" + secret
|
||||
}
|
||||
|
||||
// ParseBotToken 拆解完整 bot token。格式非法时 ok=false(不区分具体原因,
|
||||
// 调用方统一回 ACCESS_TOKEN_INVALID,避免泄漏存在性)。
|
||||
func ParseBotToken(token string) (botUserID int64, secret string, ok bool) {
|
||||
idPart, secret, found := strings.Cut(token, ":")
|
||||
if !found || idPart == "" || secret == "" {
|
||||
return 0, "", false
|
||||
}
|
||||
id, err := strconv.ParseInt(idPart, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
return id, secret, true
|
||||
}
|
||||
|
||||
// ValidBotUsername 校验 bot username:5-32 位、字母开头、[A-Za-z0-9_]、
|
||||
// 以 bot 结尾(大小写不敏感)。BotFather 等种子账号不经此校验。
|
||||
func ValidBotUsername(username string) bool {
|
||||
if len(username) < 5 || len(username) > 32 {
|
||||
return false
|
||||
}
|
||||
for i, r := range username {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9', r == '_':
|
||||
if i == 0 {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return strings.HasSuffix(strings.ToLower(username), BotUsernameSuffix)
|
||||
}
|
||||
63
internal/domain/bot_inline.go
Normal file
63
internal/domain/bot_inline.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package domain
|
||||
|
||||
const (
|
||||
MaxBotInlineResults = 50
|
||||
MaxBotInlineResultIDLen = 64
|
||||
MaxBotInlineNextOffsetLen = 64
|
||||
MaxBotInlineSwitchTextLen = 256
|
||||
MaxBotInlineWebURLLen = 2048
|
||||
MaxBotInlineWebMimeLen = 128
|
||||
MaxBotInlineWebSize = 20 * 1024 * 1024
|
||||
MaxBotPreparedInlineIDLen = 128
|
||||
)
|
||||
|
||||
type BotInlineResult struct {
|
||||
ID string
|
||||
Type string
|
||||
Title string
|
||||
Description string
|
||||
URL string
|
||||
Thumb *BotInlineWebDocument
|
||||
Content *BotInlineWebDocument
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
NoWebpage bool
|
||||
MediaAuto bool
|
||||
Media *MessageMedia
|
||||
}
|
||||
|
||||
type BotInlineWebDocument struct {
|
||||
URL string
|
||||
AccessHash int64
|
||||
Size int
|
||||
MimeType string
|
||||
Attributes []DocumentAttribute
|
||||
}
|
||||
|
||||
type BotInlineResults struct {
|
||||
QueryID int64
|
||||
BotUserID int64
|
||||
UserID int64
|
||||
Peer Peer
|
||||
Query string
|
||||
Geo *MessageGeoPoint
|
||||
Gallery bool
|
||||
Private bool
|
||||
Results []BotInlineResult
|
||||
CacheTime int
|
||||
NextOffset string
|
||||
SwitchPM *BotInlineSwitchPM
|
||||
SwitchWeb *BotInlineSwitchWebView
|
||||
PeerTypes []string
|
||||
}
|
||||
|
||||
type BotInlineSwitchPM struct {
|
||||
Text string
|
||||
StartParam string
|
||||
}
|
||||
|
||||
type BotInlineSwitchWebView struct {
|
||||
Text string
|
||||
URL string
|
||||
}
|
||||
302
internal/domain/business.go
Normal file
302
internal/domain/business.go
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxBusinessWorkHourIntervals = 28
|
||||
MaxBusinessRecipientUsers = 100
|
||||
MaxBusinessChatLinks = 100
|
||||
MaxBusinessChatLinkMessage = MaxMessageTextLength
|
||||
MaxBusinessChatLinkTitle = 64
|
||||
MaxQuickReplies = 100
|
||||
MaxQuickReplyMessages = 20
|
||||
MaxQuickReplyShortcutLength = 32
|
||||
BusinessAwayCooldownSeconds = 24 * 60 * 60
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBusinessProfileInvalid = errors.New("business profile invalid")
|
||||
ErrBusinessChatLinkInvalid = errors.New("business chat link invalid")
|
||||
ErrBusinessChatLinkNotFound = errors.New("business chat link not found")
|
||||
ErrBusinessChatLinksTooMuch = errors.New("business chat links too much")
|
||||
ErrBusinessRecipientsEmpty = errors.New("business recipients empty")
|
||||
ErrBotBusinessMissing = errors.New("bot business missing")
|
||||
ErrBotNotConnectedYet = errors.New("bot not connected yet")
|
||||
ErrBotAlreadyDisabled = errors.New("bot already disabled")
|
||||
ErrShortcutInvalid = errors.New("quick reply shortcut invalid")
|
||||
ErrShortcutOccupied = errors.New("quick reply shortcut occupied")
|
||||
ErrQuickRepliesTooMuch = errors.New("quick replies too much")
|
||||
)
|
||||
|
||||
type BusinessProfile struct {
|
||||
UserID int64
|
||||
WorkHours *BusinessWorkHours
|
||||
Location *BusinessLocation
|
||||
Intro *BusinessIntro
|
||||
Greeting *BusinessGreetingMessage
|
||||
Away *BusinessAwayMessage
|
||||
UpdatedAtUnix int64
|
||||
}
|
||||
|
||||
type BusinessWeeklyOpen struct {
|
||||
StartMinute int
|
||||
EndMinute int
|
||||
}
|
||||
|
||||
type BusinessWorkHours struct {
|
||||
TimezoneID string
|
||||
WeeklyOpen []BusinessWeeklyOpen
|
||||
OpenNow bool
|
||||
}
|
||||
|
||||
type BusinessLocation struct {
|
||||
Address string
|
||||
Geo *GeoPoint
|
||||
}
|
||||
|
||||
type GeoPoint struct {
|
||||
Lat float64
|
||||
Long float64
|
||||
}
|
||||
|
||||
type BusinessIntro struct {
|
||||
Title string
|
||||
Description string
|
||||
StickerDocumentID int64
|
||||
}
|
||||
|
||||
type BusinessRecipients struct {
|
||||
ExistingChats bool
|
||||
NewChats bool
|
||||
Contacts bool
|
||||
NonContacts bool
|
||||
ExcludeSelected bool
|
||||
Users []int64
|
||||
}
|
||||
|
||||
type BusinessBotRights struct {
|
||||
Reply bool
|
||||
ReadMessages bool
|
||||
DeleteSentMessages bool
|
||||
DeleteReceivedMessages bool
|
||||
EditName bool
|
||||
EditBio bool
|
||||
EditProfilePhoto bool
|
||||
EditUsername bool
|
||||
ViewGifts bool
|
||||
SellGifts bool
|
||||
ChangeGiftSettings bool
|
||||
TransferAndUpgradeGifts bool
|
||||
TransferStars bool
|
||||
ManageStories bool
|
||||
}
|
||||
|
||||
type BusinessBotRecipients struct {
|
||||
ExistingChats bool
|
||||
NewChats bool
|
||||
Contacts bool
|
||||
NonContacts bool
|
||||
ExcludeSelected bool
|
||||
Users []int64
|
||||
ExcludeUsers []int64
|
||||
}
|
||||
|
||||
type ConnectedBusinessBot struct {
|
||||
OwnerUserID int64
|
||||
BotUserID int64
|
||||
Recipients BusinessBotRecipients
|
||||
Rights BusinessBotRights
|
||||
CreatedAtUnix int64
|
||||
UpdatedAtUnix int64
|
||||
}
|
||||
|
||||
type ConnectedBusinessBotPeerState struct {
|
||||
OwnerUserID int64
|
||||
PeerUserID int64
|
||||
Paused bool
|
||||
Disabled bool
|
||||
UpdatedAtUnix int64
|
||||
}
|
||||
|
||||
type BusinessGreetingMessage struct {
|
||||
ShortcutID int
|
||||
Recipients BusinessRecipients
|
||||
NoActivityDays int
|
||||
}
|
||||
|
||||
type BusinessAwayScheduleKind string
|
||||
|
||||
const (
|
||||
BusinessAwayScheduleAlways BusinessAwayScheduleKind = "always"
|
||||
BusinessAwayScheduleOutsideWorkHours BusinessAwayScheduleKind = "outside_work_hours"
|
||||
BusinessAwayScheduleCustom BusinessAwayScheduleKind = "custom"
|
||||
)
|
||||
|
||||
type BusinessAwaySchedule struct {
|
||||
Kind BusinessAwayScheduleKind
|
||||
StartDate int
|
||||
EndDate int
|
||||
}
|
||||
|
||||
type BusinessAwayMessage struct {
|
||||
ShortcutID int
|
||||
Schedule BusinessAwaySchedule
|
||||
Recipients BusinessRecipients
|
||||
OfflineOnly bool
|
||||
}
|
||||
|
||||
type BusinessAutomationKind string
|
||||
|
||||
const (
|
||||
BusinessAutomationGreeting BusinessAutomationKind = "greeting"
|
||||
BusinessAutomationAway BusinessAutomationKind = "away"
|
||||
BusinessAutomationAI BusinessAutomationKind = "ai"
|
||||
)
|
||||
|
||||
type BusinessAutomationDelivery struct {
|
||||
OwnerUserID int64
|
||||
PeerUserID int64
|
||||
Kind BusinessAutomationKind
|
||||
TriggerMessageID int
|
||||
ShortcutID int
|
||||
SentAt int
|
||||
}
|
||||
|
||||
type BusinessChatLinkInput struct {
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Title string
|
||||
}
|
||||
|
||||
type BusinessChatLink struct {
|
||||
OwnerUserID int64
|
||||
Slug string
|
||||
Link string
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Title string
|
||||
Views int
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
type QuickReply struct {
|
||||
OwnerUserID int64
|
||||
ID int
|
||||
Shortcut string
|
||||
TopMessage int
|
||||
Count int
|
||||
SortOrder int
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
type QuickReplyMessage struct {
|
||||
OwnerUserID int64
|
||||
ShortcutID int
|
||||
ID int
|
||||
RandomID int64
|
||||
Date int
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
}
|
||||
|
||||
type QuickReplyList struct {
|
||||
OwnerUserID int64
|
||||
QuickReplies []QuickReply
|
||||
Messages []QuickReplyMessage
|
||||
Hash int64
|
||||
}
|
||||
|
||||
type QuickReplyMessages struct {
|
||||
OwnerUserID int64
|
||||
ShortcutID int
|
||||
Messages []QuickReplyMessage
|
||||
Count int
|
||||
Hash int64
|
||||
}
|
||||
|
||||
type QuickReplyMutationKind string
|
||||
|
||||
const (
|
||||
QuickReplyMutationList QuickReplyMutationKind = "list"
|
||||
QuickReplyMutationNew QuickReplyMutationKind = "new"
|
||||
QuickReplyMutationDelete QuickReplyMutationKind = "delete"
|
||||
QuickReplyMutationMessage QuickReplyMutationKind = "message"
|
||||
QuickReplyMutationIDs QuickReplyMutationKind = "ids"
|
||||
)
|
||||
|
||||
type QuickReplyMutation struct {
|
||||
Kind QuickReplyMutationKind
|
||||
List QuickReplyList
|
||||
QuickReply QuickReply
|
||||
ShortcutID int
|
||||
Message QuickReplyMessage
|
||||
MessageIDs []int
|
||||
Date int
|
||||
Pts int
|
||||
PtsCount int
|
||||
}
|
||||
|
||||
func NormalizeBusinessChatLinkInput(in BusinessChatLinkInput) (BusinessChatLinkInput, error) {
|
||||
in.Message = strings.TrimSpace(in.Message)
|
||||
in.Title = strings.TrimSpace(in.Title)
|
||||
if in.Message == "" || utf8.RuneCountInString(in.Message) > MaxBusinessChatLinkMessage {
|
||||
return BusinessChatLinkInput{}, ErrBusinessChatLinkInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(in.Title) > MaxBusinessChatLinkTitle {
|
||||
return BusinessChatLinkInput{}, ErrBusinessChatLinkInvalid
|
||||
}
|
||||
if len(in.Entities) > MaxMessageEntityCount {
|
||||
return BusinessChatLinkInput{}, ErrBusinessChatLinkInvalid
|
||||
}
|
||||
in.Entities = append([]MessageEntity(nil), in.Entities...)
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func NormalizeQuickReplyShortcut(shortcut string) (string, error) {
|
||||
shortcut = strings.TrimSpace(shortcut)
|
||||
if shortcut == "" || strings.ContainsAny(shortcut, "\r\n\t") || utf8.RuneCountInString(shortcut) > MaxQuickReplyShortcutLength {
|
||||
return "", ErrShortcutInvalid
|
||||
}
|
||||
return shortcut, nil
|
||||
}
|
||||
|
||||
func BusinessBotRecipientsMatch(recipients BusinessBotRecipients, existingChat, isContact bool, userID int64) bool {
|
||||
selected := false
|
||||
if existingChat && recipients.ExistingChats {
|
||||
selected = true
|
||||
}
|
||||
if !existingChat && recipients.NewChats {
|
||||
selected = true
|
||||
}
|
||||
if isContact && recipients.Contacts {
|
||||
selected = true
|
||||
}
|
||||
if !isContact && recipients.NonContacts {
|
||||
selected = true
|
||||
}
|
||||
for _, id := range recipients.Users {
|
||||
if id == userID {
|
||||
selected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, id := range recipients.ExcludeUsers {
|
||||
if id == userID {
|
||||
if recipients.ExcludeSelected {
|
||||
selected = true
|
||||
break
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
if recipients.ExcludeSelected {
|
||||
return !selected
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxChannelDifferenceLimit limits a single updates.getChannelDifference page.
|
||||
MaxChannelDifferenceLimit = 100
|
||||
|
|
@ -14,9 +20,18 @@ const (
|
|||
// MaxChannelInviteUsers limits a single inviteToChannel/createChat member batch.
|
||||
MaxChannelInviteUsers = 200
|
||||
// MaxChannelRealtimeFanout caps best-effort realtime channel pushes until a presence/subscription index exists.
|
||||
MaxChannelRealtimeFanout = 500
|
||||
MaxChannelRealtimeFanout = 2000
|
||||
// MaxSynchronousChannelDialogFanout bounds per-member channel_dialogs writes in the send transaction.
|
||||
MaxSynchronousChannelDialogFanout = 1000
|
||||
// MaxDialogUnreadCount 钳制对话未读消息 COUNT 的上界(设计 fan-out epic Phase 2 / P1-v)。
|
||||
// 广播/大群走动态 COUNT(*),积压巨大时既下发天文数字角标、又让 nudge 风暴下每个被 nudge
|
||||
// 成员的 getChannelDifference/getPeerDialogs 触发 O(积压) 扫描打爆 PG。store 层一律把未读
|
||||
// COUNT 用 LIMIT 子查询(PG)/提前 break(memory) 钳到本上界——既限定扫描工作量也限定下发数值。
|
||||
// 只影响角标显示数,不影响 read 水位/pts 真值与 hasUnread(EXISTS) 判定。
|
||||
// 取 99(角标显示上限“99+”惯例):上界即扫描上界,越低单次未读 COUNT 扫描越浅——
|
||||
// 相比旧值 1000,积压 >99 的成员每次未读 COUNT(大群读路径 + fan-out 写 + forum 话题)
|
||||
// 扫描行数约降一个数量级。值可按客户端角标显示习惯调整。
|
||||
MaxDialogUnreadCount = 99
|
||||
// MaxChannelTypingFanout caps transient typing fanout.
|
||||
MaxChannelTypingFanout = MaxChannelRealtimeFanout
|
||||
// MaxChannelAdminRankLength limits custom admin rank text.
|
||||
|
|
@ -31,6 +46,9 @@ const (
|
|||
MaxChannelPendingJoinRecentRequesters = 5
|
||||
// MaxAdminedPublicChannels limits channels.getAdminedPublicChannels payload size.
|
||||
MaxAdminedPublicChannels = 200
|
||||
// MaxSendAsChannels bounds the broadcast channels offered as channels.getSendAs candidates
|
||||
// (the user's own channels they can post groups as).
|
||||
MaxSendAsChannels = 100
|
||||
// MaxChannelAdminLogLimit limits one channels.getAdminLog page.
|
||||
MaxChannelAdminLogLimit = 100
|
||||
// MaxChannelAdminLogAdmins limits admin filter fan-in.
|
||||
|
|
@ -47,12 +65,36 @@ const (
|
|||
MaxPublicChannelSearchLimit = 50
|
||||
// MaxPublicChannelSearchQueryLength bounds public channel peer search strings.
|
||||
MaxPublicChannelSearchQueryLength = 256
|
||||
// MaxChannelReactionItems limits one channel reaction policy payload.
|
||||
MaxChannelReactionItems = 64
|
||||
// MaxChannelReactionTypes bounds how many distinct reaction types a chat may
|
||||
// allow via chatReactionsSome. DrKLO sends "enable all standard reactions" on
|
||||
// a broadcast channel as an explicit chatReactionsSome list (megagroups use
|
||||
// chatReactionsAll instead), so this MUST stay >= the advertised available
|
||||
// reactions catalog (messages.getAvailableReactions, currently ~74 entries) or
|
||||
// that legitimate "select all" payload is wrongly rejected with LIMIT_INVALID.
|
||||
// 100 leaves headroom for catalog growth while still bounding abusive payloads.
|
||||
MaxChannelReactionTypes = 100
|
||||
// MaxChannelReactionsLimit bounds the per-message distinct-reaction cap carried
|
||||
// by messages.setChatAvailableReactions.reactions_limit. Clients constrain it to
|
||||
// appConfig reactions_uniq_max (11); this wider server bound only rejects abuse
|
||||
// and must never be smaller than the reaction type count to keep the cap sane.
|
||||
MaxChannelReactionsLimit = 100
|
||||
// MaxChannelReactionEmoticonLength limits one emoji reaction string.
|
||||
MaxChannelReactionEmoticonLength = 32
|
||||
// MaxChannelMessageReactionsPerUser limits one user's reactions on one channel message.
|
||||
// MaxChannelMessageReactionsPerUser bounds the accepted sendReaction vector before trimming.
|
||||
MaxChannelMessageReactionsPerUser = 16
|
||||
// MaxMessageReactionsPerUser is the effective per-user cap on one message, matching the
|
||||
// advertised appConfig reactions_user_max_default.
|
||||
// Longer vectors keep the newest entries: official clients are told to drop older
|
||||
// reactions, so the tail of the vector wins.
|
||||
MaxMessageReactionsPerUser = 1
|
||||
// MaxMessageReactionsPerUserPremium 是会员档每用户上限,对齐 appConfig
|
||||
// reactions_user_max_premium(官方值 3)。客户端按 self premium flag 选档,
|
||||
// 服务端档位必须 ≥ 客户端宣告档位,否则 premium 用户的多 reaction 会被
|
||||
// 静默裁剪、双端视图错乱。
|
||||
MaxMessageReactionsPerUserPremium = 3
|
||||
// DefaultMessageReactionsUniqMax caps distinct reaction emojis on one message, matching the
|
||||
// advertised appConfig reactions_uniq_max. ChannelReactionPolicy.Limit overrides it per chat.
|
||||
DefaultMessageReactionsUniqMax = 11
|
||||
// MaxChannelMessageReactionRecent limits recent reactors embedded in messageReactions.
|
||||
MaxChannelMessageReactionRecent = 3
|
||||
// MaxChannelMessageReactionListLimit limits one messages.getMessageReactionsList page.
|
||||
|
|
@ -99,6 +141,9 @@ const (
|
|||
MaxChannelForumTopicTitleLength = 128
|
||||
// DefaultForumTopicIconColor is Telegram Desktop's General-topic fallback blue.
|
||||
DefaultForumTopicIconColor = 0x6FB9F0
|
||||
// ForumGeneralTopicID 是 General 话题的固定 id(Telegram 协议;客户端 readDiscussion General 用 msg_id=1)。
|
||||
// General 内消息 reply_to_top_id=1,开 forum 前的历史消息 reply_to_top_id=0,二者都归 General。
|
||||
ForumGeneralTopicID = 1
|
||||
// MaxChannelReadMentionsBatch limits one messages.readMentions clearing batch.
|
||||
MaxChannelReadMentionsBatch = 1000
|
||||
// MaxChannelReadReactionsBatch limits one messages.readReactions clearing batch.
|
||||
|
|
@ -144,12 +189,19 @@ type ChannelAdminRights struct {
|
|||
PostMessages bool
|
||||
EditMessages bool
|
||||
DeleteMessages bool
|
||||
PostStories bool
|
||||
EditStories bool
|
||||
DeleteStories bool
|
||||
BanUsers bool
|
||||
InviteUsers bool
|
||||
PinMessages bool
|
||||
AddAdmins bool
|
||||
ManageCall bool
|
||||
Anonymous bool
|
||||
ManageRanks bool
|
||||
// ManageDirectMessages 对应 TL ChatAdminRights.manage_direct_messages(flags.17)。母广播频道的
|
||||
// 管理员据此被客户端授予 monoforum(频道私信)容器的 MonoforumAdmin 身份;creator 走 amCreator 旁路。
|
||||
ManageDirectMessages bool
|
||||
}
|
||||
|
||||
// ChannelBannedRights is a domain-only representation of Telegram banned rights.
|
||||
|
|
@ -166,6 +218,7 @@ type ChannelBannedRights struct {
|
|||
ChangeInfo bool
|
||||
InviteUsers bool
|
||||
PinMessages bool
|
||||
EditRank bool
|
||||
UntilDate int
|
||||
}
|
||||
|
||||
|
|
@ -189,6 +242,83 @@ type ChannelReactionPolicy struct {
|
|||
PaidEnabled bool
|
||||
}
|
||||
|
||||
// AllowsReaction reports whether the policy accepts one message reaction.
|
||||
// The zero policy (Default) behaves like chatReactionsAll without allow_custom:
|
||||
// ordinary emoji reactions are accepted, custom emoji reactions require an
|
||||
// explicit chatReactionsSome entry or chatReactionsAll.allow_custom.
|
||||
func (p ChannelReactionPolicy) AllowsReaction(reaction MessageReaction) bool {
|
||||
if !reaction.Valid() {
|
||||
return false
|
||||
}
|
||||
switch p.Type {
|
||||
case ChannelReactionPolicyNone:
|
||||
return false
|
||||
case ChannelReactionPolicySome:
|
||||
switch reaction.Type {
|
||||
case MessageReactionEmoji:
|
||||
for _, emoticon := range p.Emoticons {
|
||||
if strings.TrimSpace(emoticon) == reaction.Value() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case MessageReactionCustomEmoji:
|
||||
for _, documentID := range p.CustomEmojiIDs {
|
||||
if documentID == reaction.DocumentID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
case ChannelReactionPolicyAll:
|
||||
if reaction.Type == MessageReactionCustomEmoji {
|
||||
return p.AllowCustom
|
||||
}
|
||||
return reaction.Type == MessageReactionEmoji
|
||||
default:
|
||||
return reaction.Type == MessageReactionEmoji
|
||||
}
|
||||
}
|
||||
|
||||
// UniqueReactionsLimit returns the per-message distinct emoji cap for the chat:
|
||||
// channelFull.reactions_limit when set, otherwise the appConfig reactions_uniq_max default.
|
||||
func (p ChannelReactionPolicy) UniqueReactionsLimit() int {
|
||||
if p.Limit > 0 {
|
||||
return p.Limit
|
||||
}
|
||||
return DefaultMessageReactionsUniqMax
|
||||
}
|
||||
|
||||
// MessageReactionsUserMax 返回 viewer 的每用户 reaction 上限档位。
|
||||
func MessageReactionsUserMax(premium bool) int {
|
||||
if premium {
|
||||
return MaxMessageReactionsPerUserPremium
|
||||
}
|
||||
return MaxMessageReactionsPerUser
|
||||
}
|
||||
|
||||
// NormalizeReactionsPerUserMax 把请求携带的档位约束到合法区间:
|
||||
// <=0(旧调用方未填)回退默认档,上限封顶 premium 档。
|
||||
func NormalizeReactionsPerUserMax(perUserMax int) int {
|
||||
if perUserMax <= 0 {
|
||||
return MaxMessageReactionsPerUser
|
||||
}
|
||||
if perUserMax > MaxMessageReactionsPerUserPremium {
|
||||
return MaxMessageReactionsPerUserPremium
|
||||
}
|
||||
return perUserMax
|
||||
}
|
||||
|
||||
// TrimMessageReactionsToUserMax enforces the per-user reaction cap by keeping the newest
|
||||
// entries (clients append new reactions at the end of the vector and drop older ones).
|
||||
// perUserMax 经 NormalizeReactionsPerUserMax 约束;premium viewer 传 premium 档。
|
||||
func TrimMessageReactionsToUserMax(reactions []MessageReaction, perUserMax int) []MessageReaction {
|
||||
perUserMax = NormalizeReactionsPerUserMax(perUserMax)
|
||||
if len(reactions) <= perUserMax {
|
||||
return reactions
|
||||
}
|
||||
return reactions[len(reactions)-perUserMax:]
|
||||
}
|
||||
|
||||
// ChannelPeerColor is a domain-only representation of PeerColor.
|
||||
type ChannelPeerColor struct {
|
||||
HasColor bool
|
||||
|
|
@ -220,6 +350,7 @@ type Channel struct {
|
|||
Title string
|
||||
About string
|
||||
Username string
|
||||
Verified bool
|
||||
Broadcast bool
|
||||
Megagroup bool
|
||||
Forum bool
|
||||
|
|
@ -235,29 +366,52 @@ type Channel struct {
|
|||
PreHistoryHidden bool
|
||||
ParticipantsHidden bool
|
||||
AntiSpam bool
|
||||
LinkedChatID int64
|
||||
SlowmodeSeconds int
|
||||
DefaultBannedRights ChannelBannedRights
|
||||
ReactionPolicy ChannelReactionPolicy
|
||||
Color ChannelPeerColor
|
||||
ProfileColor ChannelPeerColor
|
||||
EmojiStatus ChannelEmojiStatus
|
||||
ParticipantsCount int
|
||||
AdminsCount int
|
||||
KickedCount int
|
||||
BannedCount int
|
||||
TopMessageID int
|
||||
PinnedMessageID int
|
||||
Pts int
|
||||
TTLPeriod int
|
||||
Date int
|
||||
Deleted bool
|
||||
// HasLink 表示频道/群存在未撤销的导出邀请链接。Android send-as 入口会用
|
||||
// megagroup && (public || has_geo || has_link) 判定是否拉取候选列表。
|
||||
HasLink bool
|
||||
LinkedChatID int64
|
||||
// Monoforum 标记本频道是「频道私信(Direct Messages)」的 monoforum 虚拟频道。
|
||||
// LinkedMonoforumID:母频道指向其 monoforum;monoforum 反向指向母频道(双向)。
|
||||
Monoforum bool
|
||||
LinkedMonoforumID int64
|
||||
SlowmodeSeconds int
|
||||
BoostsUnrestrict int
|
||||
DefaultBannedRights ChannelBannedRights
|
||||
ReactionPolicy ChannelReactionPolicy
|
||||
Color ChannelPeerColor
|
||||
ProfileColor ChannelPeerColor
|
||||
EmojiStatus ChannelEmojiStatus
|
||||
Wallpaper *Wallpaper
|
||||
ParticipantsCount int
|
||||
AdminsCount int
|
||||
KickedCount int
|
||||
BannedCount int
|
||||
TopMessageID int
|
||||
PinnedMessageID int
|
||||
Pts int
|
||||
TTLPeriod int
|
||||
Date int
|
||||
Deleted bool
|
||||
// 活跃群通话关联(channel.call_active/call_not_empty flag 与 channelFull.call
|
||||
// 的数据源;客户端拉 dialogs/getFullChannel 时凭它重建 banner)。
|
||||
ActiveCallID int64
|
||||
ActiveCallAccessHash int64
|
||||
ActiveCallNotEmpty bool
|
||||
// 当前头像(反范式存于 channels 表)。PhotoID==0 表示无头像。
|
||||
PhotoID int64
|
||||
PhotoDCID int
|
||||
PhotoStripped []byte
|
||||
}
|
||||
|
||||
// MembersListAdminOnly 表示该频道的成员/订阅者列表仅管理员可查看。广播频道(非
|
||||
// megagroup)的订阅者列表在官方语义里恒为管理员专属——普通订阅者看不到 Subscribers/
|
||||
// Administrators/Channel Settings 等行,也不能枚举订阅者;此外成员被隐藏
|
||||
// (ParticipantsHidden) 时对非管理员同样不可见。超级群(megagroup)默认成员可见。
|
||||
// 该判定只看频道本身,是否放行还需叠加 viewer 是否管理员。
|
||||
func (c Channel) MembersListAdminOnly() bool {
|
||||
return c.ParticipantsHidden || (c.Broadcast && !c.Megagroup)
|
||||
}
|
||||
|
||||
// ChannelMember is one user's channel membership and read state.
|
||||
type ChannelMember struct {
|
||||
ChannelID int64
|
||||
|
|
@ -295,6 +449,7 @@ type ChannelDialog struct {
|
|||
PinnedOrder int
|
||||
UnreadMark bool
|
||||
ViewForumAsMessages bool
|
||||
HasScheduled bool
|
||||
DefaultSendAs *Peer
|
||||
}
|
||||
|
||||
|
|
@ -307,9 +462,32 @@ const (
|
|||
ChannelActionChatAddUser ChannelMessageActionType = "chat_add_user"
|
||||
ChannelActionChatDelete ChannelMessageActionType = "chat_delete_user"
|
||||
ChannelActionChatJoined ChannelMessageActionType = "chat_joined"
|
||||
ChannelActionEditTitle ChannelMessageActionType = "chat_edit_title"
|
||||
ChannelActionTopicCreate ChannelMessageActionType = "topic_create"
|
||||
ChannelActionTopicEdit ChannelMessageActionType = "topic_edit"
|
||||
// ChannelActionChatJoinedByLink 是经邀请链接加入的服务消息,
|
||||
// 渲染为 "X joined the group via invite link"。
|
||||
ChannelActionChatJoinedByLink ChannelMessageActionType = "chat_joined_by_link"
|
||||
ChannelActionEditTitle ChannelMessageActionType = "chat_edit_title"
|
||||
ChannelActionTopicCreate ChannelMessageActionType = "topic_create"
|
||||
ChannelActionTopicEdit ChannelMessageActionType = "topic_edit"
|
||||
// ChannelActionTodoCompletions / ChannelActionTodoAppendTasks 映射
|
||||
// messageActionTodoCompletions / messageActionTodoAppendTasks:超级群
|
||||
// checklist 协作生成的服务消息,经 reply_to 指向原 checklist 消息。
|
||||
ChannelActionTodoCompletions ChannelMessageActionType = "todo_completions"
|
||||
ChannelActionTodoAppendTasks ChannelMessageActionType = "todo_append_tasks"
|
||||
// ChannelActionGroupCall 映射 messageActionGroupCall:started(CallDuration=0)
|
||||
// 与 ended(CallDuration>0)共用同一构造器,官方语义即如此。
|
||||
ChannelActionGroupCall ChannelMessageActionType = "group_call"
|
||||
// ChannelActionInviteToGroupCall 映射 messageActionInviteToGroupCall(被邀请
|
||||
// 者通过频道消息收到可点击的入会卡片,UserIDs 为受邀人)。
|
||||
ChannelActionInviteToGroupCall ChannelMessageActionType = "invite_to_group_call"
|
||||
// ChannelActionBoostApply 映射 messageActionBoostApply。
|
||||
ChannelActionBoostApply ChannelMessageActionType = "boost_apply"
|
||||
// ChannelActionPaidMessagesPrice 映射 messageActionPaidMessagesPrice:
|
||||
// 广播频道 Direct Messages 开关/价格变更的服务消息。
|
||||
ChannelActionPaidMessagesPrice ChannelMessageActionType = "paid_messages_price"
|
||||
// ChannelActionStarGift 映射 messageActionStarGift:频道礼物的 admin-log 快照。
|
||||
ChannelActionStarGift ChannelMessageActionType = "star_gift"
|
||||
// ChannelActionSetChatWallpaper 映射 messageActionSetChatWallPaper:频道外观页设置 wallpaper。
|
||||
ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper"
|
||||
)
|
||||
|
||||
// ChannelMessageAction describes a service action without depending on tg.*.
|
||||
|
|
@ -323,6 +501,25 @@ type ChannelMessageAction struct {
|
|||
Closed *bool
|
||||
Hidden *bool
|
||||
UserIDs []int64
|
||||
// InviterUserID 仅 chat_joined_by_link 使用(messageActionChatJoinedByLink.inviter_id)。
|
||||
InviterUserID int64
|
||||
// CallID/CallAccessHash/CallDuration 仅 group_call / invite_to_group_call 使用。
|
||||
CallID int64
|
||||
CallAccessHash int64
|
||||
CallDuration int
|
||||
// Boosts 仅 boost_apply 服务消息使用。
|
||||
Boosts int
|
||||
// BroadcastMessagesAllowed/Stars 仅 paid_messages_price 服务消息使用。
|
||||
BroadcastMessagesAllowed bool
|
||||
Stars int64
|
||||
// Completed/Incompleted/TodoItems 仅 todo_* 服务消息使用。
|
||||
Completed []int
|
||||
Incompleted []int
|
||||
TodoItems []MessageTodoItem
|
||||
// StarGift 仅 star_gift 服务消息使用。
|
||||
StarGift *MessageStarGiftAction
|
||||
// Wallpaper 仅 set_chat_wallpaper 服务消息使用。
|
||||
Wallpaper *Wallpaper
|
||||
}
|
||||
|
||||
// ChannelMessage is a single stored message in a channel/supergroup.
|
||||
|
|
@ -333,38 +530,107 @@ type ChannelMessage struct {
|
|||
SenderUserID int64
|
||||
From Peer
|
||||
SendAs *Peer
|
||||
Date int
|
||||
EditDate int
|
||||
Post bool
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
Body string
|
||||
Entities []MessageEntity
|
||||
ReplyTo *MessageReply
|
||||
Forward *MessageForward
|
||||
Discussion *ChannelDiscussionRef
|
||||
Replies *ChannelMessageReplies
|
||||
Reactions *ChannelMessageReactions
|
||||
Action *ChannelMessageAction
|
||||
Media *MessageMedia
|
||||
Pinned bool
|
||||
Mentioned bool
|
||||
MediaUnread bool
|
||||
Pts int
|
||||
Deleted bool
|
||||
// SavedPeer 是 monoforum 私信子会话分组键(按订阅者分组);普通频道消息为零值。
|
||||
SavedPeer Peer
|
||||
Date int
|
||||
EditDate int
|
||||
Post bool
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
Body string
|
||||
Entities []MessageEntity
|
||||
ReplyTo *MessageReply
|
||||
Forward *MessageForward
|
||||
ViaBotID int64
|
||||
// GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。
|
||||
GroupedID int64
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
Discussion *ChannelDiscussionRef
|
||||
Replies *ChannelMessageReplies
|
||||
Reactions *ChannelMessageReactions
|
||||
Action *ChannelMessageAction
|
||||
Media *MessageMedia
|
||||
// FromBoostsApplied 是发送时的 sender boost 数快照(message.from_boosts_applied)。
|
||||
FromBoostsApplied int
|
||||
TTLPeriod int
|
||||
ExpiresAt int
|
||||
Pinned bool
|
||||
Mentioned bool
|
||||
MediaUnread bool
|
||||
// Views 是 broadcast post 的浏览数聚合;PostAuthor 是 signatures
|
||||
// 开启时的作者展示名快照。
|
||||
ViewsCount int
|
||||
PostAuthor string
|
||||
Pts int
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
// MessageReactionType identifies one stored reaction constructor without depending on TL types.
|
||||
type MessageReactionType string
|
||||
|
||||
const (
|
||||
MessageReactionEmoji MessageReactionType = "emoji"
|
||||
MessageReactionEmoji MessageReactionType = "emoji"
|
||||
MessageReactionCustomEmoji MessageReactionType = "custom_emoji"
|
||||
)
|
||||
|
||||
// MessageReaction describes one supported message reaction value.
|
||||
type MessageReaction struct {
|
||||
Type MessageReactionType
|
||||
Emoticon string
|
||||
Type MessageReactionType
|
||||
Emoticon string
|
||||
DocumentID int64
|
||||
}
|
||||
|
||||
// Value returns the canonical persisted value for a reaction constructor.
|
||||
func (r MessageReaction) Value() string {
|
||||
switch r.Type {
|
||||
case MessageReactionEmoji:
|
||||
return strings.TrimSpace(r.Emoticon)
|
||||
case MessageReactionCustomEmoji:
|
||||
if r.DocumentID <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(r.DocumentID, 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Key returns a stable identity key for de-duplication and aggregation.
|
||||
func (r MessageReaction) Key() string {
|
||||
return string(r.Type) + "\x00" + r.Value()
|
||||
}
|
||||
|
||||
// Valid reports whether a reaction can be persisted as a message reaction.
|
||||
func (r MessageReaction) Valid() bool {
|
||||
switch r.Type {
|
||||
case MessageReactionEmoji:
|
||||
value := r.Value()
|
||||
return value != "" && utf8.RuneCountInString(value) <= MaxChannelReactionEmoticonLength
|
||||
case MessageReactionCustomEmoji:
|
||||
return r.DocumentID > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MessageReactionFromValue rebuilds a domain reaction from the persisted
|
||||
// reaction_type/reaction_value pair used by stores.
|
||||
func MessageReactionFromValue(reactionType MessageReactionType, value string) (MessageReaction, bool) {
|
||||
switch reactionType {
|
||||
case MessageReactionEmoji:
|
||||
value = strings.TrimSpace(value)
|
||||
reaction := MessageReaction{Type: MessageReactionEmoji, Emoticon: value}
|
||||
return reaction, reaction.Valid()
|
||||
case MessageReactionCustomEmoji:
|
||||
documentID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil {
|
||||
return MessageReaction{}, false
|
||||
}
|
||||
reaction := MessageReaction{Type: MessageReactionCustomEmoji, DocumentID: documentID}
|
||||
return reaction, reaction.Valid()
|
||||
default:
|
||||
return MessageReaction{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// ChannelMessageReactionCount is an aggregated reaction counter for one message.
|
||||
|
|
@ -393,6 +659,9 @@ type ChannelMessageReactions struct {
|
|||
CanSeeList bool
|
||||
Results []ChannelMessageReactionCount
|
||||
Recent []ChannelMessagePeerReaction
|
||||
// Paid 是付费 reaction(Stars)聚合(nil = 无);读路径从 channel_message_paid_reactions
|
||||
// 填充、tg 转换注入 ReactionPaid 计数 + top reactors。与普通 reaction 分表存储。
|
||||
Paid *ChannelMessagePaidReactions
|
||||
}
|
||||
|
||||
// SetChannelMessageReactionsRequest replaces the current user's reactions for one message.
|
||||
|
|
@ -404,6 +673,9 @@ type SetChannelMessageReactionsRequest struct {
|
|||
Big bool
|
||||
AddToRecent bool
|
||||
Date int
|
||||
// ReactionsPerUserMax 是 viewer 的每用户上限档位(premium 双档);
|
||||
// 0 表示未填,store 侧按默认档裁剪。
|
||||
ReactionsPerUserMax int
|
||||
}
|
||||
|
||||
// ChannelMessageReactionsResult describes one reaction update.
|
||||
|
|
@ -516,7 +788,11 @@ const (
|
|||
ChannelUpdateDeleteMessages ChannelUpdateEventType = "delete_channel_messages"
|
||||
ChannelUpdateParticipant ChannelUpdateEventType = "channel_participant"
|
||||
ChannelUpdatePinnedMessages ChannelUpdateEventType = "pinned_channel_messages"
|
||||
ChannelUpdateNoop ChannelUpdateEventType = "noop"
|
||||
// ChannelUpdateWebPage 映射 updateChannelWebPage:频道消息的 pending 链接预览异步解析后
|
||||
// 就地替换为已解析卡片(按 webPage id 关联,不标记「已编辑」)。与 edit_channel_message
|
||||
// 同构(携频道 pts + 消息快照),difference/fan-out 复用,仅 tg 投影构造器不同。
|
||||
ChannelUpdateWebPage ChannelUpdateEventType = "channel_web_page"
|
||||
ChannelUpdateNoop ChannelUpdateEventType = "noop"
|
||||
)
|
||||
|
||||
// ChannelUpdateEvent is the channel-scoped durable update log entry.
|
||||
|
|
@ -582,6 +858,7 @@ const (
|
|||
ChannelAdminLogParticipantLeave ChannelAdminLogEventType = "participant_leave"
|
||||
ChannelAdminLogParticipantPromote ChannelAdminLogEventType = "participant_promote"
|
||||
ChannelAdminLogParticipantDemote ChannelAdminLogEventType = "participant_demote"
|
||||
ChannelAdminLogParticipantEditRank ChannelAdminLogEventType = "participant_edit_rank"
|
||||
ChannelAdminLogParticipantBan ChannelAdminLogEventType = "participant_ban"
|
||||
ChannelAdminLogParticipantUnban ChannelAdminLogEventType = "participant_unban"
|
||||
ChannelAdminLogParticipantKick ChannelAdminLogEventType = "participant_kick"
|
||||
|
|
@ -664,9 +941,14 @@ type ChannelAdminLogResult struct {
|
|||
|
||||
// ChannelView contains channel data personalized for a viewer.
|
||||
type ChannelView struct {
|
||||
Channel Channel
|
||||
Self ChannelMember
|
||||
Dialog ChannelDialog
|
||||
Channel Channel
|
||||
Self ChannelMember
|
||||
Dialog ChannelDialog
|
||||
SelfBoostsApplied int
|
||||
ExportedInvite *ChannelInvite
|
||||
// Forbidden 表示当前 viewer 被踢/被禁止查看:查询响应必须呈现
|
||||
// channelForbidden 形态而不是省略,客户端靠它感知自己已离开会话。
|
||||
Forbidden bool
|
||||
}
|
||||
|
||||
// ChannelParticipantList is a paged participant response.
|
||||
|
|
@ -730,7 +1012,7 @@ type ChannelRecommendationsRequest struct {
|
|||
Limit int
|
||||
}
|
||||
|
||||
// ChannelRecommendationsResult contains public broadcast channel recommendations and total count.
|
||||
// ChannelRecommendationsResult contains public broadcast recommendations and a bounded count hint.
|
||||
type ChannelRecommendationsResult struct {
|
||||
Count int
|
||||
Channels []Channel
|
||||
|
|
@ -876,6 +1158,23 @@ type EditChannelTitleResult struct {
|
|||
Recipients []int64
|
||||
}
|
||||
|
||||
// SetChannelWallpaperRequest sets or clears a channel wallpaper and may emit a service message.
|
||||
type SetChannelWallpaperRequest struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
Wallpaper *Wallpaper
|
||||
Date int
|
||||
}
|
||||
|
||||
// SetChannelWallpaperResult describes a channel wallpaper change.
|
||||
type SetChannelWallpaperResult struct {
|
||||
Channel Channel
|
||||
Message ChannelMessage
|
||||
Event ChannelUpdateEvent
|
||||
Recipients []int64
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// EditChannelAboutRequest modifies a channel/supergroup description.
|
||||
type EditChannelAboutRequest struct {
|
||||
UserID int64
|
||||
|
|
@ -904,6 +1203,16 @@ type EditChannelAdminResult struct {
|
|||
Date int
|
||||
}
|
||||
|
||||
// EditChannelMemberRankRequest sets or clears a participant's member tag (rank)
|
||||
// without touching their role or admin rights.
|
||||
type EditChannelMemberRankRequest struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
MemberID int64
|
||||
Rank string
|
||||
Date int
|
||||
}
|
||||
|
||||
// EditChannelBannedRequest modifies a participant's banned rights.
|
||||
type EditChannelBannedRequest struct {
|
||||
UserID int64
|
||||
|
|
@ -929,6 +1238,10 @@ type EditChannelBannedResult struct {
|
|||
Event ChannelUpdateEvent
|
||||
Recipients []int64
|
||||
Date int
|
||||
// Message/ServiceEvent 是 megagroup 踢人产生的 "X removed Y" 服务
|
||||
// 消息及其 channel pts 事件;纯禁言/解禁不生成。
|
||||
Message ChannelMessage
|
||||
ServiceEvent ChannelUpdateEvent
|
||||
}
|
||||
|
||||
// DeleteChannelRequest deletes a channel/supergroup. Only the creator may do this.
|
||||
|
|
@ -949,24 +1262,88 @@ type UpdateChannelUsernameRequest struct {
|
|||
type DeleteChannelResult struct {
|
||||
Channel Channel
|
||||
Recipients []int64
|
||||
// LinkedMonoforum 是随母广播频道一并软删的关联 monoforum(频道私信容器)。删父频道必须连带删它,
|
||||
// 否则会留下 monoforum=true 但 linked_monoforum_id 指向已删父频道的孤儿(客户端渲染会崩)。
|
||||
// 仅删带 Direct Messages 的母频道时非 nil;普通频道为 nil。
|
||||
LinkedMonoforum *Channel
|
||||
}
|
||||
|
||||
// SendChannelMessageRequest sends one channel/supergroup message.
|
||||
type SendChannelMessageRequest struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
RandomID int64
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
MentionUserIDs []int64
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
ReplyTo *MessageReply
|
||||
Forward *MessageForward
|
||||
SendAs *Peer
|
||||
Action *ChannelMessageAction
|
||||
Date int
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
RandomID int64
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
MentionUserIDs []int64
|
||||
SkipDeliveryUserIDs []int64
|
||||
// SkipRecipientLookup lets high-level realtime fan-out use the online member
|
||||
// read model instead of forcing store.SendChannelMessage to synchronously
|
||||
// return an active-member recipient list after commit.
|
||||
SkipRecipientLookup bool
|
||||
// PostAuthor 是 signatures 开启的 broadcast post 上快照的作者展示名。
|
||||
PostAuthor string
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
ReplyTo *MessageReply
|
||||
Forward *MessageForward
|
||||
ViaBotID int64
|
||||
// GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。
|
||||
GroupedID int64
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
SendAs *Peer
|
||||
Action *ChannelMessageAction
|
||||
Date int
|
||||
TTLPeriod int
|
||||
}
|
||||
|
||||
// SendMonoforumMessageRequest 向频道私信(monoforum)发送一条消息。MonoforumID 是 monoforum
|
||||
// 虚拟频道 id;SavedPeer 是订阅者子会话分组键(订阅者发=自己,管理员回复=目标订阅者);
|
||||
// SenderUserID 是实际发件人。发件权限(订阅者身份/管理员)在 RPC 层校验,store 只校验 monoforum 存在。
|
||||
type SendMonoforumMessageRequest struct {
|
||||
MonoforumID int64
|
||||
SenderUserID int64
|
||||
SavedPeer Peer
|
||||
RandomID int64
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Date int
|
||||
}
|
||||
|
||||
// MonoforumHistoryFilter 按订阅者子会话拉取 monoforum 私信历史。
|
||||
type MonoforumHistoryFilter struct {
|
||||
MonoforumID int64
|
||||
SavedPeer Peer
|
||||
Limit int
|
||||
OffsetID int
|
||||
}
|
||||
|
||||
// MonoforumDialog 是频道私信(monoforum)中一个订阅者子会话的摘要。读水位/未读由后续 P0
|
||||
// 切片补齐,当前置 0。
|
||||
type MonoforumDialog struct {
|
||||
SavedPeer Peer
|
||||
TopMessageID int
|
||||
TopMessageDate int
|
||||
UnreadCount int
|
||||
ReadInboxMaxID int
|
||||
ReadOutboxMaxID int
|
||||
}
|
||||
|
||||
// MonoforumDialogList 是 monoforum 订阅者子会话列表(按各子会话 top 消息 id 倒序)。
|
||||
type MonoforumDialogList struct {
|
||||
MonoforumID int64
|
||||
Channel Channel
|
||||
Dialogs []MonoforumDialog
|
||||
Messages []ChannelMessage
|
||||
Count int
|
||||
}
|
||||
|
||||
// MonoforumDialogsFilter 分页拉取 monoforum 订阅者子会话列表(按 top 消息 id 倒序 seek)。
|
||||
type MonoforumDialogsFilter struct {
|
||||
MonoforumID int64
|
||||
Limit int
|
||||
OffsetID int
|
||||
}
|
||||
|
||||
// SaveChannelDefaultSendAsRequest stores the current user's default send-as peer for a channel dialog.
|
||||
|
|
@ -985,9 +1362,13 @@ type ChannelMessageViewsRequest struct {
|
|||
Date int
|
||||
}
|
||||
|
||||
// ChannelMessageViewsResult returns current view counters by visible message id.
|
||||
// ChannelMessageViewsResult returns current view counters and lightweight reply
|
||||
// counters by visible message id.
|
||||
type ChannelMessageViewsResult struct {
|
||||
Views map[int]int
|
||||
Channel Channel
|
||||
Views map[int]int
|
||||
Replies map[int]*ChannelMessageReplies
|
||||
Peers []Peer
|
||||
}
|
||||
|
||||
// ReadChannelMessageContentsRequest marks channel/supergroup content-read hints for one viewer.
|
||||
|
|
@ -1002,6 +1383,10 @@ type ReadChannelMessageContentsResult struct {
|
|||
Channel Channel
|
||||
Messages []ChannelMessage
|
||||
ClearedUnreadReactionMessageIDs []int
|
||||
// ClearedUnreadMentionMessageIDs 是本次内容已读翻转为已读的 mention
|
||||
// 消息;客户端视口看到 @ 消息即发 readMessageContents,服务端必须
|
||||
// 同步清除否则角标在下一次 getDialogs 复活。
|
||||
ClearedUnreadMentionMessageIDs []int
|
||||
}
|
||||
|
||||
// GetChannelMessageAuthorRequest resolves the original user author of one channel message.
|
||||
|
|
@ -1018,6 +1403,13 @@ type GetChannelMessageAuthorResult struct {
|
|||
SenderUserID int64
|
||||
}
|
||||
|
||||
// ChannelPaidMessagesPriceResult describes a paid/direct-message price setting change.
|
||||
type ChannelPaidMessagesPriceResult struct {
|
||||
Channel Channel
|
||||
ServiceMessage *SendChannelMessageResult
|
||||
ServiceMessages []SendChannelMessageResult
|
||||
}
|
||||
|
||||
// SendChannelMessageResult describes a channel message send.
|
||||
type SendChannelMessageResult struct {
|
||||
Channel Channel
|
||||
|
|
@ -1026,6 +1418,13 @@ type SendChannelMessageResult struct {
|
|||
Recipients []int64
|
||||
Duplicate bool
|
||||
Discussion *SendChannelDiscussionResult
|
||||
// MentionUserIDs 是本条消息解析出的被 @ 成员;在线 fanout 按它为
|
||||
// 每个接收者投影 message.mentioned/media_unread。
|
||||
MentionUserIDs []int64
|
||||
// SkipDeliveryUserIDs 是本条消息按 bot privacy 规则被排除投递的成员(隐私 bot 对
|
||||
// 命令/@/回复以外的消息不可见)。Recipients 已扣除它们;在线 fanout 还须据此跳过这些
|
||||
// 成员的直接推送,否则在线 bot 仍能实时收到群里全部消息(持久 history/difference 已隐藏)。
|
||||
SkipDeliveryUserIDs []int64
|
||||
}
|
||||
|
||||
// SendChannelDiscussionResult describes the discussion megagroup root created for a broadcast post.
|
||||
|
|
@ -1034,6 +1433,9 @@ type SendChannelDiscussionResult struct {
|
|||
Message ChannelMessage
|
||||
Event ChannelUpdateEvent
|
||||
Recipients []int64
|
||||
// MentionUserIDs 透传 post 的 @ 目标:讨论组联动消息的实时推送也要
|
||||
// 按接收者投影 mentioned/media_unread。
|
||||
MentionUserIDs []int64
|
||||
}
|
||||
|
||||
// CreateChannelForumTopicRequest creates one forum topic root service message.
|
||||
|
|
@ -1121,21 +1523,44 @@ type DeleteChannelForumTopicHistoryRequest struct {
|
|||
}
|
||||
|
||||
// EditChannelMessageRequest edits one text message in a channel/supergroup.
|
||||
// Media 非 nil 时整体替换消息媒体快照(当前唯一调用方是 live location 续报/停止)。
|
||||
type EditChannelMessageRequest struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
ID int
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
EditDate int
|
||||
Media *MessageMedia
|
||||
// SetReplyMarkup 置位时替换 reply_markup(ReplyMarkup 为 nil/空 = 清空键盘)。
|
||||
SetReplyMarkup bool
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
// ViaBotEditBotID 非零时要求目标消息 via_bot_id 匹配对应 bot。
|
||||
ViaBotEditBotID int64
|
||||
// AllowTodoParticipantMutation 允许非作者普通成员在 checklist 的
|
||||
// others_can_* 授权下仅替换 todo media 快照;不放开普通文本/媒体编辑。
|
||||
AllowTodoParticipantMutation bool
|
||||
// TodoServiceAction 非 nil 时,编辑同事务追加一条 reply 到原 checklist
|
||||
// 的 todo 服务消息,并生成独立 channel pts。
|
||||
TodoServiceAction *ChannelMessageAction
|
||||
// MentionUserIDs 是编辑后文本解析出的 @ 目标:新增者补未读提及、
|
||||
// 被移除者清除(reply 隐式提及不受影响)。
|
||||
MentionUserIDs []int64
|
||||
EditDate int
|
||||
// WebPageResolve 置位时为服务端内部「频道链接预览就地替换」:仅换 media(不碰 body/
|
||||
// entities/edit_date),生成 ChannelUpdateWebPage 而非 edit_channel_message。幂等守卫:
|
||||
// 仅当目标当前 media 仍是 ID==ExpectedWebPageID 的 pending 链接预览才替换。
|
||||
WebPageResolve bool
|
||||
ExpectedWebPageID int64
|
||||
}
|
||||
|
||||
// EditChannelMessageResult describes one channel edit update.
|
||||
type EditChannelMessageResult struct {
|
||||
Channel Channel
|
||||
Message ChannelMessage
|
||||
Event ChannelUpdateEvent
|
||||
Recipients []int64
|
||||
Channel Channel
|
||||
Message ChannelMessage
|
||||
Event ChannelUpdateEvent
|
||||
ServiceMessage ChannelMessage
|
||||
ServiceEvent ChannelUpdateEvent
|
||||
Recipients []int64
|
||||
}
|
||||
|
||||
// DeleteChannelMessagesRequest deletes a bounded set of channel/supergroup messages.
|
||||
|
|
@ -1152,6 +1577,16 @@ type DeleteChannelMessagesResult struct {
|
|||
Event ChannelUpdateEvent
|
||||
DeletedIDs []int
|
||||
Recipients []int64
|
||||
// DiscussionDeletes 是被删 broadcast post 在 linked 讨论组里的转发根
|
||||
// 级联删除(官方删 post 同时删除讨论根)。
|
||||
DiscussionDeletes []ChannelCascadeDelete
|
||||
}
|
||||
|
||||
// ChannelCascadeDelete 描述一次跨频道的级联删除(如讨论根随 post 删除)。
|
||||
type ChannelCascadeDelete struct {
|
||||
Channel Channel
|
||||
Event ChannelUpdateEvent
|
||||
Recipients []int64
|
||||
}
|
||||
|
||||
// DeleteChannelHistoryRequest clears or deletes a bounded channel/supergroup history page.
|
||||
|
|
@ -1192,12 +1627,23 @@ type UpdateChannelPinnedMessageRequest struct {
|
|||
}
|
||||
|
||||
// UpdateChannelPinnedMessageResult describes one pinned-message update.
|
||||
// 多置顶模型:pin/unpin 互不影响其它置顶,Event.MessageIDs 仅含本次操作的
|
||||
// 消息(unpinAll 时为本批全部清除的 id)。
|
||||
type UpdateChannelPinnedMessageResult struct {
|
||||
Channel Channel
|
||||
Event ChannelUpdateEvent
|
||||
Channel Channel
|
||||
Event ChannelUpdateEvent
|
||||
// UnpinEvent 携带单置顶替换时旧置顶的 unpin 事件(无则 Pts=0)。
|
||||
UnpinEvent ChannelUpdateEvent
|
||||
Recipients []int64
|
||||
}
|
||||
|
||||
// UnpinAllChannelMessagesRequest clears every pinned message in one channel.
|
||||
type UnpinAllChannelMessagesRequest struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
Date int
|
||||
}
|
||||
|
||||
// ChannelInvite is an exported invite link without TL dependencies.
|
||||
type ChannelInvite struct {
|
||||
ChannelID int64
|
||||
|
|
@ -1379,6 +1825,7 @@ type ChannelHistoryFilter struct {
|
|||
Query string
|
||||
SenderUserID int64
|
||||
PinnedOnly bool
|
||||
MusicOnly bool
|
||||
OffsetID int
|
||||
OffsetDate int
|
||||
AddOffset int
|
||||
|
|
@ -1406,6 +1853,7 @@ type ChannelGlobalSearchRequest struct {
|
|||
Query string
|
||||
BroadcastsOnly bool
|
||||
GroupsOnly bool
|
||||
MusicOnly bool
|
||||
HasFolderID bool
|
||||
FolderID int
|
||||
OffsetRate int
|
||||
|
|
@ -1523,8 +1971,32 @@ type ReadChannelHistoryResult struct {
|
|||
StillUnreadCount int
|
||||
Changed bool
|
||||
Pts int
|
||||
Dialog ChannelDialog
|
||||
OutboxUpdates []ChannelReadOutboxUpdate
|
||||
// Forum 标记该频道是否为话题群。RPC 层据此在频道级 readHistory 后顺带推进
|
||||
// General(topic 1) 的话题级已读水位(General 消息即频道根历史,被频道级已读覆盖)。
|
||||
Forum bool
|
||||
Dialog ChannelDialog
|
||||
OutboxUpdates []ChannelReadOutboxUpdate
|
||||
}
|
||||
|
||||
// ReadChannelTopicHistoryRequest 推进 forum 单个话题的 per-viewer 已读水位(messages.readDiscussion)。
|
||||
// TopicID = 话题根消息 id(General=1)。不碰频道级 channel_members.read_inbox_max_id。
|
||||
type ReadChannelTopicHistoryRequest struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
TopicID int
|
||||
MaxID int
|
||||
Date int
|
||||
}
|
||||
|
||||
// ReadChannelTopicHistoryResult 描述一次 per-topic 已读推进结果。OutboxUpdates 每条 UserID 是
|
||||
// 该话题内待收已读回执的发送者,MaxID 为其消息被读到的水位;话题 id 即 Result.TopicID。
|
||||
type ReadChannelTopicHistoryResult struct {
|
||||
Channel Channel
|
||||
TopicID int
|
||||
MaxID int
|
||||
Changed bool
|
||||
Pts int
|
||||
OutboxUpdates []ChannelReadOutboxUpdate
|
||||
}
|
||||
|
||||
// ChannelReadOutboxUpdate advances one sender's channel outbox read watermark.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ var (
|
|||
ErrUsersTooMuch = errors.New("users too much")
|
||||
ErrUserAlreadyParticipant = errors.New("user already participant")
|
||||
ErrUserKicked = errors.New("user kicked")
|
||||
ErrUserNotParticipant = errors.New("user not participant")
|
||||
ErrBotGroupsBlocked = errors.New("bot groups blocked")
|
||||
ErrReactionInvalid = errors.New("reaction invalid")
|
||||
ErrReactionsTooMany = errors.New("reactions too many")
|
||||
)
|
||||
|
||||
// SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type Contact struct {
|
|||
Note string
|
||||
NoteEntities []MessageEntity
|
||||
Mutual bool
|
||||
CloseFriend bool
|
||||
}
|
||||
|
||||
// ContactList 是通讯录查询结果。
|
||||
|
|
@ -20,6 +21,12 @@ type ContactList struct {
|
|||
Hash int64
|
||||
}
|
||||
|
||||
// CloseFriendsEditResult describes a full close-friends list replacement.
|
||||
type CloseFriendsEditResult struct {
|
||||
AddedUserIDs []int64
|
||||
RemovedUserIDs []int64
|
||||
}
|
||||
|
||||
// BlockedContact is one owner-visible blocked peer.
|
||||
type BlockedContact struct {
|
||||
User User
|
||||
|
|
@ -74,4 +81,8 @@ type PeerSettings struct {
|
|||
ShareContact bool
|
||||
NeedContactsException bool
|
||||
HiddenPeerSettingsBar bool
|
||||
BusinessBotID int64
|
||||
BusinessBotManageURL string
|
||||
BusinessBotPaused bool
|
||||
BusinessBotCanReply bool
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ type PeerType string
|
|||
const (
|
||||
PeerTypeUser PeerType = "user"
|
||||
PeerTypeChannel PeerType = "channel"
|
||||
// PeerTypeFolder 仅用于 dialog 置顶事件中表达 dialogPeerFolder
|
||||
// (archive folder 行本身被置顶/取消置顶),ID 为 folder_id。
|
||||
PeerTypeFolder PeerType = "folder"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -23,14 +26,44 @@ const (
|
|||
MaxDialogFolderTitleRunes = 64
|
||||
// MaxDialogDraftsPerUser bounds messages.getAllDrafts / clearAllDrafts work.
|
||||
MaxDialogDraftsPerUser = 1000
|
||||
// MaxPinnedDialogsMainFolder/MaxPinnedDialogsArchiveFolder 对齐 TDesktop
|
||||
// 内置默认限额(appConfig dialogs_pinned_limit_default=5 /
|
||||
// dialogs_folder_pinned_limit_default=100);超限返回
|
||||
// PINNED_DIALOGS_TOO_MUCH,服务端兜底防止客户端截断后两端漂移。
|
||||
MaxPinnedDialogsMainFolder = 5
|
||||
MaxPinnedDialogsArchiveFolder = 100
|
||||
// premium 档对齐 appConfig dialogs_pinned_limit_premium=10 /
|
||||
// dialogs_folder_pinned_limit_premium=200。服务端档位必须 ≥ 客户端宣告
|
||||
// 档位,否则 premium 用户 pin 第 6 个会话会被拒、UI 回滚。
|
||||
MaxPinnedDialogsMainFolderPremium = 10
|
||||
MaxPinnedDialogsArchiveFolderPremium = 200
|
||||
)
|
||||
|
||||
// PinnedDialogsLimit 返回 folder 维度的置顶上限档位(premium 双档)。
|
||||
func PinnedDialogsLimit(folderID int, premium bool) int {
|
||||
if folderID == DialogArchiveFolderID {
|
||||
if premium {
|
||||
return MaxPinnedDialogsArchiveFolderPremium
|
||||
}
|
||||
return MaxPinnedDialogsArchiveFolder
|
||||
}
|
||||
if premium {
|
||||
return MaxPinnedDialogsMainFolderPremium
|
||||
}
|
||||
return MaxPinnedDialogsMainFolder
|
||||
}
|
||||
|
||||
// Peer 是业务层 peer 值对象,不依赖 TL 类型。
|
||||
type Peer struct {
|
||||
Type PeerType
|
||||
ID int64
|
||||
}
|
||||
|
||||
// IsSelfUser reports whether this peer is the current user's own user peer.
|
||||
func (p Peer) IsSelfUser(userID int64) bool {
|
||||
return userID != 0 && p.Type == PeerTypeUser && p.ID == userID
|
||||
}
|
||||
|
||||
// Dialog 是账号的一条会话摘要。
|
||||
type Dialog struct {
|
||||
Peer Peer
|
||||
|
|
@ -43,12 +76,21 @@ type Dialog struct {
|
|||
UnreadCount int
|
||||
UnreadMentions int
|
||||
UnreadReactions int
|
||||
TTLPeriod int
|
||||
ThemeEmoticon string
|
||||
HasScheduled bool
|
||||
Pinned bool
|
||||
PinnedOrder int
|
||||
UnreadMark bool
|
||||
ViewForumAsMessages bool
|
||||
PeerSettingsBarHidden bool
|
||||
Draft *DialogDraft
|
||||
// Pts 是 channel peer 当前 channel pts;客户端用 dialog.pts 初始化本地
|
||||
// channel 序列并决定 getChannelDifference 起点,channel dialog 必填。
|
||||
Pts int
|
||||
Draft *DialogDraft
|
||||
// NotifySettings 是该会话的 per-peer 通知设置(nil=未配置,投影时回落默认)。
|
||||
// 由读路径批量装配(非 dialog store 持久字段),见 rpc.withDialogNotifySettings。
|
||||
NotifySettings *PeerNotifySettings
|
||||
}
|
||||
|
||||
// DialogDraftWebPage stores a draft link preview without depending on TL input media types.
|
||||
|
|
@ -85,6 +127,21 @@ func (d DialogDraft) Empty() bool {
|
|||
d.Effect == 0
|
||||
}
|
||||
|
||||
// DialogArchiveSummary 聚合归档(folder_id=1)状态,供主列表 getDialogs
|
||||
// 第一页输出 dialogFolder 条目:TDesktop 依赖该条目发现 archive 的存在
|
||||
// (新登录设备没有任何 update 可重放,缺少它归档会话将彻底不可见)。
|
||||
type DialogArchiveSummary struct {
|
||||
// TopPeer/TopMessage 是归档内最新会话及其 top 消息(dialogFolder.peer/top_message)。
|
||||
TopPeer Peer
|
||||
TopMessage int
|
||||
// UnreadPeersCount 是归档内有未读(或手动标记未读)的会话数;
|
||||
// UnreadMessagesCount 是归档未读消息总数。当前未接 per-peer mute
|
||||
// 状态,全部计入 unmuted 桶。
|
||||
UnreadPeersCount int
|
||||
UnreadMessagesCount int
|
||||
Pinned bool
|
||||
}
|
||||
|
||||
// DialogList 是 dialogs 查询结果。
|
||||
type DialogList struct {
|
||||
Dialogs []Dialog
|
||||
|
|
@ -95,6 +152,16 @@ type DialogList struct {
|
|||
State UpdateState
|
||||
Hash int64
|
||||
Count int
|
||||
// ArchiveSummary 非 nil 时,主列表响应头部追加 dialogFolder 条目。
|
||||
ArchiveSummary *DialogArchiveSummary
|
||||
}
|
||||
|
||||
// DialogHashCheck reports whether a cached stable dialog list hash is known.
|
||||
type DialogHashCheck struct {
|
||||
Known bool
|
||||
Matched bool
|
||||
Hash int64
|
||||
Count int
|
||||
}
|
||||
|
||||
// DialogFilter 是会话列表查询条件。
|
||||
|
|
|
|||
131
internal/domain/groupcall.go
Normal file
131
internal/domain/groupcall.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// 群通话(超级群语音聊天)领域模型。信令真值在 GroupCallStore(memory/postgres
|
||||
// 双实现),版本协议要求 version 必须持久化:客户端忽略 version 小于本地缓存的
|
||||
// updateGroupCallParticipants,重启回卷会让整个房间静默失联。
|
||||
|
||||
// GroupCallState 是群通话状态。
|
||||
type GroupCallState string
|
||||
|
||||
const (
|
||||
GroupCallStateActive GroupCallState = "active"
|
||||
GroupCallStateDiscarded GroupCallState = "discarded"
|
||||
)
|
||||
|
||||
// 群通话业务错误;rpc 层映射为 GROUPCALL_* RPC_ERROR。
|
||||
var (
|
||||
ErrGroupCallInvalid = errors.New("group call invalid")
|
||||
ErrGroupCallDiscarded = errors.New("group call already discarded")
|
||||
ErrGroupCallAlreadyStarted = errors.New("group call already started")
|
||||
ErrGroupCallSSRCDuplicate = errors.New("group call ssrc duplicate")
|
||||
ErrGroupCallNotJoined = errors.New("group call participant missing")
|
||||
)
|
||||
|
||||
// GroupCall 是一场群通话的权威态。
|
||||
type GroupCall struct {
|
||||
ID int64
|
||||
AccessHash int64
|
||||
ChannelID int64
|
||||
CreatorUserID int64
|
||||
State GroupCallState
|
||||
Title string
|
||||
JoinMuted bool
|
||||
// Version 是参与者协议版本:所有参与者变更事务内 +1,单调且持久。
|
||||
Version int
|
||||
ParticipantsCount int
|
||||
CreatedAt int
|
||||
DiscardedAt int
|
||||
Duration int
|
||||
// StartedMsgID 是 messageActionGroupCall(started) 的频道消息 id(discard 时
|
||||
// 客户端用它定位起始服务消息,当前仅记录)。
|
||||
StartedMsgID int
|
||||
}
|
||||
|
||||
// Active 报告通话是否仍在进行。
|
||||
func (c GroupCall) Active() bool {
|
||||
return c.State == GroupCallStateActive
|
||||
}
|
||||
|
||||
// GroupCallParticipant 是房间内一名参与者。
|
||||
type GroupCallParticipant struct {
|
||||
CallID int64
|
||||
UserID int64
|
||||
// SSRC 是客户端在 join JSON 里自报的 audio ssrc(uint32 值域,存 int64 防符号坑)。
|
||||
SSRC int64
|
||||
JoinDate int
|
||||
ActiveDate int
|
||||
Muted bool
|
||||
// MutedByAdmin=true 时 can_self_unmute=false(管理员禁言/默认静音策略)。
|
||||
MutedByAdmin bool
|
||||
// VolumeByAdmin 是管理员设定的全局音量(1..20000),0=未设。
|
||||
VolumeByAdmin int
|
||||
// RaiseHandRating 非零表示举手中,值单调递增用于排序。
|
||||
RaiseHandRating int64
|
||||
// VideoJSON / PresentationJSON 是 tg.GroupCallParticipantVideo 的原始 JSON
|
||||
// 快照(M3/M4 启用;M0/M1 仅透明保存 self-edit,不转发)。
|
||||
VideoJSON []byte
|
||||
PresentationJSON []byte
|
||||
Left bool
|
||||
// LastCheckDate 是 checkGroupCall 保活水位。注意:客户端只在 Connecting 态
|
||||
// 发 checkGroupCall(媒体连通后心跳停止),掉线判定必须与 SFU 媒体面活性
|
||||
// 取双过期(见 sweeper),绝不能单凭此字段。
|
||||
LastCheckDate int
|
||||
}
|
||||
|
||||
// CreateGroupCallRequest 创建群通话。
|
||||
type CreateGroupCallRequest struct {
|
||||
ChannelID int64
|
||||
CreatorUserID int64
|
||||
RandomID int64
|
||||
Title string
|
||||
Now int
|
||||
}
|
||||
|
||||
// JoinGroupCallRequest 加入/重进群通话(rejoin 同主键换新 ssrc)。
|
||||
type JoinGroupCallRequest struct {
|
||||
CallID int64
|
||||
UserID int64
|
||||
SSRC int64
|
||||
Muted bool
|
||||
IsAdmin bool
|
||||
// VideoJSON 是本次 join 铸造的视频内部状态(endpoint+源组+active);rejoin
|
||||
// 整体替换并**清空旧 PresentationJSON**(客户端主连接 rejoin 后会重发
|
||||
// joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||
VideoJSON []byte
|
||||
Now int
|
||||
}
|
||||
|
||||
// GroupCallMutation 是一次参与者维度变更的结果:变更后的 call 行(含新 version)
|
||||
// 与受影响的参与者行(推送 updateGroupCallParticipants 用)。
|
||||
type GroupCallMutation struct {
|
||||
Call GroupCall
|
||||
Participant GroupCallParticipant
|
||||
}
|
||||
|
||||
// GroupCallParticipantUpdate 是 editGroupCallParticipant 的字段级更新(nil=不动)。
|
||||
type GroupCallParticipantUpdate struct {
|
||||
Muted *bool
|
||||
MutedByAdmin *bool
|
||||
VolumeByAdmin *int
|
||||
RaiseHandRating *int64
|
||||
VideoJSON *[]byte
|
||||
PresentationJSON *[]byte
|
||||
Now int
|
||||
}
|
||||
|
||||
// GroupCallParticipantOverride 是 per-viewer 视图覆盖(setter→target):
|
||||
// 本地静音/本地音量,仅 setter 自己可见,不进全房间 version。
|
||||
type GroupCallParticipantOverride struct {
|
||||
MutedByYou bool
|
||||
Volume int // 0=未设
|
||||
}
|
||||
|
||||
// GroupCallParticipantPage 是 getGroupParticipants 的分页结果。
|
||||
type GroupCallParticipantPage struct {
|
||||
Count int
|
||||
Participants []GroupCallParticipant
|
||||
NextOffset string
|
||||
Version int
|
||||
}
|
||||
|
|
@ -29,14 +29,40 @@ type FileBlob struct {
|
|||
MimeType string `json:"mime_type,omitempty"`
|
||||
}
|
||||
|
||||
// UploadPart 是 upload.saveFilePart/saveBigFilePart 累积的一个分片(落 PG,组装后清理)。
|
||||
// UploadPart 是 upload.saveFilePart/saveBigFilePart 累积的一个分片元数据。
|
||||
// 分片字节不进 PG;PG 只记录 object_key/size/hash,组装后清理 metadata 与临时对象。
|
||||
type UploadPart struct {
|
||||
OwnerUserID int64
|
||||
FileID int64
|
||||
Part int
|
||||
TotalParts int // big file 已知总数;small file 为 0
|
||||
Big bool
|
||||
Bytes []byte
|
||||
Backend MediaBackend
|
||||
ObjectKey string
|
||||
Size int64
|
||||
SHA256 []byte
|
||||
}
|
||||
|
||||
// UploadPartUsage 描述某用户当前尚未组装的上传分片占用。
|
||||
type UploadPartUsage struct {
|
||||
Bytes int64
|
||||
Parts int
|
||||
Files int
|
||||
}
|
||||
|
||||
// UploadPartSlot 描述某个 (owner,file_id,part) 槽位的当前状态,用于判断重试覆盖的配额增量。
|
||||
type UploadPartSlot struct {
|
||||
ExistingBytes int64
|
||||
ObjectKey string
|
||||
FileParts int
|
||||
Found bool
|
||||
}
|
||||
|
||||
// UploadPartQuota 限制某用户 in-flight 上传分片占用;字段 <=0 表示该维度不限制。
|
||||
type UploadPartQuota struct {
|
||||
MaxBytes int64
|
||||
MaxParts int
|
||||
MaxFiles int
|
||||
}
|
||||
|
||||
// UploadedFileRef 引用一个客户端已通过 upload.saveFilePart(Big) 上传完毕的文件。
|
||||
|
|
@ -87,22 +113,36 @@ const (
|
|||
PhotoSizeKindPath PhotoSizeKind = "path"
|
||||
// PhotoSizeKindProgressive → photoSizeProgressive(渐进式 jpeg 多段大小)。
|
||||
PhotoSizeKindProgressive PhotoSizeKind = "progressive"
|
||||
// PhotoSizeKindVideo → photo.video_sizes 里的 videoSize(animated profile video)。
|
||||
PhotoSizeKindVideo PhotoSizeKind = "video"
|
||||
// PhotoSizeKindVideoEmojiMarkup → photo.video_sizes 里的 videoSizeEmojiMarkup。
|
||||
PhotoSizeKindVideoEmojiMarkup PhotoSizeKind = "video_emoji_markup"
|
||||
// PhotoSizeKindVideoStickerMarkup → photo.video_sizes 里的 videoSizeStickerMarkup。
|
||||
PhotoSizeKindVideoStickerMarkup PhotoSizeKind = "video_sticker_markup"
|
||||
)
|
||||
|
||||
// PhotoSize 描述照片/缩略图的一种渲染尺寸。
|
||||
type PhotoSize struct {
|
||||
Kind PhotoSizeKind `json:"kind"`
|
||||
Type string `json:"type"`
|
||||
W int `json:"w,omitempty"`
|
||||
H int `json:"h,omitempty"`
|
||||
Size int `json:"size,omitempty"`
|
||||
Bytes []byte `json:"bytes,omitempty"` // stripped/cached/path 内联内容
|
||||
Sizes []int `json:"sizes,omitempty"` // progressive
|
||||
Kind PhotoSizeKind `json:"kind"`
|
||||
Type string `json:"type"`
|
||||
W int `json:"w,omitempty"`
|
||||
H int `json:"h,omitempty"`
|
||||
Size int `json:"size,omitempty"`
|
||||
Bytes []byte `json:"bytes,omitempty"` // stripped/cached/path 内联内容
|
||||
Sizes []int `json:"sizes,omitempty"` // progressive
|
||||
VideoStartTs float64 `json:"video_start_ts,omitempty"`
|
||||
EmojiID int64 `json:"emoji_id,omitempty"`
|
||||
BackgroundColors []int `json:"background_colors,omitempty"`
|
||||
StickerSetID int64 `json:"sticker_set_id,omitempty"`
|
||||
StickerSetAccessHash int64 `json:"sticker_set_access_hash,omitempty"`
|
||||
StickerSetShortName string `json:"sticker_set_short_name,omitempty"`
|
||||
StickerSetSystemKey string `json:"sticker_set_system_key,omitempty"`
|
||||
StickerID int64 `json:"sticker_id,omitempty"`
|
||||
}
|
||||
|
||||
// Downloadable 表示该尺寸需要客户端通过 upload.getFile 拉取(而非内联字节)。
|
||||
func (s PhotoSize) Downloadable() bool {
|
||||
return s.Kind == PhotoSizeKindDefault || s.Kind == PhotoSizeKindProgressive
|
||||
return s.Kind == PhotoSizeKindDefault || s.Kind == PhotoSizeKindProgressive || s.Kind == PhotoSizeKindVideo
|
||||
}
|
||||
|
||||
// DocumentAttributeKind 标识 TL DocumentAttribute 变体。
|
||||
|
|
@ -175,6 +215,39 @@ func (d Document) StickerSetRef() (id, accessHash int64, ok bool) {
|
|||
return 0, 0, false
|
||||
}
|
||||
|
||||
// IsMusic reports whether the document is a Telegram profile/shared-music song:
|
||||
// documentAttributeAudio with voice=false. MIME type is intentionally not used
|
||||
// as the source of truth because clients key their UI off the TL attribute.
|
||||
func (d Document) IsMusic() bool {
|
||||
for _, attr := range d.Attributes {
|
||||
if attr.Kind == DocAttrAudio && !attr.Voice {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsSticker reports whether the document carries a sticker attribute
|
||||
// (static / animated / video stickers all use documentAttributeSticker).
|
||||
func (d Document) IsSticker() bool {
|
||||
for _, attr := range d.Attributes {
|
||||
if attr.Kind == DocAttrSticker {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsGif reports whether the document is a savable GIF (documentAttributeAnimated).
|
||||
func (d Document) IsGif() bool {
|
||||
for _, attr := range d.Attributes {
|
||||
if attr.Kind == DocAttrAnimated {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Photo 是已存储的 Telegram 照片(头像或图片消息)。
|
||||
type Photo struct {
|
||||
ID int64 `json:"id"`
|
||||
|
|
@ -193,19 +266,261 @@ const (
|
|||
MessageMediaKindNone MessageMediaKind = ""
|
||||
MessageMediaKindPhoto MessageMediaKind = "photo"
|
||||
MessageMediaKindDocument MessageMediaKind = "document"
|
||||
MessageMediaKindContact MessageMediaKind = "contact"
|
||||
MessageMediaKindService MessageMediaKind = "service"
|
||||
MessageMediaKindGeo MessageMediaKind = "geo"
|
||||
MessageMediaKindVenue MessageMediaKind = "venue"
|
||||
MessageMediaKindDice MessageMediaKind = "dice"
|
||||
MessageMediaKindPoll MessageMediaKind = "poll"
|
||||
MessageMediaKindGeoLive MessageMediaKind = "geo_live"
|
||||
MessageMediaKindTodo MessageMediaKind = "todo"
|
||||
MessageMediaKindStory MessageMediaKind = "story"
|
||||
MessageMediaKindWebPage MessageMediaKind = "web_page"
|
||||
)
|
||||
|
||||
// MessageTodoItem 是清单中的一项(id 为列表内唯一正整数,客户端分配)。
|
||||
type MessageTodoItem struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Entities []MessageEntity `json:"entities,omitempty"`
|
||||
}
|
||||
|
||||
// MessageTodoCompletion 记录某项被谁在何时勾选完成。
|
||||
type MessageTodoCompletion struct {
|
||||
ID int `json:"id"`
|
||||
CompletedBy int64 `json:"completed_by"`
|
||||
Date int `json:"date"`
|
||||
}
|
||||
|
||||
// MessageTodo 是待办清单(messageMediaToDo)。append/toggle 经 editMessage 媒体替换链路
|
||||
// 整体更新快照(与 live location 同模式)。
|
||||
type MessageTodo struct {
|
||||
OthersCanAppend bool `json:"others_can_append,omitempty"`
|
||||
OthersCanComplete bool `json:"others_can_complete,omitempty"`
|
||||
Title string `json:"title"`
|
||||
TitleEntities []MessageEntity `json:"title_entities,omitempty"`
|
||||
Items []MessageTodoItem `json:"items"`
|
||||
Completions []MessageTodoCompletion `json:"completions,omitempty"`
|
||||
}
|
||||
|
||||
// MessageGeoPoint 是消息中携带的地理坐标点(messageMediaGeo/Venue 共用)。
|
||||
// AccessHash 在发送时随机生成,客户端会原样带回 upload.getWebFile 地图缩略请求。
|
||||
type MessageGeoPoint struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Long float64 `json:"long"`
|
||||
AccessHash int64 `json:"access_hash,omitempty"`
|
||||
AccuracyRadius int `json:"accuracy_radius,omitempty"`
|
||||
}
|
||||
|
||||
// MessageVenue 是带场所信息的位置(messageMediaVenue)。
|
||||
type MessageVenue struct {
|
||||
Geo MessageGeoPoint `json:"geo"`
|
||||
Title string `json:"title"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
VenueID string `json:"venue_id,omitempty"`
|
||||
VenueType string `json:"venue_type,omitempty"`
|
||||
}
|
||||
|
||||
// MessageDice 是互动 emoji(messageMediaDice)。Value 在发送落库时由服务端
|
||||
// 一次定值,此后对所有 viewer 与转发快照保持不变。
|
||||
type MessageDice struct {
|
||||
Emoticon string `json:"emoticon"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
// MessageGeoLive 是实时位置(messageMediaGeoLive)。客户端按 message.date + Period
|
||||
// 自行计算过期;停止共享 = editMessage 把 Period 改为已逝时长(立即过期),服务端无后台任务。
|
||||
type MessageGeoLive struct {
|
||||
Geo MessageGeoPoint `json:"geo"`
|
||||
Heading int `json:"heading,omitempty"`
|
||||
Period int `json:"period"`
|
||||
ProximityNotificationRadius int `json:"proximity_notification_radius,omitempty"`
|
||||
}
|
||||
|
||||
// MessageStory is a story shared as messageMediaStory. Peer+ID are the stable
|
||||
// reference; Story is the viewer-visible snapshot captured when the message was
|
||||
// sent so history/difference can replay without re-resolving the source story.
|
||||
type MessageStory struct {
|
||||
Peer Peer `json:"peer"`
|
||||
ID int `json:"id"`
|
||||
ViaMention bool `json:"via_mention,omitempty"`
|
||||
Story *Story `json:"story,omitempty"`
|
||||
}
|
||||
|
||||
// MessageWebPageState 区分链接预览的三种 TL 形态:pending(webPagePending,
|
||||
// 已知链接但预览尚未异步抓取完成)、done(webPage,已解析的预览卡片)、
|
||||
// empty(webPageEmpty,已解析但该链接无可展示内容)。
|
||||
type MessageWebPageState string
|
||||
|
||||
const (
|
||||
MessageWebPageStatePending MessageWebPageState = "pending"
|
||||
MessageWebPageStateDone MessageWebPageState = "done"
|
||||
MessageWebPageStateEmpty MessageWebPageState = "empty"
|
||||
)
|
||||
|
||||
// MessageWebPage 是以 messageMediaWebPage 形式挂载的链接预览。URL 是稳定引用,
|
||||
// 其余字段是服务端在解析时捕获的快照,使 history/difference 无需重新抓取即可回放
|
||||
// (与 MessageStory 同模式)。State 决定投影为 webPagePending / webPage / webPageEmpty。
|
||||
//
|
||||
// ForceLargeMedia/ForceSmallMedia/Manual/Safe 是 messageMediaWebPage 外层 wrapper
|
||||
// 的标志位;HasLargeMedia 是 webPage 本体的标志位。ID 必须在 pending→done 切换前后
|
||||
// 保持稳定,客户端按 webPage id 关联占位与解析结果。
|
||||
type MessageWebPage struct {
|
||||
State MessageWebPageState `json:"state"`
|
||||
ID int64 `json:"id"`
|
||||
URL string `json:"url"`
|
||||
DisplayURL string `json:"display_url,omitempty"`
|
||||
Hash int `json:"hash,omitempty"`
|
||||
Date int `json:"date,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
SiteName string `json:"site_name,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Photo *Photo `json:"photo,omitempty"`
|
||||
|
||||
ForceLargeMedia bool `json:"force_large_media,omitempty"`
|
||||
ForceSmallMedia bool `json:"force_small_media,omitempty"`
|
||||
Manual bool `json:"manual,omitempty"`
|
||||
Safe bool `json:"safe,omitempty"`
|
||||
HasLargeMedia bool `json:"has_large_media,omitempty"`
|
||||
}
|
||||
|
||||
// MessageServiceActionKind 标识私聊服务消息动作。
|
||||
type MessageServiceActionKind string
|
||||
|
||||
const (
|
||||
MessageServiceActionSuggestProfilePhoto MessageServiceActionKind = "suggest_profile_photo"
|
||||
// MessageServiceActionPinMessage 映射 messageActionPinMessage:非
|
||||
// pm_oneside 私聊置顶生成的服务消息,被置顶消息经 reply_to 指向。
|
||||
MessageServiceActionPinMessage MessageServiceActionKind = "pin_message"
|
||||
// MessageServiceActionPhoneCall 映射 messageActionPhoneCall:私聊通话
|
||||
// 结束(含 missed 超时)后落历史的通话条目,sender 恒为主叫。
|
||||
MessageServiceActionPhoneCall MessageServiceActionKind = "phone_call"
|
||||
// MessageServiceActionBotAllowed 映射 messageActionBotAllowed:用户授权
|
||||
// bot 后在 bot 私聊中留下的服务消息。
|
||||
MessageServiceActionBotAllowed MessageServiceActionKind = "bot_allowed"
|
||||
// MessageServiceActionWebViewDataSent 映射 messageActionWebViewDataSent*
|
||||
//:simple webview 把 data 回传给 bot 后在私聊中留下的服务消息。
|
||||
MessageServiceActionWebViewDataSent MessageServiceActionKind = "web_view_data_sent"
|
||||
// MessageServiceActionRequestedPeer 映射 messageActionRequestedPeer*:用户
|
||||
// 响应 bot 的 request-peer 按钮后,把所选 peer 作为可恢复服务消息发给 bot。
|
||||
MessageServiceActionRequestedPeer MessageServiceActionKind = "requested_peer"
|
||||
// MessageServiceActionSetChatTheme 映射 messageActionSetChatTheme:
|
||||
// 私聊双方共享的 chat theme token 变更。
|
||||
MessageServiceActionSetChatTheme MessageServiceActionKind = "set_chat_theme"
|
||||
// MessageServiceActionStarGift 映射 messageActionStarGift:收到一份 Star 礼物。
|
||||
// 礼物快照(贴纸/星价)内嵌在 action 里,收礼人无需额外拉取即可渲染气泡。
|
||||
MessageServiceActionStarGift MessageServiceActionKind = "star_gift"
|
||||
)
|
||||
|
||||
// MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。
|
||||
// Reason 取值同 PhoneCallDiscardReason;Duration 仅通话真正建立后非零。
|
||||
type MessagePhoneCallAction struct {
|
||||
CallID int64 `json:"call_id"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
}
|
||||
|
||||
// MessageBotAllowedAction 是 messageActionBotAllowed 的协议中立载荷。
|
||||
type MessageBotAllowedAction struct {
|
||||
AttachMenu bool `json:"attach_menu,omitempty"`
|
||||
FromRequest bool `json:"from_request,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
MaxWebViewDataButtonTextLen = MaxBotMenuButtonTextLen
|
||||
MaxWebViewDataPayloadLen = MaxMessageTextLength
|
||||
)
|
||||
|
||||
// MessageWebViewDataAction 是 messageActionWebViewDataSent* 的协议中立载荷。
|
||||
type MessageWebViewDataAction struct {
|
||||
ButtonText string `json:"button_text"`
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// MessageRequestedPeerAction 是 messageActionRequestedPeer* 的协议中立载荷。
|
||||
type MessageRequestedPeerAction struct {
|
||||
ButtonID int `json:"button_id"`
|
||||
Peers []Peer `json:"peers"`
|
||||
}
|
||||
|
||||
// MessageServiceAction 是私聊服务消息动作的协议中立表示。
|
||||
type MessageServiceAction struct {
|
||||
Kind MessageServiceActionKind `json:"kind"`
|
||||
Photo *Photo `json:"photo,omitempty"`
|
||||
Call *MessagePhoneCallAction `json:"call,omitempty"`
|
||||
BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"`
|
||||
WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"`
|
||||
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
|
||||
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
|
||||
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
|
||||
}
|
||||
|
||||
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
|
||||
// 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方;NameHidden 时下发不暴露 from。
|
||||
type MessageStarGiftAction struct {
|
||||
GiftID int64 `json:"gift_id"`
|
||||
Stars int64 `json:"stars"`
|
||||
ConvertStars int64 `json:"convert_stars,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Sticker *Document `json:"sticker,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
FromUserID int64 `json:"from_user_id,omitempty"`
|
||||
PeerUserID int64 `json:"peer_user_id,omitempty"`
|
||||
PeerChannelID int64 `json:"peer_channel_id,omitempty"`
|
||||
SavedID int64 `json:"saved_id,omitempty"`
|
||||
NameHidden bool `json:"name_hidden,omitempty"`
|
||||
Saved bool `json:"saved,omitempty"`
|
||||
Converted bool `json:"converted,omitempty"`
|
||||
}
|
||||
|
||||
// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。
|
||||
type MessageMedia struct {
|
||||
Kind MessageMediaKind `json:"kind"`
|
||||
Photo *Photo `json:"photo,omitempty"`
|
||||
Document *Document `json:"document,omitempty"`
|
||||
Spoiler bool `json:"spoiler,omitempty"`
|
||||
TTLSeconds int `json:"ttl_seconds,omitempty"`
|
||||
Nopremium bool `json:"nopremium,omitempty"`
|
||||
Voice bool `json:"voice,omitempty"`
|
||||
Round bool `json:"round,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
Kind MessageMediaKind `json:"kind"`
|
||||
Photo *Photo `json:"photo,omitempty"`
|
||||
Document *Document `json:"document,omitempty"`
|
||||
Contact *MessageContact `json:"contact,omitempty"`
|
||||
ServiceAction *MessageServiceAction `json:"service_action,omitempty"`
|
||||
Geo *MessageGeoPoint `json:"geo,omitempty"`
|
||||
Venue *MessageVenue `json:"venue,omitempty"`
|
||||
Dice *MessageDice `json:"dice,omitempty"`
|
||||
Poll *MessagePoll `json:"poll,omitempty"`
|
||||
GeoLive *MessageGeoLive `json:"geo_live,omitempty"`
|
||||
Todo *MessageTodo `json:"todo,omitempty"`
|
||||
Story *MessageStory `json:"story,omitempty"`
|
||||
WebPage *MessageWebPage `json:"web_page,omitempty"`
|
||||
Spoiler bool `json:"spoiler,omitempty"`
|
||||
TTLSeconds int `json:"ttl_seconds,omitempty"`
|
||||
Nopremium bool `json:"nopremium,omitempty"`
|
||||
Voice bool `json:"voice,omitempty"`
|
||||
Round bool `json:"round,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
// InvertMedia 映射 message.invert_media:媒体(典型为链接预览)渲染在文本上方。
|
||||
// 存于媒体快照而非消息行,避免新增消息表列;读时投影为 tg.Message.invert_media。
|
||||
InvertMedia bool `json:"invert_media,omitempty"`
|
||||
}
|
||||
|
||||
// IsMusic reports whether the message media is a music document rather than a
|
||||
// voice note, video, or generic file.
|
||||
func (m *MessageMedia) IsMusic() bool {
|
||||
return m != nil &&
|
||||
m.Kind == MessageMediaKindDocument &&
|
||||
m.Document != nil &&
|
||||
!m.Voice &&
|
||||
m.Document.IsMusic()
|
||||
}
|
||||
|
||||
// MessageContact is a shared contact card attached to a message.
|
||||
type MessageContact struct {
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name,omitempty"`
|
||||
Vcard string `json:"vcard,omitempty"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
// IsZero 表示无媒体(用于落库时跳过空快照、转换时回退 MessageMediaEmpty)。
|
||||
|
|
@ -213,12 +528,27 @@ func (m *MessageMedia) IsZero() bool {
|
|||
return m == nil || m.Kind == MessageMediaKindNone
|
||||
}
|
||||
|
||||
// HasUnreadPayload 表示该媒体是否参与 media_unread("未听")状态。
|
||||
// 只有 voice/round 才有此语义:客户端只为它们渲染未听标记并上报
|
||||
// readMessageContents;photo/document 置位只会留下永不清除的脏状态。
|
||||
func (m *MessageMedia) HasUnreadPayload() bool {
|
||||
if m.IsZero() || m.Kind != MessageMediaKindDocument {
|
||||
return false
|
||||
}
|
||||
return m.Voice || m.Round
|
||||
}
|
||||
|
||||
// StickerPack 是 emoji→文档 id 的映射条目(messages.stickerSet.packs)。
|
||||
type StickerPack struct {
|
||||
Emoticon string `json:"emoticon"`
|
||||
DocumentIDs []int64 `json:"document_ids"`
|
||||
}
|
||||
|
||||
// StickerSetSystemKeyEmojiDefaultStatuses 是 inputStickerSetEmojiDefaultStatuses
|
||||
// 对应的系统集标识:premium 用户 emoji status 选择器的"默认状态"主体集
|
||||
// (messages.getStickerSet 与 account.getDefaultEmojiStatuses 共用)。
|
||||
const StickerSetSystemKeyEmojiDefaultStatuses = "emoji_default_statuses"
|
||||
|
||||
// StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。
|
||||
type StickerSetKind string
|
||||
|
||||
|
|
@ -273,6 +603,7 @@ type ProfilePhotoRef struct {
|
|||
DCID int
|
||||
Stripped []byte // photoStrippedSize 内联缩略图,可空
|
||||
Personal bool // true 表示 viewer 私有联系人头像
|
||||
HasVideo bool // true 表示 photo.video_sizes 非空,需输出 userProfilePhoto.has_video
|
||||
}
|
||||
|
||||
// StrippedFromSizes 从照片尺寸列表里取出 stripped 缩略图字节(用于 UserProfilePhoto/ChatPhoto 占位)。
|
||||
|
|
@ -285,6 +616,17 @@ func StrippedFromSizes(sizes []PhotoSize) []byte {
|
|||
return nil
|
||||
}
|
||||
|
||||
// PhotoHasVideo 从照片尺寸快照推导 UserProfilePhoto.has_video。
|
||||
func PhotoHasVideo(sizes []PhotoSize) bool {
|
||||
for _, s := range sizes {
|
||||
switch s.Kind {
|
||||
case PhotoSizeKindVideo, PhotoSizeKindVideoEmojiMarkup, PhotoSizeKindVideoStickerMarkup:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StickerSetRefKind 标识 InputStickerSet 的解析方式。
|
||||
type StickerSetRefKind string
|
||||
|
||||
|
|
@ -319,6 +661,24 @@ type AvailableReaction struct {
|
|||
Order int `json:"order,omitempty"`
|
||||
}
|
||||
|
||||
// AvailableEffect 是一条消息发送特效(messages.getAvailableEffects)。引用三个文档:
|
||||
// 选择器静态图标 / 气泡上的特效贴纸 / 全屏特效动画(后两者可为 0)。属全局静态目录,
|
||||
// 服务端 seed 后常驻内存。
|
||||
type AvailableEffect struct {
|
||||
ID int64 `json:"id"`
|
||||
Emoticon string `json:"emoticon"`
|
||||
StaticIconID int64 `json:"static_icon_id,omitempty"`
|
||||
EffectStickerID int64 `json:"effect_sticker_id"`
|
||||
EffectAnimationID int64 `json:"effect_animation_id,omitempty"`
|
||||
PremiumRequired bool `json:"premium_required,omitempty"`
|
||||
Order int `json:"order,omitempty"`
|
||||
}
|
||||
|
||||
// DocumentIDs 收集该 effect 引用的全部文档 id(去零去重)。
|
||||
func (e AvailableEffect) DocumentIDs() []int64 {
|
||||
return dedupNonZeroIDs(e.StaticIconID, e.EffectStickerID, e.EffectAnimationID)
|
||||
}
|
||||
|
||||
// DocumentIDs 收集该 reaction 引用的全部文档 id(去零去重,便于批量加载)。
|
||||
func (r AvailableReaction) DocumentIDs() []int64 {
|
||||
raw := []int64{
|
||||
|
|
@ -339,3 +699,20 @@ func (r AvailableReaction) DocumentIDs() []int64 {
|
|||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupNonZeroIDs 返回去零去重后的 id 列表(保序)。
|
||||
func dedupNonZeroIDs(ids ...int64) []int64 {
|
||||
out := make([]int64, 0, len(ids))
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
157
internal/domain/media_category.go
Normal file
157
internal/domain/media_category.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package domain
|
||||
|
||||
// MediaCategory 是共享媒体标签页的基础类别。一条消息可同时属于多个类别
|
||||
// (例如带链接的图片 = Photo + URL)。落库为媒体索引表的 category 列(SMALLINT)。
|
||||
//
|
||||
// 这里只存"基础类别";客户端的复合过滤器(PhotoVideo / RoundVoice)在查询期映射为
|
||||
// 多个基础类别的并集(见 MediaCategoriesForFilter 的调用方)。
|
||||
type MediaCategory int16
|
||||
|
||||
const (
|
||||
MediaCategoryNone MediaCategory = 0
|
||||
MediaCategoryPhoto MediaCategory = 1 // media.kind=photo
|
||||
MediaCategoryVideo MediaCategory = 2 // document 含 video 属性且非 round
|
||||
MediaCategoryGif MediaCategory = 3 // document 含 animated 属性
|
||||
MediaCategoryFile MediaCategory = 4 // 通用文档(无 video/audio/animated/sticker 属性)
|
||||
MediaCategoryMusic MediaCategory = 5 // document 含 audio 属性且 voice=false
|
||||
MediaCategoryVoice MediaCategory = 6 // document 含 audio 属性且 voice=true
|
||||
MediaCategoryRoundVideo MediaCategory = 7 // document 含 video 属性且 round_message=true(视频消息)
|
||||
MediaCategoryURL MediaCategory = 8 // 文本含 url/text_url/email 实体或 messageMediaWebPage
|
||||
MediaCategoryPoll MediaCategory = 9 // media.kind=poll
|
||||
)
|
||||
|
||||
// MediaCategoryCounts 是共享媒体索引按基础类别聚合出的精确计数。
|
||||
type MediaCategoryCounts map[MediaCategory]int
|
||||
|
||||
// CountAny 返回给定基础类别并集的计数。当前复合 filter 只由互斥类别组成
|
||||
// (PhotoVideo=Photo+Video, RoundVoice=Voice+RoundVideo),因此可直接相加。
|
||||
func (c MediaCategoryCounts) CountAny(categories []MediaCategory) int {
|
||||
if len(categories) == 0 {
|
||||
return 0
|
||||
}
|
||||
seen := make(map[MediaCategory]struct{}, len(categories))
|
||||
total := 0
|
||||
for _, category := range categories {
|
||||
if category == MediaCategoryNone {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[category]; ok {
|
||||
continue
|
||||
}
|
||||
seen[category] = struct{}{}
|
||||
total += c[category]
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// MediaSearchRequest 是共享媒体标签页分页查询的入参(messages.search 媒体过滤分支)。
|
||||
// Categories 是该标签页映射到的基础类别并集(PhotoVideo→[Photo,Video]、RoundVoice→[Voice,RoundVideo])。
|
||||
// 分页对齐历史语义:OffsetID 为游标(返回 id 严格小于它)、AddOffset 为额外偏移、MaxID/MinID 为闭区间。
|
||||
type MediaSearchRequest struct {
|
||||
Categories []MediaCategory
|
||||
OffsetID int
|
||||
AddOffset int
|
||||
Limit int
|
||||
MaxID int
|
||||
MinID int
|
||||
KnownCount int
|
||||
HasKnownCount bool
|
||||
}
|
||||
|
||||
// ClassifyMediaCategories 返回一条消息所属的全部共享媒体类别(可为空:无媒体且无链接,
|
||||
// 或贴纸/地理/联系人等不进任何媒体标签页的载荷)。是媒体索引的唯一分类真值,写路径据此
|
||||
// 维护索引、迁移回填须与其语义一致。
|
||||
func ClassifyMediaCategories(media *MessageMedia, entities []MessageEntity) []MediaCategory {
|
||||
cats := make([]MediaCategory, 0, 2)
|
||||
add := func(category MediaCategory) {
|
||||
if category == MediaCategoryNone {
|
||||
return
|
||||
}
|
||||
for _, existing := range cats {
|
||||
if existing == category {
|
||||
return
|
||||
}
|
||||
}
|
||||
cats = append(cats, category)
|
||||
}
|
||||
if media != nil {
|
||||
switch media.Kind {
|
||||
case MessageMediaKindPhoto:
|
||||
add(MediaCategoryPhoto)
|
||||
case MessageMediaKindDocument:
|
||||
if c, ok := classifyDocumentCategory(media.Document); ok {
|
||||
add(c)
|
||||
}
|
||||
case MessageMediaKindPoll:
|
||||
add(MediaCategoryPoll)
|
||||
case MessageMediaKindWebPage:
|
||||
add(MediaCategoryURL)
|
||||
}
|
||||
}
|
||||
if hasURLEntity(entities) {
|
||||
add(MediaCategoryURL)
|
||||
}
|
||||
return cats
|
||||
}
|
||||
|
||||
// classifyDocumentCategory 按 TL DocumentAttribute 判定一个文档落入哪个媒体类别。
|
||||
// 客户端的标签页 UI 以 TL 属性(而非 MIME)为准,故这里也只看属性。优先级:
|
||||
// sticker(不入任何标签页)> animated(GIF) > audio(music/voice) > video(video/round) > 通用文件。
|
||||
func classifyDocumentCategory(doc *Document) (MediaCategory, bool) {
|
||||
if doc == nil {
|
||||
return MediaCategoryNone, false
|
||||
}
|
||||
var (
|
||||
hasSticker bool
|
||||
hasAnimated bool
|
||||
hasAudio bool
|
||||
audioVoice bool
|
||||
hasVideo bool
|
||||
videoRound bool
|
||||
)
|
||||
for _, a := range doc.Attributes {
|
||||
switch a.Kind {
|
||||
case DocAttrSticker, DocAttrCustomEmoji:
|
||||
hasSticker = true
|
||||
case DocAttrAnimated:
|
||||
hasAnimated = true
|
||||
case DocAttrAudio:
|
||||
hasAudio = true
|
||||
if a.Voice {
|
||||
audioVoice = true
|
||||
}
|
||||
case DocAttrVideo:
|
||||
hasVideo = true
|
||||
if a.RoundMessage {
|
||||
videoRound = true
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case hasSticker:
|
||||
return MediaCategoryNone, false // 贴纸/自定义 emoji 不出现在共享媒体
|
||||
case hasAnimated:
|
||||
return MediaCategoryGif, true
|
||||
case hasAudio:
|
||||
if audioVoice {
|
||||
return MediaCategoryVoice, true
|
||||
}
|
||||
return MediaCategoryMusic, true
|
||||
case hasVideo:
|
||||
if videoRound {
|
||||
return MediaCategoryRoundVideo, true
|
||||
}
|
||||
return MediaCategoryVideo, true
|
||||
default:
|
||||
return MediaCategoryFile, true
|
||||
}
|
||||
}
|
||||
|
||||
func hasURLEntity(entities []MessageEntity) bool {
|
||||
for _, e := range entities {
|
||||
if e.Type == MessageEntityURL || e.Type == MessageEntityTextURL || e.Type == MessageEntityEmail {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
51
internal/domain/media_category_test.go
Normal file
51
internal/domain/media_category_test.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifyMediaCategories(t *testing.T) {
|
||||
doc := func(attrs ...DocumentAttribute) *MessageMedia {
|
||||
return &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{Attributes: attrs}}
|
||||
}
|
||||
urlEnt := []MessageEntity{{Type: MessageEntityURL}}
|
||||
textURLEnt := []MessageEntity{{Type: MessageEntityTextURL}}
|
||||
emailEnt := []MessageEntity{{Type: MessageEntityEmail}}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
media *MessageMedia
|
||||
entities []MessageEntity
|
||||
want []MediaCategory
|
||||
}{
|
||||
{"nil media no entities", nil, nil, []MediaCategory{}},
|
||||
{"photo", &MessageMedia{Kind: MessageMediaKindPhoto, Photo: &Photo{}}, nil, []MediaCategory{MediaCategoryPhoto}},
|
||||
{"poll", &MessageMedia{Kind: MessageMediaKindPoll}, nil, []MediaCategory{MediaCategoryPoll}},
|
||||
{"video", doc(DocumentAttribute{Kind: DocAttrVideo}), nil, []MediaCategory{MediaCategoryVideo}},
|
||||
{"round video note", doc(DocumentAttribute{Kind: DocAttrVideo, RoundMessage: true}), nil, []MediaCategory{MediaCategoryRoundVideo}},
|
||||
{"gif animation", doc(DocumentAttribute{Kind: DocAttrAnimated}), nil, []MediaCategory{MediaCategoryGif}},
|
||||
{"music", doc(DocumentAttribute{Kind: DocAttrAudio}), nil, []MediaCategory{MediaCategoryMusic}},
|
||||
{"voice", doc(DocumentAttribute{Kind: DocAttrAudio, Voice: true}), nil, []MediaCategory{MediaCategoryVoice}},
|
||||
{"generic file", doc(DocumentAttribute{Kind: DocAttrFilename, FileName: "x.pdf"}), nil, []MediaCategory{MediaCategoryFile}},
|
||||
{"sticker excluded", doc(DocumentAttribute{Kind: DocAttrSticker}), nil, []MediaCategory{}},
|
||||
{"animated sticker excluded", doc(DocumentAttribute{Kind: DocAttrSticker}, DocumentAttribute{Kind: DocAttrAnimated}), nil, []MediaCategory{}},
|
||||
{"photo with url", &MessageMedia{Kind: MessageMediaKindPhoto, Photo: &Photo{}}, urlEnt, []MediaCategory{MediaCategoryPhoto, MediaCategoryURL}},
|
||||
{"text-only url", nil, urlEnt, []MediaCategory{MediaCategoryURL}},
|
||||
{"text-only text_url", nil, textURLEnt, []MediaCategory{MediaCategoryURL}},
|
||||
{"text-only email", nil, emailEnt, []MediaCategory{MediaCategoryURL}},
|
||||
{"webpage media", &MessageMedia{Kind: MessageMediaKindWebPage, WebPage: &MessageWebPage{State: MessageWebPageStateDone, ID: 1, URL: "https://example.test"}}, nil, []MediaCategory{MediaCategoryURL}},
|
||||
{"webpage media with url entity deduped", &MessageMedia{Kind: MessageMediaKindWebPage, WebPage: &MessageWebPage{State: MessageWebPageStateDone, ID: 1, URL: "https://example.test"}}, urlEnt, []MediaCategory{MediaCategoryURL}},
|
||||
{"file with link", doc(DocumentAttribute{Kind: DocAttrFilename}), urlEnt, []MediaCategory{MediaCategoryFile, MediaCategoryURL}},
|
||||
{"geo not indexed", &MessageMedia{Kind: MessageMediaKindGeo}, nil, []MediaCategory{}},
|
||||
{"contact not indexed", &MessageMedia{Kind: MessageMediaKindContact}, nil, []MediaCategory{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ClassifyMediaCategories(tc.media, tc.entities)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("ClassifyMediaCategories = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2,15 +2,13 @@ package domain
|
|||
|
||||
import "errors"
|
||||
|
||||
// 媒体 / 文件相关业务错误。rpc 层据此映射为对应 rpc_error(见 internal/rpc/errors.go)。
|
||||
// 文件分片上传与媒体相关业务错误。rpc 层据此映射为对应 rpc_error(见 internal/rpc/errors.go):
|
||||
// 文件分片(part invalid/parts invalid/part too big)、上传配额、照片与文档。
|
||||
var (
|
||||
ErrFilePartInvalid = errors.New("file part invalid")
|
||||
ErrFilePartsInvalid = errors.New("file parts invalid")
|
||||
ErrFilePartTooBig = errors.New("file part too big")
|
||||
ErrFileReference = errors.New("file reference invalid")
|
||||
ErrMediaInvalid = errors.New("media invalid")
|
||||
ErrMediaEmpty = errors.New("media empty")
|
||||
ErrPhotoInvalid = errors.New("photo invalid")
|
||||
ErrStickersetInvalid = errors.New("stickerset invalid")
|
||||
ErrDocumentInvalid = errors.New("document invalid")
|
||||
ErrFilePartInvalid = errors.New("file part invalid")
|
||||
ErrFilePartsInvalid = errors.New("file parts invalid")
|
||||
ErrFilePartTooBig = errors.New("file part too big")
|
||||
ErrUploadQuotaExceeded = errors.New("upload quota exceeded")
|
||||
ErrPhotoInvalid = errors.New("photo invalid")
|
||||
ErrDocumentInvalid = errors.New("document invalid")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,25 @@ package domain
|
|||
type MessageEntityType string
|
||||
|
||||
const (
|
||||
MessageEntityBold MessageEntityType = "bold"
|
||||
MessageEntityBold MessageEntityType = "bold"
|
||||
MessageEntityItalic MessageEntityType = "italic"
|
||||
MessageEntityUnderline MessageEntityType = "underline"
|
||||
MessageEntityStrike MessageEntityType = "strike"
|
||||
MessageEntityCode MessageEntityType = "code"
|
||||
MessageEntityPre MessageEntityType = "pre"
|
||||
MessageEntityTextURL MessageEntityType = "text_url"
|
||||
MessageEntityMentionName MessageEntityType = "mention_name"
|
||||
MessageEntitySpoiler MessageEntityType = "spoiler"
|
||||
MessageEntityBlockquote MessageEntityType = "blockquote"
|
||||
MessageEntityCustomEmoji MessageEntityType = "custom_emoji"
|
||||
MessageEntityMention MessageEntityType = "mention"
|
||||
MessageEntityHashtag MessageEntityType = "hashtag"
|
||||
MessageEntityCashtag MessageEntityType = "cashtag"
|
||||
MessageEntityBotCommand MessageEntityType = "bot_command"
|
||||
MessageEntityURL MessageEntityType = "url"
|
||||
MessageEntityEmail MessageEntityType = "email"
|
||||
MessageEntityPhone MessageEntityType = "phone"
|
||||
MessageEntityBankCard MessageEntityType = "bank_card"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -55,7 +73,11 @@ func ValidateMessageReplyBounds(reply *MessageReply) error {
|
|||
if reply.TopMessageID < 0 || reply.TopMessageID > MaxMessageBoxID {
|
||||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
if reply.MessageID == 0 && reply.TopMessageID == 0 {
|
||||
if reply.StoryID < 0 || reply.StoryID > MaxStoryID {
|
||||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
// story 回复(StoryID>0)不携带 MessageID/TopMessageID;普通回复至少有其一。
|
||||
if reply.MessageID == 0 && reply.TopMessageID == 0 && reply.StoryID == 0 {
|
||||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
if reply.QuoteOffset < 0 || reply.QuoteOffset > MaxMessageReplyQuoteOffset {
|
||||
|
|
@ -69,6 +91,16 @@ type MessageEntity struct {
|
|||
Type MessageEntityType
|
||||
Offset int
|
||||
Length int
|
||||
// URL 仅 text_url 使用。
|
||||
URL string
|
||||
// UserID 仅 mention_name 使用。
|
||||
UserID int64
|
||||
// Language 仅 pre 使用。
|
||||
Language string
|
||||
// DocumentID 仅 custom_emoji 使用。
|
||||
DocumentID int64
|
||||
// Collapsed 仅 blockquote 使用。
|
||||
Collapsed bool
|
||||
}
|
||||
|
||||
// Message 是账号视角下的一条私聊消息。
|
||||
|
|
@ -90,9 +122,56 @@ type Message struct {
|
|||
Forward *MessageForward
|
||||
Reactions *ChannelMessageReactions
|
||||
Pts int
|
||||
TTLPeriod int
|
||||
ExpiresAt int
|
||||
Media *MessageMedia
|
||||
MediaUnread bool
|
||||
ReactionUnread bool
|
||||
ViaBotID int64
|
||||
// GroupedID 是相册分组 id:同一次 sendMultiMedia 的各条消息共享同一非零值,
|
||||
// 客户端据此把它们渲染成一个相册组。非相册消息恒 0。双盒持同一值。
|
||||
GroupedID int64
|
||||
// Effect 是消息特效 id(message.effect,flags2.2?long):私聊 1-1 专属动画特效
|
||||
// (🎉/👍 等),发送方与接收方双盒持同一非零值并各自播放一次;非特效消息恒 0。
|
||||
// 转发不携带特效(新消息恒 0)。仅私聊;群/频道不渲染。
|
||||
Effect int64
|
||||
// ReplyMarkup 是 bot 消息携带的 inline keyboard 快照(P3)。仅 bot 出站消息可
|
||||
// 非空;普通用户消息恒 nil(发送侧 is_bot 闸门)。双盒持同一快照(无 per-viewer 差异)。
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
// RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。
|
||||
RichMessage *MessageRichMessage
|
||||
// Pinned 是 owner 视角的置顶标志(官方私聊多置顶语义:双方各自
|
||||
// 的 box 行独立持有,非 pm_oneside 操作两侧同步翻转)。
|
||||
Pinned bool
|
||||
// SavedPeer 是 Saved Messages 分会话分组键(message.saved_peer_id)。
|
||||
// 仅 self-chat box 行非零:直发笔记 = self;转发进收藏夹 = 源会话 peer;
|
||||
// 存量回填兜底 hidden author 占位 user 2666000。非 self-chat 行恒零值。
|
||||
SavedPeer Peer
|
||||
}
|
||||
|
||||
// MessageRichMessage 是 Layer 227 富文本消息(richMessage)的协议中立快照:一组 IV
|
||||
// PageBlock(Blocks)+ 内嵌已解析的 Photos/Documents。
|
||||
//
|
||||
// Blocks 存 gotd TL 序列化后的 []tg.PageBlockClass 不透明字节——PageBlock 体系庞大且
|
||||
// input(inputRichMessage.blocks) 与 output(richMessage.blocks) 同构、原样透传,故不在
|
||||
// domain 逐类型建模;rpc 层负责 tg.PageBlock 向量 ↔ bytes 的序列化(domain 不依赖 tg)。
|
||||
// 与 message media 同理,Photos/Documents 存已解析快照(含 viewer 无关的 access_hash),
|
||||
// 投影复用 tgPhoto/tgDocument。Phase 1 仅支持 inputRichMessage(blocks 形态),不解析
|
||||
// HTML/Markdown 变体。
|
||||
//
|
||||
// 已知局限:Blocks 是 gotd 线格式不透明字节,跨 gotd 版本(PageBlock 构造器变更)可能
|
||||
// 失效——富文本消息为全新实验特性、无存量数据,Phase 1 接受该耦合。
|
||||
type MessageRichMessage struct {
|
||||
Rtl bool `json:"rtl,omitempty"`
|
||||
Part bool `json:"part,omitempty"`
|
||||
Blocks []byte `json:"blocks,omitempty"`
|
||||
Photos []Photo `json:"photos,omitempty"`
|
||||
Documents []Document `json:"documents,omitempty"`
|
||||
}
|
||||
|
||||
// IsZero 表示无富文本载荷(落库时跳过空快照、投影时不下发 rich_message)。
|
||||
func (m *MessageRichMessage) IsZero() bool {
|
||||
return m == nil || (len(m.Blocks) == 0 && len(m.Photos) == 0 && len(m.Documents) == 0)
|
||||
}
|
||||
|
||||
// MessageReply describes a message reply/thread header without depending on TL types.
|
||||
|
|
@ -104,6 +183,9 @@ type MessageReply struct {
|
|||
QuoteText string
|
||||
QuoteEntities []MessageEntity
|
||||
QuoteOffset int
|
||||
// StoryID > 0 表示这是一条对 story 的回复(评论):MessageID 为 0,Peer 为 story 作者,
|
||||
// 投影为 messageReplyStoryHeader 而非普通 messageReplyHeader。
|
||||
StoryID int
|
||||
}
|
||||
|
||||
// MessageForward 描述一条转发消息的原始作者信息。
|
||||
|
|
@ -126,17 +208,24 @@ type MessageList struct {
|
|||
|
||||
// MessageFilter 描述历史/搜索查询条件。
|
||||
type MessageFilter struct {
|
||||
HasPeer bool
|
||||
Peer Peer
|
||||
Query string
|
||||
OffsetID int
|
||||
OffsetDate int
|
||||
AddOffset int
|
||||
Limit int
|
||||
MaxID int
|
||||
MinID int
|
||||
Hash int64
|
||||
HasPeer bool
|
||||
Peer Peer
|
||||
Query string
|
||||
OffsetID int
|
||||
OffsetDate int
|
||||
AddOffset int
|
||||
Limit int
|
||||
MaxID int
|
||||
MinID int
|
||||
Hash int64
|
||||
// PinnedOnly 仅返回置顶消息(messages.search filterPinned 与
|
||||
// userFull.pinned_msg_id 的查询路径)。
|
||||
PinnedOnly bool
|
||||
MusicOnly bool
|
||||
NeedTotalCount bool
|
||||
// SavedPeer 非零时仅返回 self-chat 中该 saved 子会话的消息
|
||||
// (messages.getSavedHistory);Peer 必须同时是 self。
|
||||
SavedPeer Peer
|
||||
}
|
||||
|
||||
// SendPrivateTextRequest 是私聊文本/媒体发送命令。
|
||||
|
|
@ -155,6 +244,19 @@ type SendPrivateTextRequest struct {
|
|||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
RecipientBlocked bool
|
||||
TTLPeriod int
|
||||
ViaBotID int64
|
||||
// GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。
|
||||
GroupedID int64
|
||||
// Effect 消息特效 id(私聊专属,0 表无特效;调用方已对 catalog 校验过合法性)。
|
||||
Effect int64
|
||||
// BusinessAutomationKind is internal app-layer metadata used to suppress
|
||||
// recursive greeting/away automation for server-generated replies.
|
||||
BusinessAutomationKind BusinessAutomationKind
|
||||
// ReplyMarkup 是 bot 出站消息的 inline keyboard 快照(P3);普通用户发送恒 nil。
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
// RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。
|
||||
RichMessage *MessageRichMessage
|
||||
}
|
||||
|
||||
// SendPrivateTextResult 描述一次私聊文本发送的双端结果。
|
||||
|
|
@ -166,6 +268,26 @@ type SendPrivateTextResult struct {
|
|||
Duplicate bool
|
||||
}
|
||||
|
||||
// SetPrivateChatThemeRequest changes the shared theme token for a private dialog.
|
||||
type SetPrivateChatThemeRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
Emoticon string
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
RecipientBlocked bool
|
||||
}
|
||||
|
||||
// SetPrivateChatThemeResult describes the state change and optional service message.
|
||||
type SetPrivateChatThemeResult struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
Emoticon string
|
||||
Changed bool
|
||||
Send SendPrivateTextResult
|
||||
}
|
||||
|
||||
// SetPrivateMessageReactionsRequest replaces the current user's reactions for one private message.
|
||||
type SetPrivateMessageReactionsRequest struct {
|
||||
UserID int64
|
||||
|
|
@ -175,6 +297,9 @@ type SetPrivateMessageReactionsRequest struct {
|
|||
Big bool
|
||||
AddToRecent bool
|
||||
Date int
|
||||
// ReactionsPerUserMax 是 viewer 的每用户上限档位(premium 双档);
|
||||
// 0 表示未填,store 侧按默认档裁剪。
|
||||
ReactionsPerUserMax int
|
||||
}
|
||||
|
||||
// PrivateMessageReactionsRequest fetches reaction summaries for exact private message ids.
|
||||
|
|
@ -205,6 +330,7 @@ type ForwardPrivateMessagesRequest struct {
|
|||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
RecipientBlocked bool
|
||||
TTLPeriod int
|
||||
}
|
||||
|
||||
// ForwardPrivateMessagesResult 描述一次私聊转发的 owner 维度结果。
|
||||
|
|
@ -233,6 +359,7 @@ type ReadHistoryResult struct {
|
|||
Peer Peer
|
||||
MaxID int
|
||||
StillUnreadCount int
|
||||
ChannelPts int
|
||||
Changed bool
|
||||
InboxEvent UpdateEvent
|
||||
OutboxChanged bool
|
||||
|
|
@ -254,6 +381,10 @@ type ReadMessageContentsResult struct {
|
|||
OwnerUserID int64
|
||||
MessageIDs []int
|
||||
Event UpdateEvent
|
||||
// SenderEvents 是对端发送者的内容已读回执:reader 听完 voice/round 后,
|
||||
// 每个原 sender 收到一条 updateReadMessagesContents,messages 用 sender
|
||||
// 自己视角的 box id。
|
||||
SenderEvents []UpdateEvent
|
||||
}
|
||||
|
||||
// OutboxReadDateRequest 是 messages.getOutboxReadDate 查询。
|
||||
|
|
@ -263,16 +394,35 @@ type OutboxReadDateRequest struct {
|
|||
ID int
|
||||
}
|
||||
|
||||
// EditMessageRequest 是账号视角下编辑一条已发送私聊文本消息的命令。
|
||||
// EditMessageRequest 是账号视角下编辑一条已发送私聊消息的命令。
|
||||
// Media 非 nil 时整体替换消息媒体快照(当前唯一调用方是 live location 续报/停止,
|
||||
// rpc 层负责限定媒体种类);nil 表示纯文本编辑。
|
||||
type EditMessageRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
ID int
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
EditDate int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
// SetReplyMarkup 置位时替换 reply_markup(ReplyMarkup 为 nil/空 = 清空键盘);
|
||||
// 未置位则保留原 markup。仅 bot 编辑自己消息时由 RPC 层置位(P3)。
|
||||
SetReplyMarkup bool
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
// ViaBotEditBotID 非零时允许对应 bot 编辑经由它发送的 inline 私聊消息。
|
||||
ViaBotEditBotID int64
|
||||
// AllowTodoParticipantMutation 允许 checklist 参与者在 others_can_* 授权下通过
|
||||
// edit 事件替换 todo 媒体快照。仅 RPC todo handler 设置;store 仍会限制为
|
||||
// todo->todo 且正文/entities/markup 不变,避免普通消息越权编辑。
|
||||
AllowTodoParticipantMutation bool
|
||||
// WebPageResolve 置位时为服务端内部「链接预览就地替换」:仅替换 media(不碰 body/
|
||||
// entities/edit_date,故不标记「已编辑」),生成 UpdateEventWebPage 而非 edit_message。
|
||||
// 幂等守卫:仅当目标当前 media 仍是 ID==ExpectedWebPageID 的 pending 链接预览才替换,
|
||||
// 否则返回 ErrMessageNotModified(消息已删/已改/已解析)。
|
||||
WebPageResolve bool
|
||||
ExpectedWebPageID int64
|
||||
}
|
||||
|
||||
// EditedMessageForUser 描述一次编辑对某个 owner 视角造成的影响。
|
||||
|
|
@ -315,9 +465,13 @@ type DeleteMessagesRequest struct {
|
|||
|
||||
// DeleteHistoryRequest 是账号视角下清空某个 peer 历史的命令。
|
||||
type DeleteHistoryRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
MaxID int
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
MaxID int
|
||||
// MinDate/MaxDate 是"按日期删除"的闭区间(unix 秒,0 表示不限);
|
||||
// 客户端先本地销毁再发请求,服务端静默忽略会造成删除复活。
|
||||
MinDate int
|
||||
MaxDate int
|
||||
JustClear bool
|
||||
Revoke bool
|
||||
Date int
|
||||
|
|
@ -358,3 +512,151 @@ func (r DeleteMessagesResult) Changed() bool {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PinPrivateMessageRequest 是 messages.updatePinnedMessage 的私聊命令
|
||||
// (含 Saved Messages = self peer)。
|
||||
type PinPrivateMessageRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
MessageID int
|
||||
Pinned bool
|
||||
// PmOneside 仅置顶在本侧(官方私聊置顶框"同时为对方置顶"未勾选时),
|
||||
// 不向对端翻转、不生成服务消息。unpin 无此语义,恒双侧清除。
|
||||
PmOneside bool
|
||||
Silent bool
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// PinnedMessagesForUser 描述置顶状态变化对某个 owner 视角的影响。
|
||||
type PinnedMessagesForUser struct {
|
||||
UserID int64
|
||||
Peer Peer
|
||||
MessageIDs []int
|
||||
Pinned bool
|
||||
Event UpdateEvent
|
||||
}
|
||||
|
||||
// MaxUnpinAllBatch 限制单次 unpinAllMessages 实际清除的置顶数量;
|
||||
// 超出部分由客户端按 affectedHistory.Offset>0 续发清除,单条
|
||||
// updatePinnedMessages 的 messages 向量随之有界。
|
||||
const MaxUnpinAllBatch = 1000
|
||||
|
||||
// PinPrivateMessageResult 描述私聊置顶/取消置顶的 owner 维度结果。
|
||||
// Updated 为空表示状态未变化(幂等 no-op,不烧 pts)。
|
||||
type PinPrivateMessageResult struct {
|
||||
OwnerUserID int64
|
||||
Updated []PinnedMessagesForUser
|
||||
// Offset 非 0 表示 unpinAll 还有剩余批次待清。
|
||||
Offset int
|
||||
}
|
||||
|
||||
// Self 返回当前请求账号的置顶结果。
|
||||
func (r PinPrivateMessageResult) Self() PinnedMessagesForUser {
|
||||
for _, item := range r.Updated {
|
||||
if item.UserID == r.OwnerUserID {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return PinnedMessagesForUser{UserID: r.OwnerUserID}
|
||||
}
|
||||
|
||||
// Changed 表示本次操作是否实际翻转了任何 owner 视角的置顶状态。
|
||||
func (r PinPrivateMessageResult) Changed() bool {
|
||||
for _, item := range r.Updated {
|
||||
if len(item.MessageIDs) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UnpinAllPrivateMessagesRequest 是 messages.unpinAllMessages 的私聊命令。
|
||||
type UnpinAllPrivateMessagesRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// ScheduledMessage is a pending account-owned message that has not entered
|
||||
// normal peer history yet.
|
||||
type ScheduledMessage struct {
|
||||
OwnerUserID int64
|
||||
ID int
|
||||
Peer Peer
|
||||
RandomID int64
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
ReplyTo *MessageReply
|
||||
Forward *MessageForward
|
||||
SendAs *Peer
|
||||
ScheduleDate int
|
||||
ScheduleRepeatPeriod int
|
||||
CreatedAt int
|
||||
UpdatedAt int
|
||||
State string
|
||||
SentMessageID int
|
||||
}
|
||||
|
||||
// ScheduledMessageList is a bounded scheduled queue page.
|
||||
type ScheduledMessageList struct {
|
||||
Messages []ScheduledMessage
|
||||
Count int
|
||||
Hash int64
|
||||
}
|
||||
|
||||
// ScheduleMessageRequest creates one scheduled message.
|
||||
type ScheduleMessageRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
RandomID int64
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
ReplyTo *MessageReply
|
||||
Forward *MessageForward
|
||||
SendAs *Peer
|
||||
ScheduleDate int
|
||||
ScheduleRepeatPeriod int
|
||||
Date int
|
||||
}
|
||||
|
||||
// EditScheduledMessageRequest updates one pending scheduled message before it
|
||||
// enters normal history.
|
||||
type EditScheduledMessageRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
ID int
|
||||
SetMessage bool
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
ScheduleDate int
|
||||
Date int
|
||||
}
|
||||
|
||||
// ScheduledMessageFilter selects scheduled messages for one owner/peer.
|
||||
type ScheduledMessageFilter struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
IDs []int
|
||||
Limit int
|
||||
Hash int64
|
||||
}
|
||||
|
||||
// ScheduledMessageClaim describes a due/manual scheduled dispatch claim.
|
||||
type ScheduledMessageClaim struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
IDs []int
|
||||
Now int
|
||||
Limit int
|
||||
LeaseUntil int
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,16 @@ import "errors"
|
|||
|
||||
var (
|
||||
ErrMessageIDInvalid = errors.New("message id invalid")
|
||||
ErrMessageEmpty = errors.New("message empty")
|
||||
ErrMessageAuthorRequired = errors.New("message author required")
|
||||
ErrMessageNotModified = errors.New("message not modified")
|
||||
ErrMessageNotReadYet = errors.New("message not read yet")
|
||||
ErrReplyMessageIDInvalid = errors.New("reply message id invalid")
|
||||
ErrChatForwardsRestricted = errors.New("chat forwards restricted")
|
||||
// ErrPinnedSavedDialogsTooMuch 映射 PINNED_TOO_MUCH:收藏夹子会话置顶
|
||||
// 数量达到 MaxPinnedSavedDialogs 上限。
|
||||
ErrPinnedSavedDialogsTooMuch = errors.New("pinned saved dialogs too much")
|
||||
// ErrPinnedDialogsTooMuch 映射 PINNED_DIALOGS_TOO_MUCH:目标 folder 内
|
||||
// 置顶会话数量达到上限。
|
||||
ErrPinnedDialogsTooMuch = errors.New("pinned dialogs too much")
|
||||
)
|
||||
|
|
|
|||
150
internal/domain/message_markup.go
Normal file
150
internal/domain/message_markup.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxMarkupRows 限制 inline keyboard 行数。
|
||||
MaxMarkupRows = 100
|
||||
// MaxMarkupButtonsPerRow 限制单行按钮数。
|
||||
MaxMarkupButtonsPerRow = 8
|
||||
// MaxMarkupButtonsTotal 限制 inline keyboard 总按钮数(对齐官方约 100)。
|
||||
MaxMarkupButtonsTotal = 100
|
||||
// MaxCallbackDataLen 是 callback 按钮 data 的字节上限(对齐 Bot API 1-64)。
|
||||
MaxCallbackDataLen = 64
|
||||
// MaxMarkupButtonTextLen 是按钮文本长度上限(rune 计数)。
|
||||
MaxMarkupButtonTextLen = 256
|
||||
// MaxBotCallbackAnswerLen 是 callback answer 弹窗/toast 文本上限。
|
||||
MaxBotCallbackAnswerLen = 200
|
||||
// MaxStartParamLen 是 messages.startBot 深链 payload 上限(对齐官方 64)。
|
||||
MaxStartParamLen = 64
|
||||
)
|
||||
|
||||
// markup / callback 业务错误。
|
||||
var (
|
||||
// ErrButtonDataInvalid 表示 callback data 越界(>64 字节)。
|
||||
ErrButtonDataInvalid = errors.New("button data invalid")
|
||||
// ErrButtonInvalid 表示键盘结构非法(行/按钮数超限、文本空/过长)。
|
||||
ErrButtonInvalid = errors.New("button invalid")
|
||||
// ErrButtonURLInvalid 表示 url 按钮的链接非法(非 https)。
|
||||
ErrButtonURLInvalid = errors.New("button url invalid")
|
||||
// ErrButtonTypeInvalid 表示按钮类型 P3 不支持(webview/game/url_auth/request_* 等)。
|
||||
ErrButtonTypeInvalid = errors.New("button type invalid")
|
||||
// ErrStartParamInvalid 表示 startBot 的 start_param 越界。
|
||||
ErrStartParamInvalid = errors.New("start param invalid")
|
||||
)
|
||||
|
||||
// MarkupButtonType 标识 P3 支持的 inline 按钮类型。
|
||||
type MarkupButtonType string
|
||||
|
||||
const (
|
||||
// MarkupButtonCallback 是 keyboardButtonCallback(点击触发 getBotCallbackAnswer)。
|
||||
MarkupButtonCallback MarkupButtonType = "callback"
|
||||
// MarkupButtonURL 是 keyboardButtonUrl(点击打开链接)。
|
||||
MarkupButtonURL MarkupButtonType = "url"
|
||||
)
|
||||
|
||||
// MarkupButton 是一颗 inline keyboard 按钮(P3 仅 callback/url)。
|
||||
type MarkupButton struct {
|
||||
Type MarkupButtonType `json:"type"`
|
||||
Text string `json:"text"`
|
||||
// Data 仅 callback 使用:原始字节(含 0x00/非 UTF-8/高位)。json 自动 base64
|
||||
// 编解码,保证经 JSONB 列字节级 round-trip(updateBotCallbackQuery.data 须原样)。
|
||||
Data []byte `json:"data,omitempty"`
|
||||
// URL 仅 url 使用。
|
||||
URL string `json:"url,omitempty"`
|
||||
// RequiresPassword 仅 callback 使用(keyboardButtonCallback.requires_password,
|
||||
// 2FA SRP 校验 P3 stub)。
|
||||
RequiresPassword bool `json:"requires_password,omitempty"`
|
||||
}
|
||||
|
||||
// MessageReplyMarkup 是消息携带的 inline keyboard 快照(P3 仅 ReplyInlineMarkup)。
|
||||
type MessageReplyMarkup struct {
|
||||
Inline [][]MarkupButton `json:"inline,omitempty"`
|
||||
}
|
||||
|
||||
// IsZero 报告 markup 是否为空(无任何按钮)。空 markup 不写 wire flag、不入库。
|
||||
func (m *MessageReplyMarkup) IsZero() bool {
|
||||
if m == nil {
|
||||
return true
|
||||
}
|
||||
for _, row := range m.Inline {
|
||||
if len(row) > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ValidateReplyMarkup 校验 inline keyboard 结构与各按钮,校验须先于落库(I9)。
|
||||
// 空 markup 合法(视为清空/无键盘)。
|
||||
func ValidateReplyMarkup(m *MessageReplyMarkup) error {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
if len(m.Inline) > MaxMarkupRows {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
total := 0
|
||||
for _, row := range m.Inline {
|
||||
if len(row) > MaxMarkupButtonsPerRow {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
total += len(row)
|
||||
if total > MaxMarkupButtonsTotal {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
for i := range row {
|
||||
if err := validateMarkupButton(row[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMarkupButton(b MarkupButton) error {
|
||||
text := strings.TrimSpace(b.Text)
|
||||
if text == "" || utf8.RuneCountInString(b.Text) > MaxMarkupButtonTextLen {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
switch b.Type {
|
||||
case MarkupButtonCallback:
|
||||
if len(b.Data) > MaxCallbackDataLen {
|
||||
return ErrButtonDataInvalid
|
||||
}
|
||||
case MarkupButtonURL:
|
||||
if err := validateButtonURL(b.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// webview/game/url_auth/request_* 等 P3 未实现类型:拒绝,绝不半实现下发。
|
||||
return ErrButtonTypeInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateButtonURL(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || len(raw) > MaxBotMenuButtonURLLen {
|
||||
return ErrButtonURLInvalid
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" {
|
||||
return ErrButtonURLInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BotCallbackAnswer 是 bot 对一次 callback query 的应答(setBotCallbackAnswer →
|
||||
// 解挂等待中的 getBotCallbackAnswer)。
|
||||
type BotCallbackAnswer struct {
|
||||
Alert bool
|
||||
Message string
|
||||
URL string
|
||||
CacheTime int
|
||||
}
|
||||
77
internal/domain/message_markup_test.go
Normal file
77
internal/domain/message_markup_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func cb(text string, data []byte) MarkupButton {
|
||||
return MarkupButton{Type: MarkupButtonCallback, Text: text, Data: data}
|
||||
}
|
||||
|
||||
func TestValidateReplyMarkup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
m *MessageReplyMarkup
|
||||
want error
|
||||
}{
|
||||
{"nil ok", nil, nil},
|
||||
{"empty ok", &MessageReplyMarkup{}, nil},
|
||||
{"callback ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{cb("ok", []byte("d"))}}}, nil},
|
||||
{"callback 64B ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{cb("ok", make([]byte, 64))}}}, nil},
|
||||
{"callback 65B bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{cb("ok", make([]byte, 65))}}}, ErrButtonDataInvalid},
|
||||
{"empty text bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{cb("", []byte("d"))}}}, ErrButtonInvalid},
|
||||
{"url https ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "https://example.com/x"}}}}, nil},
|
||||
{"url http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "http://example.com"}}}}, ErrButtonURLInvalid},
|
||||
{"url javascript bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "javascript:alert(1)"}}}}, ErrButtonURLInvalid},
|
||||
{"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid},
|
||||
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "webview", Text: "x"}}}}, ErrButtonTypeInvalid},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := ValidateReplyMarkup(tt.m); !errors.Is(err, tt.want) {
|
||||
t.Fatalf("ValidateReplyMarkup = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReplyMarkupLimits(t *testing.T) {
|
||||
// 行数上限。
|
||||
tooManyRows := &MessageReplyMarkup{Inline: make([][]MarkupButton, MaxMarkupRows+1)}
|
||||
for i := range tooManyRows.Inline {
|
||||
tooManyRows.Inline[i] = []MarkupButton{cb("x", []byte("d"))}
|
||||
}
|
||||
if err := ValidateReplyMarkup(tooManyRows); !errors.Is(err, ErrButtonInvalid) {
|
||||
t.Fatalf("rows over limit = %v, want ErrButtonInvalid", err)
|
||||
}
|
||||
// 单行按钮数上限。
|
||||
wideRow := make([]MarkupButton, MaxMarkupButtonsPerRow+1)
|
||||
for i := range wideRow {
|
||||
wideRow[i] = cb("x", []byte("d"))
|
||||
}
|
||||
if err := ValidateReplyMarkup(&MessageReplyMarkup{Inline: [][]MarkupButton{wideRow}}); !errors.Is(err, ErrButtonInvalid) {
|
||||
t.Fatalf("row width over limit = %v, want ErrButtonInvalid", err)
|
||||
}
|
||||
// 文本长度上限。
|
||||
longText := strings.Repeat("a", MaxMarkupButtonTextLen+1)
|
||||
if err := ValidateReplyMarkup(&MessageReplyMarkup{Inline: [][]MarkupButton{{cb(longText, []byte("d"))}}}); !errors.Is(err, ErrButtonInvalid) {
|
||||
t.Fatalf("text over limit = %v, want ErrButtonInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageReplyMarkupIsZero(t *testing.T) {
|
||||
if !(*MessageReplyMarkup)(nil).IsZero() {
|
||||
t.Fatal("nil markup must be zero")
|
||||
}
|
||||
if !(&MessageReplyMarkup{}).IsZero() {
|
||||
t.Fatal("empty markup must be zero")
|
||||
}
|
||||
if !(&MessageReplyMarkup{Inline: [][]MarkupButton{{}}}).IsZero() {
|
||||
t.Fatal("markup with only empty rows must be zero")
|
||||
}
|
||||
if (&MessageReplyMarkup{Inline: [][]MarkupButton{{cb("x", nil)}}}).IsZero() {
|
||||
t.Fatal("markup with a button must not be zero")
|
||||
}
|
||||
}
|
||||
70
internal/domain/notify.go
Normal file
70
internal/domain/notify.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package domain
|
||||
|
||||
// NotifyScopeKind 区分通知设置的作用域:具体 peer / 三类全局默认(私聊/群/频道)。
|
||||
type NotifyScopeKind string
|
||||
|
||||
const (
|
||||
NotifyScopePeer NotifyScopeKind = "peer"
|
||||
NotifyScopeUsers NotifyScopeKind = "users"
|
||||
NotifyScopeChats NotifyScopeKind = "chats"
|
||||
NotifyScopeBroadcasts NotifyScopeKind = "broadcasts"
|
||||
)
|
||||
|
||||
// NotifyScope 唯一标识一条通知设置:peer 作用域用 Peer(+TopicID 区分 forum 话题,
|
||||
// 0=整 peer),三类默认作用域 Peer/TopicID 为空。
|
||||
type NotifyScope struct {
|
||||
Kind NotifyScopeKind
|
||||
Peer Peer
|
||||
TopicID int
|
||||
}
|
||||
|
||||
// PeerNotifySettings 是 peerNotifySettings 的业务层表达。各字段为指针=可选:
|
||||
// nil 表示该项未设置(客户端按所属类别默认继承)。声音字段恒为默认,v1 不建模
|
||||
// 自定义铃声(custom sound 罕见,留后续)。
|
||||
type PeerNotifySettings struct {
|
||||
ShowPreviews *bool
|
||||
Silent *bool
|
||||
MuteUntil *int
|
||||
StoriesMuted *bool
|
||||
StoriesHideSender *bool
|
||||
}
|
||||
|
||||
// IsZero 报告该设置是否全部未设置(无需持久化/可视为继承默认)。
|
||||
func (s PeerNotifySettings) IsZero() bool {
|
||||
return s.ShowPreviews == nil && s.Silent == nil && s.MuteUntil == nil &&
|
||||
s.StoriesMuted == nil && s.StoriesHideSender == nil
|
||||
}
|
||||
|
||||
// NotifyException 是一个 peer(可含 forum 话题)的非默认通知设置,供
|
||||
// account.getNotifyExceptions 列出"有自定义通知设置的会话"全局索引。
|
||||
type NotifyException struct {
|
||||
Peer Peer
|
||||
TopicID int
|
||||
Settings PeerNotifySettings
|
||||
}
|
||||
|
||||
// Clone 深拷贝指针字段,供内存 store 隔离。
|
||||
func (s PeerNotifySettings) Clone() PeerNotifySettings {
|
||||
out := PeerNotifySettings{}
|
||||
if s.ShowPreviews != nil {
|
||||
v := *s.ShowPreviews
|
||||
out.ShowPreviews = &v
|
||||
}
|
||||
if s.Silent != nil {
|
||||
v := *s.Silent
|
||||
out.Silent = &v
|
||||
}
|
||||
if s.MuteUntil != nil {
|
||||
v := *s.MuteUntil
|
||||
out.MuteUntil = &v
|
||||
}
|
||||
if s.StoriesMuted != nil {
|
||||
v := *s.StoriesMuted
|
||||
out.StoriesMuted = &v
|
||||
}
|
||||
if s.StoriesHideSender != nil {
|
||||
v := *s.StoriesHideSender
|
||||
out.StoriesHideSender = &v
|
||||
}
|
||||
return out
|
||||
}
|
||||
46
internal/domain/paid_reaction.go
Normal file
46
internal/domain/paid_reaction.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package domain
|
||||
|
||||
// 频道帖子付费 reaction(messages.sendPaidReaction):用户花 Stars 为一条频道消息「点赞」,
|
||||
// 星数在 (channel,message,user) 上累计;消息展示 ReactionPaid 总星数 + top reactors 排行。
|
||||
// 与 Stars 账本(stars.go)配合:rpc 先 Debit 再在此累计。
|
||||
|
||||
const (
|
||||
// MaxPaidReactionStarsPerRequest 是单次 sendPaidReaction 的星数上限(对齐官方 stars_paid_reaction_amount_max)。
|
||||
MaxPaidReactionStarsPerRequest = 10000
|
||||
// MaxPaidReactionTopReactors 是 top reactors 排行展示条数。
|
||||
MaxPaidReactionTopReactors = 3
|
||||
)
|
||||
|
||||
// PaidReactor 是某 reactor 对一条消息累计投入的付费 reaction 星数。
|
||||
type PaidReactor struct {
|
||||
UserID int64
|
||||
Stars int64
|
||||
Anonymous bool
|
||||
My bool // 是否为当前 viewer(投影时按视角置位)
|
||||
}
|
||||
|
||||
// ChannelMessagePaidReactions 是一条频道消息的付费 reaction 聚合(携带在消息上 / reaction 更新里)。
|
||||
type ChannelMessagePaidReactions struct {
|
||||
TotalStars int64 // 全体 reactor 投入星数之和
|
||||
MyStars int64 // 当前 viewer 投入的星数(0 = 未投)
|
||||
MyAnonymous bool // 当前 viewer 是否匿名投入
|
||||
TopReactors []PaidReactor // 按 Stars DESC,含当前 viewer
|
||||
}
|
||||
|
||||
// SendChannelPaidReactionRequest 为一条频道消息增投付费 reaction 星数。
|
||||
type SendChannelPaidReactionRequest struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
MessageID int
|
||||
Stars int64
|
||||
Anonymous bool // 隐私:是否匿名投入
|
||||
Date int
|
||||
}
|
||||
|
||||
// ChannelMessagePaidReactionResult 是增投后的结果,供 rpc 投影与扇出。
|
||||
type ChannelMessagePaidReactionResult struct {
|
||||
Channel Channel
|
||||
Message ChannelMessage
|
||||
Paid ChannelMessagePaidReactions
|
||||
Recipients []int64
|
||||
}
|
||||
50
internal/domain/passkey.go
Normal file
50
internal/domain/passkey.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// Passkey 相关错误。
|
||||
var (
|
||||
ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid")
|
||||
ErrPasskeyNotFound = errors.New("passkey not found")
|
||||
ErrPasskeyInvalid = errors.New("passkey invalid")
|
||||
ErrPasskeyUserHandleInvalid = errors.New("passkey user handle invalid")
|
||||
)
|
||||
|
||||
// PasskeyChallengePurpose 区分注册挑战与登录挑战。
|
||||
type PasskeyChallengePurpose string
|
||||
|
||||
const (
|
||||
PasskeyChallengeLogin PasskeyChallengePurpose = "login"
|
||||
PasskeyChallengeRegister PasskeyChallengePurpose = "register"
|
||||
)
|
||||
|
||||
// PasskeyChallenge 是一次性 WebAuthn 挑战的元数据(短 TTL、用后即焚)。
|
||||
// 注册挑战绑定发起用户;登录挑战(discoverable)UserID=0,用户由 user_handle 反查。
|
||||
type PasskeyChallenge struct {
|
||||
Purpose PasskeyChallengePurpose
|
||||
UserID int64
|
||||
ExpiresAt int64 // unix 秒
|
||||
}
|
||||
|
||||
// PasskeyCredential 是一条已注册的 passkey 凭据(WebAuthn 公钥)。
|
||||
type PasskeyCredential struct {
|
||||
CredentialID []byte // 原始 credential id 字节(对外以 base64url 暴露)
|
||||
UserID int64
|
||||
PublicKey []byte // COSE 公钥原始字节
|
||||
SignCount uint32
|
||||
AAGUID []byte
|
||||
Name string
|
||||
Transports []string
|
||||
CreatedAt int64 // unix 秒
|
||||
LastUsedAt int64 // unix 秒;0 表示从未用于登录
|
||||
}
|
||||
|
||||
// Clone 深拷贝(切片字段),避免内存 store 暴露内部引用。
|
||||
func (c PasskeyCredential) Clone() PasskeyCredential {
|
||||
out := c
|
||||
out.CredentialID = append([]byte(nil), c.CredentialID...)
|
||||
out.PublicKey = append([]byte(nil), c.PublicKey...)
|
||||
out.AAGUID = append([]byte(nil), c.AAGUID...)
|
||||
out.Transports = append([]string(nil), c.Transports...)
|
||||
return out
|
||||
}
|
||||
141
internal/domain/phone.go
Normal file
141
internal/domain/phone.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package domain
|
||||
|
||||
// PhoneCallState 是私聊 1:1 通话信令状态机的服务端状态。
|
||||
// 状态迁移、TL 视角映射与推送策略见 docs 与 internal/app/phone。
|
||||
type PhoneCallState string
|
||||
|
||||
const (
|
||||
// PhoneCallStateRequested:requestCall 已受理,已向被叫推 phoneCallRequested。
|
||||
PhoneCallStateRequested PhoneCallState = "requested"
|
||||
// PhoneCallStateRinging:至少一台被叫设备上报 receivedCall(主叫据 receive_date 切振铃 UI)。
|
||||
PhoneCallStateRinging PhoneCallState = "ringing"
|
||||
// PhoneCallStateAccepted:被叫 acceptCall 已落 g_b,等主叫 confirmCall。
|
||||
PhoneCallStateAccepted PhoneCallState = "accepted"
|
||||
// PhoneCallStateConfirmed:主叫 confirmCall 完成密钥交换,通话进行中。
|
||||
PhoneCallStateConfirmed PhoneCallState = "confirmed"
|
||||
// PhoneCallStateDiscarded:终态。作为 tombstone 保留一段时间吸收晚到 RPC 后回收。
|
||||
PhoneCallStateDiscarded PhoneCallState = "discarded"
|
||||
)
|
||||
|
||||
// PhoneCallDiscardReason 是挂断原因,与 TL phoneCallDiscardReason* 一一对应。
|
||||
type PhoneCallDiscardReason string
|
||||
|
||||
const (
|
||||
PhoneCallDiscardReasonMissed PhoneCallDiscardReason = "missed"
|
||||
PhoneCallDiscardReasonDisconnect PhoneCallDiscardReason = "disconnect"
|
||||
PhoneCallDiscardReasonHangup PhoneCallDiscardReason = "hangup"
|
||||
PhoneCallDiscardReasonBusy PhoneCallDiscardReason = "busy"
|
||||
// PhoneCallDiscardReasonMigrateConference 是升级为 conference call 的迁移挂断;
|
||||
// 当前不支持 conference,仅原样回显 reason。
|
||||
PhoneCallDiscardReasonMigrateConference PhoneCallDiscardReason = "migrate_conference"
|
||||
)
|
||||
|
||||
// PhoneCallProtocol 是 tgcalls 协议协商参数(TL phoneCallProtocol)。
|
||||
type PhoneCallProtocol struct {
|
||||
UDPP2P bool
|
||||
UDPReflector bool
|
||||
MinLayer int
|
||||
MaxLayer int
|
||||
LibraryVersions []string
|
||||
}
|
||||
|
||||
// SessionRef 标识一台具体设备连接(信令定向推送 fast-path 的锚点,允许失效)。
|
||||
type SessionRef struct {
|
||||
RawAuthKeyID [8]byte
|
||||
SessionID int64
|
||||
}
|
||||
|
||||
// Zero 报告锚点是否未记录。
|
||||
func (s SessionRef) Zero() bool {
|
||||
return s == SessionRef{}
|
||||
}
|
||||
|
||||
// PhoneCallConnection 是下发给双方的 WebRTC STUN/TURN 服务条目
|
||||
// (TL phoneConnectionWebrtc)。requestCall 受理时签发,全生命周期稳定。
|
||||
type PhoneCallConnection struct {
|
||||
ID int64
|
||||
IP string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Stun bool
|
||||
Turn bool
|
||||
}
|
||||
|
||||
// PhoneCallRequest 是 phone.requestCall 受理入参(隐私/拉黑等校验在 rpc 层先行完成)。
|
||||
type PhoneCallRequest struct {
|
||||
CalleeID int64
|
||||
RandomID int64
|
||||
GAHash []byte
|
||||
Video bool
|
||||
Protocol PhoneCallProtocol
|
||||
CallerDevice SessionRef
|
||||
// PrivacyP2P 是 phone_p2p 隐私的双向 AND(rpc 层算定;强制 relay 时置 false)。
|
||||
PrivacyP2P bool
|
||||
// Connections 是为本通话签发的 STUN/TURN 列表(可空:TURN 未启用时退回
|
||||
// 纯信令交换 host candidates 的 LAN 直连)。
|
||||
Connections []PhoneCallConnection
|
||||
}
|
||||
|
||||
// PhoneCall 是一通私聊通话的服务端权威态。密钥材料(GAHash/GB/GA)只存活于
|
||||
// 进程内 registry,随 tombstone 回收销毁,绝不落任何持久化存储。
|
||||
type PhoneCall struct {
|
||||
ID int64
|
||||
AccessHash int64
|
||||
// AdminID 是主叫,ParticipantID 是被叫;与 TL phoneCall 字段同名,全生命周期不变。
|
||||
AdminID int64
|
||||
ParticipantID int64
|
||||
Video bool
|
||||
State PhoneCallState
|
||||
|
||||
Date int // requestCall 受理时刻(unix 秒)
|
||||
ReceiveDate int // 首台被叫设备 receivedCall 时刻;0 表示尚未触达
|
||||
StartDate int // confirmCall 完成时刻
|
||||
DiscardedAt int // 进入终态时刻(tombstone GC 依据)
|
||||
|
||||
GAHash []byte // 32B,requestCall 携带的承诺
|
||||
GB []byte // 256B,acceptCall 携带
|
||||
GA []byte // 256B,confirmCall 揭示(服务端核验 SHA256(GA)==GAHash)
|
||||
KeyFingerprint int64 // E2E 指纹,服务端无法验证,仅转发
|
||||
|
||||
// Protocol 是协商结果(accept 时计算);CallerProtocol/CalleeProtocol 保留原始两份。
|
||||
Protocol PhoneCallProtocol
|
||||
CallerProtocol PhoneCallProtocol
|
||||
CalleeProtocol PhoneCallProtocol
|
||||
|
||||
P2PAllowed bool
|
||||
DiscardReason PhoneCallDiscardReason
|
||||
Duration int
|
||||
|
||||
// PrivacyP2P 与 Connections 自 PhoneCallRequest 原样保留(见其注释)。
|
||||
PrivacyP2P bool
|
||||
Connections []PhoneCallConnection
|
||||
|
||||
RandomID int64 // 主叫侧幂等去重键的一半(callerID+RandomID)
|
||||
|
||||
// 设备锚点:requestCall / acceptCall 的来源设备,仅作定向推送提示。
|
||||
CallerDevice SessionRef
|
||||
CalleeDevice SessionRef
|
||||
}
|
||||
|
||||
// Terminal 报告通话是否已进入终态。
|
||||
func (c PhoneCall) Terminal() bool {
|
||||
return c.State == PhoneCallStateDiscarded
|
||||
}
|
||||
|
||||
// PeerOf 返回 userID 在本通话中的对端;userID 不是参与者时返回 0。
|
||||
func (c PhoneCall) PeerOf(userID int64) int64 {
|
||||
switch userID {
|
||||
case c.AdminID:
|
||||
return c.ParticipantID
|
||||
case c.ParticipantID:
|
||||
return c.AdminID
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// HasParticipant 报告 userID 是否为本通话参与者。
|
||||
func (c PhoneCall) HasParticipant(userID int64) bool {
|
||||
return userID != 0 && (userID == c.AdminID || userID == c.ParticipantID)
|
||||
}
|
||||
325
internal/domain/poll.go
Normal file
325
internal/domain/poll.go
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// 本文件定义投票(poll)的业务对象与共享语义:
|
||||
//
|
||||
// - MessagePoll 是随消息 media JSONB 落库的「渲染安全定义快照」——不含 quiz 正确答案与
|
||||
// solution 等服务端机密;Closed 仅是发送时刻快照,读路径必须用 polls 权威态覆盖。
|
||||
// - PollDefinition 是 polls 权威表行(含机密与可变 closed),投票校验与结果门控只信它。
|
||||
// - 投票校验(ValidatePollVote)与结果门控(ResolvePollResults)是纯函数,由 memory 与
|
||||
// postgres 两个 store 共用,杜绝双实现行为漂移。
|
||||
//
|
||||
// 转发不复制 poll:转发快照携带同一 poll id,投票/关闭全局聚合(与官方一致)。
|
||||
|
||||
const (
|
||||
// MaxPollQuestionLength 与官方 poll question 上限一致。
|
||||
MaxPollQuestionLength = 255
|
||||
// MinPollAnswers / MaxPollAnswers 是答案个数边界(官方 2..10)。
|
||||
MinPollAnswers = 2
|
||||
MaxPollAnswers = 10
|
||||
// MaxPollAnswerTextLength 是单个答案文本上限(官方 100)。
|
||||
MaxPollAnswerTextLength = 100
|
||||
// MaxPollSolutionLength 是 quiz 解释文本上限(官方 200)。
|
||||
MaxPollSolutionLength = 200
|
||||
// MinPollClosePeriod / MaxPollClosePeriod 是自动关闭倒计时边界(官方 5..600 秒)。
|
||||
MinPollClosePeriod = 5
|
||||
MaxPollClosePeriod = 600
|
||||
// MaxPollRecentVoters 是 recent_voters 截断(与 reaction recent 同款,官方 3)。
|
||||
MaxPollRecentVoters = 3
|
||||
// MaxPollVotesPageLimit 是 messages.getPollVotes 单页上限。
|
||||
MaxPollVotesPageLimit = 50
|
||||
)
|
||||
|
||||
// 投票链路业务错误;rpc 层映射为对应 RPC error 文本。
|
||||
var (
|
||||
ErrPollInvalid = errors.New("poll invalid")
|
||||
ErrPollNotFound = errors.New("poll not found")
|
||||
ErrPollClosed = errors.New("poll closed")
|
||||
ErrPollOptionInvalid = errors.New("poll option invalid")
|
||||
ErrPollRevoteNotAllowed = errors.New("poll revote not allowed")
|
||||
ErrPollNotCreator = errors.New("poll can only be closed by creator")
|
||||
ErrPollNotPublic = errors.New("poll voters are not public")
|
||||
)
|
||||
|
||||
// MessagePollAnswer 是一个候选答案(文本 + option 字节键 + 可选配图)。
|
||||
// Media 仅允许 photo/document 快照(rpc 层限制输入类型)。
|
||||
type MessagePollAnswer struct {
|
||||
Text string `json:"text"`
|
||||
Entities []MessageEntity `json:"entities,omitempty"`
|
||||
Option []byte `json:"option"`
|
||||
Media *MessageMedia `json:"media,omitempty"`
|
||||
}
|
||||
|
||||
// MessagePoll 是消息 media 上的 poll 定义快照(渲染安全,不含机密)。
|
||||
type MessagePoll struct {
|
||||
ID int64 `json:"id"`
|
||||
Question string `json:"question"`
|
||||
QuestionEntities []MessageEntity `json:"question_entities,omitempty"`
|
||||
Answers []MessagePollAnswer `json:"answers"`
|
||||
// Closed 是发送时刻快照;读路径以 polls 权威态覆盖(转发副本/关闭后历史一致性靠它)。
|
||||
Closed bool `json:"closed,omitempty"`
|
||||
PublicVoters bool `json:"public_voters,omitempty"`
|
||||
MultipleChoice bool `json:"multiple_choice,omitempty"`
|
||||
Quiz bool `json:"quiz,omitempty"`
|
||||
RevotingDisabled bool `json:"revoting_disabled,omitempty"`
|
||||
ShuffleAnswers bool `json:"shuffle_answers,omitempty"`
|
||||
HideResultsUntilClose bool `json:"hide_results_until_close,omitempty"`
|
||||
ClosePeriod int `json:"close_period,omitempty"`
|
||||
CloseDate int `json:"close_date,omitempty"`
|
||||
|
||||
// AttachedMedia 是 poll 题干配图(messageMediaPoll.attached_media),photo/document 快照。
|
||||
AttachedMedia *MessageMedia `json:"attached_media,omitempty"`
|
||||
|
||||
// Results 在读路径按 viewer 填充(含 chosen/correct/solution 门控),不落库。
|
||||
Results *MessagePollResults `json:"-"`
|
||||
}
|
||||
|
||||
// MessagePollAnswerVoters 是一个答案的聚合结果(已按 viewer 门控)。
|
||||
type MessagePollAnswerVoters struct {
|
||||
Option []byte
|
||||
Voters int
|
||||
Chosen bool
|
||||
Correct bool
|
||||
}
|
||||
|
||||
// MessagePollResults 是按 viewer 解析后的聚合结果。
|
||||
type MessagePollResults struct {
|
||||
TotalVoters int
|
||||
Voters []MessagePollAnswerVoters // 与 MessagePoll.Answers 同序
|
||||
RecentVoters []int64 // 仅 public_voters 填充,截断 MaxPollRecentVoters
|
||||
ViewerVoted bool
|
||||
Solution string // 仅 quiz 且 (viewer 已投 || 已关闭) 下发
|
||||
SolutionEntities []MessageEntity
|
||||
}
|
||||
|
||||
// PollDefinition 是 polls 权威表行:可变状态(closed)+ 服务端机密 + 校验所需选项集。
|
||||
type PollDefinition struct {
|
||||
ID int64
|
||||
CreatorUserID int64
|
||||
Options [][]byte // 合法选项集合(与答案顺序一致)
|
||||
PublicVoters bool
|
||||
MultipleChoice bool
|
||||
Quiz bool
|
||||
RevotingDisabled bool
|
||||
HideResultsUntilClose bool
|
||||
Closed bool
|
||||
ClosePeriod int
|
||||
CloseDate int
|
||||
CorrectOptions [][]byte
|
||||
Solution string
|
||||
SolutionEntities []MessageEntity
|
||||
}
|
||||
|
||||
// ClosedAt 返回 now 时刻 poll 是否应视为已关闭(显式关闭或 close_date 已过)。
|
||||
func (d PollDefinition) ClosedAt(now int) bool {
|
||||
return d.Closed || (d.CloseDate > 0 && now >= d.CloseDate)
|
||||
}
|
||||
|
||||
// HasOption 判断 option 是否在合法选项集内。
|
||||
func (d PollDefinition) HasOption(option []byte) bool {
|
||||
for _, candidate := range d.Options {
|
||||
if bytes.Equal(candidate, option) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PollVote 是一个用户的投票行。Options 保留客户端提交顺序。
|
||||
type PollVote struct {
|
||||
PollID int64
|
||||
UserID int64
|
||||
Options [][]byte
|
||||
Date int
|
||||
}
|
||||
|
||||
// PollAggregates 是 store 层产出的原始聚合,交由 ResolvePollResults 做 viewer 门控。
|
||||
// Counts 的 key 是 string(option)。
|
||||
type PollAggregates struct {
|
||||
Counts map[string]int
|
||||
TotalVoters int
|
||||
RecentVoters []int64 // 按 vote_date DESC 截断 MaxPollRecentVoters;仅 public_voters 需要
|
||||
ViewerOptions [][]byte // viewer 自己的投票(nil = 未投)
|
||||
}
|
||||
|
||||
// ValidatePollVote 校验一次投票提交;existing 是 viewer 当前投票(nil=未投),options 为空表示撤票。
|
||||
// memory 与 postgres store 必须共用本函数,保持双实现一致。
|
||||
func ValidatePollVote(def PollDefinition, existing [][]byte, options [][]byte, now int) error {
|
||||
if def.ID == 0 {
|
||||
return ErrPollNotFound
|
||||
}
|
||||
if def.ClosedAt(now) {
|
||||
return ErrPollClosed
|
||||
}
|
||||
if def.Quiz || def.RevotingDisabled {
|
||||
// quiz / revoting_disabled:不可撤票、不可改票。
|
||||
if len(existing) > 0 || len(options) == 0 {
|
||||
return ErrPollRevoteNotAllowed
|
||||
}
|
||||
}
|
||||
if def.Quiz {
|
||||
if len(options) != 1 {
|
||||
return ErrPollOptionInvalid
|
||||
}
|
||||
} else if len(options) > 1 && !def.MultipleChoice {
|
||||
return ErrPollOptionInvalid
|
||||
}
|
||||
if len(options) > len(def.Options) {
|
||||
return ErrPollOptionInvalid
|
||||
}
|
||||
seen := make(map[string]struct{}, len(options))
|
||||
for _, option := range options {
|
||||
if !def.HasOption(option) {
|
||||
return ErrPollOptionInvalid
|
||||
}
|
||||
key := string(option)
|
||||
if _, dup := seen[key]; dup {
|
||||
return ErrPollOptionInvalid
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolvePollResults 把原始聚合解析成 viewer 视角结果:chosen 标 viewer 自己的选项;
|
||||
// correct/solution 仅 quiz 且 (viewer 已投 || 已关闭) 揭示;
|
||||
// hide_results_until_close 在关闭前对非创建者隐藏计数(防恶意客户端绕过 UI)。
|
||||
func ResolvePollResults(def PollDefinition, agg PollAggregates, viewerUserID int64, now int) MessagePollResults {
|
||||
closed := def.ClosedAt(now)
|
||||
viewerVoted := len(agg.ViewerOptions) > 0
|
||||
reveal := def.Quiz && (viewerVoted || closed)
|
||||
hideCounts := def.HideResultsUntilClose && !closed && viewerUserID != def.CreatorUserID
|
||||
chosen := make(map[string]struct{}, len(agg.ViewerOptions))
|
||||
for _, option := range agg.ViewerOptions {
|
||||
chosen[string(option)] = struct{}{}
|
||||
}
|
||||
correct := make(map[string]struct{}, len(def.CorrectOptions))
|
||||
if reveal {
|
||||
for _, option := range def.CorrectOptions {
|
||||
correct[string(option)] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := MessagePollResults{
|
||||
TotalVoters: agg.TotalVoters,
|
||||
Voters: make([]MessagePollAnswerVoters, 0, len(def.Options)),
|
||||
ViewerVoted: viewerVoted,
|
||||
}
|
||||
for _, option := range def.Options {
|
||||
key := string(option)
|
||||
_, isChosen := chosen[key]
|
||||
_, isCorrect := correct[key]
|
||||
voters := agg.Counts[key]
|
||||
if hideCounts {
|
||||
voters = 0
|
||||
}
|
||||
out.Voters = append(out.Voters, MessagePollAnswerVoters{
|
||||
Option: option,
|
||||
Voters: voters,
|
||||
Chosen: isChosen,
|
||||
Correct: isCorrect,
|
||||
})
|
||||
}
|
||||
if def.PublicVoters && !hideCounts && len(agg.RecentVoters) > 0 {
|
||||
recent := agg.RecentVoters
|
||||
if len(recent) > MaxPollRecentVoters {
|
||||
recent = recent[:MaxPollRecentVoters]
|
||||
}
|
||||
out.RecentVoters = append([]int64(nil), recent...)
|
||||
}
|
||||
if reveal {
|
||||
out.Solution = def.Solution
|
||||
out.SolutionEntities = append([]MessageEntity(nil), def.SolutionEntities...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyPollState 把权威态与解析结果写回 media 上的定义快照(读路径 enrichment 终点)。
|
||||
func ApplyPollState(poll *MessagePoll, def PollDefinition, results MessagePollResults, now int) {
|
||||
if poll == nil {
|
||||
return
|
||||
}
|
||||
poll.Closed = def.ClosedAt(now)
|
||||
poll.Results = &results
|
||||
}
|
||||
|
||||
// VotePrivateMessagePollRequest 是私聊消息投票请求(msg id 为 viewer box id)。
|
||||
type VotePrivateMessagePollRequest struct {
|
||||
UserID int64
|
||||
Peer Peer
|
||||
MessageID int
|
||||
Options [][]byte // 空 = 撤票
|
||||
Date int
|
||||
}
|
||||
|
||||
// PrivateMessagePollResult 返回两个 owner 视角的消息(media 已按各自 owner enrich)。
|
||||
type PrivateMessagePollResult struct {
|
||||
PollID int64
|
||||
Messages []Message
|
||||
}
|
||||
|
||||
// ClosePrivateMessagePollRequest 关闭私聊消息上的 poll(仅 poll 创建者)。
|
||||
type ClosePrivateMessagePollRequest struct {
|
||||
UserID int64
|
||||
Peer Peer
|
||||
MessageID int
|
||||
Date int
|
||||
}
|
||||
|
||||
// VoteChannelMessagePollRequest 是频道/超级群消息投票请求。
|
||||
type VoteChannelMessagePollRequest struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
MessageID int
|
||||
Options [][]byte
|
||||
Date int
|
||||
}
|
||||
|
||||
// ChannelMessagePollResult 返回投票者视角消息与建议推送收件人。
|
||||
type ChannelMessagePollResult struct {
|
||||
PollID int64
|
||||
Channel Channel
|
||||
Message ChannelMessage
|
||||
Recipients []int64
|
||||
}
|
||||
|
||||
// ChannelPollFanoutViews 是频道 poll fan-out 的批量 per-viewer enrich 结果(消除逐 viewer GetMessages
|
||||
// 的 N+1):viewer-invariant 聚合(counts/total/recent)只算一次 + 批量 viewerOptions + 批量成员可见性,
|
||||
// 每 viewer 用同一 ResolvePollResults 合成,与逐 viewer 路径字节同源。
|
||||
// - Found:poll 消息存在且确为 poll(否则视为无可投影)。
|
||||
// - Message:基础消息快照(未 per-viewer enrich),供 app 层叠加 bot 历史可见性过滤。
|
||||
// - Polls:key 存在=已评估该 viewer;值为 nil=该 viewer 不可见(非成员/pre-history 隐藏);
|
||||
// 非 nil=该 viewer 视角已 enrich 的 poll。bot 历史过滤由 app 层在此基础上叠加(置 nil)。
|
||||
type ChannelPollFanoutViews struct {
|
||||
Found bool
|
||||
Message ChannelMessage
|
||||
Polls map[int64]*MessagePoll
|
||||
}
|
||||
|
||||
// CloseChannelMessagePollRequest 关闭频道消息上的 poll(仅 poll 创建者)。
|
||||
type CloseChannelMessagePollRequest struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
MessageID int
|
||||
Date int
|
||||
}
|
||||
|
||||
// PollVotesListRequest 是 messages.getPollVotes 的分页请求。
|
||||
type PollVotesListRequest struct {
|
||||
PollID int64
|
||||
Option []byte // 可选:仅列出投了该选项的人
|
||||
OffsetDate int // 0 = 第一页;翻页用上页末行 (date,user_id)
|
||||
OffsetUserID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
// PollVotesList 是投票人分页结果。
|
||||
type PollVotesList struct {
|
||||
Count int // 满足过滤条件的总数
|
||||
Votes []PollVote
|
||||
// HasMore 为 true 时 rpc 层用末行 (Date,UserID) 编码 next_offset。
|
||||
HasMore bool
|
||||
}
|
||||
93
internal/domain/premium_test.go
Normal file
93
internal/domain/premium_test.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPremiumActiveAt(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
cases := []struct {
|
||||
name string
|
||||
user User
|
||||
want bool
|
||||
}{
|
||||
{"zero until = 非会员", User{}, false},
|
||||
{"future until = 会员", User{PremiumUntil: int(now + 3600)}, true},
|
||||
{"past until = 已到期", User{PremiumUntil: int(now - 1)}, false},
|
||||
{"恰好 now = 已到期(边界闭区间外)", User{PremiumUntil: int(now)}, false},
|
||||
{"bot 永不会员(双保险)", User{Bot: true, BotInfoVersion: 1, PremiumUntil: int(now + 3600)}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.user.PremiumActiveAt(now); got != tc.want {
|
||||
t.Errorf("%s: PremiumActiveAt = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmojiStatusActiveAt(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
premium := int(now + 3600)
|
||||
cases := []struct {
|
||||
name string
|
||||
user User
|
||||
want bool
|
||||
}{
|
||||
{"未设置", User{PremiumUntil: premium}, false},
|
||||
{"永久状态", User{PremiumUntil: premium, EmojiStatusDocumentID: 7}, true},
|
||||
{"未过期状态", User{PremiumUntil: premium, EmojiStatusDocumentID: 7, EmojiStatusUntil: int(now + 60)}, true},
|
||||
{"已过期状态", User{PremiumUntil: premium, EmojiStatusDocumentID: 7, EmojiStatusUntil: int(now - 60)}, false},
|
||||
{"会员到期后残值不下发", User{PremiumUntil: int(now - 1), EmojiStatusDocumentID: 7}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.user.EmojiStatusActiveAt(now); got != tc.want {
|
||||
t.Errorf("%s: EmojiStatusActiveAt = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimMessageReactionsToUserMaxTiers(t *testing.T) {
|
||||
reactions := []MessageReaction{
|
||||
{Type: MessageReactionEmoji, Emoticon: "👍"},
|
||||
{Type: MessageReactionEmoji, Emoticon: "❤"},
|
||||
{Type: MessageReactionEmoji, Emoticon: "🔥"},
|
||||
{Type: MessageReactionEmoji, Emoticon: "🎉"},
|
||||
}
|
||||
// 默认档(含 0 = 旧调用方未填)裁到 1,保尾部最新。
|
||||
for _, max := range []int{0, -1, MaxMessageReactionsPerUser} {
|
||||
got := TrimMessageReactionsToUserMax(reactions, max)
|
||||
if len(got) != 1 || got[0].Emoticon != "🎉" {
|
||||
t.Fatalf("default tier (max=%d) = %+v, want [🎉]", max, got)
|
||||
}
|
||||
}
|
||||
// premium 档裁到 3,保尾部最新。
|
||||
got := TrimMessageReactionsToUserMax(reactions, MessageReactionsUserMax(true))
|
||||
if len(got) != 3 || got[0].Emoticon != "❤" || got[2].Emoticon != "🎉" {
|
||||
t.Fatalf("premium tier = %+v, want [❤ 🔥 🎉]", got)
|
||||
}
|
||||
// 超出 premium 档的请求值被封顶。
|
||||
if got := TrimMessageReactionsToUserMax(reactions, 99); len(got) != MaxMessageReactionsPerUserPremium {
|
||||
t.Fatalf("over-cap tier = %d entries, want %d", len(got), MaxMessageReactionsPerUserPremium)
|
||||
}
|
||||
if MessageReactionsUserMax(false) != MaxMessageReactionsPerUser {
|
||||
t.Fatalf("MessageReactionsUserMax(false) = %d", MessageReactionsUserMax(false))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedDialogsLimitTiers(t *testing.T) {
|
||||
cases := []struct {
|
||||
folderID int
|
||||
premium bool
|
||||
want int
|
||||
}{
|
||||
{DialogMainFolderID, false, MaxPinnedDialogsMainFolder},
|
||||
{DialogMainFolderID, true, MaxPinnedDialogsMainFolderPremium},
|
||||
{DialogArchiveFolderID, false, MaxPinnedDialogsArchiveFolder},
|
||||
{DialogArchiveFolderID, true, MaxPinnedDialogsArchiveFolderPremium},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := PinnedDialogsLimit(tc.folderID, tc.premium); got != tc.want {
|
||||
t.Errorf("PinnedDialogsLimit(%d, %v) = %d, want %d", tc.folderID, tc.premium, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
87
internal/domain/saved_dialog.go
Normal file
87
internal/domain/saved_dialog.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package domain
|
||||
|
||||
// SavedHiddenAuthorUserID 是官方 hidden-author 收藏夹子会话的占位 user id
|
||||
// (TDesktop kSavedHiddenAuthorId / TDLib HIDDEN_AUTHOR_DIALOG_ID)。
|
||||
// 运行时新写入永远能确定源会话,不产生该值;仅存量回填中
|
||||
// 「fwd 头只有 from_name、源会话已不可知」的消息归入此子会话。
|
||||
const SavedHiddenAuthorUserID int64 = 2666000
|
||||
|
||||
// MaxPinnedSavedDialogs 是收藏夹子会话置顶上限(官方 premium 上限同级)。
|
||||
const MaxPinnedSavedDialogs = 100
|
||||
|
||||
// MaxSavedDialogsLimit 是 messages.getSavedDialogs 单页上限。
|
||||
const MaxSavedDialogsLimit = 100
|
||||
|
||||
// SavedPeerForSelfChat 计算 self-chat 新消息的 saved 子会话分组键,与
|
||||
// TDLib SavedMessagesTopicId 语义对齐:转发带源会话(saved_from_peer)→
|
||||
// 源会话;fwd 头仅 from_name 且源会话不可知 → hidden author 占位(防御
|
||||
// 分支,运行时转发总是带源会话);其余(直发、drop_author)→ self。
|
||||
func SavedPeerForSelfChat(selfUserID int64, forward *MessageForward) Peer {
|
||||
if forward != nil {
|
||||
if forward.SavedFrom.ID != 0 {
|
||||
return forward.SavedFrom
|
||||
}
|
||||
if forward.From.ID == 0 && forward.FromName != "" {
|
||||
return Peer{Type: PeerTypeUser, ID: SavedHiddenAuthorUserID}
|
||||
}
|
||||
}
|
||||
return Peer{Type: PeerTypeUser, ID: selfUserID}
|
||||
}
|
||||
|
||||
// SavedDialog 是收藏夹的一个子会话(savedDialog TL 构造器的业务形态)。
|
||||
type SavedDialog struct {
|
||||
Peer Peer
|
||||
// TopMessage 是该子会话当前最新可见消息的 owner 视角 box id。
|
||||
TopMessage int
|
||||
Pinned bool
|
||||
}
|
||||
|
||||
// SavedDialogList 是 getSavedDialogs/getPinnedSavedDialogs/getSavedDialogsByID
|
||||
// 的业务层结果。Messages 与 Dialogs 一一对应(top message 全量行)。
|
||||
type SavedDialogList struct {
|
||||
Dialogs []SavedDialog
|
||||
Messages []Message
|
||||
Users []User
|
||||
Channels []Channel
|
||||
// Count 是过滤口径下(含/不含 pinned)的子会话总数。
|
||||
Count int
|
||||
// Full 为 true 表示本页已含过滤口径下的全部剩余子会话
|
||||
// (映射 messages.savedDialogs;false 映射 savedDialogsSlice)。
|
||||
Full bool
|
||||
}
|
||||
|
||||
// SavedDialogsFilter 描述 getSavedDialogs 分页条件。
|
||||
type SavedDialogsFilter struct {
|
||||
ExcludePinned bool
|
||||
// OffsetID 是上一页最后一个子会话的 top message box id;0 或 >= MaxMessageBoxID
|
||||
// 视为从最新开始(TDesktop 首页传 0,DrKLO Android 首页传 int32 max)。
|
||||
OffsetID int
|
||||
// OffsetDate/OffsetPeer 仅作合法性校验;box id 单调于时间,分页统一按
|
||||
// top box id 严格降序推进。
|
||||
OffsetDate int
|
||||
OffsetPeer Peer
|
||||
Limit int
|
||||
}
|
||||
|
||||
// DeleteSavedHistoryRequest 是 messages.deleteSavedHistory 的业务命令:
|
||||
// 删除 self-chat 中一个 saved 子会话的消息。self-chat 单侧,无 revoke 语义。
|
||||
type DeleteSavedHistoryRequest struct {
|
||||
OwnerUserID int64
|
||||
// SavedPeer 是要清空的子会话分组键。
|
||||
SavedPeer Peer
|
||||
MaxID int
|
||||
MinDate int
|
||||
MaxDate int
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// DeleteSavedHistoryResult 描述一次 saved 子会话删除批次。
|
||||
type DeleteSavedHistoryResult struct {
|
||||
// MessageIDs 是本批被删除的 owner 视角 box id。
|
||||
MessageIDs []int
|
||||
Event UpdateEvent
|
||||
// More 为 true 表示仍有剩余批次,客户端按 affectedHistory.offset 续发。
|
||||
More bool
|
||||
}
|
||||
23
internal/domain/saved_music.go
Normal file
23
internal/domain/saved_music.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package domain
|
||||
|
||||
const (
|
||||
// MaxSavedMusicItems bounds one account's profile music list in the current
|
||||
// small-scale compatibility phase.
|
||||
MaxSavedMusicItems = 1000
|
||||
)
|
||||
|
||||
// SaveMusicRequest mutates the current user's ordered profile music list.
|
||||
type SaveMusicRequest struct {
|
||||
UserID int64
|
||||
Document Document
|
||||
Unsave bool
|
||||
AfterDocumentID int64
|
||||
Date int
|
||||
}
|
||||
|
||||
// SavedMusicList is an ordered page of music documents pinned to a user profile.
|
||||
type SavedMusicList struct {
|
||||
UserID int64
|
||||
Documents []Document
|
||||
Count int
|
||||
}
|
||||
188
internal/domain/secretchat.go
Normal file
188
internal/domain/secretchat.go
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// SecretChatState 是私聊端对端加密(Secret Chat / EncryptedChat)握手状态机的
|
||||
// 服务端权威态。服务端是盲中继:g_a/g_b/key_fingerprint/加密 bytes 全部不透明
|
||||
// 存储与原样转发,永不接触 DH 共享密钥与明文。设计见 docs/secret-chat-module.md。
|
||||
type SecretChatState string
|
||||
|
||||
const (
|
||||
// SecretChatStateRequested:requestEncryption 已受理、等接受方 acceptEncryption。
|
||||
// 同一服务端态对发起方投影为 encryptedChatWaiting、对接受方投影为 encryptedChatRequested。
|
||||
SecretChatStateRequested SecretChatState = "requested"
|
||||
// SecretChatStateNormal:acceptEncryption 完成 g_b/key_fingerprint 落库,握手成型。
|
||||
SecretChatStateNormal SecretChatState = "normal"
|
||||
// SecretChatStateDiscarded:终态,任一方 discardEncryption 触发。
|
||||
SecretChatStateDiscarded SecretChatState = "discarded"
|
||||
)
|
||||
|
||||
// Secret chat 存储层错误(memory/postgres 双实现共用,行为契约由 storetest 钉死)。
|
||||
var (
|
||||
// ErrSecretChatNotFound:chat_id 不存在 → CHAT_ID_INVALID / ENCRYPTION_ID_INVALID。
|
||||
ErrSecretChatNotFound = errors.New("secretchat: not found")
|
||||
// ErrSecretChatAlreadyAccepted:accept 一个已成型的密聊 → ENCRYPTION_ALREADY_ACCEPTED。
|
||||
ErrSecretChatAlreadyAccepted = errors.New("secretchat: already accepted")
|
||||
// ErrSecretChatAlreadyDeclined:accept 一个已销毁的密聊 → ENCRYPTION_ALREADY_DECLINED。
|
||||
ErrSecretChatAlreadyDeclined = errors.New("secretchat: already declined")
|
||||
// ErrSecretChatIDConflict:chat_id 主键撞键(计数器回退);调用方按 AtLeast 重分配重试。
|
||||
ErrSecretChatIDConflict = errors.New("secretchat: chat id conflict")
|
||||
)
|
||||
|
||||
// SecretChat 是一通私聊密聊的服务端权威态(durable,跨重启存活)。
|
||||
// 字段命名对齐 TL encryptedChat*;ID 是 int32 量级,access_hash/admin_id/
|
||||
// participant_id/key_fingerprint 是 int64。绑定维度是设备级(perm auth_key 的 int64 值)。
|
||||
type SecretChat struct {
|
||||
// ID 是 chat_id,全局单调 int32 序列;双方共享同一 id。
|
||||
ID int
|
||||
// AdminAccessHash / ParticipantAccessHash 双视角不同(TL "check sum depending on user ID")。
|
||||
AdminAccessHash int64
|
||||
ParticipantAccessHash int64
|
||||
// AdminUserID 是发起方,ParticipantUserID 是接受方;据此判角色。
|
||||
AdminUserID int64
|
||||
ParticipantUserID int64
|
||||
// AdminAuthKeyID / ParticipantAuthKeyID 是绑定设备的 perm auth_key(authKeyIDToInt64 小端值)。
|
||||
// ParticipantAuthKeyID 在 accept 前为 0(建链前邀请对 participant 账号级可见)。
|
||||
AdminAuthKeyID int64
|
||||
ParticipantAuthKeyID int64
|
||||
|
||||
State SecretChatState
|
||||
|
||||
// GA 是 requestEncryption 携带的发起方公钥(盲存,左补零 256B)。
|
||||
// GB 是 acceptEncryption 携带的接受方公钥(accept 后落库)。
|
||||
// 投影 GAOrB:对 admin 视角是 GB,对 participant 视角是 GA(TL 注释钉死)。
|
||||
GA []byte
|
||||
GB []byte
|
||||
// KeyFingerprint 是接受方算出的共享密钥指纹,服务端原样 int64 中继、绝不重算。
|
||||
KeyFingerprint int64
|
||||
|
||||
// Layer 是密聊内层 layer 快照(不解析);FolderID 透传 requested 归档意图。
|
||||
Layer int
|
||||
FolderID int
|
||||
// HistoryDeleted 是 discard 时是否要求对端删整个会话历史。
|
||||
HistoryDeleted bool
|
||||
|
||||
// RandomID 是 requestEncryption 的 int32 幂等键(同发起设备同 random_id 返同 chat)。
|
||||
RandomID int32
|
||||
// Date 是受理时刻(unix 秒)。
|
||||
Date int
|
||||
}
|
||||
|
||||
// Terminal 报告密聊是否已销毁。
|
||||
func (c SecretChat) Terminal() bool {
|
||||
return c.State == SecretChatStateDiscarded
|
||||
}
|
||||
|
||||
// HasParticipant 报告 userID 是否为本密聊的发起方或接受方。
|
||||
func (c SecretChat) HasParticipant(userID int64) bool {
|
||||
return userID != 0 && (userID == c.AdminUserID || userID == c.ParticipantUserID)
|
||||
}
|
||||
|
||||
// IsAdmin 报告 userID 是否为发起方。
|
||||
func (c SecretChat) IsAdmin(userID int64) bool {
|
||||
return userID != 0 && userID == c.AdminUserID
|
||||
}
|
||||
|
||||
// PeerOf 返回 userID 在本密聊中的对端;userID 不是参与者时返回 0。
|
||||
func (c SecretChat) PeerOf(userID int64) int64 {
|
||||
switch userID {
|
||||
case c.AdminUserID:
|
||||
return c.ParticipantUserID
|
||||
case c.ParticipantUserID:
|
||||
return c.AdminUserID
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// PeerAuthKeyOf 返回 userID 的对端绑定设备 perm auth_key(int64);非参与者或对端
|
||||
// 未绑定返回 0。admin 的对端是 participant 设备,反之亦然。
|
||||
func (c SecretChat) PeerAuthKeyOf(userID int64) int64 {
|
||||
switch userID {
|
||||
case c.AdminUserID:
|
||||
return c.ParticipantAuthKeyID
|
||||
case c.ParticipantUserID:
|
||||
return c.AdminAuthKeyID
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// AccessHashFor 返回 userID 视角的 access_hash(双方不同);非参与者返回 0。
|
||||
func (c SecretChat) AccessHashFor(userID int64) int64 {
|
||||
switch userID {
|
||||
case c.AdminUserID:
|
||||
return c.AdminAccessHash
|
||||
case c.ParticipantUserID:
|
||||
return c.ParticipantAccessHash
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// EncryptedFileRef 是密聊文件的服务端快照(P2,盲中继:内容是加密 bytes,不解密)。
|
||||
type EncryptedFileRef struct {
|
||||
ID int64
|
||||
AccessHash int64
|
||||
Size int64
|
||||
DCID int
|
||||
KeyFingerprint int // int32 量级
|
||||
}
|
||||
|
||||
// SecretChatMessage 是密聊 qts 投递队列里的一条不透明加密消息(updateNewEncryptedMessage
|
||||
// 的载荷)。Bytes 是客户端加密的 DecryptedMessage,服务端盲存盲转、永不解密。
|
||||
// 按接收方设备(ReceiverAuthKeyID)的 qts 序列投递。
|
||||
type SecretChatMessage struct {
|
||||
ReceiverAuthKeyID int64
|
||||
Qts int
|
||||
ReceiverUserID int64
|
||||
ChatID int
|
||||
RandomID int64
|
||||
Date int
|
||||
IsService bool
|
||||
Bytes []byte
|
||||
File *EncryptedFileRef
|
||||
}
|
||||
|
||||
// EncryptedStateEventType 区分无 qts 的密聊状态事件种类。
|
||||
type EncryptedStateEventType int
|
||||
|
||||
const (
|
||||
// EncryptedStateEventEncryption:updateEncryption(握手态变化);投递时按 secret_chats
|
||||
// 权威态重建(不固化快照)。
|
||||
EncryptedStateEventEncryption EncryptedStateEventType = 1
|
||||
// EncryptedStateEventRead:updateEncryptedMessagesRead(已读回执),携 MaxDate。
|
||||
EncryptedStateEventRead EncryptedStateEventType = 2
|
||||
)
|
||||
|
||||
// EncryptedStateEvent 是无 qts 的 durable 密聊状态事件(离线 getDifference 补偿)。
|
||||
// TargetAuthKeyID=0 表示账号级(所有设备可见),非 0 表示绑定设备定向。
|
||||
type EncryptedStateEvent struct {
|
||||
ID int64
|
||||
TargetUserID int64
|
||||
TargetAuthKeyID int64
|
||||
ChatID int
|
||||
Type EncryptedStateEventType
|
||||
MaxDate int
|
||||
Date int
|
||||
}
|
||||
|
||||
// SecretMessageDelivery 是 sendEncrypted* 要投递给对端设备的一条加密消息(rpc 层组装,
|
||||
// service 分配接收设备 qts 并落库)。
|
||||
type SecretMessageDelivery struct {
|
||||
RandomID int64
|
||||
Bytes []byte
|
||||
IsService bool
|
||||
File *EncryptedFileRef
|
||||
Date int
|
||||
}
|
||||
|
||||
// SecretChatRequest 是 requestEncryption 受理入参(隐私/拉黑/self/bot 校验在 rpc 层先行)。
|
||||
type SecretChatRequest struct {
|
||||
AdminUserID int64
|
||||
AdminAuthKeyID int64
|
||||
ParticipantUserID int64
|
||||
RandomID int32
|
||||
GA []byte
|
||||
Date int
|
||||
}
|
||||
115
internal/domain/star_gift.go
Normal file
115
internal/domain/star_gift.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Star gift(payments.sendStarsForm + inputInvoiceStarGift)领域模型。目录是从已 seed 的
|
||||
// animated_emoji 合成的静态集合(StarGift);peer 收到的礼物实例落 peer_star_gifts(SavedStarGift)。
|
||||
// 与 Stars 账本配合:发礼 Debit、转换回 Stars 时 Credit。
|
||||
|
||||
// StarGift 是一个可购买礼物目录项(合成、非用户持有)。
|
||||
type StarGift struct {
|
||||
ID int64
|
||||
Stars int64 // 购买价(Stars)
|
||||
ConvertStars int64 // 收礼人可转换回的 Stars(v1 = Stars,全额)
|
||||
Title string // 可选标题
|
||||
Sticker Document // 礼物贴纸快照(tg 投影必须是带 sticker 属性的有效 Document,否则客户端丢弃)
|
||||
}
|
||||
|
||||
// SavedStarGift 是一条已收到的礼物实例(peer_star_gifts 一行)。
|
||||
type SavedStarGift struct {
|
||||
ID int64
|
||||
Owner Peer // 收礼 peer(user/channel)
|
||||
FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露)
|
||||
GiftID int64 // → StarGift.ID
|
||||
MsgID int // 用户礼物的私聊 msg_id;频道礼物不进历史,固定为 0
|
||||
SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id;用户礼物为 0
|
||||
Date int // 收到时刻 Unix 秒
|
||||
NameHidden bool // 送礼人请求隐藏姓名
|
||||
Unsaved bool // 未展示在个人资料(saveStarGift 切换)
|
||||
Converted bool // 已转换回 Stars(终态,从列表排除)
|
||||
ConvertStars int64 // 转换可退回的 Stars
|
||||
Message string // 附言(可选)
|
||||
}
|
||||
|
||||
// SavedStarGiftRef 是 payments.getSavedStarGift/saveStarGift/convertStarGift 的协议中立引用。
|
||||
// 用户礼物使用 inputSavedStarGiftUser.msg_id;频道礼物使用 inputSavedStarGiftChat.peer + saved_id。
|
||||
type SavedStarGiftRef struct {
|
||||
Owner Peer
|
||||
MsgID int
|
||||
SavedID int64
|
||||
}
|
||||
|
||||
// Valid reports whether the reference has the identity required by its owner kind.
|
||||
func (r SavedStarGiftRef) Valid() bool {
|
||||
switch r.Owner.Type {
|
||||
case PeerTypeUser:
|
||||
return r.Owner.ID != 0 && r.MsgID > 0
|
||||
case PeerTypeChannel:
|
||||
return r.Owner.ID != 0 && r.SavedID > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SavedStarGiftPage 是一页已收到礼物 + keyset 分页游标。
|
||||
type SavedStarGiftPage struct {
|
||||
Gifts []SavedStarGift
|
||||
NextOffset string // 空 = 无更多页(末页必须省略,客户端据此停止翻页)
|
||||
Count int // 总数(未转换、按 excludeUnsaved 过滤后)
|
||||
}
|
||||
|
||||
// Star gift 边界常量。
|
||||
const (
|
||||
// MaxSavedStarGiftsLimit 是 getSavedStarGifts 单页上限。
|
||||
MaxSavedStarGiftsLimit = 100
|
||||
// MaxStarGiftMessageRunes 限制附言长度(对齐 stargifts_message_length_max 量级)。
|
||||
MaxStarGiftMessageRunes = 255
|
||||
// MaxStarGiftsOffsetBytes 是 keyset 游标字符串长度上限。
|
||||
MaxStarGiftsOffsetBytes = 64
|
||||
)
|
||||
|
||||
// Star gift 哨兵错误(rpc 层 errors.Is 映射为 tgerr)。
|
||||
var (
|
||||
// ErrStarGiftInvalid 表示礼物 id 不在目录里。
|
||||
ErrStarGiftInvalid = errors.New("stargift: invalid gift id")
|
||||
// ErrStarGiftNotFound 表示找不到该已收到礼物实例。
|
||||
ErrStarGiftNotFound = errors.New("stargift: saved gift not found")
|
||||
// ErrStarGiftAlreadyConverted 表示礼物已转换回 Stars(不可重复转换)。
|
||||
ErrStarGiftAlreadyConverted = errors.New("stargift: already converted")
|
||||
)
|
||||
|
||||
// StarGiftCatalogHash 由目录的 (gift_id, stars) 折叠出稳定 hash,供 getStarGifts NotModified。
|
||||
func StarGiftCatalogHash(catalog []StarGift) int {
|
||||
var h uint64
|
||||
for _, g := range catalog {
|
||||
h ^= uint64(g.ID)
|
||||
h = h*0x4f25 + uint64(g.ID)
|
||||
h = h*0x4f25 + uint64(g.Stars)
|
||||
}
|
||||
return int(h & 0x7fffffff)
|
||||
}
|
||||
|
||||
// EncodeStarGiftCursor / DecodeStarGiftCursor 是 saved gifts keyset 游标(最后一条实例 id)。
|
||||
func EncodeStarGiftCursor(id int64) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))
|
||||
}
|
||||
|
||||
// DecodeStarGiftCursor 反解游标;无法解析(含空串)返回 ok=false(调用方从首页开始)。
|
||||
func DecodeStarGiftCursor(s string) (int64, bool) {
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
id, err := strconv.ParseInt(string(raw), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
92
internal/domain/stars.go
Normal file
92
internal/domain/stars.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Stars 本地账本领域模型(无 TL 类型,镜像 boost.go 风格)。本实现是本地账本、
|
||||
// 非真实支付:余额为整数 Stars(线上 Nanos 恒 0),借记原子、永不为负。
|
||||
|
||||
// StarsBalance 是一个账号的当前可用 Stars 余额。
|
||||
type StarsBalance struct {
|
||||
UserID int64
|
||||
Balance int64 // 当前可花费 Stars,恒 >= 0
|
||||
Granted bool // 起始授予是否已应用(惰性首读授予的幂等守卫)
|
||||
}
|
||||
|
||||
// StarsTransactionReason 标记一条流水的语义(投影到 tg.StarsTransaction 的标志位/标题)。
|
||||
type StarsTransactionReason string
|
||||
|
||||
const (
|
||||
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
|
||||
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
|
||||
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
|
||||
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
|
||||
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
|
||||
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
|
||||
)
|
||||
|
||||
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0(含 refund/收取),借记 < 0。
|
||||
type StarsTransaction struct {
|
||||
ID int64 // 单调递增账本 id(keyset 游标)
|
||||
UserID int64 // 账本归属
|
||||
Peer Peer // 对手方(grant/topup 等无对手时为零 Peer)
|
||||
Amount int64 // 带符号金额
|
||||
Date int // Unix 秒
|
||||
Reason StarsTransactionReason
|
||||
Title string // 可选,投影到 tg.StarsTransaction.Title
|
||||
Description string // 可选,投影到 tg.StarsTransaction.Description
|
||||
}
|
||||
|
||||
// IsCredit 报告该流水是否为入账(贷记),投影到 tg.StarsTransaction.Refund。
|
||||
func (t StarsTransaction) IsCredit() bool { return t.Amount > 0 }
|
||||
|
||||
// StarsTransactionPage 是一页账本流水 + 当前余额 + 分页游标 + 对手方用户富化集合。
|
||||
type StarsTransactionPage struct {
|
||||
Balance int64
|
||||
Transactions []StarsTransaction
|
||||
NextOffset string // 空表示无更多页(DrKLO 据此停止翻页,勿在末页给非空值)
|
||||
Users []User // History 中提到的对手方用户,供 tg Users 富化
|
||||
}
|
||||
|
||||
// Stars 账本边界常量。
|
||||
const (
|
||||
// DefaultStarsStartingGrant 是惰性首读授予的起始 Stars 余额(本地测试用)。
|
||||
DefaultStarsStartingGrant = 1000
|
||||
// MaxStarsTransactionsLimit 是 getStarsTransactions 单页上限。
|
||||
MaxStarsTransactionsLimit = 100
|
||||
// MaxStarsTransactionsOffsetBytes 是 keyset 游标字符串长度上限。
|
||||
MaxStarsTransactionsOffsetBytes = 64
|
||||
)
|
||||
|
||||
// Stars 账本哨兵错误(rpc 层 errors.Is 匹配后映射为 tgerr,仿 ErrPremiumRequired)。
|
||||
var (
|
||||
// ErrStarsInsufficient 表示余额不足以完成借记(映射 BALANCE_TOO_LOW)。
|
||||
ErrStarsInsufficient = errors.New("stars: insufficient balance")
|
||||
// ErrStarsInvalidAmount 表示金额非法(<=0)。
|
||||
ErrStarsInvalidAmount = errors.New("stars: invalid amount")
|
||||
)
|
||||
|
||||
// EncodeStarsCursor 把 keyset 游标(最后一条流水 id)编码为客户端不透明字符串。
|
||||
func EncodeStarsCursor(id int64) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))
|
||||
}
|
||||
|
||||
// DecodeStarsCursor 反解 EncodeStarsCursor;无法解析(含空串)时返回 ok=false,
|
||||
// 调用方应据此从首页开始(客户端只会回传我们给过的游标,畸形仅作兜底)。
|
||||
func DecodeStarsCursor(s string) (int64, bool) {
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
id, err := strconv.ParseInt(string(raw), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
46
internal/domain/sticker_collection.go
Normal file
46
internal/domain/sticker_collection.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrStickerInvalid 表示输入文档不是合法的贴纸/GIF(faveSticker/saveGif 等)。
|
||||
var ErrStickerInvalid = errors.New("sticker document invalid")
|
||||
|
||||
// StickerCollectionKind 区分一个用户的几类个人贴纸集合。
|
||||
type StickerCollectionKind string
|
||||
|
||||
const (
|
||||
// StickerCollectionFaved 收藏的贴纸(messages.faveSticker / getFavedStickers)。
|
||||
StickerCollectionFaved StickerCollectionKind = "faved"
|
||||
// StickerCollectionRecent 最近使用的贴纸(saveRecentSticker / getRecentStickers)。
|
||||
StickerCollectionRecent StickerCollectionKind = "recent"
|
||||
// StickerCollectionRecentAttached attach 菜单里最近用于媒体的贴纸(attached=true)。
|
||||
StickerCollectionRecentAttached StickerCollectionKind = "recent_attached"
|
||||
// StickerCollectionGif 保存的 GIF(messages.saveGif / getSavedGifs)。
|
||||
StickerCollectionGif StickerCollectionKind = "gif"
|
||||
)
|
||||
|
||||
// 各集合容量上界(最新在前,超出截断最旧)。仅为限制存储增长,非严格 Telegram 配额;
|
||||
// faved 不做 premium 分档(用户决策范围外),统一一个上界。
|
||||
const (
|
||||
MaxFavedStickers = 100
|
||||
MaxRecentStickers = 30
|
||||
MaxSavedGifs = 200
|
||||
)
|
||||
|
||||
// MaxStickerCollectionItems 返回某类集合的容量上界。
|
||||
func MaxStickerCollectionItems(kind StickerCollectionKind) int {
|
||||
switch kind {
|
||||
case StickerCollectionFaved:
|
||||
return MaxFavedStickers
|
||||
case StickerCollectionGif:
|
||||
return MaxSavedGifs
|
||||
default:
|
||||
return MaxRecentStickers
|
||||
}
|
||||
}
|
||||
|
||||
// StickerCollectionItem 是集合内一项:文档 id + 入列时间(recent 的 used_at / faved 的收藏时刻)。
|
||||
type StickerCollectionItem struct {
|
||||
DocumentID int64
|
||||
Date int
|
||||
}
|
||||
557
internal/domain/story.go
Normal file
557
internal/domain/story.go
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxStoryID keeps story ids inside TL int / PostgreSQL int4 bounds.
|
||||
MaxStoryID = MaxMessageBoxID
|
||||
// MaxStoryIDs limits exact-id story RPCs and view increments.
|
||||
MaxStoryIDs = 200
|
||||
// MaxStoryListLimit bounds one active/archive/pinned story page.
|
||||
MaxStoryListLimit = 100
|
||||
// MaxStoryInteractionListLimit bounds one story viewer/reaction page.
|
||||
MaxStoryInteractionListLimit = 100
|
||||
// MaxStoryInteractionOffsetLength bounds server-generated owner interaction
|
||||
// cursors before they reach store-side parsing or query predicates.
|
||||
MaxStoryInteractionOffsetLength = 64
|
||||
// MaxStoryAlbumOffset bounds stories.getAlbumStories count-offset paging
|
||||
// before a future album store can turn malformed requests into deep OFFSET
|
||||
// work. DrKLO sends loadedObjects.size(), so normal UI paging stays small.
|
||||
MaxStoryAlbumOffset = 10000
|
||||
// MaxStoryPinnedToTop bounds stories.togglePinnedToTop. It matches the
|
||||
// client default stories_pinned_to_top_count_max used by TDesktop/DrKLO.
|
||||
MaxStoryPinnedToTop = 3
|
||||
// MaxStoryPrivacyFanoutTargets bounds one privacy-change fanout candidate set.
|
||||
MaxStoryPrivacyFanoutTargets = 5000
|
||||
// MaxStorySendAsChannels bounds stories.getChatsToSend channel candidates.
|
||||
MaxStorySendAsChannels = 200
|
||||
// MaxStoryMediaAreas bounds one story's overlay/click target vector.
|
||||
MaxStoryMediaAreas = 16
|
||||
// MaxStoryMediaAreaURLLength bounds mediaAreaUrl click targets stored in
|
||||
// one story snapshot.
|
||||
MaxStoryMediaAreaURLLength = 2048
|
||||
// MaxStoryStarGiftSlugLength bounds collectible gift slugs stored in one
|
||||
// story snapshot. TDesktop/DrKLO treat the slug as a deep-link path token.
|
||||
MaxStoryStarGiftSlugLength = 255
|
||||
// MaxStoryGeoAddressPartLength bounds optional mediaAreaGeoPoint address
|
||||
// labels stored in one story snapshot.
|
||||
MaxStoryGeoAddressPartLength = 256
|
||||
// MaxStoryWeatherEmojiLength bounds mediaAreaWeather emoji labels stored in
|
||||
// one story snapshot.
|
||||
MaxStoryWeatherEmojiLength = 32
|
||||
// DefaultStoryPeriod is the Layer 225 sendStory period when the optional
|
||||
// period flag is absent.
|
||||
DefaultStoryPeriod = 86400
|
||||
// MaxStoryViewQueryLength bounds q in stories.getStoryViewsList before it
|
||||
// reaches store-side LIKE/search predicates.
|
||||
MaxStoryViewQueryLength = 128
|
||||
// DefaultStoryCanSendRemaining is the development-stage story quota returned
|
||||
// by canSendStory until production limits are modeled.
|
||||
DefaultStoryCanSendRemaining = 100
|
||||
)
|
||||
|
||||
var (
|
||||
ErrStoryIDInvalid = errors.New("story id invalid")
|
||||
ErrStoryNotFound = errors.New("story not found")
|
||||
ErrStoryPeerInvalid = errors.New("story peer invalid")
|
||||
ErrStoryNotModified = errors.New("story not modified")
|
||||
ErrStoryOffsetInvalid = errors.New("story offset invalid")
|
||||
ErrStoryPeriodInvalid = errors.New("story period invalid")
|
||||
)
|
||||
|
||||
// ValidateStoryInteractionOffset validates stories.getStoryViewsList and
|
||||
// stories.getStoryReactionsList keyset cursors without depending on TL types.
|
||||
func ValidateStoryInteractionOffset(offset string, reactionsOnly bool) error {
|
||||
return validateStoryInteractionOffset(offset, reactionsOnly, false)
|
||||
}
|
||||
|
||||
// ValidateStoryReactionInteractionOffset validates stories.getStoryReactionsList
|
||||
// cursors. When forwardsFirst is set, reaction-list pages may legitimately use
|
||||
// group 1 for ordinary reaction rows after public repost rows.
|
||||
func ValidateStoryReactionInteractionOffset(offset string, forwardsFirst bool) error {
|
||||
return validateStoryInteractionOffset(offset, true, forwardsFirst)
|
||||
}
|
||||
|
||||
func validateStoryInteractionOffset(offset string, reactionsOnly, forwardsFirst bool) error {
|
||||
if offset == "" {
|
||||
return nil
|
||||
}
|
||||
if len(offset) > MaxStoryInteractionOffsetLength {
|
||||
return ErrStoryOffsetInvalid
|
||||
}
|
||||
parts := strings.Split(offset, ":")
|
||||
if len(parts) != 3 && len(parts) != 4 {
|
||||
return ErrStoryOffsetInvalid
|
||||
}
|
||||
group, err1 := strconv.Atoi(parts[0])
|
||||
date, err2 := strconv.Atoi(parts[1])
|
||||
viewerID, err3 := strconv.ParseInt(parts[2], 10, 64)
|
||||
var messageID int
|
||||
var err4 error
|
||||
if len(parts) == 4 {
|
||||
messageID, err4 = strconv.Atoi(parts[3])
|
||||
}
|
||||
reactionGroupInvalid := reactionsOnly && group != 0 && !(forwardsFirst && group == 1)
|
||||
if err1 != nil || err2 != nil || err3 != nil || err4 != nil ||
|
||||
group < 0 || group > 1 || reactionGroupInvalid ||
|
||||
date <= 0 || viewerID == 0 || messageID < 0 || messageID > MaxMessageBoxID {
|
||||
return ErrStoryOffsetInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Story is a protocol-neutral Telegram story snapshot owned by one peer.
|
||||
type Story struct {
|
||||
Owner Peer
|
||||
ID int
|
||||
RandomID int64
|
||||
Date int
|
||||
ExpireDate int
|
||||
Deleted bool
|
||||
Pinned bool
|
||||
PinnedToTopOrder int
|
||||
Public bool
|
||||
CloseFriends bool
|
||||
Contacts bool
|
||||
SelectedContacts bool
|
||||
NoForwards bool
|
||||
Edited bool
|
||||
Out bool
|
||||
PrivacyRules []PrivacyRule
|
||||
AllowUserIDs []int64
|
||||
DisallowUserIDs []int64
|
||||
Caption string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
MediaAreas []StoryMediaArea
|
||||
Forward *StoryForward
|
||||
Views StoryViews
|
||||
SentReaction *MessageReaction
|
||||
}
|
||||
|
||||
// Active reports whether the story belongs in the active strip at now.
|
||||
func (s Story) Active(now int) bool {
|
||||
return !s.Deleted && s.ExpireDate > now
|
||||
}
|
||||
|
||||
// Interactable reports whether a viewer may create new view/reaction state.
|
||||
// Expired profile-pinned stories remain visible from profiles, but ordinary
|
||||
// expired stories must not accept stale interaction writes.
|
||||
func (s Story) Interactable(now int) bool {
|
||||
return !s.Deleted && (s.ExpireDate > now || s.Pinned)
|
||||
}
|
||||
|
||||
// VisibleTo reports story visibility with no relationship facts.
|
||||
func (s Story) VisibleTo(viewerUserID int64) bool {
|
||||
return s.VisibleToWithFacts(viewerUserID, false, false)
|
||||
}
|
||||
|
||||
// VisibleToWithFacts reports story visibility using already-loaded viewer facts.
|
||||
func (s Story) VisibleToWithFacts(viewerUserID int64, viewerIsContact, viewerCloseFriend bool) bool {
|
||||
return s.VisibleToWithStoryFacts(viewerUserID, viewerIsContact, viewerCloseFriend, false)
|
||||
}
|
||||
|
||||
// VisibleToWithStoryFacts reports story visibility using already-loaded viewer
|
||||
// facts, including owner-side story blocklist membership.
|
||||
func (s Story) VisibleToWithStoryFacts(viewerUserID int64, viewerIsContact, viewerCloseFriend, viewerStoryBlocked bool) bool {
|
||||
if viewerUserID == 0 {
|
||||
return false
|
||||
}
|
||||
if s.Owner.Type == PeerTypeUser && s.Owner.ID == viewerUserID {
|
||||
return true
|
||||
}
|
||||
if viewerStoryBlocked {
|
||||
return false
|
||||
}
|
||||
if int64InSet(s.DisallowUserIDs, viewerUserID) {
|
||||
return false
|
||||
}
|
||||
if int64InSet(s.AllowUserIDs, viewerUserID) {
|
||||
return true
|
||||
}
|
||||
if s.Public {
|
||||
return true
|
||||
}
|
||||
if s.Contacts && viewerIsContact {
|
||||
return true
|
||||
}
|
||||
if s.CloseFriends && viewerCloseFriend {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func int64InSet(ids []int64, needle int64) bool {
|
||||
for _, id := range ids {
|
||||
if id == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StoryForward contains the protocol-neutral source of a reposted story.
|
||||
type StoryForward struct {
|
||||
// Source is the durable server-side source story owner used for counters
|
||||
// and owner interaction lists. From is the client-visible clickable peer
|
||||
// and may be empty when forwards privacy requires a from_name-only header.
|
||||
Source Peer
|
||||
From Peer
|
||||
FromName string
|
||||
StoryID int
|
||||
Modified bool
|
||||
}
|
||||
|
||||
// StoryViews is the owner-visible aggregate of story views and reactions.
|
||||
type StoryViews struct {
|
||||
ViewsCount int
|
||||
ForwardsCount int
|
||||
ReactionsCount int
|
||||
Reactions []ChannelMessageReactionCount
|
||||
RecentViewers []int64
|
||||
HasViewers bool
|
||||
}
|
||||
|
||||
// StoryView is one viewer's durable view/reaction row.
|
||||
type StoryView struct {
|
||||
Owner Peer
|
||||
StoryID int
|
||||
ViewerID int64
|
||||
Date int
|
||||
Reaction *MessageReaction
|
||||
// Repost is set for public repost interactions. It is protocol-neutral and
|
||||
// converted by rpc to storyViewPublicRepost.
|
||||
Repost *Story
|
||||
PublicForward *StoryPublicForward
|
||||
Blocked bool
|
||||
BlockedMyStoriesFrom bool
|
||||
}
|
||||
|
||||
// StoryPublicForward is a public channel/supergroup message that shared a story
|
||||
// via messageMediaStory. It is converted by rpc to storyViewPublicForward or
|
||||
// storyReactionPublicForward.
|
||||
type StoryPublicForward struct {
|
||||
Message ChannelMessage
|
||||
}
|
||||
|
||||
// StoryViewListRequest pages owner-visible story viewers.
|
||||
type StoryViewListRequest struct {
|
||||
ViewerUserID int64
|
||||
Owner Peer
|
||||
StoryID int
|
||||
Offset string
|
||||
Limit int
|
||||
Query string
|
||||
JustContacts bool
|
||||
ReactionsFirst bool
|
||||
ForwardsFirst bool
|
||||
}
|
||||
|
||||
// StoryViewList is a bounded page for stories.getStoryViewsList.
|
||||
type StoryViewList struct {
|
||||
Count int
|
||||
ViewsCount int
|
||||
ForwardsCount int
|
||||
ReactionsCount int
|
||||
Views []StoryView
|
||||
NextOffset string
|
||||
}
|
||||
|
||||
// StoryReactionListRequest pages peers that reacted to one story.
|
||||
type StoryReactionListRequest struct {
|
||||
ViewerUserID int64
|
||||
Owner Peer
|
||||
StoryID int
|
||||
Reaction *MessageReaction
|
||||
Offset string
|
||||
Limit int
|
||||
ForwardsFirst bool
|
||||
CanViewOwnerInteractions bool
|
||||
}
|
||||
|
||||
// StoryReactionList is a bounded page for stories.getStoryReactionsList.
|
||||
type StoryReactionList struct {
|
||||
Count int
|
||||
Reactions []StoryView
|
||||
NextOffset string
|
||||
}
|
||||
|
||||
// StoryMessageForwardListRequest pages public channel/supergroup messages that
|
||||
// shared one source story as messageMediaStory.
|
||||
type StoryMessageForwardListRequest struct {
|
||||
ViewerUserID int64
|
||||
Owner Peer
|
||||
StoryID int
|
||||
Offset string
|
||||
Limit int
|
||||
ReactionsFirst bool
|
||||
ForwardsFirst bool
|
||||
}
|
||||
|
||||
// StoryMessageForwardList is a bounded page of public message forwards.
|
||||
type StoryMessageForwardList struct {
|
||||
Count int
|
||||
Forwards []StoryView
|
||||
NextOffset string
|
||||
}
|
||||
|
||||
// StoryPublicForwardListRequest pages public repost stories for one source story.
|
||||
type StoryPublicForwardListRequest struct {
|
||||
ViewerUserID int64
|
||||
Owner Peer
|
||||
StoryID int
|
||||
Offset string
|
||||
Limit int
|
||||
}
|
||||
|
||||
// StoryPublicForwardList is a bounded page of public repost story forwards.
|
||||
type StoryPublicForwardList struct {
|
||||
Count int
|
||||
Forwards []StoryView
|
||||
NextOffset string
|
||||
}
|
||||
|
||||
// PeerStories is the story list for one owner peer from one viewer's perspective.
|
||||
type PeerStories struct {
|
||||
Peer Peer
|
||||
MaxReadID int
|
||||
Stories []Story
|
||||
Users []User
|
||||
Channels []Channel
|
||||
}
|
||||
|
||||
// StoryList is a bounded story page.
|
||||
type StoryList struct {
|
||||
Count int
|
||||
State string
|
||||
HasMore bool
|
||||
// Hidden reports that this list is the viewer's hidden story source list.
|
||||
Hidden bool
|
||||
Stories []Story
|
||||
// PinnedToTop contains the owner-visible top-pinned story IDs in display
|
||||
// order for stories.stories.pinned_to_top.
|
||||
PinnedToTop []int
|
||||
Peers []PeerStories
|
||||
Users []User
|
||||
Channels []Channel
|
||||
}
|
||||
|
||||
// StoryListCursor is the opaque getAllStories seek position after one peer.
|
||||
type StoryListCursor struct {
|
||||
Set bool
|
||||
Date int
|
||||
Peer Peer
|
||||
}
|
||||
|
||||
// StoryListDigest summarizes a complete viewer-scoped active story source list.
|
||||
type StoryListDigest struct {
|
||||
Count int
|
||||
Hash uint64
|
||||
}
|
||||
|
||||
// DigestStoryPeerList returns a deterministic digest for an already ordered
|
||||
// complete active/hidden story peer list.
|
||||
func DigestStoryPeerList(peers []PeerStories) StoryListDigest {
|
||||
h := fnv.New64a()
|
||||
fmt.Fprintf(h, "story-list|count=%d|", len(peers))
|
||||
for _, peer := range peers {
|
||||
fmt.Fprintf(h, "p=%s:%d:%d:%d|", peer.Peer.Type, peer.Peer.ID, peer.MaxReadID, len(peer.Stories))
|
||||
for _, story := range peer.Stories {
|
||||
fmt.Fprintf(
|
||||
h,
|
||||
"s=%d:%d:%d:%t:%t:%d:%t:%t:%t:%t:%t:%t:%d:%d:%#v:%#v:%#v:%#v:%#v:%#v:%#v:%#v|",
|
||||
story.ID,
|
||||
story.Date,
|
||||
story.ExpireDate,
|
||||
story.Deleted,
|
||||
story.Pinned,
|
||||
story.PinnedToTopOrder,
|
||||
story.Public,
|
||||
story.CloseFriends,
|
||||
story.Contacts,
|
||||
story.SelectedContacts,
|
||||
story.Edited,
|
||||
story.Out,
|
||||
story.Views.ViewsCount,
|
||||
story.Views.ReactionsCount,
|
||||
story.SentReaction,
|
||||
story.Caption,
|
||||
story.Entities,
|
||||
story.Media,
|
||||
story.MediaAreas,
|
||||
story.Forward,
|
||||
story.PrivacyRules,
|
||||
story.AllowUserIDs,
|
||||
)
|
||||
fmt.Fprintf(h, "disallow=%#v|noforwards=%t|", story.DisallowUserIDs, story.NoForwards)
|
||||
}
|
||||
}
|
||||
return StoryListDigest{Count: len(peers), Hash: h.Sum64()}
|
||||
}
|
||||
|
||||
// StoryReadState is the per viewer+owner read boundary.
|
||||
type StoryReadState struct {
|
||||
ViewerID int64
|
||||
Peer Peer
|
||||
MaxReadID int
|
||||
Date int
|
||||
}
|
||||
|
||||
// RecentStory is the domain counterpart of TL recentStory.
|
||||
type RecentStory struct {
|
||||
Peer Peer
|
||||
MaxID int
|
||||
Live bool
|
||||
}
|
||||
|
||||
// PeerStoryProjection is the per-viewer story summary embedded into peer objects.
|
||||
type PeerStoryProjection struct {
|
||||
Peer Peer
|
||||
Recent RecentStory
|
||||
Hidden bool
|
||||
}
|
||||
|
||||
// StoryReadResult describes a readStories mutation.
|
||||
type StoryReadResult struct {
|
||||
ViewerID int64
|
||||
Peer Peer
|
||||
MaxReadID int
|
||||
Advanced bool
|
||||
Date int
|
||||
}
|
||||
|
||||
// StoryReactionResult describes one story reaction mutation.
|
||||
type StoryReactionResult struct {
|
||||
ViewerID int64
|
||||
Peer Peer
|
||||
StoryID int
|
||||
Reaction *MessageReaction
|
||||
Story Story
|
||||
Changed bool
|
||||
Date int
|
||||
}
|
||||
|
||||
// StoryMediaAreaKind identifies a protocol-neutral story overlay area.
|
||||
type StoryMediaAreaKind string
|
||||
|
||||
const (
|
||||
StoryMediaAreaSuggestedReaction StoryMediaAreaKind = "suggested_reaction"
|
||||
StoryMediaAreaURL StoryMediaAreaKind = "url"
|
||||
StoryMediaAreaGeoPoint StoryMediaAreaKind = "geo_point"
|
||||
StoryMediaAreaVenue StoryMediaAreaKind = "venue"
|
||||
StoryMediaAreaWeather StoryMediaAreaKind = "weather"
|
||||
StoryMediaAreaChannelPost StoryMediaAreaKind = "channel_post"
|
||||
StoryMediaAreaStarGift StoryMediaAreaKind = "star_gift"
|
||||
)
|
||||
|
||||
// StoryMediaAreaCoordinates is a percentage-based story media overlay box.
|
||||
type StoryMediaAreaCoordinates struct {
|
||||
X float64
|
||||
Y float64
|
||||
W float64
|
||||
H float64
|
||||
Rotation float64
|
||||
Radius float64
|
||||
HasRadius bool
|
||||
}
|
||||
|
||||
// StoryGeoPointAddress is the protocol-neutral counterpart of geoPointAddress.
|
||||
type StoryGeoPointAddress struct {
|
||||
CountryISO2 string
|
||||
State string
|
||||
City string
|
||||
Street string
|
||||
}
|
||||
|
||||
// StoryMediaArea is a protocol-neutral media area stored with a story snapshot.
|
||||
type StoryMediaArea struct {
|
||||
Kind StoryMediaAreaKind
|
||||
Coordinates StoryMediaAreaCoordinates
|
||||
Dark bool
|
||||
Flipped bool
|
||||
Reaction *MessageReaction
|
||||
URL string
|
||||
Geo *MessageGeoPoint
|
||||
GeoAddress *StoryGeoPointAddress
|
||||
Venue *MessageVenue
|
||||
WeatherEmoji string
|
||||
TemperatureC float64
|
||||
Color int
|
||||
ChannelID int64
|
||||
MsgID int
|
||||
StarGiftSlug string
|
||||
}
|
||||
|
||||
// StoryCreateRequest creates one owner story with a store-assigned monotonic ID.
|
||||
type StoryCreateRequest struct {
|
||||
Owner Peer
|
||||
RandomID int64
|
||||
Date int
|
||||
Period int
|
||||
Pinned bool
|
||||
Public bool
|
||||
CloseFriends bool
|
||||
Contacts bool
|
||||
SelectedContacts bool
|
||||
NoForwards bool
|
||||
PrivacyRules []PrivacyRule
|
||||
AllowUserIDs []int64
|
||||
DisallowUserIDs []int64
|
||||
Caption string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
MediaAreas []StoryMediaArea
|
||||
Forward *StoryForward
|
||||
}
|
||||
|
||||
// StoryCreateResult describes a sendStory mutation.
|
||||
type StoryCreateResult struct {
|
||||
Story Story
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// StoryEditRequest applies a partial story edit.
|
||||
type StoryEditRequest struct {
|
||||
Owner Peer
|
||||
ID int
|
||||
Media *MessageMedia
|
||||
UpdateMedia bool
|
||||
Caption string
|
||||
Entities []MessageEntity
|
||||
UpdateCaption bool
|
||||
Public bool
|
||||
CloseFriends bool
|
||||
Contacts bool
|
||||
SelectedContacts bool
|
||||
PrivacyRules []PrivacyRule
|
||||
AllowUserIDs []int64
|
||||
DisallowUserIDs []int64
|
||||
UpdatePrivacy bool
|
||||
MediaAreas []StoryMediaArea
|
||||
UpdateMediaAreas bool
|
||||
}
|
||||
|
||||
// StoryEditResult describes a story edit and its previous visible facts.
|
||||
type StoryEditResult struct {
|
||||
Story Story
|
||||
Previous Story
|
||||
}
|
||||
|
||||
// StoryMutationResult describes story mutations that may emit updateStory.
|
||||
type StoryMutationResult struct {
|
||||
Peer Peer
|
||||
IDs []int
|
||||
Stories []Story
|
||||
Previous []Story
|
||||
}
|
||||
|
||||
// UpsertStoryRequest inserts or replaces a story snapshot.
|
||||
type UpsertStoryRequest struct {
|
||||
Story Story
|
||||
}
|
||||
|
|
@ -3,6 +3,11 @@ package domain
|
|||
const (
|
||||
// OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。
|
||||
OfficialSystemUserID int64 = 777000
|
||||
|
||||
// BotFatherUserID 是内置 BotFather 账号,与官方 @BotFather 同 ID。
|
||||
BotFatherUserID int64 = 93372553
|
||||
// BotFatherAccessHash 固定不变;与迁移 0090 的种子行双写,必须保持一致。
|
||||
BotFatherAccessHash int64 = 7421896403922962293
|
||||
)
|
||||
|
||||
// OfficialSystemUser 返回第一阶段内置的官方系统账号。
|
||||
|
|
@ -17,3 +22,47 @@ func OfficialSystemUser() User {
|
|||
Support: true,
|
||||
}
|
||||
}
|
||||
|
||||
// BotFatherUser 返回内置 BotFather 账号。username 不以 bot 结尾属种子例外(与官方一致)。
|
||||
func BotFatherUser() User {
|
||||
return User{
|
||||
ID: BotFatherUserID,
|
||||
AccessHash: BotFatherAccessHash,
|
||||
FirstName: "BotFather",
|
||||
Username: "BotFather",
|
||||
Verified: true,
|
||||
Bot: true,
|
||||
BotInfoVersion: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// SystemUserByID 返回内置系统账号;非系统账号返回 ok=false。
|
||||
// 所有对 777000 的硬编码注入点统一经此函数,新增内置账号只改这里。
|
||||
func SystemUserByID(id int64) (User, bool) {
|
||||
switch id {
|
||||
case OfficialSystemUserID:
|
||||
return OfficialSystemUser(), true
|
||||
case BotFatherUserID:
|
||||
return BotFatherUser(), true
|
||||
}
|
||||
return User{}, false
|
||||
}
|
||||
|
||||
func IsSystemUserID(id int64) bool {
|
||||
_, ok := SystemUserByID(id)
|
||||
return ok
|
||||
}
|
||||
|
||||
func SystemUserByPhone(phone string) (User, bool) {
|
||||
phone = NormalizePhone(phone)
|
||||
for _, id := range []int64{OfficialSystemUserID, BotFatherUserID} {
|
||||
u, ok := SystemUserByID(id)
|
||||
if !ok || u.Phone == "" {
|
||||
continue
|
||||
}
|
||||
if NormalizePhone(u.Phone) == phone {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
return User{}, false
|
||||
}
|
||||
|
|
|
|||
185
internal/domain/themes.go
Normal file
185
internal/domain/themes.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// 自定义云主题(account.createTheme 等)相关错误。
|
||||
var (
|
||||
ErrThemeNotFound = errors.New("theme not found")
|
||||
ErrThemeInvalid = errors.New("theme invalid")
|
||||
ErrThemeSlugTaken = errors.New("theme slug taken")
|
||||
ErrThemeFormatInvalid = errors.New("theme format invalid")
|
||||
)
|
||||
|
||||
// ThemeBaseKind 对应 TL baseTheme 的五个变体(classic/day/night/tinted/arctic)。
|
||||
// 客户端按当前 base theme 选取匹配的 ThemeSettings。
|
||||
type ThemeBaseKind string
|
||||
|
||||
const (
|
||||
ThemeBaseClassic ThemeBaseKind = "classic"
|
||||
ThemeBaseDay ThemeBaseKind = "day"
|
||||
ThemeBaseNight ThemeBaseKind = "night"
|
||||
ThemeBaseTinted ThemeBaseKind = "tinted"
|
||||
ThemeBaseArctic ThemeBaseKind = "arctic"
|
||||
)
|
||||
|
||||
// ThemeWallpaperSpec 是一份主题设置里的纯渐变墙纸(对应 wallPaperNoFile + wallPaperSettings)。
|
||||
// 自定义云主题只用纯色渐变(无下载),与静态目录一致。
|
||||
type ThemeWallpaperSpec struct {
|
||||
BackgroundColors []int `json:"background_colors,omitempty"` // 最多 4 个
|
||||
Intensity int `json:"intensity,omitempty"`
|
||||
Rotation int `json:"rotation,omitempty"`
|
||||
Blur bool `json:"blur,omitempty"`
|
||||
Motion bool `json:"motion,omitempty"`
|
||||
Emoticon string `json:"emoticon,omitempty"`
|
||||
Dark bool `json:"dark,omitempty"`
|
||||
}
|
||||
|
||||
// WallpaperSettings 是频道/私聊 wallpaper 的 domain-only 设置。Has* 字段保留 TL
|
||||
// conditional flag 语义,避免黑色(0)这类合法取值在持久化时丢失。
|
||||
type WallpaperSettings struct {
|
||||
Blur bool `json:"blur,omitempty"`
|
||||
Motion bool `json:"motion,omitempty"`
|
||||
HasBackgroundColor bool `json:"has_background_color,omitempty"`
|
||||
BackgroundColor int `json:"background_color,omitempty"`
|
||||
HasSecondBackgroundColor bool `json:"has_second_background_color,omitempty"`
|
||||
SecondBackgroundColor int `json:"second_background_color,omitempty"`
|
||||
HasThirdBackgroundColor bool `json:"has_third_background_color,omitempty"`
|
||||
ThirdBackgroundColor int `json:"third_background_color,omitempty"`
|
||||
HasFourthBackgroundColor bool `json:"has_fourth_background_color,omitempty"`
|
||||
FourthBackgroundColor int `json:"fourth_background_color,omitempty"`
|
||||
HasIntensity bool `json:"has_intensity,omitempty"`
|
||||
Intensity int `json:"intensity,omitempty"`
|
||||
HasRotation bool `json:"has_rotation,omitempty"`
|
||||
Rotation int `json:"rotation,omitempty"`
|
||||
HasEmoticon bool `json:"has_emoticon,omitempty"`
|
||||
Emoticon string `json:"emoticon,omitempty"`
|
||||
}
|
||||
|
||||
// Empty reports whether no explicit wallpaper rendering settings are present.
|
||||
func (s WallpaperSettings) Empty() bool {
|
||||
return !s.Blur &&
|
||||
!s.Motion &&
|
||||
!s.HasBackgroundColor &&
|
||||
!s.HasSecondBackgroundColor &&
|
||||
!s.HasThirdBackgroundColor &&
|
||||
!s.HasFourthBackgroundColor &&
|
||||
!s.HasIntensity &&
|
||||
!s.HasRotation &&
|
||||
!s.HasEmoticon
|
||||
}
|
||||
|
||||
// Wallpaper 是频道/私聊当前 wallpaper 的 domain-only 描述。NoFile=true 对应
|
||||
// wallPaperNoFile;否则 ID+AccessHash/Slug 引用 catalog/document wallpaper。
|
||||
type Wallpaper struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
AccessHash int64 `json:"access_hash,omitempty"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
NoFile bool `json:"no_file,omitempty"`
|
||||
Default bool `json:"default,omitempty"`
|
||||
Pattern bool `json:"pattern,omitempty"`
|
||||
Dark bool `json:"dark,omitempty"`
|
||||
Settings WallpaperSettings `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// CloneWallpaperPtr returns a detached wallpaper pointer.
|
||||
func CloneWallpaperPtr(in *Wallpaper) *Wallpaper {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
return &out
|
||||
}
|
||||
|
||||
// WallpaperEqual compares optional wallpapers by value.
|
||||
func WallpaperEqual(a, b *Wallpaper) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// ThemeSettingsSpec 是一个 base theme 下的配色(对应 themeSettings/inputThemeSettings)。
|
||||
// BaseTheme 与 AccentColor 必填(TL 编码器要求 base_theme 非空)。
|
||||
type ThemeSettingsSpec struct {
|
||||
BaseTheme ThemeBaseKind `json:"base_theme"`
|
||||
AccentColor int `json:"accent_color"`
|
||||
OutboxAccentColor int `json:"outbox_accent_color,omitempty"`
|
||||
HasOutboxAccent bool `json:"has_outbox_accent,omitempty"`
|
||||
MessageColors []int `json:"message_colors,omitempty"`
|
||||
MessageColorsAnimated bool `json:"message_colors_animated,omitempty"`
|
||||
Wallpaper *ThemeWallpaperSpec `json:"wallpaper,omitempty"`
|
||||
}
|
||||
|
||||
// Theme 是一份持久化的自定义云主题。document_id 软引用 documents(无硬外键),
|
||||
// settings 仅 accent 主题非空;完整 .attheme 主题靠 DocumentID 下载。
|
||||
type Theme struct {
|
||||
ID int64
|
||||
AccessHash int64
|
||||
CreatorUserID int64
|
||||
Slug string
|
||||
Title string
|
||||
Emoticon string
|
||||
ForChat bool
|
||||
DocumentID int64
|
||||
Settings []ThemeSettingsSpec
|
||||
InstallsCount int
|
||||
CreatedAt int64 // unix 秒
|
||||
}
|
||||
|
||||
// Clone 深拷贝(切片字段),避免内存 store 暴露内部引用。
|
||||
func (t Theme) Clone() Theme {
|
||||
out := t
|
||||
out.Settings = cloneThemeSettings(t.Settings)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneThemeSettings(in []ThemeSettingsSpec) []ThemeSettingsSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]ThemeSettingsSpec, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = s
|
||||
out[i].MessageColors = append([]int(nil), s.MessageColors...)
|
||||
if s.Wallpaper != nil {
|
||||
wp := *s.Wallpaper
|
||||
wp.BackgroundColors = append([]int(nil), s.Wallpaper.BackgroundColors...)
|
||||
out[i].Wallpaper = &wp
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsCreator 报告 userID 是否为该主题的创建者(决定客户端再次上传走 create 还是 update)。
|
||||
func (t Theme) IsCreator(userID int64) bool {
|
||||
return t.CreatorUserID != 0 && t.CreatorUserID == userID
|
||||
}
|
||||
|
||||
// ThemeRef 引用一份主题:按 id+access_hash(inputTheme)或 slug(inputThemeSlug,深链)。
|
||||
type ThemeRef struct {
|
||||
ID int64
|
||||
AccessHash int64
|
||||
Slug string
|
||||
}
|
||||
|
||||
// IsZero 报告该引用是否为空(既无 id 也无 slug)。
|
||||
func (r ThemeRef) IsZero() bool { return r.ID == 0 && r.Slug == "" }
|
||||
|
||||
// ThemeSpec 是 createTheme 的输入。Slug 为空表示由服务端自动分配。
|
||||
type ThemeSpec struct {
|
||||
CreatorUserID int64
|
||||
Slug string
|
||||
Title string
|
||||
Emoticon string
|
||||
ForChat bool
|
||||
DocumentID int64
|
||||
Settings []ThemeSettingsSpec
|
||||
}
|
||||
|
||||
// ThemeUpdate 是 updateTheme 的部分更新;nil 字段表示不改。
|
||||
type ThemeUpdate struct {
|
||||
Slug *string
|
||||
Title *string
|
||||
DocumentID *int64
|
||||
Settings *[]ThemeSettingsSpec
|
||||
}
|
||||
|
|
@ -4,25 +4,65 @@ package domain
|
|||
type UpdateEventType string
|
||||
|
||||
const (
|
||||
UpdateEventNewMessage UpdateEventType = "new_message"
|
||||
UpdateEventReadHistoryInbox UpdateEventType = "read_history_inbox"
|
||||
UpdateEventReadHistoryOutbox UpdateEventType = "read_history_outbox"
|
||||
UpdateEventReadMessageContents UpdateEventType = "read_message_contents"
|
||||
UpdateEventEditMessage UpdateEventType = "edit_message"
|
||||
UpdateEventMessageReactions UpdateEventType = "message_reactions"
|
||||
UpdateEventContactsReset UpdateEventType = "contacts_reset"
|
||||
UpdateEventDialogPinned UpdateEventType = "dialog_pinned"
|
||||
UpdateEventPinnedDialogs UpdateEventType = "pinned_dialogs"
|
||||
UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark"
|
||||
UpdateEventPeerSettings UpdateEventType = "peer_settings"
|
||||
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
|
||||
UpdateEventDialogFilter UpdateEventType = "dialog_filter"
|
||||
UpdateEventDialogFilterOrder UpdateEventType = "dialog_filter_order"
|
||||
UpdateEventDialogFilters UpdateEventType = "dialog_filters"
|
||||
UpdateEventFolderPeers UpdateEventType = "folder_peers"
|
||||
UpdateEventChannelAvailable UpdateEventType = "channel_available_messages"
|
||||
UpdateEventChannelViewForum UpdateEventType = "channel_view_forum_as_messages"
|
||||
UpdateEventNoop UpdateEventType = "noop"
|
||||
UpdateEventNewMessage UpdateEventType = "new_message"
|
||||
UpdateEventReadHistoryInbox UpdateEventType = "read_history_inbox"
|
||||
UpdateEventReadHistoryOutbox UpdateEventType = "read_history_outbox"
|
||||
// forum 话题级已读:messages.readDiscussion 推进 per-topic 水位后下发。
|
||||
UpdateEventReadChannelDiscussionInbox UpdateEventType = "read_channel_discussion_inbox"
|
||||
UpdateEventReadChannelDiscussionOutbox UpdateEventType = "read_channel_discussion_outbox"
|
||||
UpdateEventReadMessageContents UpdateEventType = "read_message_contents"
|
||||
UpdateEventEditMessage UpdateEventType = "edit_message"
|
||||
// UpdateEventWebPage 映射 updateWebPage:异步解析完成后把消息里的 pending 链接预览
|
||||
// 占位就地替换为已解析卡片。携带账号 pts(非 LacksWirePts),消息快照经 box JOIN 重建,
|
||||
// 故 difference/dispatch 与 edit_message 同走通用消息事件路径,仅 tg 投影构造器不同。
|
||||
UpdateEventWebPage UpdateEventType = "web_page"
|
||||
UpdateEventMessageReactions UpdateEventType = "message_reactions"
|
||||
// UpdateEventMessagePoll 映射 updateMessagePoll(投票/关闭后 poll 状态变化;
|
||||
// Message 为该 owner 视角消息,media 在 difference 重放时按 viewer 重新 enrich)。
|
||||
// 与 reaction 同款:占账号 pts 但 TL 构造器无 pts,见 LacksWirePts。
|
||||
UpdateEventMessagePoll UpdateEventType = "message_poll"
|
||||
UpdateEventContactsReset UpdateEventType = "contacts_reset"
|
||||
UpdateEventDialogPinned UpdateEventType = "dialog_pinned"
|
||||
UpdateEventPinnedDialogs UpdateEventType = "pinned_dialogs"
|
||||
UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark"
|
||||
UpdateEventPeerSettings UpdateEventType = "peer_settings"
|
||||
UpdateEventPeerStoryBlocked UpdateEventType = "peer_story_blocked"
|
||||
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
|
||||
// UpdateEventPinnedMessages 映射 updatePinnedMessages(私聊置顶/取消
|
||||
// 置顶;MessageIDs 是该 owner 自己视角的 box id,Bool 为 pinned)。
|
||||
// TL 构造器自带账号 pts/pts_count,不属于 LacksWirePts。
|
||||
UpdateEventPinnedMessages UpdateEventType = "pinned_messages"
|
||||
UpdateEventDialogFilter UpdateEventType = "dialog_filter"
|
||||
UpdateEventDialogFilterOrder UpdateEventType = "dialog_filter_order"
|
||||
UpdateEventDialogFilters UpdateEventType = "dialog_filters"
|
||||
UpdateEventFolderPeers UpdateEventType = "folder_peers"
|
||||
UpdateEventChannelAvailable UpdateEventType = "channel_available_messages"
|
||||
UpdateEventChannelViewForum UpdateEventType = "channel_view_forum_as_messages"
|
||||
UpdateEventStory UpdateEventType = "story"
|
||||
UpdateEventReadStories UpdateEventType = "read_stories"
|
||||
UpdateEventSentStoryReaction UpdateEventType = "sent_story_reaction"
|
||||
UpdateEventNewStoryReaction UpdateEventType = "new_story_reaction"
|
||||
UpdateEventQuickReplies UpdateEventType = "quick_replies"
|
||||
UpdateEventNewQuickReply UpdateEventType = "new_quick_reply"
|
||||
UpdateEventDeleteQuickReply UpdateEventType = "delete_quick_reply"
|
||||
UpdateEventQuickReplyMessage UpdateEventType = "quick_reply_message"
|
||||
UpdateEventDeleteQuickReplyMessages UpdateEventType = "delete_quick_reply_messages"
|
||||
// UpdateEventChannelState 表示当前账号与某频道的成员关系发生变化
|
||||
// (leave/kick),离线设备经 difference 收到 updateChannel 后重新拉取
|
||||
// channel 状态并移除会话。
|
||||
UpdateEventChannelState UpdateEventType = "channel_state"
|
||||
// UpdateEventDraftMessage 映射 updateDraftMessage(云草稿变更;Peer 为会话,
|
||||
// MaxID 复用为 forum top_msg_id)。事件只是"该会话草稿变过"的标记——草稿是
|
||||
// 绝对状态而非增量,difference/outbox 重放时按 Peer 重载当前草稿填充 Draft
|
||||
// 字段(已删则下发 draftMessageEmpty),不在事件行里固化内容快照。
|
||||
UpdateEventDraftMessage UpdateEventType = "draft_message"
|
||||
// UpdateEventSavedDialogPinned 映射 updateSavedDialogPinned(收藏夹
|
||||
// 子会话置顶翻转;Peer 为子会话分组键,Bool 为 pinned)。
|
||||
UpdateEventSavedDialogPinned UpdateEventType = "saved_dialog_pinned"
|
||||
// UpdateEventPinnedSavedDialogs 映射 updatePinnedSavedDialogs
|
||||
// (收藏夹置顶顺序整表,Peers 为新顺序)。
|
||||
UpdateEventPinnedSavedDialogs UpdateEventType = "pinned_saved_dialogs"
|
||||
UpdateEventNoop UpdateEventType = "noop"
|
||||
)
|
||||
|
||||
// UpdateEvent 是账号视角的增量事件,按 user_id + pts 顺序持久化。
|
||||
|
|
@ -33,6 +73,7 @@ type UpdateEvent struct {
|
|||
PtsCount int
|
||||
Date int
|
||||
Message Message
|
||||
Story Story
|
||||
Peer Peer
|
||||
Peers []Peer
|
||||
Bool bool
|
||||
|
|
@ -40,13 +81,73 @@ type UpdateEvent struct {
|
|||
MessageIDs []int
|
||||
MaxID int
|
||||
StillUnreadCount int
|
||||
Users []User
|
||||
Channels []Channel
|
||||
FilterID int
|
||||
DialogFilter *DialogFolder
|
||||
FilterOrder []int
|
||||
FolderPeers []FolderPeerUpdate
|
||||
TagsEnabled bool
|
||||
ChannelPts int
|
||||
// TopMsgID 仅 forum per-topic 已读事件(read_channel_discussion_*)使用:承载话题 id
|
||||
// (General=1),与 MaxID(=read_max_id) 一起映射 updateReadChannelDiscussionInbox/Outbox。
|
||||
TopMsgID int
|
||||
Users []User
|
||||
Channels []Channel
|
||||
FilterID int
|
||||
// FolderID 是 read 类事件发生时该会话所在的物理 folder(0 主列表/1 归档),
|
||||
// 填入 updateReadChannelInbox.folder_id。
|
||||
FolderID int
|
||||
DialogFilter *DialogFolder
|
||||
// Draft 仅 draft_message 事件使用:不持久化,difference/outbox 重放时按
|
||||
// Peer(+MaxID=top_msg_id) 重载当前草稿填充;nil 表示草稿已删(下发 empty)。
|
||||
Draft *DialogDraft
|
||||
FilterOrder []int
|
||||
FolderPeers []FolderPeerUpdate
|
||||
TagsEnabled bool
|
||||
Reaction *MessageReaction
|
||||
QuickReplies []QuickReply
|
||||
QuickReply QuickReply
|
||||
QuickReplyMessage QuickReplyMessage
|
||||
}
|
||||
|
||||
// LacksWirePts 表示该事件占用了账号 pts,但它对应的 TL update 构造器没有
|
||||
// 账号 pts 字段(reaction、channel 已读、dialog/folder/settings 状态类)。
|
||||
// 在线投递这类事件必须附带显式 pts 簿记,否则客户端水位与服务端错位,
|
||||
// 下一条真正带 pts 的更新会被判为空洞。
|
||||
func (e UpdateEvent) LacksWirePts() bool {
|
||||
switch e.Type {
|
||||
case UpdateEventMessageReactions,
|
||||
UpdateEventMessagePoll,
|
||||
UpdateEventDraftMessage,
|
||||
UpdateEventChannelState,
|
||||
UpdateEventContactsReset,
|
||||
UpdateEventDialogPinned,
|
||||
UpdateEventPinnedDialogs,
|
||||
UpdateEventSavedDialogPinned,
|
||||
UpdateEventPinnedSavedDialogs,
|
||||
UpdateEventDialogUnreadMark,
|
||||
UpdateEventPeerSettings,
|
||||
UpdateEventPeerStoryBlocked,
|
||||
UpdateEventDialogFilter,
|
||||
UpdateEventDialogFilterOrder,
|
||||
UpdateEventDialogFilters,
|
||||
UpdateEventChannelAvailable,
|
||||
UpdateEventChannelViewForum,
|
||||
UpdateEventStory,
|
||||
UpdateEventReadStories,
|
||||
UpdateEventSentStoryReaction,
|
||||
UpdateEventNewStoryReaction,
|
||||
UpdateEventQuickReplies,
|
||||
UpdateEventNewQuickReply,
|
||||
UpdateEventDeleteQuickReply,
|
||||
UpdateEventQuickReplyMessage,
|
||||
UpdateEventDeleteQuickReplyMessages:
|
||||
// updateFolderPeers 自带 pts/pts_count,不在此列。
|
||||
return true
|
||||
case UpdateEventReadChannelDiscussionInbox, UpdateEventReadChannelDiscussionOutbox:
|
||||
// forum 话题已读映射 updateReadChannelDiscussionInbox/Outbox,无账号 pts 字段。
|
||||
return true
|
||||
case UpdateEventReadHistoryInbox, UpdateEventReadHistoryOutbox:
|
||||
// channel peer 映射 updateReadChannelInbox/Outbox,无账号 pts 字段;
|
||||
// 私聊形态自带 pts/pts_count。
|
||||
return e.Peer.Type == PeerTypeChannel
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateDifference 是 updates.getDifference 的业务层结果。
|
||||
|
|
@ -63,4 +164,5 @@ type UpdateDifference struct {
|
|||
type ChannelDifferenceNudge struct {
|
||||
ChannelID int64
|
||||
Pts int
|
||||
Channel *ChannelView
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,19 @@ package domain
|
|||
// 777000 等兼容系统账号低于该区间,业务注册用户从这里开始递增。
|
||||
const UserIDSequenceBase int64 = 1780243200
|
||||
|
||||
// PeerColor is a domain-only representation of Telegram peerColor.
|
||||
// HasColor preserves explicit color=0, which is distinct from "color unset".
|
||||
type PeerColor struct {
|
||||
HasColor bool
|
||||
Color int
|
||||
BackgroundEmojiID int64
|
||||
}
|
||||
|
||||
// Empty reports whether no explicit color/profile color state is set.
|
||||
func (c PeerColor) Empty() bool {
|
||||
return !c.HasColor && c.BackgroundEmojiID == 0
|
||||
}
|
||||
|
||||
// User 是一个账号。第一阶段仅保留登录链路必须字段;
|
||||
// access_hash 为任何 InputUser 校验所必须,不可省。
|
||||
type User struct {
|
||||
|
|
@ -21,15 +34,53 @@ type User struct {
|
|||
Support bool
|
||||
Contact bool
|
||||
Mutual bool
|
||||
CloseFriend bool
|
||||
// Bot 标识 bot 账号;置位时 BotInfoVersion 必须 ≥1(TDesktop 只认
|
||||
// user TL 是否携带 bot_info_version 字段,且与 bot flag 共用 bit14)。
|
||||
Bot bool
|
||||
BotInfoVersion int
|
||||
// PremiumUntil 是会员到期 Unix 秒;0 表示非会员。premium 状态的唯一权威
|
||||
// 来源是该字段,由读取路径经 PremiumActiveAt 即时派生——到期后无需等
|
||||
// 后台 sweeper 翻转即停止下发 premium(sweeper 只负责清理与通知推送)。
|
||||
PremiumUntil int
|
||||
// EmojiStatusDocumentID / EmojiStatusUntil 是用户自定义 emoji status
|
||||
//(premium 专属,account.updateEmojiStatus)。DocumentID==0 表示未设置;
|
||||
// Until==0 表示永久。
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int
|
||||
// Birthday 是用户公开生日(account.updateBirthday)。零值表示未设置。
|
||||
Birthday Birthday
|
||||
// PersonalChannelID 是资料页展示的「个人频道」(account.updatePersonalChannel);
|
||||
// 0 表示未设置。资料投影时按它取频道对象与最新一帖。
|
||||
PersonalChannelID int64
|
||||
Color PeerColor
|
||||
ProfileColor PeerColor
|
||||
// Profile photo fields are filled by app-layer user projection. PhotoID==0 表示无头像。
|
||||
PhotoID int64
|
||||
PhotoDCID int
|
||||
PhotoStripped []byte
|
||||
PhotoPersonal bool
|
||||
PhotoHasVideo bool
|
||||
LastSeenAt int
|
||||
Status UserStatus
|
||||
}
|
||||
|
||||
// PremiumActiveAt 报告用户在 now(Unix 秒)时刻是否为有效会员。
|
||||
// bot 永不为会员(官方语义;授予路径同样排除 bot,这里是双保险)。
|
||||
func (u User) PremiumActiveAt(now int64) bool {
|
||||
return !u.Bot && u.PremiumUntil > 0 && int64(u.PremiumUntil) > now
|
||||
}
|
||||
|
||||
// EmojiStatusActiveAt 报告用户在 now(Unix 秒)时刻是否有生效的 emoji status
|
||||
// (已设置且未过期;Until==0 表示永久)。emoji status 是 premium 专属,到期
|
||||
// 降级后即便列仍有残值也不再下发。
|
||||
func (u User) EmojiStatusActiveAt(now int64) bool {
|
||||
if !u.PremiumActiveAt(now) || u.EmojiStatusDocumentID == 0 {
|
||||
return false
|
||||
}
|
||||
return u.EmojiStatusUntil == 0 || int64(u.EmojiStatusUntil) > now
|
||||
}
|
||||
|
||||
// UserStatusKind is a protocol-neutral account presence state.
|
||||
type UserStatusKind int
|
||||
|
||||
|
|
@ -53,6 +104,29 @@ type UserStatus struct {
|
|||
WasOnline int
|
||||
}
|
||||
|
||||
// Birthday 是用户公开生日。Day/Month 为 0 表示未设置;Year 为 0 表示只填了月日不含年份。
|
||||
type Birthday struct {
|
||||
Day int
|
||||
Month int
|
||||
Year int
|
||||
}
|
||||
|
||||
// IsSet 报告生日是否已设置(必须有合法月日)。
|
||||
func (b Birthday) IsSet() bool {
|
||||
return b.Day != 0 && b.Month != 0
|
||||
}
|
||||
|
||||
// ValidBirthday 校验月/日(年份可选)是否在合法范围内。清除生日传零值即可(IsSet 为 false)。
|
||||
func ValidBirthday(b Birthday) bool {
|
||||
if b.Month < 1 || b.Month > 12 || b.Day < 1 || b.Day > 31 {
|
||||
return false
|
||||
}
|
||||
if b.Year != 0 && (b.Year < 1900 || b.Year > 2100) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// UserProfileUpdate 描述 account.updateProfile 的可选字段更新。
|
||||
type UserProfileUpdate struct {
|
||||
FirstName string
|
||||
|
|
|
|||
|
|
@ -9,4 +9,12 @@ var (
|
|||
ErrPhoneNotOccupied = errors.New("phone not occupied")
|
||||
ErrFirstNameInvalid = errors.New("first name invalid")
|
||||
ErrAboutTooLong = errors.New("about too long")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserSendRestricted = errors.New("user send restricted")
|
||||
// ErrPremiumRequired 表示该操作仅限有效会员(PREMIUM_ACCOUNT_REQUIRED)。
|
||||
ErrPremiumRequired = errors.New("premium account required")
|
||||
// ErrPremiumBotUnsupported 表示 bot 账号不可被授予会员(官方语义)。
|
||||
ErrPremiumBotUnsupported = errors.New("bot accounts cannot be premium")
|
||||
// ErrBirthdayInvalid 表示生日的月/日/年不在合法范围(BIRTHDAY_INVALID)。
|
||||
ErrBirthdayInvalid = errors.New("birthday invalid")
|
||||
)
|
||||
|
|
|
|||
75
internal/domain/webpage.go
Normal file
75
internal/domain/webpage.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"hash/fnv"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NormalizeWebPageURL 把用户消息里的链接规范化为去重用的稳定形态:仅接受 http/https、
|
||||
// 拒绝带 userinfo 的 URL(SSRF/伪装防御)、小写 scheme 与 host、去掉默认端口与 fragment,
|
||||
// 保留 path 与 query 原样。返回规范化 URL 与是否可预览(不可预览返回 false)。
|
||||
func NormalizeWebPageURL(raw string) (string, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", false
|
||||
}
|
||||
if u.User != nil {
|
||||
return "", false
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
if host == "" {
|
||||
return "", false
|
||||
}
|
||||
// 去掉与 scheme 对应的默认端口,避免 example.com 与 example.com:80 哈希不同。
|
||||
if h, port, ok := splitHostPort(host); ok {
|
||||
if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
|
||||
host = h
|
||||
}
|
||||
}
|
||||
out := scheme + "://" + host + u.EscapedPath()
|
||||
if u.RawQuery != "" {
|
||||
out += "?" + u.RawQuery
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// splitHostPort 拆分 host:port;无端口时 ok=false。不用 net.SplitHostPort 以免 IPv6
|
||||
// 字面量裸 host(无端口)被当作错误。
|
||||
func splitHostPort(host string) (string, string, bool) {
|
||||
// IPv6 字面量形如 [::1]:443;只在末尾 ] 之后找端口。
|
||||
idx := strings.LastIndexByte(host, ':')
|
||||
if idx < 0 {
|
||||
return host, "", false
|
||||
}
|
||||
if close := strings.LastIndexByte(host, ']'); close >= 0 && idx < close {
|
||||
return host, "", false
|
||||
}
|
||||
return host[:idx], host[idx+1:], true
|
||||
}
|
||||
|
||||
// IsPendingWebPageMedia 报告 media 是否为 ID==id 的 pending 链接预览占位(异步解析的
|
||||
// 幂等守卫:仅这种状态才允许被解析结果就地替换)。
|
||||
func IsPendingWebPageMedia(m *MessageMedia, id int64) bool {
|
||||
return m != nil &&
|
||||
m.Kind == MessageMediaKindWebPage &&
|
||||
m.WebPage != nil &&
|
||||
m.WebPage.State == MessageWebPageStatePending &&
|
||||
m.WebPage.ID == id
|
||||
}
|
||||
|
||||
// WebPageURLHash 计算规范化 URL 的稳定 63-bit 哈希,同时用作 web_pages 行的主键与
|
||||
// webPage id(保证 pending 占位与 done 解析携带同一 id)。FNV-1a/64 取正。
|
||||
func WebPageURLHash(normalized string) int64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(normalized))
|
||||
return int64(h.Sum64() & 0x7fffffffffffffff)
|
||||
}
|
||||
58
internal/domain/webpage_test.go
Normal file
58
internal/domain/webpage_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeWebPageURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"lowercase-host-drop-fragment", "https://Example.COM/Path?q=1#frag", "https://example.com/Path?q=1", true},
|
||||
{"strip-default-http-port", "http://example.com:80/x", "http://example.com/x", true},
|
||||
{"strip-default-https-port", "https://example.com:443", "https://example.com", true},
|
||||
{"keep-nondefault-port", "http://example.com:8080/x", "http://example.com:8080/x", true},
|
||||
{"keep-query-case", "https://e.com/a?B=C", "https://e.com/a?B=C", true},
|
||||
{"reject-scheme", "ftp://example.com/x", "", false},
|
||||
{"reject-userinfo", "https://user:pass@example.com/x", "", false},
|
||||
{"reject-no-host", "https:///path", "", false},
|
||||
{"reject-plain-text", "not a url", "", false},
|
||||
{"reject-empty", " ", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := NormalizeWebPageURL(tc.in)
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Fatalf("NormalizeWebPageURL(%q) = (%q,%v), want (%q,%v)", tc.in, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebPageURLHashStableAndPositive(t *testing.T) {
|
||||
const u = "https://example.com/article"
|
||||
h1 := WebPageURLHash(u)
|
||||
h2 := WebPageURLHash(u)
|
||||
if h1 != h2 {
|
||||
t.Fatalf("hash not deterministic: %d != %d", h1, h2)
|
||||
}
|
||||
if h1 < 0 {
|
||||
t.Fatalf("hash must be non-negative (used as TL id), got %d", h1)
|
||||
}
|
||||
if WebPageURLHash("https://other.example/x") == h1 {
|
||||
t.Fatalf("distinct URLs collided")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeThenHashDedupes 验证规范化后等价的 URL 哈希一致(去重键稳定)。
|
||||
func TestNormalizeThenHashDedupes(t *testing.T) {
|
||||
a, _ := NormalizeWebPageURL("https://Example.com:443/x")
|
||||
b, _ := NormalizeWebPageURL("https://example.com/x")
|
||||
if a != b {
|
||||
t.Fatalf("normalized forms differ: %q vs %q", a, b)
|
||||
}
|
||||
if WebPageURLHash(a) != WebPageURLHash(b) {
|
||||
t.Fatalf("equivalent URLs hashed differently")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue