feat: sync Bot API gateway support

This commit is contained in:
A 2026-07-09 13:49:24 +08:00
parent 9a501f900a
commit 4c0cc2b7a7
44 changed files with 4609 additions and 49 deletions

View file

@ -0,0 +1,95 @@
package channels
import (
"context"
"encoding/binary"
"hash/fnv"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const (
activeBotMemberIDsMaxEntries = 8192
)
type activeBotMemberIDsCacheKey struct {
viewerUserID int64
channelID int64
limit int
}
type activeBotMemberIDsCache struct {
cache *readmodelcache.Cache[activeBotMemberIDsCacheKey, []int64]
}
func newActiveBotMemberIDsCache() *activeBotMemberIDsCache {
return &activeBotMemberIDsCache{
cache: readmodelcache.New[activeBotMemberIDsCacheKey, []int64](readmodelcache.Config[activeBotMemberIDsCacheKey, []int64]{
MaxEntries: activeBotMemberIDsMaxEntries,
Clone: cloneInt64s,
}),
}
}
func (c *activeBotMemberIDsCache) getOrLoad(ctx context.Context, key activeBotMemberIDsCacheKey, load func() ([]int64, error)) ([]int64, error) {
if c == nil {
return load()
}
return c.cache.GetOrLoad(ctx, key, load)
}
func (c *activeBotMemberIDsCache) getOrLoadVersioned(ctx context.Context, key activeBotMemberIDsCacheKey, hash int64, load func() ([]int64, error)) ([]int64, error) {
if c == nil {
return load()
}
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
}
func (c *activeBotMemberIDsCache) invalidateChannel(channelID int64) {
if c == nil || channelID == 0 {
return
}
c.cache.InvalidateWhere(func(key activeBotMemberIDsCacheKey) bool {
return key.channelID == channelID
})
}
func (c *activeBotMemberIDsCache) flush() {
if c == nil {
return
}
c.cache.Flush()
}
func (s *Service) channelBotMemberIDsHash(ctx context.Context, viewerUserID, channelID int64, key activeBotMemberIDsCacheKey) (int64, error) {
keys := []store.ReadModelKey{
{Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID},
{Model: readmodel.ModelChannelParticipants, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID},
{Model: readmodel.ModelChannelMember, OwnerUserID: viewerUserID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
}
rows, err := s.versions.ReadModelHashes(ctx, keys)
if err != nil {
return 0, err
}
base := rows[keys[0]]
participants := rows[keys[1]]
if base == 0 || participants == 0 {
return 0, nil
}
return readmodel.MixHashes(base, participants, rows[keys[2]], botMemberIDsKeyHash(key)), nil
}
func botMemberIDsKeyHash(key activeBotMemberIDsCacheKey) int64 {
h := fnv.New64a()
var buf [8]byte
binary.LittleEndian.PutUint64(buf[:], uint64(key.limit))
_, _ = h.Write(buf[:])
sum := int64(h.Sum64() & 0x7fffffffffffffff)
if sum == 0 {
return 1
}
return sum
}

View file

@ -106,20 +106,72 @@ func (s *Service) skippedBotDeliveryUserIDs(ctx context.Context, req domain.Send
if s.bots == nil || req.ChannelID == 0 || req.UserID == 0 {
return nil, nil
}
if lister, ok := s.channels.(activeChannelBotMemberIDLister); ok {
memberIDs, err := lister.ListActiveChannelBotMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxSynchronousChannelDialogFanout)
if err != nil {
return nil, err
}
return s.skippedBotDeliveryUserIDsForIDs(ctx, req, memberIDs)
}
memberIDs, err := s.channels.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxSynchronousChannelDialogFanout)
memberIDs, err := s.loadActiveBotMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxSynchronousChannelDialogFanout)
if err != nil {
return nil, err
}
return s.skippedBotDeliveryUserIDsForIDs(ctx, req, memberIDs)
}
// ActiveBotMemberIDs returns active bot members for non-privacy-critical producers
// such as Bot API update queue fanout. Privacy delivery decisions use
// loadActiveBotMemberIDs directly to avoid stale-cache leaks.
func (s *Service) ActiveBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) {
if s == nil || s.channels == nil || viewerUserID == 0 || channelID == 0 {
return nil, domain.ErrChannelInvalid
}
if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout {
limit = domain.MaxSynchronousChannelDialogFanout
}
key := activeBotMemberIDsCacheKey{viewerUserID: viewerUserID, channelID: channelID, limit: limit}
if s.versions != nil {
hash, err := s.channelBotMemberIDsHash(ctx, viewerUserID, channelID, key)
if err != nil {
return nil, err
}
if hash != 0 {
return s.botMemberIDsCache.getOrLoadVersioned(ctx, key, hash, func() ([]int64, error) {
return s.loadActiveBotMemberIDs(ctx, viewerUserID, channelID, limit)
})
}
return s.loadActiveBotMemberIDs(ctx, viewerUserID, channelID, limit)
}
return s.botMemberIDsCache.getOrLoad(ctx, key, func() ([]int64, error) {
return s.loadActiveBotMemberIDs(ctx, viewerUserID, channelID, limit)
})
}
func (s *Service) loadActiveBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) {
if s == nil || s.channels == nil || viewerUserID == 0 || channelID == 0 {
return nil, domain.ErrChannelInvalid
}
if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout {
limit = domain.MaxSynchronousChannelDialogFanout
}
if lister, ok := s.channels.(activeChannelBotMemberIDLister); ok {
return lister.ListActiveChannelBotMemberIDs(ctx, viewerUserID, channelID, limit)
}
if s.bots == nil {
return nil, nil
}
memberIDs, err := s.channels.ListActiveChannelMemberIDs(ctx, viewerUserID, channelID, limit)
if err != nil {
return nil, err
}
profiles, err := s.botProfiles(ctx, memberIDs)
if err != nil {
return nil, err
}
out := make([]int64, 0, len(profiles))
for _, id := range uniqueNonZero(memberIDs) {
if _, found := profiles[id]; found {
out = append(out, id)
}
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out, nil
}
func (s *Service) skippedBotDeliveryUserIDsForIDs(ctx context.Context, req domain.SendChannelMessageRequest, memberIDs []int64) ([]int64, error) {
profiles, err := s.botProfiles(ctx, memberIDs)
if err != nil {

View file

@ -17,11 +17,12 @@ type Service struct {
versions store.ReadModelVersionStore
sendGate SendPermissionChecker
viewCache *channelViewReadModelCache
resolveCache *channelResolveReadModelCache
mediaCountCache *mediaCountReadModelCache
participantCache *participantsReadModelCache
activeIDsCache *activeChannelIDsReadModelCache
viewCache *channelViewReadModelCache
resolveCache *channelResolveReadModelCache
mediaCountCache *mediaCountReadModelCache
participantCache *participantsReadModelCache
activeIDsCache *activeChannelIDsReadModelCache
botMemberIDsCache *activeBotMemberIDsCache
}
type Option func(*Service)
@ -33,12 +34,13 @@ type SendPermissionChecker interface {
// NewService creates a channel service.
func NewService(channels store.ChannelStore, opts ...Option) *Service {
s := &Service{
channels: channels,
viewCache: newChannelViewReadModelCache(defaultChannelViewReadModelTTL),
resolveCache: newChannelResolveReadModelCache(defaultChannelResolveReadModelTTL),
mediaCountCache: newMediaCountReadModelCache(defaultMediaCountReadModelTTL),
participantCache: newParticipantsReadModelCache(defaultParticipantsReadModelTTL),
activeIDsCache: newActiveChannelIDsReadModelCache(defaultActiveChannelIDsReadModelTTL),
channels: channels,
viewCache: newChannelViewReadModelCache(defaultChannelViewReadModelTTL),
resolveCache: newChannelResolveReadModelCache(defaultChannelResolveReadModelTTL),
mediaCountCache: newMediaCountReadModelCache(defaultMediaCountReadModelTTL),
participantCache: newParticipantsReadModelCache(defaultParticipantsReadModelTTL),
activeIDsCache: newActiveChannelIDsReadModelCache(defaultActiveChannelIDsReadModelTTL),
botMemberIDsCache: newActiveBotMemberIDsCache(),
}
for _, opt := range opts {
opt(s)
@ -238,6 +240,7 @@ func (s *Service) InviteToChannel(ctx context.Context, userID, channelID int64,
if err == nil {
s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(0, res.Members)...)
s.participantCache.invalidateChannel(channelID)
s.invalidateActiveBotMemberIDs(channelID)
}
return res, err
}
@ -251,6 +254,7 @@ func (s *Service) JoinChannel(ctx context.Context, userID, channelID int64, date
if err == nil {
s.invalidateActiveChannelIDs(userID)
s.participantCache.invalidateChannel(channelID)
s.invalidateActiveBotMemberIDs(channelID)
}
return res, err
}
@ -264,6 +268,7 @@ func (s *Service) LeaveChannel(ctx context.Context, userID, channelID int64, dat
if err == nil {
s.invalidateActiveChannelIDs(userID)
s.participantCache.invalidateChannel(channelID)
s.invalidateActiveBotMemberIDs(channelID)
}
return res, err
}
@ -325,6 +330,7 @@ func (s *Service) EditAdmin(ctx context.Context, userID int64, req domain.EditCh
if err == nil {
s.invalidateActiveChannelIDs(req.MemberID)
s.participantCache.invalidateChannel(req.ChannelID)
s.invalidateActiveBotMemberIDs(req.ChannelID)
}
return res, err
}
@ -344,6 +350,7 @@ func (s *Service) TransferOwnership(ctx context.Context, userID int64, req domai
if err == nil {
s.invalidateActiveChannelIDs(req.UserID, req.NewOwnerID)
s.participantCache.invalidateChannel(req.ChannelID)
s.invalidateActiveBotMemberIDs(req.ChannelID)
}
return res, err
}
@ -363,6 +370,7 @@ func (s *Service) EditMemberRank(ctx context.Context, userID int64, req domain.E
res, err := s.channels.EditChannelMemberRank(ctx, req)
if err == nil {
s.participantCache.invalidateChannel(req.ChannelID)
s.invalidateActiveBotMemberIDs(req.ChannelID)
}
return res, err
}
@ -382,6 +390,7 @@ func (s *Service) EditBanned(ctx context.Context, userID int64, req domain.EditC
if err == nil {
s.invalidateActiveChannelIDs(req.Participant.ID)
s.participantCache.invalidateChannel(req.ChannelID)
s.invalidateActiveBotMemberIDs(req.ChannelID)
}
return res, err
}
@ -414,6 +423,7 @@ func (s *Service) DeleteChannel(ctx context.Context, userID int64, req domain.De
res, err := s.channels.DeleteChannel(ctx, req)
if err == nil {
s.invalidateActiveChannelIDs(uniqueUserIDs(append([]int64{userID}, res.Recipients...)...)...)
s.invalidateActiveBotMemberIDs(req.ChannelID)
}
return res, err
}
@ -2124,6 +2134,24 @@ func (s *Service) invalidateActiveChannelIDs(userIDs ...int64) {
s.activeIDsCache.invalidateUsers(userIDs...)
}
func (s *Service) invalidateActiveBotMemberIDs(channelID int64) {
if s == nil || s.botMemberIDsCache == nil {
return
}
s.botMemberIDsCache.invalidateChannel(channelID)
}
func (s *Service) InvalidateActiveBotMemberIDsReadModel(channelID int64) {
s.invalidateActiveBotMemberIDs(channelID)
}
func (s *Service) FlushActiveBotMemberIDsReadModel() {
if s == nil || s.botMemberIDsCache == nil {
return
}
s.botMemberIDsCache.flush()
}
func normalizeChannelUsername(username string) string {
username = strings.TrimSpace(username)
username = strings.TrimPrefix(username, "@")

View file

@ -57,15 +57,16 @@ func (p testBotProfiles) BotInfo(_ context.Context, botUserID int64) (domain.Bot
type countingChannelStore struct {
*memory.ChannelStore
mu sync.Mutex
getChannelCalls int
resolveChannelCalls int
countMediaCalls int
getParticipantCalls int
listActiveIDsCalls int
resolveStarted chan struct{}
resolveRelease <-chan struct{}
resolveStartOnce sync.Once
mu sync.Mutex
getChannelCalls int
resolveChannelCalls int
countMediaCalls int
getParticipantCalls int
listActiveIDsCalls int
listActiveMemberIDsCalls int
resolveStarted chan struct{}
resolveRelease <-chan struct{}
resolveStartOnce sync.Once
}
func (s *countingChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) {
@ -102,6 +103,11 @@ func (s *countingChannelStore) ListActiveChannelIDsForUser(ctx context.Context,
return s.ChannelStore.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
}
func (s *countingChannelStore) ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) {
s.listActiveMemberIDsCalls++
return s.ChannelStore.ListActiveChannelMemberIDs(ctx, viewerUserID, channelID, limit)
}
type fakeReadModelVersions struct {
hashes map[store.ReadModelKey]int64
}
@ -343,6 +349,102 @@ func TestActiveChannelIDsForUserCachesEmptyMissingReadModelHash(t *testing.T) {
}
}
func TestActiveBotMemberIDsCachesAndInvalidatesOnMembershipWrite(t *testing.T) {
ctx := context.Background()
base := &countingChannelStore{ChannelStore: memory.NewChannelStore()}
bots := testBotProfiles{
1003: {BotUserID: 1003},
1004: {BotUserID: 1004},
}
service := NewService(base, WithBotProfileResolver(bots))
created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
Title: "Bot Cache",
MemberUserIDs: []int64{1002, 1003},
Date: 1700004115,
})
if err != nil {
t.Fatalf("CreateMegagroupFromCreateChat: %v", err)
}
channelID := created.Channel.ID
first, err := service.ActiveBotMemberIDs(ctx, 1001, channelID, domain.MaxSynchronousChannelDialogFanout)
if err != nil {
t.Fatalf("ActiveBotMemberIDs first: %v", err)
}
if want := []int64{1003}; !slices.Equal(first, want) {
t.Fatalf("ActiveBotMemberIDs first = %v, want %v", first, want)
}
first[0] = 9999
second, err := service.ActiveBotMemberIDs(ctx, 1001, channelID, domain.MaxSynchronousChannelDialogFanout)
if err != nil {
t.Fatalf("ActiveBotMemberIDs second: %v", err)
}
if want := []int64{1003}; !slices.Equal(second, want) {
t.Fatalf("ActiveBotMemberIDs cached = %v, want %v", second, want)
}
if base.listActiveMemberIDsCalls != 1 {
t.Fatalf("ListActiveChannelMemberIDs calls = %d, want 1 after cache hit", base.listActiveMemberIDsCalls)
}
if _, err := service.InviteToChannel(ctx, 1001, channelID, []int64{1004}, 1700004116); err != nil {
t.Fatalf("InviteToChannel bot: %v", err)
}
third, err := service.ActiveBotMemberIDs(ctx, 1001, channelID, domain.MaxSynchronousChannelDialogFanout)
if err != nil {
t.Fatalf("ActiveBotMemberIDs after invite: %v", err)
}
if want := []int64{1003, 1004}; !slices.Equal(third, want) {
t.Fatalf("ActiveBotMemberIDs after invite = %v, want %v", third, want)
}
if base.listActiveMemberIDsCalls != 2 {
t.Fatalf("ListActiveChannelMemberIDs calls = %d, want reload after invalidation", base.listActiveMemberIDsCalls)
}
}
func TestActiveBotMemberIDsReloadsOnReadModelHashChange(t *testing.T) {
ctx := context.Background()
base := &countingChannelStore{ChannelStore: memory.NewChannelStore()}
bots := testBotProfiles{1003: {BotUserID: 1003}}
creator := NewService(base, WithBotProfileResolver(bots))
created, err := creator.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
Title: "Bot Hash",
MemberUserIDs: []int64{1002, 1003},
Date: 1700004117,
})
if err != nil {
t.Fatalf("CreateMegagroupFromCreateChat: %v", err)
}
channelID := created.Channel.ID
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
baseKey := store.ReadModelKey{Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}
participantsKey := store.ReadModelKey{Model: readmodel.ModelChannelParticipants, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}
memberKey := store.ReadModelKey{Model: readmodel.ModelChannelMember, OwnerUserID: 1001, PeerType: peer.Type, PeerID: peer.ID}
versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{
baseKey: 401,
participantsKey: 402,
memberKey: 403,
}}
service := NewService(base, WithBotProfileResolver(bots), WithReadModelVersions(versions))
if _, err := service.ActiveBotMemberIDs(ctx, 1001, channelID, domain.MaxSynchronousChannelDialogFanout); err != nil {
t.Fatalf("ActiveBotMemberIDs first: %v", err)
}
if _, err := service.ActiveBotMemberIDs(ctx, 1001, channelID, domain.MaxSynchronousChannelDialogFanout); err != nil {
t.Fatalf("ActiveBotMemberIDs cached: %v", err)
}
if base.listActiveMemberIDsCalls != 1 {
t.Fatalf("ListActiveChannelMemberIDs calls = %d, want 1 before hash change", base.listActiveMemberIDsCalls)
}
versions.hashes[participantsKey] = 404
if _, err := service.ActiveBotMemberIDs(ctx, 1001, channelID, domain.MaxSynchronousChannelDialogFanout); err != nil {
t.Fatalf("ActiveBotMemberIDs after hash change: %v", err)
}
if base.listActiveMemberIDsCalls != 2 {
t.Fatalf("ListActiveChannelMemberIDs calls = %d, want reload after hash change", base.listActiveMemberIDsCalls)
}
}
func TestActiveChannelIDsCacheInvalidatesOnMembershipWrite(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001