chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
91
internal/app/channels/active_ids_cache.go
Normal file
91
internal/app/channels/active_ids_cache.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultActiveChannelIDsReadModelTTL = 24 * time.Hour
|
||||
activeChannelIDsReadModelMaxEntries = 8192
|
||||
activeChannelIDsNoVersionHash = -1
|
||||
)
|
||||
|
||||
type activeChannelIDsCacheKey struct {
|
||||
userID int64
|
||||
afterChannelID int64
|
||||
limit int
|
||||
}
|
||||
|
||||
// activeChannelIDsReadModelCache 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU / clone)。
|
||||
// 无版本(version 缺失)时用 activeChannelIDsNoVersionHash 哨兵作版本,仍享 TTL+epoch 失效。
|
||||
type activeChannelIDsReadModelCache struct {
|
||||
cache *readmodelcache.Cache[activeChannelIDsCacheKey, []int64]
|
||||
}
|
||||
|
||||
func newActiveChannelIDsReadModelCache(ttl time.Duration) *activeChannelIDsReadModelCache {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultActiveChannelIDsReadModelTTL
|
||||
}
|
||||
return &activeChannelIDsReadModelCache{
|
||||
cache: readmodelcache.New[activeChannelIDsCacheKey, []int64](readmodelcache.Config[activeChannelIDsCacheKey, []int64]{
|
||||
MaxEntries: activeChannelIDsReadModelMaxEntries,
|
||||
TTL: ttl,
|
||||
Clone: cloneInt64s,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *activeChannelIDsReadModelCache) getOrLoad(ctx context.Context, key activeChannelIDsCacheKey, hash int64, load func() ([]int64, error)) ([]int64, error) {
|
||||
if c == nil {
|
||||
return load()
|
||||
}
|
||||
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
|
||||
}
|
||||
|
||||
func (c *activeChannelIDsReadModelCache) invalidateUsers(userIDs ...int64) {
|
||||
if c == nil || len(userIDs) == 0 {
|
||||
return
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if userID != 0 {
|
||||
seen[userID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.InvalidateWhere(func(k activeChannelIDsCacheKey) bool {
|
||||
_, ok := seen[k.userID]
|
||||
return ok
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) cachedActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) {
|
||||
if s.activeIDsCache == nil || s.versions == nil {
|
||||
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
|
||||
}
|
||||
hash, ok, err := s.versions.ReadModelHash(ctx, readmodel.ModelChannelActiveIDs, userID, domain.PeerTypeUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok || hash == 0 {
|
||||
hash = activeChannelIDsNoVersionHash
|
||||
}
|
||||
key := activeChannelIDsCacheKey{userID: userID, afterChannelID: afterChannelID, limit: limit}
|
||||
return s.activeIDsCache.getOrLoad(ctx, key, hash, func() ([]int64, error) {
|
||||
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
|
||||
})
|
||||
}
|
||||
|
||||
func cloneInt64s(in []int64) []int64 {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]int64(nil), in...)
|
||||
}
|
||||
311
internal/app/channels/bot_policy.go
Normal file
311
internal/app/channels/bot_policy.go
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// BotProfileResolver is the domain-only view of bot metadata used by channel policy.
|
||||
type BotProfileResolver interface {
|
||||
BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error)
|
||||
}
|
||||
|
||||
type botProfileBatchResolver interface {
|
||||
BotInfos(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error)
|
||||
}
|
||||
|
||||
type activeChannelBotMemberLister interface {
|
||||
ListActiveChannelBotMembers(ctx context.Context, viewerUserID, channelID int64, offset, limit int) (domain.ChannelParticipantList, error)
|
||||
}
|
||||
|
||||
type activeChannelBotMemberIDLister interface {
|
||||
ListActiveChannelBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error)
|
||||
}
|
||||
|
||||
func (s *Service) getBotParticipants(ctx context.Context, userID, channelID int64, offset, limit int) (domain.ChannelParticipantList, error) {
|
||||
if lister, ok := s.channels.(activeChannelBotMemberLister); ok {
|
||||
return lister.ListActiveChannelBotMembers(ctx, userID, channelID, offset, limit)
|
||||
}
|
||||
channel, viewer, active, err := s.channels.ListActiveChannelMembers(ctx, userID, channelID, domain.MaxSynchronousChannelDialogFanout)
|
||||
if err != nil {
|
||||
return domain.ChannelParticipantList{}, err
|
||||
}
|
||||
if channel.ParticipantsHidden && !channelServiceMemberIsAdmin(viewer) {
|
||||
return domain.ChannelParticipantList{Channel: channel, Count: 0}, nil
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset > domain.MaxChannelParticipantsOffset {
|
||||
offset = domain.MaxChannelParticipantsOffset
|
||||
}
|
||||
ids := make([]int64, 0, len(active))
|
||||
for _, member := range active {
|
||||
if member.UserID != 0 {
|
||||
ids = append(ids, member.UserID)
|
||||
}
|
||||
}
|
||||
profiles, err := s.botProfiles(ctx, ids)
|
||||
if err != nil {
|
||||
return domain.ChannelParticipantList{}, err
|
||||
}
|
||||
members := make([]domain.ChannelMember, 0, limit)
|
||||
count := 0
|
||||
for _, member := range active {
|
||||
if _, found := profiles[member.UserID]; !found {
|
||||
continue
|
||||
}
|
||||
if count >= offset && len(members) < limit {
|
||||
members = append(members, member)
|
||||
}
|
||||
count++
|
||||
}
|
||||
return domain.ChannelParticipantList{Channel: channel, Participants: members, Count: count}, nil
|
||||
}
|
||||
|
||||
func (s *Service) botProfiles(ctx context.Context, ids []int64) (map[int64]domain.BotProfile, error) {
|
||||
if len(ids) == 0 || s.bots == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if batch, ok := s.bots.(botProfileBatchResolver); ok {
|
||||
return batch.BotInfos(ctx, ids)
|
||||
}
|
||||
out := make(map[int64]domain.BotProfile)
|
||||
for _, id := range uniqueNonZero(ids) {
|
||||
profile, found, err := s.bots.BotInfo(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
out[id] = profile
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) rejectBlockedBotInvites(ctx context.Context, userIDs []int64) error {
|
||||
if s.bots == nil {
|
||||
return nil
|
||||
}
|
||||
for _, id := range uniqueNonZero(userIDs) {
|
||||
profile, found, err := s.bots.BotInfo(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found && profile.Nochats {
|
||||
return domain.ErrBotGroupsBlocked
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) skippedBotDeliveryUserIDs(ctx context.Context, req domain.SendChannelMessageRequest) ([]int64, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.skippedBotDeliveryUserIDsForIDs(ctx, req, memberIDs)
|
||||
}
|
||||
|
||||
func (s *Service) skippedBotDeliveryUserIDsForIDs(ctx context.Context, req domain.SendChannelMessageRequest, memberIDs []int64) ([]int64, error) {
|
||||
profiles, err := s.botProfiles(ctx, memberIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg := domain.ChannelMessage{
|
||||
ChannelID: req.ChannelID,
|
||||
SenderUserID: req.UserID,
|
||||
Body: req.Message,
|
||||
ReplyTo: req.ReplyTo,
|
||||
Action: req.Action,
|
||||
}
|
||||
skip := make([]int64, 0)
|
||||
for _, id := range memberIDs {
|
||||
if id == req.UserID {
|
||||
continue
|
||||
}
|
||||
profile, found := profiles[id]
|
||||
if !found || profile.ChatHistory {
|
||||
continue
|
||||
}
|
||||
visible, err := s.botCanSeeChannelMessage(ctx, id, msg, req.MentionUserIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !visible {
|
||||
skip = append(skip, id)
|
||||
}
|
||||
}
|
||||
return skip, nil
|
||||
}
|
||||
|
||||
func (s *Service) filterBotChannelHistory(ctx context.Context, userID int64, history domain.ChannelHistory) domain.ChannelHistory {
|
||||
if s.bots == nil || userID == 0 || history.Channel.ID == 0 {
|
||||
return history
|
||||
}
|
||||
profile, found, err := s.bots.BotInfo(ctx, userID)
|
||||
if err != nil || !found || profile.ChatHistory {
|
||||
return history
|
||||
}
|
||||
filtered := history
|
||||
filtered.Messages = make([]domain.ChannelMessage, 0, len(history.Messages))
|
||||
for _, msg := range history.Messages {
|
||||
if visible, err := s.botCanSeeChannelMessage(ctx, userID, msg, nil); err == nil && visible {
|
||||
filtered.Messages = append(filtered.Messages, msg)
|
||||
}
|
||||
}
|
||||
filtered.Count = len(filtered.Messages)
|
||||
filtered.Users = nil
|
||||
filtered.Channels = nil
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (s *Service) filterBotChannelDifference(ctx context.Context, userID int64, diff domain.ChannelDifference) domain.ChannelDifference {
|
||||
if s.bots == nil || userID == 0 || diff.Channel.ID == 0 {
|
||||
return diff
|
||||
}
|
||||
profile, found, err := s.bots.BotInfo(ctx, userID)
|
||||
if err != nil || !found || profile.ChatHistory {
|
||||
return diff
|
||||
}
|
||||
filtered := diff
|
||||
filtered.NewMessages = nil
|
||||
filtered.OtherUpdates = nil
|
||||
filtered.Events = nil
|
||||
filtered.Users = nil
|
||||
filtered.Channels = nil
|
||||
if diff.TooLong {
|
||||
for _, msg := range diff.NewMessages {
|
||||
if visible, err := s.botCanSeeChannelMessage(ctx, userID, msg, nil); err == nil && visible {
|
||||
filtered.NewMessages = append(filtered.NewMessages, msg)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
for _, event := range diff.Events {
|
||||
visibleEvent, ok := s.filterBotChannelEvent(ctx, userID, event)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
filtered.Events = append(filtered.Events, visibleEvent)
|
||||
switch visibleEvent.Type {
|
||||
case domain.ChannelUpdateNewMessage:
|
||||
filtered.NewMessages = append(filtered.NewMessages, visibleEvent.Message)
|
||||
default:
|
||||
filtered.OtherUpdates = append(filtered.OtherUpdates, visibleEvent)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (s *Service) filterBotChannelEvent(ctx context.Context, botUserID int64, event domain.ChannelUpdateEvent) (domain.ChannelUpdateEvent, bool) {
|
||||
switch event.Type {
|
||||
case domain.ChannelUpdateNewMessage, domain.ChannelUpdateEditMessage:
|
||||
if event.Message.ID == 0 {
|
||||
return event, true
|
||||
}
|
||||
visible, err := s.botCanSeeChannelMessage(ctx, botUserID, event.Message, nil)
|
||||
if err != nil || !visible {
|
||||
return domain.ChannelUpdateEvent{}, false
|
||||
}
|
||||
return event, true
|
||||
case domain.ChannelUpdateDeleteMessages:
|
||||
// 删除事件只携带消息 id、不含任何内容,且在线推送路径(channels_updates 的
|
||||
// channelDeleteMessagesUpdates→enqueueChannelFanout)本就对全体成员无差别投递删除。
|
||||
// 若在此按可见性过滤,会因被删消息无法重取(GetChannelMessages 带 AND NOT deleted
|
||||
// 恒返空)而把整条 delete 事件丢弃——privacy bot 经 getChannelDifference 补差时将
|
||||
// 对所有删除失明(连它本可见消息的删除也收不到),客户端缓存残留"未删"态。故直接放行,
|
||||
// 与在线推送行为一致(删除 id 不泄漏内容)。
|
||||
return event, true
|
||||
case domain.ChannelUpdatePinnedMessages:
|
||||
if len(event.MessageIDs) == 0 {
|
||||
return event, true
|
||||
}
|
||||
ids := make([]int, 0, len(event.MessageIDs))
|
||||
for _, id := range event.MessageIDs {
|
||||
history, err := s.channels.GetChannelMessages(ctx, botUserID, event.ChannelID, []int{id})
|
||||
if err != nil || len(history.Messages) == 0 {
|
||||
continue
|
||||
}
|
||||
visible, err := s.botCanSeeChannelMessage(ctx, botUserID, history.Messages[0], nil)
|
||||
if err == nil && visible {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return domain.ChannelUpdateEvent{}, false
|
||||
}
|
||||
event.MessageIDs = ids
|
||||
return event, true
|
||||
default:
|
||||
return event, true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) botCanSeeChannelMessage(ctx context.Context, botUserID int64, msg domain.ChannelMessage, mentionUserIDs []int64) (bool, error) {
|
||||
if botUserID == 0 {
|
||||
return true, nil
|
||||
}
|
||||
if msg.SenderUserID == botUserID {
|
||||
return true, nil
|
||||
}
|
||||
if msg.Mentioned || containsInt64(mentionUserIDs, botUserID) {
|
||||
return true, nil
|
||||
}
|
||||
if msg.Action != nil && containsInt64(msg.Action.UserIDs, botUserID) {
|
||||
return true, nil
|
||||
}
|
||||
if messageIsCommand(msg.Body) {
|
||||
return true, nil
|
||||
}
|
||||
if msg.ReplyTo != nil && msg.ReplyTo.MessageID > 0 {
|
||||
history, err := s.channels.GetChannelMessages(ctx, botUserID, msg.ChannelID, []int{msg.ReplyTo.MessageID})
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
for _, target := range history.Messages {
|
||||
if target.SenderUserID == botUserID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func messageIsCommand(message string) bool {
|
||||
message = strings.TrimSpace(message)
|
||||
return strings.HasPrefix(message, "/") && len(message) > 1
|
||||
}
|
||||
|
||||
func containsInt64(ids []int64, target int64) bool {
|
||||
for _, id := range ids {
|
||||
if id == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func channelServiceMemberIsAdmin(member domain.ChannelMember) bool {
|
||||
return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin
|
||||
}
|
||||
|
||||
func mergeSkippedUserIDs(a, b []int64) []int64 {
|
||||
out := append(append([]int64(nil), a...), b...)
|
||||
out = uniqueNonZero(out)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out
|
||||
}
|
||||
134
internal/app/channels/media_count_cache.go
Normal file
134
internal/app/channels/media_count_cache.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMediaCountReadModelTTL = 24 * time.Hour
|
||||
mediaCountReadModelMaxEntries = 8192
|
||||
)
|
||||
|
||||
type mediaCountCacheKey struct {
|
||||
userID int64
|
||||
channelID int64
|
||||
}
|
||||
|
||||
// mediaCountReadModelCache 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU / clone)。
|
||||
type mediaCountReadModelCache struct {
|
||||
cache *readmodelcache.Cache[mediaCountCacheKey, domain.MediaCategoryCounts]
|
||||
}
|
||||
|
||||
func newMediaCountReadModelCache(ttl time.Duration) *mediaCountReadModelCache {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultMediaCountReadModelTTL
|
||||
}
|
||||
return &mediaCountReadModelCache{
|
||||
cache: readmodelcache.New[mediaCountCacheKey, domain.MediaCategoryCounts](readmodelcache.Config[mediaCountCacheKey, domain.MediaCategoryCounts]{
|
||||
MaxEntries: mediaCountReadModelMaxEntries,
|
||||
TTL: ttl,
|
||||
Clone: cloneMediaCategoryCounts,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *mediaCountReadModelCache) getOrLoad(ctx context.Context, key mediaCountCacheKey, hash int64, load func() (domain.MediaCategoryCounts, error)) (domain.MediaCategoryCounts, error) {
|
||||
if c == nil {
|
||||
return load()
|
||||
}
|
||||
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
|
||||
}
|
||||
|
||||
func (c *mediaCountReadModelCache) invalidateChannel(channelID int64) {
|
||||
if c == nil || c.cache == nil || channelID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.InvalidateWhere(func(key mediaCountCacheKey) bool {
|
||||
return key.channelID == channelID
|
||||
})
|
||||
}
|
||||
|
||||
func (c *mediaCountReadModelCache) invalidateViewer(userID, channelID int64) {
|
||||
if c == nil || c.cache == nil || userID == 0 || channelID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.Invalidate(mediaCountCacheKey{userID: userID, channelID: channelID})
|
||||
}
|
||||
|
||||
func (c *mediaCountReadModelCache) flush() {
|
||||
if c == nil || c.cache == nil {
|
||||
return
|
||||
}
|
||||
c.cache.Flush()
|
||||
}
|
||||
|
||||
func (s *Service) InvalidateChannelMediaCountReadModel(channelID int64) {
|
||||
if s == nil || s.mediaCountCache == nil {
|
||||
return
|
||||
}
|
||||
s.mediaCountCache.invalidateChannel(channelID)
|
||||
}
|
||||
|
||||
func (s *Service) InvalidateChannelMediaCountReadModelForViewer(userID, channelID int64) {
|
||||
if s == nil || s.mediaCountCache == nil {
|
||||
return
|
||||
}
|
||||
s.mediaCountCache.invalidateViewer(userID, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) FlushChannelMediaCountReadModel() {
|
||||
if s == nil || s.mediaCountCache == nil {
|
||||
return
|
||||
}
|
||||
s.mediaCountCache.flush()
|
||||
}
|
||||
|
||||
func (s *Service) cachedChannelMediaCounts(ctx context.Context, userID, channelID int64) (domain.MediaCategoryCounts, error) {
|
||||
if s.mediaCountCache == nil || s.versions == nil {
|
||||
return s.channels.CountChannelMediaCategories(ctx, userID, channelID)
|
||||
}
|
||||
hash, err := s.channelMediaCountHash(ctx, userID, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hash == 0 {
|
||||
return s.channels.CountChannelMediaCategories(ctx, userID, channelID)
|
||||
}
|
||||
key := mediaCountCacheKey{userID: userID, channelID: channelID}
|
||||
return s.mediaCountCache.getOrLoad(ctx, key, hash, func() (domain.MediaCategoryCounts, error) {
|
||||
return s.channels.CountChannelMediaCategories(ctx, userID, channelID)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) channelMediaCountHash(ctx context.Context, userID, channelID int64) (int64, error) {
|
||||
keys := []store.ReadModelKey{
|
||||
{Model: readmodel.ModelChannelMediaCounts, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID},
|
||||
{Model: readmodel.ModelChannelMember, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
|
||||
}
|
||||
rows, err := s.versions.ReadModelHashes(ctx, keys)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
media := rows[keys[0]]
|
||||
if media == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return readmodel.MixHashes(media, rows[keys[1]]), nil
|
||||
}
|
||||
|
||||
func cloneMediaCategoryCounts(in domain.MediaCategoryCounts) domain.MediaCategoryCounts {
|
||||
if len(in) == 0 {
|
||||
return domain.MediaCategoryCounts{}
|
||||
}
|
||||
out := make(domain.MediaCategoryCounts, len(in))
|
||||
for category, count := range in {
|
||||
out[category] = count
|
||||
}
|
||||
return out
|
||||
}
|
||||
62
internal/app/channels/music_filter_test.go
Normal file
62
internal/app/channels/music_filter_test.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestChannelHistoryMusicOnlyFiltersAudioDocuments(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewChannelStore()
|
||||
service := NewService(store)
|
||||
created, err := service.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Music Filter",
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{1002},
|
||||
Date: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
music := domain.Document{
|
||||
ID: 301,
|
||||
AccessHash: 3001,
|
||||
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAudio, AudioDuration: 200, Title: "Channel Song"}},
|
||||
}
|
||||
voice := domain.Document{
|
||||
ID: 302,
|
||||
AccessHash: 3002,
|
||||
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAudio, Voice: true, AudioDuration: 4}},
|
||||
}
|
||||
if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
RandomID: 1,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &voice, Voice: true},
|
||||
Date: 11,
|
||||
}); err != nil {
|
||||
t.Fatalf("SendMessage voice: %v", err)
|
||||
}
|
||||
if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
RandomID: 2,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &music},
|
||||
Date: 12,
|
||||
}); err != nil {
|
||||
t.Fatalf("SendMessage music: %v", err)
|
||||
}
|
||||
|
||||
history, err := service.GetHistory(ctx, 1002, domain.ChannelHistoryFilter{
|
||||
ChannelID: created.Channel.ID,
|
||||
MusicOnly: true,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetHistory music: %v", err)
|
||||
}
|
||||
if len(history.Messages) != 1 || history.Messages[0].Media == nil || history.Messages[0].Media.Document == nil || history.Messages[0].Media.Document.ID != music.ID {
|
||||
t.Fatalf("music history = %+v, want only music document", history.Messages)
|
||||
}
|
||||
}
|
||||
160
internal/app/channels/participants_cache.go
Normal file
160
internal/app/channels/participants_cache.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"hash/fnv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultParticipantsReadModelTTL = 30 * time.Minute
|
||||
participantsReadModelMaxEntries = 8192
|
||||
)
|
||||
|
||||
type participantsCacheKey struct {
|
||||
userID int64
|
||||
channelID int64
|
||||
kind domain.ChannelParticipantsFilterKind
|
||||
query string
|
||||
offset int
|
||||
limit int
|
||||
}
|
||||
|
||||
// participantsReadModelCache 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU 单条驱逐 / clone)。
|
||||
// LRU 终于给 (query,offset,limit) 维度的 page-key 上界,消掉原先无界 query-string 基数增长。
|
||||
type participantsReadModelCache struct {
|
||||
cache *readmodelcache.Cache[participantsCacheKey, domain.ChannelParticipantList]
|
||||
}
|
||||
|
||||
func newParticipantsReadModelCache(ttl time.Duration) *participantsReadModelCache {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultParticipantsReadModelTTL
|
||||
}
|
||||
return &participantsReadModelCache{
|
||||
cache: readmodelcache.New[participantsCacheKey, domain.ChannelParticipantList](readmodelcache.Config[participantsCacheKey, domain.ChannelParticipantList]{
|
||||
MaxEntries: participantsReadModelMaxEntries,
|
||||
TTL: ttl,
|
||||
Clone: cloneParticipantList,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *participantsReadModelCache) getOrLoad(ctx context.Context, key participantsCacheKey, hash int64, load func() (domain.ChannelParticipantList, error)) (domain.ChannelParticipantList, error) {
|
||||
if c == nil {
|
||||
return load()
|
||||
}
|
||||
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
|
||||
}
|
||||
|
||||
func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
|
||||
filter, offset, limit = normalizeParticipantsRequest(filter, offset, limit)
|
||||
if s.participantCache == nil || s.versions == nil {
|
||||
return s.loadParticipants(ctx, userID, channelID, filter, offset, limit)
|
||||
}
|
||||
key := participantsCacheKey{
|
||||
userID: userID,
|
||||
channelID: channelID,
|
||||
kind: filter.Kind,
|
||||
query: normalizeParticipantsQuery(filter.Query),
|
||||
offset: offset,
|
||||
limit: limit,
|
||||
}
|
||||
hash, err := s.channelParticipantsHash(ctx, userID, channelID, key)
|
||||
if err != nil {
|
||||
return domain.ChannelParticipantList{}, err
|
||||
}
|
||||
if hash == 0 {
|
||||
return s.loadParticipants(ctx, userID, channelID, filter, offset, limit)
|
||||
}
|
||||
return s.participantCache.getOrLoad(ctx, key, hash, func() (domain.ChannelParticipantList, error) {
|
||||
list, err := s.loadParticipants(ctx, userID, channelID, filter, offset, limit)
|
||||
if err != nil {
|
||||
return domain.ChannelParticipantList{}, err
|
||||
}
|
||||
list.Hash = hash
|
||||
return list, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) loadParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
|
||||
if filter.Kind == domain.ChannelParticipantsBots && s.bots != nil {
|
||||
return s.getBotParticipants(ctx, userID, channelID, offset, limit)
|
||||
}
|
||||
return s.channels.GetParticipants(ctx, userID, channelID, filter, offset, limit)
|
||||
}
|
||||
|
||||
func (s *Service) channelParticipantsHash(ctx context.Context, userID, channelID int64, key participantsCacheKey) (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: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
|
||||
{Model: readmodel.ModelContactAccount, OwnerUserID: userID, PeerType: domain.PeerTypeUser, PeerID: userID},
|
||||
}
|
||||
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]], rows[keys[3]], participantsPageHash(key)), nil
|
||||
}
|
||||
|
||||
func normalizeParticipantsRequest(filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantsFilter, int, int) {
|
||||
if filter.Kind == "" {
|
||||
filter.Kind = domain.ChannelParticipantsRecent
|
||||
}
|
||||
filter.Query = normalizeParticipantsQuery(filter.Query)
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset > domain.MaxChannelParticipantsOffset {
|
||||
offset = domain.MaxChannelParticipantsOffset
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxChannelParticipantsLimit {
|
||||
limit = domain.MaxChannelParticipantsLimit
|
||||
}
|
||||
return filter, offset, limit
|
||||
}
|
||||
|
||||
func normalizeParticipantsQuery(query string) string {
|
||||
return strings.ToLower(strings.TrimSpace(query))
|
||||
}
|
||||
|
||||
func participantsPageHash(key participantsCacheKey) int64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(key.kind))
|
||||
_, _ = h.Write([]byte{0})
|
||||
_, _ = h.Write([]byte(key.query))
|
||||
var buf [16]byte
|
||||
binary.LittleEndian.PutUint64(buf[0:8], uint64(key.offset))
|
||||
binary.LittleEndian.PutUint64(buf[8:16], uint64(key.limit))
|
||||
_, _ = h.Write(buf[:])
|
||||
sum := int64(h.Sum64() & 0x7fffffffffffffff)
|
||||
if sum == 0 {
|
||||
return 1
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
func cloneParticipantList(in domain.ChannelParticipantList) domain.ChannelParticipantList {
|
||||
in.Channel = cloneChannel(in.Channel)
|
||||
in.Participants = append([]domain.ChannelMember(nil), in.Participants...)
|
||||
if len(in.Users) > 0 {
|
||||
in.Users = make([]domain.User, len(in.Users))
|
||||
for i, user := range in.Users {
|
||||
user.PhotoStripped = append([]byte(nil), user.PhotoStripped...)
|
||||
in.Users[i] = user
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
107
internal/app/channels/read_model_cache.go
Normal file
107
internal/app/channels/read_model_cache.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// Full channel views are version-token guarded by channel_base,
|
||||
// channel_member, and dialog_light. Keep the snapshot long-lived; write-side
|
||||
// read-model bumps, not time, drive correctness.
|
||||
defaultChannelViewReadModelTTL = 24 * time.Hour
|
||||
channelViewReadModelMaxEntries = 8192
|
||||
)
|
||||
|
||||
type channelViewCacheKey struct {
|
||||
userID int64
|
||||
channelID int64
|
||||
}
|
||||
|
||||
// channelViewReadModelCache 与 channelResolveReadModelCache 都缓存 domain.ChannelView,
|
||||
// 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU / clone)。
|
||||
type channelViewReadModelCache struct {
|
||||
cache *readmodelcache.Cache[channelViewCacheKey, domain.ChannelView]
|
||||
}
|
||||
|
||||
func newChannelViewReadModelCache(ttl time.Duration) *channelViewReadModelCache {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultChannelViewReadModelTTL
|
||||
}
|
||||
return &channelViewReadModelCache{
|
||||
cache: readmodelcache.New[channelViewCacheKey, domain.ChannelView](readmodelcache.Config[channelViewCacheKey, domain.ChannelView]{
|
||||
MaxEntries: channelViewReadModelMaxEntries,
|
||||
TTL: ttl,
|
||||
Clone: cloneChannelView,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *channelViewReadModelCache) getOrLoad(ctx context.Context, key channelViewCacheKey, hash int64, load func() (domain.ChannelView, error)) (domain.ChannelView, error) {
|
||||
if c == nil {
|
||||
return load()
|
||||
}
|
||||
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
|
||||
}
|
||||
|
||||
func (s *Service) cachedChannelView(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) {
|
||||
if s.viewCache == nil || s.versions == nil {
|
||||
return s.channels.GetChannel(ctx, userID, channelID)
|
||||
}
|
||||
hash, err := s.channelViewHash(ctx, userID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelView{}, err
|
||||
}
|
||||
if hash == 0 {
|
||||
return s.channels.GetChannel(ctx, userID, channelID)
|
||||
}
|
||||
key := channelViewCacheKey{userID: userID, channelID: channelID}
|
||||
return s.viewCache.getOrLoad(ctx, key, hash, func() (domain.ChannelView, error) {
|
||||
return s.channels.GetChannel(ctx, userID, channelID)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) channelViewHash(ctx context.Context, userID, channelID int64) (int64, error) {
|
||||
keys := []store.ReadModelKey{
|
||||
{Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID},
|
||||
{Model: readmodel.ModelChannelMember, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
|
||||
{Model: readmodel.ModelDialogLight, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
|
||||
// 快照含 SelfBoostsApplied:必须把 channel_self_boosts 纳入校验 token,否则 apply/revoke
|
||||
// 加成不会失效这份长 TTL 快照。注:boost 自然到期是 time-based、不触发写,故其残余仍受 TTL 约束。
|
||||
{Model: readmodel.ModelChannelSelfBoosts, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
|
||||
}
|
||||
rows, err := s.versions.ReadModelHashes(ctx, keys)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
base := rows[keys[0]]
|
||||
if base == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return readmodel.MixHashes(base, rows[keys[1]], rows[keys[2]], rows[keys[3]]), nil
|
||||
}
|
||||
|
||||
func cloneChannelView(in domain.ChannelView) domain.ChannelView {
|
||||
in.Channel = cloneChannel(in.Channel)
|
||||
if in.Dialog.DefaultSendAs != nil {
|
||||
peer := *in.Dialog.DefaultSendAs
|
||||
in.Dialog.DefaultSendAs = &peer
|
||||
}
|
||||
if in.ExportedInvite != nil {
|
||||
invite := *in.ExportedInvite
|
||||
in.ExportedInvite = &invite
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func cloneChannel(in domain.Channel) domain.Channel {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
in.ReactionPolicy.Emoticons = append([]string(nil), in.ReactionPolicy.Emoticons...)
|
||||
in.ReactionPolicy.CustomEmojiIDs = append([]int64(nil), in.ReactionPolicy.CustomEmojiIDs...)
|
||||
return in
|
||||
}
|
||||
74
internal/app/channels/resolve_cache.go
Normal file
74
internal/app/channels/resolve_cache.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultChannelResolveReadModelTTL = 24 * time.Hour
|
||||
channelResolveReadModelMaxEntries = 16384
|
||||
)
|
||||
|
||||
// channelResolveReadModelCache 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU / clone)。
|
||||
type channelResolveReadModelCache struct {
|
||||
cache *readmodelcache.Cache[channelViewCacheKey, domain.ChannelView]
|
||||
}
|
||||
|
||||
func newChannelResolveReadModelCache(ttl time.Duration) *channelResolveReadModelCache {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultChannelResolveReadModelTTL
|
||||
}
|
||||
return &channelResolveReadModelCache{
|
||||
cache: readmodelcache.New[channelViewCacheKey, domain.ChannelView](readmodelcache.Config[channelViewCacheKey, domain.ChannelView]{
|
||||
MaxEntries: channelResolveReadModelMaxEntries,
|
||||
TTL: ttl,
|
||||
Clone: cloneChannelView,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *channelResolveReadModelCache) getOrLoad(ctx context.Context, key channelViewCacheKey, hash int64, load func() (domain.ChannelView, error)) (domain.ChannelView, error) {
|
||||
if c == nil {
|
||||
return load()
|
||||
}
|
||||
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
|
||||
}
|
||||
|
||||
func (s *Service) cachedResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) {
|
||||
if s.resolveCache == nil || s.versions == nil {
|
||||
return s.channels.ResolveChannel(ctx, userID, channelID)
|
||||
}
|
||||
hash, err := s.channelResolveHash(ctx, userID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelView{}, err
|
||||
}
|
||||
if hash == 0 {
|
||||
return s.channels.ResolveChannel(ctx, userID, channelID)
|
||||
}
|
||||
key := channelViewCacheKey{userID: userID, channelID: channelID}
|
||||
return s.resolveCache.getOrLoad(ctx, key, hash, func() (domain.ChannelView, error) {
|
||||
return s.channels.ResolveChannel(ctx, userID, channelID)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) channelResolveHash(ctx context.Context, userID, channelID int64) (int64, error) {
|
||||
keys := []store.ReadModelKey{
|
||||
{Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID},
|
||||
{Model: readmodel.ModelChannelMember, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
|
||||
}
|
||||
rows, err := s.versions.ReadModelHashes(ctx, keys)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
base := rows[keys[0]]
|
||||
if base == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return readmodel.MixHashes(base, rows[keys[1]]), nil
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package channels
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
|
|
@ -12,11 +13,57 @@ import (
|
|||
// Service exposes channel/supergroup business operations.
|
||||
type Service struct {
|
||||
channels store.ChannelStore
|
||||
bots BotProfileResolver
|
||||
versions store.ReadModelVersionStore
|
||||
sendGate SendPermissionChecker
|
||||
|
||||
viewCache *channelViewReadModelCache
|
||||
resolveCache *channelResolveReadModelCache
|
||||
mediaCountCache *mediaCountReadModelCache
|
||||
participantCache *participantsReadModelCache
|
||||
activeIDsCache *activeChannelIDsReadModelCache
|
||||
}
|
||||
|
||||
type Option func(*Service)
|
||||
|
||||
type SendPermissionChecker interface {
|
||||
CanSendMessages(ctx context.Context, userID int64) error
|
||||
}
|
||||
|
||||
// NewService creates a channel service.
|
||||
func NewService(channels store.ChannelStore) *Service {
|
||||
return &Service{channels: channels}
|
||||
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),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// WithBotProfileResolver enables group bot membership and privacy policy checks.
|
||||
func WithBotProfileResolver(bots BotProfileResolver) Option {
|
||||
return func(s *Service) {
|
||||
s.bots = bots
|
||||
}
|
||||
}
|
||||
|
||||
// WithReadModelVersions enables version-token guarded channel full-view caching.
|
||||
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
||||
return func(s *Service) {
|
||||
s.versions = v
|
||||
}
|
||||
}
|
||||
|
||||
func WithSendPermissionChecker(c SendPermissionChecker) Option {
|
||||
return func(s *Service) {
|
||||
s.sendGate = c
|
||||
}
|
||||
}
|
||||
|
||||
// CreateMegagroupFromCreateChat handles messages.createChat by directly creating a megagroup.
|
||||
|
|
@ -44,10 +91,24 @@ func (s *Service) CreateChannel(ctx context.Context, userID int64, req domain.Cr
|
|||
if len(req.MemberUserIDs) > domain.MaxChannelInviteUsers {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if err := s.rejectBlockedBotInvites(ctx, req.MemberUserIDs); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if !req.Broadcast && !req.Megagroup {
|
||||
req.Broadcast = true
|
||||
}
|
||||
return s.channels.CreateChannel(ctx, req)
|
||||
res, err := s.channels.CreateChannel(ctx, req)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(userID, res.Members)...)
|
||||
// 官方语义:创建即生成创建者的永久主链接(DrKLO 建频道后立刻
|
||||
// getExportedChatInvites 取 invites[0])。失败不阻断创建——
|
||||
// ListExportedInvites 首页自愈会兜底补上。
|
||||
if res.Channel.ID != 0 {
|
||||
_, _ = s.channels.EnsurePermanentInvite(ctx, res.Channel.ID, userID, req.Date)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// GetChannel returns channel data personalized for userID.
|
||||
|
|
@ -58,6 +119,67 @@ func (s *Service) GetChannel(ctx context.Context, userID, channelID int64) (doma
|
|||
return s.channels.GetChannel(ctx, userID, channelID)
|
||||
}
|
||||
|
||||
// GetChannelReadModel returns the full channel view through a version-token guarded
|
||||
// read model cache. It is intended for read-only RPC projection paths, not write
|
||||
// permission checks.
|
||||
func (s *Service) GetChannelReadModel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||
return domain.ChannelView{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.cachedChannelView(ctx, userID, channelID)
|
||||
}
|
||||
|
||||
// ResolveChannel 是 GetChannel 的轻量版(仅访问校验 + Channel/Self,跳过 dialog/boost 查询),
|
||||
// 供只需 access_hash / 频道标志的 peer 解析路径用。访问语义与 GetChannel 一致。
|
||||
func (s *Service) ResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||
return domain.ChannelView{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.cachedResolveChannel(ctx, userID, channelID)
|
||||
}
|
||||
|
||||
// SearchChannelMedia 返回某频道中属于给定媒体类别的消息(共享媒体标签页)。
|
||||
func (s *Service) SearchChannelMedia(ctx context.Context, userID, channelID int64, req domain.MediaSearchRequest) (domain.ChannelHistory, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||
return domain.ChannelHistory{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SearchChannelMedia(ctx, userID, channelID, req)
|
||||
}
|
||||
|
||||
// CountChannelMediaCategories 返回某频道对当前 viewer 可见消息按基础媒体类别聚合的精确计数。
|
||||
func (s *Service) CountChannelMediaCategories(ctx context.Context, userID, channelID int64) (domain.MediaCategoryCounts, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||
return domain.MediaCategoryCounts{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.cachedChannelMediaCounts(ctx, userID, channelID)
|
||||
}
|
||||
|
||||
// GetChannels returns channel data personalized for userID, ordered by the first occurrence in channelIDs.
|
||||
func (s *Service) GetChannels(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
return nil, domain.ErrChannelInvalid
|
||||
}
|
||||
ids := uniqueNonZero(channelIDs)
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if s.versions != nil {
|
||||
out := make([]domain.ChannelView, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
view, err := s.GetChannelReadModel(ctx, userID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelPrivate) || errors.Is(err, domain.ErrChannelInvalid) {
|
||||
continue
|
||||
}
|
||||
return s.channels.GetChannels(ctx, userID, ids)
|
||||
}
|
||||
out = append(out, view)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return s.channels.GetChannels(ctx, userID, ids)
|
||||
}
|
||||
|
||||
// GetJoinableChannel returns a channel shell so RPC can verify access hash before join.
|
||||
func (s *Service) GetJoinableChannel(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||
|
|
@ -66,6 +188,14 @@ func (s *Service) GetJoinableChannel(ctx context.Context, userID, channelID int6
|
|||
return s.channels.GetChannelByID(ctx, channelID)
|
||||
}
|
||||
|
||||
// GetChannelByID returns the non-personalized channel base row for internal admin use.
|
||||
func (s *Service) GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.GetChannelByID(ctx, channelID)
|
||||
}
|
||||
|
||||
// GetParticipants returns a bounded participants page.
|
||||
func (s *Service) GetParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||
|
|
@ -74,7 +204,7 @@ func (s *Service) GetParticipants(ctx context.Context, userID, channelID int64,
|
|||
if utf8.RuneCountInString(filter.Query) > domain.MaxChannelParticipantsQueryLength {
|
||||
return domain.ChannelParticipantList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.GetParticipants(ctx, userID, channelID, filter, offset, capLimit(limit, domain.MaxChannelParticipantsLimit))
|
||||
return s.cachedParticipants(ctx, userID, channelID, filter, offset, limit)
|
||||
}
|
||||
|
||||
// GetParticipant returns one participant.
|
||||
|
|
@ -85,6 +215,14 @@ func (s *Service) GetParticipant(ctx context.Context, userID, channelID, partici
|
|||
return s.channels.GetParticipant(ctx, userID, channelID, participantUserID)
|
||||
}
|
||||
|
||||
// FutureCreatorAfterLeave returns the member that will become creator if userID leaves.
|
||||
func (s *Service) FutureCreatorAfterLeave(ctx context.Context, userID, channelID int64) (domain.ChannelMember, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||
return domain.ChannelMember{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.FutureCreatorAfterLeave(ctx, channelID, userID)
|
||||
}
|
||||
|
||||
// InviteToChannel invites users to a channel/supergroup.
|
||||
func (s *Service) InviteToChannel(ctx context.Context, userID, channelID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || len(userIDs) == 0 {
|
||||
|
|
@ -93,7 +231,14 @@ func (s *Service) InviteToChannel(ctx context.Context, userID, channelID int64,
|
|||
if len(userIDs) > domain.MaxChannelInviteUsers {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.InviteToChannel(ctx, channelID, userID, userIDs, date)
|
||||
if err := s.rejectBlockedBotInvites(ctx, userIDs); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
res, err := s.channels.InviteToChannel(ctx, channelID, userID, userIDs, date)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(0, res.Members)...)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// JoinChannel joins current user to a channel/supergroup.
|
||||
|
|
@ -101,7 +246,11 @@ func (s *Service) JoinChannel(ctx context.Context, userID, channelID int64, date
|
|||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.JoinChannel(ctx, channelID, userID, date)
|
||||
res, err := s.channels.JoinChannel(ctx, channelID, userID, date)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(userID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// LeaveChannel leaves current user from a channel/supergroup.
|
||||
|
|
@ -109,7 +258,11 @@ func (s *Service) LeaveChannel(ctx context.Context, userID, channelID int64, dat
|
|||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.LeaveChannel(ctx, channelID, userID, date)
|
||||
res, err := s.channels.LeaveChannel(ctx, channelID, userID, date)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(userID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// EditTitle edits a channel/supergroup title.
|
||||
|
|
@ -126,6 +279,20 @@ func (s *Service) EditTitle(ctx context.Context, userID int64, req domain.EditCh
|
|||
return s.channels.EditChannelTitle(ctx, req)
|
||||
}
|
||||
|
||||
// SetWallpaper sets or clears the channel/supergroup wallpaper.
|
||||
func (s *Service) SetWallpaper(ctx context.Context, userID int64, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
return domain.SetChannelWallpaperResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = userID
|
||||
}
|
||||
if req.UserID != userID || req.ChannelID == 0 {
|
||||
return domain.SetChannelWallpaperResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelWallpaper(ctx, req)
|
||||
}
|
||||
|
||||
// EditAbout edits a channel/supergroup description.
|
||||
func (s *Service) EditAbout(ctx context.Context, userID int64, req domain.EditChannelAboutRequest) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
@ -154,6 +321,21 @@ func (s *Service) EditAdmin(ctx context.Context, userID int64, req domain.EditCh
|
|||
return s.channels.EditChannelAdmin(ctx, req)
|
||||
}
|
||||
|
||||
// EditMemberRank sets or clears a participant's member tag without touching
|
||||
// their role or admin rights.
|
||||
func (s *Service) EditMemberRank(ctx context.Context, userID int64, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = userID
|
||||
}
|
||||
if req.UserID != userID || req.ChannelID == 0 || req.MemberID == 0 || len(req.Rank) > domain.MaxChannelAdminRankLength {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.EditChannelMemberRank(ctx, req)
|
||||
}
|
||||
|
||||
// EditBanned edits a participant's banned rights.
|
||||
func (s *Service) EditBanned(ctx context.Context, userID int64, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
@ -165,7 +347,11 @@ func (s *Service) EditBanned(ctx context.Context, userID int64, req domain.EditC
|
|||
if req.UserID != userID || req.ChannelID == 0 || req.Participant.Type != domain.PeerTypeUser || req.Participant.ID == 0 {
|
||||
return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.EditChannelBanned(ctx, req)
|
||||
res, err := s.channels.EditChannelBanned(ctx, req)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(req.Participant.ID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// EditDefaultBannedRights edits the channel/supergroup default restrictions.
|
||||
|
|
@ -193,7 +379,11 @@ func (s *Service) DeleteChannel(ctx context.Context, userID int64, req domain.De
|
|||
if req.UserID != userID || req.ChannelID == 0 {
|
||||
return domain.DeleteChannelResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.DeleteChannel(ctx, req)
|
||||
res, err := s.channels.DeleteChannel(ctx, req)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(uniqueUserIDs(append([]int64{userID}, res.Recipients...)...)...)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// CheckUsername checks whether a channel username is syntactically valid and free.
|
||||
|
|
@ -226,6 +416,14 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, req domain.U
|
|||
return s.channels.UpdateUsername(ctx, req)
|
||||
}
|
||||
|
||||
// SetVerified sets or clears the channel/supergroup verified badge through the internal admin path.
|
||||
func (s *Service) SetVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelVerified(ctx, channelID, verified)
|
||||
}
|
||||
|
||||
// ListAdminedPublicChannels returns public channels/supergroups administered by user.
|
||||
func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
@ -234,6 +432,22 @@ func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) (
|
|||
return s.channels.ListAdminedPublicChannels(ctx, userID)
|
||||
}
|
||||
|
||||
// ListStoryPostableChannels returns channels where user can publish stories.
|
||||
func (s *Service) ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.channels.ListStoryPostableChannels(ctx, userID)
|
||||
}
|
||||
|
||||
// ListSendAsChannels returns the broadcast channels the user may post messages as in groups.
|
||||
func (s *Service) ListSendAsChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.channels.ListSendAsChannels(ctx, userID)
|
||||
}
|
||||
|
||||
// ResolvePublicUsername resolves a public channel/supergroup username visible to userID.
|
||||
func (s *Service) ResolvePublicUsername(ctx context.Context, userID int64, username string) (domain.Channel, bool, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
@ -318,9 +532,9 @@ func (s *Service) SetRestrictedSponsored(ctx context.Context, userID, channelID
|
|||
}
|
||||
|
||||
// SetPaidMessagesPrice stores the currently advertised paid-message price state.
|
||||
func (s *Service) SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error) {
|
||||
func (s *Service) SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.ChannelPaidMessagesPriceResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || stars < 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
return domain.ChannelPaidMessagesPriceResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetPaidMessagesPrice(ctx, userID, channelID, stars, broadcastMessagesAllowed)
|
||||
}
|
||||
|
|
@ -341,6 +555,15 @@ func (s *Service) SetSlowMode(ctx context.Context, userID, channelID int64, seco
|
|||
return s.channels.SetSlowMode(ctx, userID, channelID, seconds)
|
||||
}
|
||||
|
||||
// SetBoostsToUnblockRestrictions stores the boost threshold that lets boosted
|
||||
// members bypass default send-message restrictions.
|
||||
func (s *Service) SetBoostsToUnblockRestrictions(ctx context.Context, userID, channelID int64, boosts int) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || boosts < 0 || boosts > domain.MaxChannelBoostsToUnblockRestrictions {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetBoostsToUnblockRestrictions(ctx, userID, channelID, boosts)
|
||||
}
|
||||
|
||||
// SetNoForwards toggles channel/supergroup content protection.
|
||||
func (s *Service) SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||
|
|
@ -349,6 +572,30 @@ func (s *Service) SetNoForwards(ctx context.Context, userID, channelID int64, en
|
|||
return s.channels.SetNoForwards(ctx, userID, channelID, enabled)
|
||||
}
|
||||
|
||||
// SetHistoryTTL updates channel/supergroup message auto-delete period.
|
||||
func (s *Service) SetHistoryTTL(ctx context.Context, userID, channelID int64, period int, date int) (domain.Channel, []int64, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || period < 0 {
|
||||
return domain.Channel{}, nil, domain.ErrChannelInvalid
|
||||
}
|
||||
ttl, ok := s.channels.(store.ChannelHistoryTTLStore)
|
||||
if !ok {
|
||||
return domain.Channel{}, nil, domain.ErrChannelInvalid
|
||||
}
|
||||
return ttl.SetChannelHistoryTTL(ctx, userID, channelID, period, date)
|
||||
}
|
||||
|
||||
// ClaimExpiredMessages returns expired channel delete batches for the TTL worker.
|
||||
func (s *Service) ClaimExpiredMessages(ctx context.Context, now, limit int) ([]domain.DeleteChannelMessagesRequest, error) {
|
||||
if s == nil || s.channels == nil {
|
||||
return nil, nil
|
||||
}
|
||||
ttl, ok := s.channels.(store.ChannelHistoryTTLStore)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return ttl.ClaimExpiredChannelMessages(ctx, now, limit)
|
||||
}
|
||||
|
||||
// SetJoinToSend toggles whether non-members must join before sending in a megagroup.
|
||||
func (s *Service) SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||
|
|
@ -370,9 +617,9 @@ func (s *Service) SetAvailableReactions(ctx context.Context, userID, channelID i
|
|||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if len(policy.Emoticons)+len(policy.CustomEmojiIDs) > domain.MaxChannelReactionItems ||
|
||||
if len(policy.Emoticons)+len(policy.CustomEmojiIDs) > domain.MaxChannelReactionTypes ||
|
||||
policy.Limit < 0 ||
|
||||
policy.Limit > domain.MaxChannelReactionItems {
|
||||
policy.Limit > domain.MaxChannelReactionsLimit {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
for _, emoticon := range policy.Emoticons {
|
||||
|
|
@ -380,6 +627,11 @@ func (s *Service) SetAvailableReactions(ctx context.Context, userID, channelID i
|
|||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
}
|
||||
for _, documentID := range policy.CustomEmojiIDs {
|
||||
if documentID <= 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
}
|
||||
return s.channels.SetAvailableReactions(ctx, userID, channelID, policy)
|
||||
}
|
||||
|
||||
|
|
@ -402,6 +654,55 @@ func (s *Service) SetEmojiStatus(ctx context.Context, userID, channelID int64, s
|
|||
return s.channels.SetEmojiStatus(ctx, userID, channelID, status)
|
||||
}
|
||||
|
||||
func (s *Service) GetPremiumBoostStatus(ctx context.Context, userID, channelID int64, now int) (domain.PremiumBoostStatus, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || now < 0 {
|
||||
return domain.PremiumBoostStatus{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.GetPremiumBoostStatus(ctx, userID, channelID, now)
|
||||
}
|
||||
|
||||
func (s *Service) ListPremiumBoosts(ctx context.Context, userID, channelID int64, gifts bool, offset string, limit, now int) (domain.PremiumBoostList, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || now < 0 || len(offset) > domain.MaxPremiumBoostsOffsetBytes {
|
||||
return domain.PremiumBoostList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = domain.MaxPremiumBoostsListLimit
|
||||
}
|
||||
if limit > domain.MaxPremiumBoostsListLimit {
|
||||
limit = domain.MaxPremiumBoostsListLimit
|
||||
}
|
||||
return s.channels.ListPremiumBoosts(ctx, userID, channelID, gifts, offset, limit, now)
|
||||
}
|
||||
|
||||
func (s *Service) GetPremiumMyBoosts(ctx context.Context, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || now < 0 || premiumUntil < 0 {
|
||||
return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.GetPremiumMyBoosts(ctx, userID, now, premiumUntil)
|
||||
}
|
||||
|
||||
func (s *Service) ApplyPremiumBoost(ctx context.Context, userID, channelID int64, slots []int, now, premiumUntil int) (domain.PremiumMyBoosts, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || len(slots) == 0 || len(slots) > domain.MaxPremiumBoostSlotsPerApply || now < 0 || premiumUntil < 0 {
|
||||
return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
|
||||
}
|
||||
for _, slot := range slots {
|
||||
if slot <= 0 {
|
||||
return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
|
||||
}
|
||||
}
|
||||
if premiumUntil <= now {
|
||||
return domain.PremiumMyBoosts{}, domain.ErrPremiumRequired
|
||||
}
|
||||
return s.channels.ApplyPremiumBoost(ctx, userID, channelID, slots, now, premiumUntil)
|
||||
}
|
||||
|
||||
func (s *Service) GetPremiumUserBoosts(ctx context.Context, userID, channelID, targetUserID int64, now int) (domain.PremiumBoostList, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 || targetUserID == 0 || now < 0 {
|
||||
return domain.PremiumBoostList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.GetPremiumUserBoosts(ctx, userID, channelID, targetUserID, now)
|
||||
}
|
||||
|
||||
// ListAdminLog returns one bounded, channel-scoped admin log page.
|
||||
func (s *Service) ListAdminLog(ctx context.Context, userID int64, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
@ -432,6 +733,46 @@ func (s *Service) GetChannelForChangeInfo(ctx context.Context, userID, channelID
|
|||
return domain.ChannelView{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
|
||||
// CanPostStory validates whether the current user can publish a channel story.
|
||||
func (s *Service) CanPostStory(ctx context.Context, userID, channelID int64) error {
|
||||
return s.canManageStory(ctx, userID, channelID, func(rights domain.ChannelAdminRights) bool {
|
||||
return rights.PostStories
|
||||
})
|
||||
}
|
||||
|
||||
// CanEditStory validates whether the current user can edit a channel story.
|
||||
func (s *Service) CanEditStory(ctx context.Context, userID, channelID int64) error {
|
||||
return s.canManageStory(ctx, userID, channelID, func(rights domain.ChannelAdminRights) bool {
|
||||
return rights.EditStories
|
||||
})
|
||||
}
|
||||
|
||||
// CanDeleteStory validates whether the current user can delete a channel story.
|
||||
func (s *Service) CanDeleteStory(ctx context.Context, userID, channelID int64) error {
|
||||
return s.canManageStory(ctx, userID, channelID, func(rights domain.ChannelAdminRights) bool {
|
||||
return rights.DeleteStories
|
||||
})
|
||||
}
|
||||
|
||||
// CanPinStory validates whether the current user can change channel story pin state.
|
||||
func (s *Service) CanPinStory(ctx context.Context, userID, channelID int64) error {
|
||||
return s.CanEditStory(ctx, userID, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) canManageStory(ctx context.Context, userID, channelID int64, allowed func(domain.ChannelAdminRights) bool) error {
|
||||
view, err := s.GetChannel(ctx, userID, channelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if view.Self.Role == domain.ChannelRoleCreator {
|
||||
return nil
|
||||
}
|
||||
if view.Self.Role == domain.ChannelRoleAdmin && allowed(view.Self.AdminRights) {
|
||||
return nil
|
||||
}
|
||||
return domain.ErrChannelAdminRequired
|
||||
}
|
||||
|
||||
// SaveDefaultSendAs persists the current user's default send-as peer for one channel/supergroup dialog.
|
||||
func (s *Service) SaveDefaultSendAs(ctx context.Context, userID int64, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
@ -482,6 +823,49 @@ func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req dom
|
|||
return s.channels.SetChannelMessageReactions(ctx, req)
|
||||
}
|
||||
|
||||
// SendPaidReaction 为一条广播频道消息增投付费 reaction 星数;扣费在 rpc 层经 Stars 账本
|
||||
// Debit 完成,本方法只负责累计与聚合。
|
||||
func (s *Service) SendPaidReaction(ctx context.Context, userID int64, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
|
||||
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = userID
|
||||
}
|
||||
if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID || req.Stars <= 0 || req.Stars > domain.MaxPaidReactionStarsPerRequest {
|
||||
return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.AddChannelMessagePaidReaction(ctx, req)
|
||||
}
|
||||
|
||||
// VoteMessagePoll 给频道/超级群消息上的 poll 投票(options 为空 = 撤票)。
|
||||
func (s *Service) VoteMessagePoll(ctx context.Context, userID int64, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
|
||||
return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = userID
|
||||
}
|
||||
if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID {
|
||||
return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.VoteChannelMessagePoll(ctx, req)
|
||||
}
|
||||
|
||||
// CloseMessagePoll 关闭频道/超级群消息上的 poll(仅 poll 创建者)。
|
||||
func (s *Service) CloseMessagePoll(ctx context.Context, userID int64, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
|
||||
return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = userID
|
||||
}
|
||||
if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID {
|
||||
return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.CloseChannelMessagePoll(ctx, req)
|
||||
}
|
||||
|
||||
// GetMessageReactions returns reaction summaries for exact channel/supergroup message ids.
|
||||
func (s *Service) GetMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 {
|
||||
|
|
@ -813,9 +1197,24 @@ func (s *Service) SendMessage(ctx context.Context, userID int64, req domain.Send
|
|||
if req.UserID != userID {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if err := s.ensureCanSend(ctx, req.UserID); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
skipped, err := s.skippedBotDeliveryUserIDs(ctx, req)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
req.SkipDeliveryUserIDs = mergeSkippedUserIDs(req.SkipDeliveryUserIDs, skipped)
|
||||
return s.channels.SendChannelMessage(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) ensureCanSend(ctx context.Context, userID int64) error {
|
||||
if s == nil || s.sendGate == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.sendGate.CanSendMessages(ctx, userID)
|
||||
}
|
||||
|
||||
// EditMessage edits a channel/supergroup text message.
|
||||
func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
@ -830,6 +1229,23 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit
|
|||
return s.channels.EditChannelMessage(ctx, req)
|
||||
}
|
||||
|
||||
// GetInlineBotMessage returns one live channel message addressed by a signed inline id.
|
||||
func (s *Service) GetInlineBotMessage(ctx context.Context, botID, channelID int64, id int) (domain.Channel, domain.ChannelMessage, bool, error) {
|
||||
if s == nil || s.channels == nil || botID == 0 || channelID == 0 || id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return domain.Channel{}, domain.ChannelMessage{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.GetChannelMessageForInlineBot(ctx, botID, channelID, id)
|
||||
}
|
||||
|
||||
// EditInlineBotMessage edits a channel message through its via-bot inline id.
|
||||
func (s *Service) EditInlineBotMessage(ctx context.Context, botID int64, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) {
|
||||
if s == nil || s.channels == nil || botID == 0 || req.ChannelID == 0 || req.ID <= 0 || req.UserID == 0 {
|
||||
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
req.ViaBotEditBotID = botID
|
||||
return s.channels.EditChannelMessage(ctx, req)
|
||||
}
|
||||
|
||||
// DeleteMessages deletes a bounded set of channel/supergroup messages.
|
||||
func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
@ -889,6 +1305,20 @@ func (s *Service) UpdatePinnedMessage(ctx context.Context, userID int64, req dom
|
|||
return s.channels.UpdatePinnedMessage(ctx, req)
|
||||
}
|
||||
|
||||
// UnpinAllMessages clears every pinned message in a channel/supergroup.
|
||||
func (s *Service) UnpinAllMessages(ctx context.Context, userID int64, req domain.UnpinAllChannelMessagesRequest) (domain.UpdateChannelPinnedMessageResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = userID
|
||||
}
|
||||
if req.UserID != userID || req.ChannelID == 0 {
|
||||
return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.UnpinAllChannelMessages(ctx, req)
|
||||
}
|
||||
|
||||
// ExportInvite exports a channel/supergroup invite link.
|
||||
func (s *Service) ExportInvite(ctx context.Context, userID int64, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
@ -926,7 +1356,11 @@ func (s *Service) ImportInvite(ctx context.Context, userID int64, req domain.Imp
|
|||
return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty
|
||||
}
|
||||
req.Hash = strings.TrimSpace(req.Hash)
|
||||
return s.channels.ImportInvite(ctx, req)
|
||||
res, err := s.channels.ImportInvite(ctx, req)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(userID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// ListExportedInvites returns a bounded invite management page.
|
||||
|
|
@ -942,6 +1376,13 @@ func (s *Service) ListExportedInvites(ctx context.Context, userID int64, req dom
|
|||
}
|
||||
req.OffsetHash = strings.TrimSpace(req.OffsetHash)
|
||||
req.Limit = capLimit(req.Limit, domain.MaxChannelInviteListLimit)
|
||||
// 官方语义自愈:管理员查看自己的有效链接首页时,永久主链接必须存在
|
||||
//(存量频道/创建路径漏建借此补上;权限校验由 store 内部完成)。
|
||||
if req.AdminUserID == userID && !req.Revoked && req.OffsetDate == 0 && req.OffsetHash == "" {
|
||||
if _, err := s.channels.EnsurePermanentInvite(ctx, req.ChannelID, userID, 0); err != nil {
|
||||
return domain.ChannelInviteList{}, err
|
||||
}
|
||||
}
|
||||
return s.channels.ListExportedInvites(ctx, req)
|
||||
}
|
||||
|
||||
|
|
@ -1056,7 +1497,11 @@ func (s *Service) HideChatJoinRequest(ctx context.Context, userID int64, req dom
|
|||
if req.UserID != userID || req.ChannelID == 0 || req.TargetUserID == 0 {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.HideChatJoinRequest(ctx, req)
|
||||
res, err := s.channels.HideChatJoinRequest(ctx, req)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(req.TargetUserID, res.Members)...)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// HideAllChatJoinRequests approves or dismisses pending join requests in a bounded batch.
|
||||
|
|
@ -1072,7 +1517,11 @@ func (s *Service) HideAllChatJoinRequests(ctx context.Context, userID int64, req
|
|||
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
req.Limit = capLimit(req.Limit, domain.MaxChannelHideJoinRequests)
|
||||
return s.channels.HideAllChatJoinRequests(ctx, req)
|
||||
res, err := s.channels.HideAllChatJoinRequests(ctx, req)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(0, res.Members)...)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// ListDialogs returns current user's channel/supergroup dialog page.
|
||||
|
|
@ -1180,7 +1629,11 @@ func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.Ch
|
|||
return domain.ChannelHistory{}, domain.ErrChannelInvalid
|
||||
}
|
||||
filter.Limit = capLimit(filter.Limit, 100)
|
||||
return s.channels.ListChannelHistory(ctx, userID, filter)
|
||||
history, err := s.channels.ListChannelHistory(ctx, userID, filter)
|
||||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
return s.filterBotChannelHistory(ctx, userID, history), nil
|
||||
}
|
||||
|
||||
// SearchPosts returns a bounded page of public channel/supergroup posts.
|
||||
|
|
@ -1223,7 +1676,7 @@ func (s *Service) SearchJoinedMessages(ctx context.Context, userID int64, req do
|
|||
return domain.ChannelHistory{}, nil
|
||||
}
|
||||
req.Query = strings.TrimSpace(req.Query)
|
||||
if req.Query == "" {
|
||||
if req.Query == "" && !req.MusicOnly {
|
||||
return domain.ChannelHistory{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if utf8.RuneCountInString(req.Query) > domain.MaxChannelHistoryQueryLength {
|
||||
|
|
@ -1243,7 +1696,56 @@ func (s *Service) GetReplies(ctx context.Context, userID int64, filter domain.Ch
|
|||
}
|
||||
filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset)
|
||||
filter.Limit = capLimit(filter.Limit, domain.MaxChannelRepliesLimit)
|
||||
return s.channels.ListChannelReplies(ctx, userID, filter)
|
||||
history, err := s.channels.ListChannelReplies(ctx, userID, filter)
|
||||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
return s.filterBotChannelHistory(ctx, userID, history), nil
|
||||
}
|
||||
|
||||
// SendMonoforumMessage 发送频道私信(monoforum)。发件权限(订阅者身份 / monoforum 管理员)
|
||||
// 由 RPC 层校验,此处仅参数校验并委托 store(store 不要求发件人是 monoforum 成员)。
|
||||
func (s *Service) SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) {
|
||||
if s == nil || s.channels == nil || req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if err := s.ensureCanSend(ctx, req.SenderUserID); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
return s.channels.SendMonoforumMessage(ctx, req)
|
||||
}
|
||||
|
||||
// ListMonoforumHistory 拉取某订阅者在频道私信(monoforum)内的历史。
|
||||
func (s *Service) ListMonoforumHistory(ctx context.Context, filter domain.MonoforumHistoryFilter) (domain.ChannelHistory, error) {
|
||||
if s == nil || s.channels == nil || filter.MonoforumID == 0 || filter.SavedPeer.ID == 0 {
|
||||
return domain.ChannelHistory{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID {
|
||||
return domain.ChannelHistory{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
filter.Limit = capLimit(filter.Limit, domain.MaxChannelRepliesLimit)
|
||||
return s.channels.ListMonoforumHistory(ctx, filter)
|
||||
}
|
||||
|
||||
// ListMonoforumDialogs 列出 monoforum 的订阅者子会话(管理员视角私信列表)。访问权限(仅管理员)
|
||||
// 由 RPC 层校验。
|
||||
func (s *Service) ListMonoforumDialogs(ctx context.Context, filter domain.MonoforumDialogsFilter) (domain.MonoforumDialogList, error) {
|
||||
if s == nil || s.channels == nil || filter.MonoforumID == 0 {
|
||||
return domain.MonoforumDialogList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID {
|
||||
return domain.MonoforumDialogList{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
filter.Limit = capLimit(filter.Limit, domain.MaxChannelRepliesLimit)
|
||||
return s.channels.ListMonoforumDialogs(ctx, filter)
|
||||
}
|
||||
|
||||
// ResolveMonoforumSend 按 id 取 monoforum 频道(不要求成员身份)并返回调用者是否为其母频道管理员。
|
||||
func (s *Service) ResolveMonoforumSend(ctx context.Context, viewerUserID, monoforumID int64) (domain.Channel, bool, error) {
|
||||
if s == nil || s.channels == nil || viewerUserID == 0 || monoforumID == 0 {
|
||||
return domain.Channel{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.ResolveMonoforumSend(ctx, viewerUserID, monoforumID)
|
||||
}
|
||||
|
||||
// GetUnreadMentions returns a bounded unread mention page for a channel/supergroup.
|
||||
|
|
@ -1321,7 +1823,73 @@ func (s *Service) GetMessages(ctx context.Context, userID, channelID int64, ids
|
|||
return domain.ChannelHistory{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
}
|
||||
return s.channels.GetChannelMessages(ctx, userID, channelID, ids)
|
||||
history, err := s.channels.GetChannelMessages(ctx, userID, channelID, ids)
|
||||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
return s.filterBotChannelHistory(ctx, userID, history), nil
|
||||
}
|
||||
|
||||
// ChannelPollFanoutViews 批量为一组 viewer 返回频道 poll 消息的 per-viewer enrich(fan-out 模板化,
|
||||
// 消除逐 viewer GetMessages 的 N+1)。store 负责成员/AvailableMinID 可见性 + 模板聚合;此处叠加
|
||||
// bot 历史可见性过滤(复刻 filterBotChannelHistory:无 ChatHistory 的 bot 看不到该消息→置 nil)。
|
||||
// 返回 map[viewer]:key 存在=已评估(nil=不可见,调用方据此跳过且无需回退);非 nil=该 viewer enrich poll。
|
||||
func (s *Service) ChannelPollFanoutViews(ctx context.Context, channelID int64, msgID int, viewers []int64, now int) (map[int64]*domain.MessagePoll, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 || msgID <= 0 || len(viewers) == 0 {
|
||||
return map[int64]*domain.MessagePoll{}, nil
|
||||
}
|
||||
views, err := s.channels.ChannelPollFanoutViews(ctx, channelID, msgID, viewers, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !views.Found {
|
||||
return map[int64]*domain.MessagePoll{}, nil
|
||||
}
|
||||
if s.bots != nil {
|
||||
for viewer, poll := range views.Polls {
|
||||
if poll == nil {
|
||||
continue
|
||||
}
|
||||
if !s.botViewerCanSeeChannelMessage(ctx, viewer, views.Message) {
|
||||
views.Polls[viewer] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return views.Polls, nil
|
||||
}
|
||||
|
||||
// botViewerCanSeeChannelMessage 复刻 filterBotChannelHistory 的单消息判定:非 bot 或带 ChatHistory
|
||||
// 的 bot 一律可见;无 ChatHistory 的 bot 按 botCanSeeChannelMessage 判定该条消息是否可见。
|
||||
func (s *Service) botViewerCanSeeChannelMessage(ctx context.Context, viewer int64, msg domain.ChannelMessage) bool {
|
||||
if s.bots == nil || viewer == 0 {
|
||||
return true
|
||||
}
|
||||
profile, found, err := s.bots.BotInfo(ctx, viewer)
|
||||
if err != nil || !found || profile.ChatHistory {
|
||||
return true
|
||||
}
|
||||
visible, err := s.botCanSeeChannelMessage(ctx, viewer, msg, nil)
|
||||
return err == nil && visible
|
||||
}
|
||||
|
||||
// ListStoryMessageForwards returns public channel/supergroup messages that
|
||||
// shared a source story as messageMediaStory.
|
||||
func (s *Service) ListStoryMessageForwards(ctx context.Context, userID int64, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
return domain.StoryMessageForwardList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID || req.Owner.ID == 0 {
|
||||
return domain.StoryMessageForwardList{}, domain.ErrStoryIDInvalid
|
||||
}
|
||||
if req.Owner.Type != domain.PeerTypeUser && req.Owner.Type != domain.PeerTypeChannel {
|
||||
return domain.StoryMessageForwardList{}, domain.ErrStoryPeerInvalid
|
||||
}
|
||||
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
|
||||
return domain.StoryMessageForwardList{}, err
|
||||
}
|
||||
req.ViewerUserID = userID
|
||||
req.Limit = capLimit(req.Limit, domain.MaxStoryInteractionListLimit)
|
||||
return s.channels.ListStoryMessageForwards(ctx, req)
|
||||
}
|
||||
|
||||
// GetDiscussionMessage returns the root message used to open a discussion thread.
|
||||
|
|
@ -1349,6 +1917,28 @@ func (s *Service) ReadHistory(ctx context.Context, userID int64, req domain.Read
|
|||
return s.channels.ReadChannelHistory(ctx, req)
|
||||
}
|
||||
|
||||
// ReadTopicHistory advances current user's per-topic read watermark inside a forum.
|
||||
func (s *Service) ReadTopicHistory(ctx context.Context, userID int64, req domain.ReadChannelTopicHistoryRequest) (domain.ReadChannelTopicHistoryResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.TopicID <= 0 {
|
||||
return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = userID
|
||||
}
|
||||
if req.UserID != userID {
|
||||
return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.ReadChannelTopicHistory(ctx, req)
|
||||
}
|
||||
|
||||
// GeneralForumTopic 现算 forum General 话题(id=1)对 viewer 的状态。
|
||||
func (s *Service) GeneralForumTopic(ctx context.Context, userID, channelID int64) (domain.ChannelForumTopic, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||
return domain.ChannelForumTopic{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.GeneralForumTopic(ctx, userID, channelID)
|
||||
}
|
||||
|
||||
// GetMessageReadParticipants returns a bounded read receipt list for a small megagroup message.
|
||||
func (s *Service) GetMessageReadParticipants(ctx context.Context, userID int64, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
|
||||
|
|
@ -1372,7 +1962,7 @@ func (s *Service) ActiveChannelIDsForUser(ctx context.Context, userID, afterChan
|
|||
if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout {
|
||||
limit = domain.MaxSynchronousChannelDialogFanout
|
||||
}
|
||||
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
|
||||
return s.cachedActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
|
||||
}
|
||||
|
||||
// DirtyActiveChannelsForUser pages active joined channels with channel events after sinceDate.
|
||||
|
|
@ -1433,7 +2023,19 @@ func (s *Service) GetDifference(ctx context.Context, userID int64, req domain.Ch
|
|||
return domain.ChannelDifference{}, domain.ErrChannelInvalid
|
||||
}
|
||||
req.Limit = capLimit(req.Limit, domain.MaxChannelDifferenceLimit)
|
||||
return s.channels.ListChannelDifference(ctx, req)
|
||||
diff, err := s.channels.ListChannelDifference(ctx, req)
|
||||
if err != nil {
|
||||
return domain.ChannelDifference{}, err
|
||||
}
|
||||
return s.filterBotChannelDifference(ctx, userID, diff), nil
|
||||
}
|
||||
|
||||
// ClearDanglingPinnedMessage 清除指向已删除消息的悬挂置顶值(unpinAll 自愈)。
|
||||
func (s *Service) ClearDanglingPinnedMessage(ctx context.Context, channelID int64, messageID int) error {
|
||||
if s == nil || s.channels == nil || channelID == 0 || messageID <= 0 {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.ClearDanglingPinnedMessage(ctx, channelID, messageID)
|
||||
}
|
||||
|
||||
func capLimit(limit, max int) int {
|
||||
|
|
@ -1449,28 +2051,6 @@ func capLimit(limit, max int) int {
|
|||
return limit
|
||||
}
|
||||
|
||||
func uniqueNonZeroLimit(ids []int64, limit int) []int64 {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]int64, 0, minInt(len(ids), limit))
|
||||
seen := make(map[int64]struct{}, minInt(len(ids), limit))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniqueNonZero(ids []int64) []int64 {
|
||||
out := make([]int64, 0, len(ids))
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
|
|
@ -1487,11 +2067,26 @@ func uniqueNonZero(ids []int64) []int64 {
|
|||
return out
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
func uniqueUserIDs(ids ...int64) []int64 {
|
||||
return uniqueNonZero(ids)
|
||||
}
|
||||
|
||||
func activeMembershipUserIDsFromMembers(primary int64, members []domain.ChannelMember) []int64 {
|
||||
ids := make([]int64, 0, len(members)+1)
|
||||
if primary != 0 {
|
||||
ids = append(ids, primary)
|
||||
}
|
||||
return b
|
||||
for _, member := range members {
|
||||
ids = append(ids, member.UserID)
|
||||
}
|
||||
return uniqueNonZero(ids)
|
||||
}
|
||||
|
||||
func (s *Service) invalidateActiveChannelIDs(userIDs ...int64) {
|
||||
if s == nil || s.activeIDsCache == nil {
|
||||
return
|
||||
}
|
||||
s.activeIDsCache.invalidateUsers(userIDs...)
|
||||
}
|
||||
|
||||
func normalizeChannelUsername(username string) string {
|
||||
|
|
|
|||
38
internal/app/channels/service_groupcall.go
Normal file
38
internal/app/channels/service_groupcall.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// SetActiveCall 写入/清除频道行上的活跃群通话关联(groupcalls 模块专用)。
|
||||
func (s *Service) SetActiveCall(ctx context.Context, channelID, callID, callAccessHash int64, notEmpty bool) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetActiveCall(ctx, channelID, callID, callAccessHash, notEmpty)
|
||||
}
|
||||
|
||||
// AppendCallServiceMessage 生成群通话服务消息(started/ended/invite,带频道 pts)。
|
||||
func (s *Service) AppendCallServiceMessage(ctx context.Context, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) {
|
||||
if s == nil || s.channels == nil {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if err := s.ensureCanSend(ctx, senderUserID); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
return s.channels.AppendCallServiceMessage(ctx, channelID, senderUserID, date, action)
|
||||
}
|
||||
|
||||
// AppendStarGiftAdminLog 记录频道 Star gift 的 Recent Actions 快照;它不是频道历史消息,
|
||||
// 因此不产生 channel pts / updateNewChannelMessage / subscriber fanout。
|
||||
func (s *Service) AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error {
|
||||
if s == nil || s.channels == nil {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
if err := s.ensureCanSend(ctx, senderUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.channels.AppendStarGiftAdminLog(ctx, channelID, senderUserID, savedID, date, action)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue