chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
27
internal/app/messages/business_ai_echo.go
Normal file
27
internal/app/messages/business_ai_echo.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package messages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type EchoBusinessAutomationProvider struct{}
|
||||
|
||||
func NewEchoBusinessAutomationProvider() EchoBusinessAutomationProvider {
|
||||
return EchoBusinessAutomationProvider{}
|
||||
}
|
||||
|
||||
func (EchoBusinessAutomationProvider) BusinessAutomationReplies(_ context.Context, input BusinessAutomationReplyInput) ([]domain.QuickReplyMessage, error) {
|
||||
body := input.TriggerMessage.Body
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []domain.QuickReplyMessage{{
|
||||
ID: 1,
|
||||
Date: input.Now,
|
||||
Message: body,
|
||||
Entities: append([]domain.MessageEntity(nil), input.TriggerMessage.Entities...),
|
||||
}}, nil
|
||||
}
|
||||
418
internal/app/messages/business_automation.go
Normal file
418
internal/app/messages/business_automation.go
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
package messages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash/fnv"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type BusinessAutomationOnlineChecker interface {
|
||||
IsUserOnline(userID int64) bool
|
||||
}
|
||||
|
||||
type BusinessAutomationReplyProvider interface {
|
||||
BusinessAutomationReplies(ctx context.Context, input BusinessAutomationReplyInput) ([]domain.QuickReplyMessage, error)
|
||||
}
|
||||
|
||||
type BusinessAutomationReplyInput struct {
|
||||
Kind domain.BusinessAutomationKind
|
||||
OwnerUserID int64
|
||||
CustomerUserID int64
|
||||
Profile domain.BusinessProfile
|
||||
TriggerMessage domain.Message
|
||||
Templates []domain.QuickReplyMessage
|
||||
Now int
|
||||
}
|
||||
|
||||
type businessAutomationConfig struct {
|
||||
store store.BusinessAutomationStore
|
||||
online BusinessAutomationOnlineChecker
|
||||
replyProvider BusinessAutomationReplyProvider
|
||||
}
|
||||
|
||||
type BusinessAutomationOption func(*businessAutomationConfig)
|
||||
|
||||
func WithBusinessAutomation(business store.BusinessAutomationStore, opts ...BusinessAutomationOption) Option {
|
||||
return func(s *Service) {
|
||||
cfg := &businessAutomationConfig{store: business}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
s.business = cfg
|
||||
}
|
||||
}
|
||||
|
||||
func WithBusinessAutomationOnlineChecker(online BusinessAutomationOnlineChecker) BusinessAutomationOption {
|
||||
return func(cfg *businessAutomationConfig) {
|
||||
cfg.online = online
|
||||
}
|
||||
}
|
||||
|
||||
func WithBusinessAutomationReplyProvider(provider BusinessAutomationReplyProvider) BusinessAutomationOption {
|
||||
return func(cfg *businessAutomationConfig) {
|
||||
cfg.replyProvider = provider
|
||||
}
|
||||
}
|
||||
|
||||
type businessAutomationContext struct {
|
||||
ownerUserID int64
|
||||
customerUserID int64
|
||||
existingChat bool
|
||||
lastActivityDate int
|
||||
isContact bool
|
||||
}
|
||||
|
||||
func (s *Service) prepareBusinessAutomation(ctx context.Context, req domain.SendPrivateTextRequest) (businessAutomationContext, bool) {
|
||||
if !s.shouldConsiderBusinessAutomation(req) {
|
||||
return businessAutomationContext{}, false
|
||||
}
|
||||
out := businessAutomationContext{
|
||||
ownerUserID: req.RecipientUserID,
|
||||
customerUserID: req.SenderUserID,
|
||||
}
|
||||
if s.dialogs != nil {
|
||||
list, err := s.dialogs.ListByPeers(ctx, req.RecipientUserID, []domain.Peer{{Type: domain.PeerTypeUser, ID: req.SenderUserID}})
|
||||
if err != nil {
|
||||
return businessAutomationContext{}, false
|
||||
}
|
||||
if len(list.Dialogs) > 0 && list.Dialogs[0].TopMessage > 0 {
|
||||
out.existingChat = true
|
||||
out.lastActivityDate = list.Dialogs[0].TopMessageDate
|
||||
}
|
||||
}
|
||||
if s.contacts != nil {
|
||||
_, ok, err := s.contacts.Get(ctx, req.RecipientUserID, req.SenderUserID)
|
||||
if err != nil {
|
||||
return businessAutomationContext{}, false
|
||||
}
|
||||
out.isContact = ok
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func (s *Service) shouldConsiderBusinessAutomation(req domain.SendPrivateTextRequest) bool {
|
||||
if s == nil || s.business == nil || s.business.store == nil {
|
||||
return false
|
||||
}
|
||||
if req.BusinessAutomationKind != "" || req.RecipientBlocked {
|
||||
return false
|
||||
}
|
||||
if req.SenderUserID == 0 || req.RecipientUserID == 0 || req.SenderUserID == req.RecipientUserID {
|
||||
return false
|
||||
}
|
||||
if s.botResponder != nil && (s.botResponder.HandlesBot(req.SenderUserID) || s.botResponder.HandlesBot(req.RecipientUserID)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) runBusinessAutomation(ctx context.Context, req domain.SendPrivateTextRequest, res domain.SendPrivateTextResult, automation businessAutomationContext) {
|
||||
now := req.Date
|
||||
if now == 0 {
|
||||
now = res.RecipientMessage.Date
|
||||
}
|
||||
if now == 0 {
|
||||
now = int(time.Now().Unix())
|
||||
}
|
||||
trigger := res.RecipientMessage
|
||||
if trigger.ID == 0 {
|
||||
return
|
||||
}
|
||||
delivered, err := s.deliverConnectedBusinessBotAutomation(ctx, trigger, automation, now)
|
||||
if err != nil || delivered {
|
||||
return
|
||||
}
|
||||
profile, ok, err := s.business.store.GetBusinessProfile(ctx, automation.ownerUserID)
|
||||
if err != nil || !ok {
|
||||
return
|
||||
}
|
||||
profile.UserID = automation.ownerUserID
|
||||
if profile.Greeting != nil && s.businessGreetingEligible(*profile.Greeting, automation, now) {
|
||||
_ = s.deliverBusinessAutomation(ctx, profile, trigger, automation.customerUserID, domain.BusinessAutomationGreeting, profile.Greeting.ShortcutID, now)
|
||||
return
|
||||
}
|
||||
if profile.Away != nil && s.businessAwayEligible(ctx, profile, *profile.Away, automation, now) {
|
||||
_ = s.deliverBusinessAutomation(ctx, profile, trigger, automation.customerUserID, domain.BusinessAutomationAway, profile.Away.ShortcutID, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) businessGreetingEligible(greeting domain.BusinessGreetingMessage, automation businessAutomationContext, now int) bool {
|
||||
if !businessRecipientsMatch(greeting.Recipients, automation.existingChat, automation.isContact, automation.customerUserID) {
|
||||
return false
|
||||
}
|
||||
if !automation.existingChat {
|
||||
return true
|
||||
}
|
||||
if automation.lastActivityDate <= 0 {
|
||||
return false
|
||||
}
|
||||
return now-automation.lastActivityDate >= greeting.NoActivityDays*24*60*60
|
||||
}
|
||||
|
||||
func (s *Service) businessAwayEligible(ctx context.Context, profile domain.BusinessProfile, away domain.BusinessAwayMessage, automation businessAutomationContext, now int) bool {
|
||||
if !businessRecipientsMatch(away.Recipients, automation.existingChat, automation.isContact, automation.customerUserID) {
|
||||
return false
|
||||
}
|
||||
if away.OfflineOnly && s.business.online != nil && s.business.online.IsUserOnline(automation.ownerUserID) {
|
||||
return false
|
||||
}
|
||||
if !businessAwayScheduleActive(profile.WorkHours, away.Schedule, now) {
|
||||
return false
|
||||
}
|
||||
last, ok, err := s.business.store.LastBusinessAutomationDelivery(ctx, automation.ownerUserID, automation.customerUserID, domain.BusinessAutomationAway)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if away.Schedule.Kind == domain.BusinessAwayScheduleCustom && last.SentAt < away.Schedule.StartDate {
|
||||
return true
|
||||
}
|
||||
return now-last.SentAt >= domain.BusinessAwayCooldownSeconds
|
||||
}
|
||||
|
||||
func (s *Service) deliverConnectedBusinessBotAutomation(ctx context.Context, trigger domain.Message, automation businessAutomationContext, now int) (bool, error) {
|
||||
if s.business.replyProvider == nil {
|
||||
return false, nil
|
||||
}
|
||||
connected, ok, err := s.business.store.GetConnectedBusinessBot(ctx, automation.ownerUserID)
|
||||
if err != nil || !ok || connected.BotUserID == 0 || !connected.Rights.Reply {
|
||||
return false, err
|
||||
}
|
||||
state, stateFound, err := s.business.store.GetConnectedBusinessBotPeerState(ctx, automation.ownerUserID, automation.customerUserID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if stateFound && (state.Paused || state.Disabled) {
|
||||
return false, nil
|
||||
}
|
||||
if !domain.BusinessBotRecipientsMatch(connected.Recipients, automation.existingChat, automation.isContact, automation.customerUserID) {
|
||||
return false, nil
|
||||
}
|
||||
profile := domain.BusinessProfile{UserID: automation.ownerUserID}
|
||||
msgs, err := s.businessAutomationMessages(ctx, profile, trigger, automation.customerUserID, domain.BusinessAutomationAI, 0, now)
|
||||
if err != nil || len(msgs) == 0 {
|
||||
return false, err
|
||||
}
|
||||
if s.contacts != nil {
|
||||
blocked, err := s.contacts.IsBlocked(ctx, automation.customerUserID, automation.ownerUserID)
|
||||
if err != nil || blocked {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
reserved, err := s.business.store.ReserveBusinessAutomationDelivery(ctx, domain.BusinessAutomationDelivery{
|
||||
OwnerUserID: automation.ownerUserID,
|
||||
PeerUserID: automation.customerUserID,
|
||||
Kind: domain.BusinessAutomationAI,
|
||||
TriggerMessageID: trigger.ID,
|
||||
ShortcutID: 0,
|
||||
SentAt: now,
|
||||
})
|
||||
if err != nil || !reserved {
|
||||
return false, err
|
||||
}
|
||||
for i, msg := range msgs {
|
||||
_, err := s.SendPrivateText(ctx, automation.ownerUserID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: automation.ownerUserID,
|
||||
RecipientUserID: automation.customerUserID,
|
||||
RandomID: businessAutomationRandomID(domain.BusinessAutomationAI, automation.ownerUserID, automation.customerUserID, trigger.ID, msg.ID, i),
|
||||
Message: msg.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), msg.Entities...),
|
||||
Date: now,
|
||||
ViaBotID: connected.BotUserID,
|
||||
BusinessAutomationKind: domain.BusinessAutomationAI,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Service) deliverBusinessAutomation(ctx context.Context, profile domain.BusinessProfile, trigger domain.Message, customerUserID int64, kind domain.BusinessAutomationKind, shortcutID int, now int) error {
|
||||
if shortcutID <= 0 {
|
||||
return nil
|
||||
}
|
||||
msgs, err := s.businessAutomationMessages(ctx, profile, trigger, customerUserID, kind, shortcutID, now)
|
||||
if err != nil || len(msgs) == 0 {
|
||||
return err
|
||||
}
|
||||
if s.contacts != nil {
|
||||
blocked, err := s.contacts.IsBlocked(ctx, customerUserID, profile.UserID)
|
||||
if err != nil || blocked {
|
||||
return err
|
||||
}
|
||||
}
|
||||
reserved, err := s.business.store.ReserveBusinessAutomationDelivery(ctx, domain.BusinessAutomationDelivery{
|
||||
OwnerUserID: profile.UserID,
|
||||
PeerUserID: customerUserID,
|
||||
Kind: kind,
|
||||
TriggerMessageID: trigger.ID,
|
||||
ShortcutID: shortcutID,
|
||||
SentAt: now,
|
||||
})
|
||||
if err != nil || !reserved {
|
||||
return err
|
||||
}
|
||||
for i, msg := range msgs {
|
||||
_, err := s.SendPrivateText(ctx, profile.UserID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: profile.UserID,
|
||||
RecipientUserID: customerUserID,
|
||||
RandomID: businessAutomationRandomID(kind, profile.UserID, customerUserID, trigger.ID, msg.ID, i),
|
||||
Message: msg.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), msg.Entities...),
|
||||
Date: now,
|
||||
BusinessAutomationKind: kind,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) businessAutomationMessages(ctx context.Context, profile domain.BusinessProfile, trigger domain.Message, customerUserID int64, kind domain.BusinessAutomationKind, shortcutID int, now int) ([]domain.QuickReplyMessage, error) {
|
||||
var templateMessages []domain.QuickReplyMessage
|
||||
templates, err := s.business.store.GetQuickReplyMessages(ctx, profile.UserID, shortcutID, nil)
|
||||
if err != nil {
|
||||
if s.business.replyProvider == nil || !errors.Is(err, domain.ErrShortcutInvalid) {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
templateMessages = templates.Messages
|
||||
}
|
||||
msgs := cloneBusinessAutomationMessages(templateMessages)
|
||||
if s.business.replyProvider != nil {
|
||||
msgs, err = s.business.replyProvider.BusinessAutomationReplies(ctx, BusinessAutomationReplyInput{
|
||||
Kind: kind,
|
||||
OwnerUserID: profile.UserID,
|
||||
CustomerUserID: customerUserID,
|
||||
Profile: profile,
|
||||
TriggerMessage: trigger,
|
||||
Templates: cloneBusinessAutomationMessages(templateMessages),
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgs = cloneBusinessAutomationMessages(msgs)
|
||||
}
|
||||
out := make([]domain.QuickReplyMessage, 0, len(msgs))
|
||||
for _, msg := range msgs {
|
||||
if msg.Message == "" || utf8.RuneCountInString(msg.Message) > domain.MaxMessageTextLength || len(msg.Entities) > domain.MaxMessageEntityCount {
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
if len(out) >= domain.MaxQuickReplyMessages {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func businessRecipientsMatch(recipients domain.BusinessRecipients, existingChat, isContact bool, userID int64) bool {
|
||||
selected := false
|
||||
if existingChat && recipients.ExistingChats {
|
||||
selected = true
|
||||
}
|
||||
if !existingChat && recipients.NewChats {
|
||||
selected = true
|
||||
}
|
||||
if isContact && recipients.Contacts {
|
||||
selected = true
|
||||
}
|
||||
if !isContact && recipients.NonContacts {
|
||||
selected = true
|
||||
}
|
||||
for _, id := range recipients.Users {
|
||||
if id == userID {
|
||||
selected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if recipients.ExcludeSelected {
|
||||
return !selected
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func businessAwayScheduleActive(hours *domain.BusinessWorkHours, schedule domain.BusinessAwaySchedule, now int) bool {
|
||||
switch schedule.Kind {
|
||||
case domain.BusinessAwayScheduleAlways:
|
||||
return true
|
||||
case domain.BusinessAwayScheduleCustom:
|
||||
return now >= schedule.StartDate && now < schedule.EndDate
|
||||
case domain.BusinessAwayScheduleOutsideWorkHours:
|
||||
open, ok := businessWorkHoursOpen(hours, now)
|
||||
return ok && !open
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func businessWorkHoursOpen(hours *domain.BusinessWorkHours, now int) (bool, bool) {
|
||||
if hours == nil || hours.TimezoneID == "" || len(hours.WeeklyOpen) == 0 {
|
||||
return false, false
|
||||
}
|
||||
loc, err := time.LoadLocation(hours.TimezoneID)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
local := time.Unix(int64(now), 0).In(loc)
|
||||
weekday := (int(local.Weekday()) + 6) % 7
|
||||
minute := weekday*24*60 + local.Hour()*60 + local.Minute()
|
||||
const weekMinutes = 7 * 24 * 60
|
||||
for _, item := range hours.WeeklyOpen {
|
||||
if item.StartMinute < 0 || item.EndMinute <= item.StartMinute || item.EndMinute > 8*24*60 {
|
||||
continue
|
||||
}
|
||||
if item.EndMinute <= weekMinutes {
|
||||
if minute >= item.StartMinute && minute < item.EndMinute {
|
||||
return true, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if minute >= item.StartMinute || minute+weekMinutes < item.EndMinute {
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
return false, true
|
||||
}
|
||||
|
||||
func businessAutomationRandomID(kind domain.BusinessAutomationKind, ownerUserID, customerUserID int64, triggerMessageID, templateMessageID, index int) int64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(kind))
|
||||
_, _ = h.Write([]byte{0})
|
||||
writeBusinessAutomationHashInt64(h, ownerUserID)
|
||||
writeBusinessAutomationHashInt64(h, customerUserID)
|
||||
writeBusinessAutomationHashInt64(h, int64(triggerMessageID))
|
||||
writeBusinessAutomationHashInt64(h, int64(templateMessageID))
|
||||
writeBusinessAutomationHashInt64(h, int64(index))
|
||||
id := int64(h.Sum64() & 0x7fffffffffffffff)
|
||||
if id == 0 {
|
||||
return 1
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func writeBusinessAutomationHashInt64(h interface{ Write([]byte) (int, error) }, v int64) {
|
||||
var buf [8]byte
|
||||
binary.BigEndian.PutUint64(buf[:], uint64(v))
|
||||
_, _ = h.Write(buf[:])
|
||||
}
|
||||
|
||||
func cloneBusinessAutomationMessages(in []domain.QuickReplyMessage) []domain.QuickReplyMessage {
|
||||
out := make([]domain.QuickReplyMessage, 0, len(in))
|
||||
for _, msg := range in {
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
163
internal/app/messages/private_media_count_cache.go
Normal file
163
internal/app/messages/private_media_count_cache.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package messages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPrivateMediaCountReadModelTTL = 24 * time.Hour
|
||||
privateMediaCountReadModelMaxEntries = 8192
|
||||
)
|
||||
|
||||
type privateMediaCountCacheKey struct {
|
||||
userID int64
|
||||
peerID int64
|
||||
}
|
||||
|
||||
type privateMediaCountSnapshot struct {
|
||||
counts domain.MediaCategoryCounts
|
||||
hash int64
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
type privateMediaCountReadModelCache struct {
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.RWMutex
|
||||
snapshots map[privateMediaCountCacheKey]privateMediaCountSnapshot
|
||||
epoch uint64
|
||||
}
|
||||
|
||||
func newPrivateMediaCountReadModelCache(ttl time.Duration) *privateMediaCountReadModelCache {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultPrivateMediaCountReadModelTTL
|
||||
}
|
||||
return &privateMediaCountReadModelCache{
|
||||
ttl: ttl,
|
||||
now: time.Now,
|
||||
snapshots: make(map[privateMediaCountCacheKey]privateMediaCountSnapshot, 1024),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) cachedPrivateMediaCounts(ctx context.Context, userID, peerID int64) (domain.MediaCategoryCounts, error) {
|
||||
if s.privateMediaCountCache == nil || s.versions == nil {
|
||||
return s.messages.CountPrivateMediaCategories(ctx, userID, peerID)
|
||||
}
|
||||
hash, found, err := s.versions.ReadModelHash(ctx, readmodel.ModelPrivateMediaCounts, userID, domain.PeerTypeUser, peerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found || hash == 0 {
|
||||
return s.messages.CountPrivateMediaCategories(ctx, userID, peerID)
|
||||
}
|
||||
key := privateMediaCountCacheKey{userID: userID, peerID: peerID}
|
||||
loadEpoch := s.privateMediaCountCache.cacheEpoch()
|
||||
if snap, ok := s.privateMediaCountCache.lookup(key, s.privateMediaCountCache.now(), hash); ok {
|
||||
return clonePrivateMediaCategoryCounts(snap.counts), nil
|
||||
}
|
||||
counts, err := s.messages.CountPrivateMediaCategories(ctx, userID, peerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.privateMediaCountCache.putIfEpoch(key, counts, hash, loadEpoch)
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (c *privateMediaCountReadModelCache) lookup(key privateMediaCountCacheKey, now time.Time, currentHash int64) (privateMediaCountSnapshot, bool) {
|
||||
c.mu.RLock()
|
||||
snap, ok := c.snapshots[key]
|
||||
c.mu.RUnlock()
|
||||
if !ok || !snap.expireAt.After(now) {
|
||||
if ok {
|
||||
c.invalidate(key)
|
||||
}
|
||||
return privateMediaCountSnapshot{}, false
|
||||
}
|
||||
if currentHash != 0 && snap.hash != currentHash {
|
||||
return privateMediaCountSnapshot{}, false
|
||||
}
|
||||
return snap, true
|
||||
}
|
||||
|
||||
func (c *privateMediaCountReadModelCache) putIfEpoch(key privateMediaCountCacheKey, counts domain.MediaCategoryCounts, hash int64, expectedEpoch uint64) {
|
||||
if c == nil || key.userID == 0 || key.peerID == 0 || hash == 0 {
|
||||
return
|
||||
}
|
||||
if c.cacheEpoch() != expectedEpoch {
|
||||
return
|
||||
}
|
||||
snap := privateMediaCountSnapshot{
|
||||
counts: clonePrivateMediaCategoryCounts(counts),
|
||||
hash: hash,
|
||||
expireAt: c.now().Add(c.ttl),
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.epoch != expectedEpoch {
|
||||
return
|
||||
}
|
||||
if len(c.snapshots) >= privateMediaCountReadModelMaxEntries {
|
||||
c.snapshots = make(map[privateMediaCountCacheKey]privateMediaCountSnapshot, 1024)
|
||||
}
|
||||
c.snapshots[key] = snap
|
||||
}
|
||||
|
||||
func (c *privateMediaCountReadModelCache) invalidate(keys ...privateMediaCountCacheKey) {
|
||||
if c == nil || len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.epoch++
|
||||
for _, key := range keys {
|
||||
delete(c.snapshots, key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *privateMediaCountReadModelCache) flush() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.epoch++
|
||||
c.snapshots = make(map[privateMediaCountCacheKey]privateMediaCountSnapshot, 1024)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) InvalidatePrivateMediaCountReadModel(userID, peerID int64) {
|
||||
if s == nil || s.privateMediaCountCache == nil || userID == 0 || peerID == 0 {
|
||||
return
|
||||
}
|
||||
s.privateMediaCountCache.invalidate(privateMediaCountCacheKey{userID: userID, peerID: peerID})
|
||||
}
|
||||
|
||||
func (s *Service) FlushPrivateMediaCountReadModel() {
|
||||
if s == nil || s.privateMediaCountCache == nil {
|
||||
return
|
||||
}
|
||||
s.privateMediaCountCache.flush()
|
||||
}
|
||||
|
||||
func (c *privateMediaCountReadModelCache) cacheEpoch() uint64 {
|
||||
c.mu.RLock()
|
||||
epoch := c.epoch
|
||||
c.mu.RUnlock()
|
||||
return epoch
|
||||
}
|
||||
|
||||
func clonePrivateMediaCategoryCounts(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
|
||||
}
|
||||
|
|
@ -10,12 +10,32 @@ import (
|
|||
|
||||
// Service 提供消息历史、搜索与已读业务。
|
||||
type Service struct {
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
projector *userprojection.Projector
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
versions store.ReadModelVersionStore
|
||||
projector *userprojection.Projector
|
||||
botResponder BotResponder
|
||||
sendGate SendPermissionChecker
|
||||
business *businessAutomationConfig
|
||||
|
||||
privateMediaCountCache *privateMediaCountReadModelCache
|
||||
}
|
||||
|
||||
type SendPermissionChecker interface {
|
||||
CanSendMessages(ctx context.Context, userID int64) error
|
||||
}
|
||||
|
||||
// BotResponder 响应投递给服务端内置 bot(BotFather)的私聊消息。
|
||||
// 实现方在用户消息已成功入库后被同步调用;回复失败只能记日志,
|
||||
// 绝不允许影响用户消息发送结果。
|
||||
type BotResponder interface {
|
||||
// HandlesBot 报告 botUserID 是否为该 responder 负责的内置 bot。
|
||||
HandlesBot(botUserID int64) bool
|
||||
// OnPrivateMessage 处理一条投递给内置 bot 的消息;msg 为 bot 视角收件 box 行。
|
||||
OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message)
|
||||
}
|
||||
|
||||
// Option adjusts optional message service dependencies.
|
||||
|
|
@ -36,9 +56,27 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
// WithBotResponder 启用服务端内置 bot(BotFather)对私聊消息的自动应答。
|
||||
func WithBotResponder(r BotResponder) Option {
|
||||
return func(s *Service) { s.botResponder = r }
|
||||
}
|
||||
|
||||
func WithSendPermissionChecker(c SendPermissionChecker) Option {
|
||||
return func(s *Service) { s.sendGate = c }
|
||||
}
|
||||
|
||||
// WithReadModelVersions enables durable hash-token guarded media count caching.
|
||||
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
||||
return func(s *Service) { s.versions = v }
|
||||
}
|
||||
|
||||
// NewService 创建 messages 服务。
|
||||
func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...Option) *Service {
|
||||
s := &Service{messages: messages, dialogs: dialogs}
|
||||
s := &Service{
|
||||
messages: messages,
|
||||
dialogs: dialogs,
|
||||
privateMediaCountCache: newPrivateMediaCountReadModelCache(defaultPrivateMediaCountReadModelTTL),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
|
|
@ -58,7 +96,102 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.
|
|||
if req.SenderUserID == 0 {
|
||||
req.SenderUserID = userID
|
||||
}
|
||||
return s.messages.SendPrivateText(ctx, req)
|
||||
if err := s.ensureCanSend(ctx, req.SenderUserID); err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
automation, automationOK := s.prepareBusinessAutomation(ctx, req)
|
||||
res, err := s.messages.SendPrivateText(ctx, req)
|
||||
if err == nil && !res.Duplicate && automationOK {
|
||||
s.runBusinessAutomation(ctx, req, res, automation)
|
||||
}
|
||||
// 内置 bot 应答:用户消息已提交(幂等重放除外)后同步触发;responder 自行
|
||||
// 兜错,不回传失败。bot 自己发出的消息不触发(SenderUserID 不会是内置 bot
|
||||
// 的对话对象集合里关心的方向——hook 只看收件人)。
|
||||
if err == nil && !res.Duplicate && req.BusinessAutomationKind == "" && s.botResponder != nil && s.botResponder.HandlesBot(req.RecipientUserID) {
|
||||
s.botResponder.OnPrivateMessage(ctx, req.RecipientUserID, res.RecipientMessage)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// SetChatTheme updates the shared private-chat theme and records the timeline service message.
|
||||
func (s *Service) SetChatTheme(ctx context.Context, userID int64, req domain.SetPrivateChatThemeRequest) (domain.SetPrivateChatThemeResult, error) {
|
||||
out := domain.SetPrivateChatThemeResult{
|
||||
OwnerUserID: userID,
|
||||
Peer: req.Peer,
|
||||
Emoticon: req.Emoticon,
|
||||
}
|
||||
if s == nil || s.messages == nil || s.dialogs == nil || userID == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
if req.OwnerUserID != userID || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 {
|
||||
return out, domain.ErrMessageIDInvalid
|
||||
}
|
||||
changedSelf, err := s.dialogs.SetChatTheme(ctx, userID, req.Peer, req.Emoticon)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
|
||||
changedPeer := false
|
||||
if req.Peer.ID != userID && !req.RecipientBlocked {
|
||||
changedPeer, err = s.dialogs.SetChatTheme(ctx, req.Peer.ID, otherPeer, req.Emoticon)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
out.Changed = changedSelf || changedPeer
|
||||
if !out.Changed {
|
||||
return out, nil
|
||||
}
|
||||
send, err := s.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: userID,
|
||||
RecipientUserID: req.Peer.ID,
|
||||
RandomID: chatThemeServiceMessageRandomID(userID, req.Peer.ID, req.Emoticon, req.Date),
|
||||
Media: chatThemeServiceMedia(req.Emoticon),
|
||||
Silent: true,
|
||||
Date: req.Date,
|
||||
OriginAuthKeyID: req.OriginAuthKeyID,
|
||||
OriginSessionID: req.OriginSessionID,
|
||||
RecipientBlocked: req.RecipientBlocked,
|
||||
})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Send = send
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func chatThemeServiceMedia(emoticon string) *domain.MessageMedia {
|
||||
return &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionSetChatTheme,
|
||||
ChatThemeEmoticon: emoticon,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func chatThemeServiceMessageRandomID(userID, peerUserID int64, emoticon string, date int) int64 {
|
||||
var id int64 = 0x43485448454d45
|
||||
id ^= userID << 21
|
||||
id ^= peerUserID << 7
|
||||
id ^= int64(date) << 33
|
||||
for _, r := range emoticon {
|
||||
id = (id << 5) - id + int64(r)
|
||||
}
|
||||
if id == 0 {
|
||||
return 0x43485401
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ForwardPrivateMessages 转发当前账号可见的私聊文本消息。
|
||||
|
|
@ -69,6 +202,9 @@ func (s *Service) ForwardPrivateMessages(ctx context.Context, userID int64, req
|
|||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
if err := s.ensureCanSend(ctx, req.OwnerUserID); err != nil {
|
||||
return domain.ForwardPrivateMessagesResult{OwnerUserID: req.OwnerUserID}, err
|
||||
}
|
||||
return s.messages.ForwardPrivateMessages(ctx, req)
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +230,22 @@ func (s *Service) Search(ctx context.Context, userID int64, filter domain.Messag
|
|||
return s.list(ctx, userID, filter)
|
||||
}
|
||||
|
||||
// SearchPrivateMedia 返回某私聊会话中属于给定媒体类别的消息(共享媒体标签页)。
|
||||
func (s *Service) SearchPrivateMedia(ctx context.Context, userID, peerID int64, req domain.MediaSearchRequest) (domain.MessageList, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 || peerID == 0 {
|
||||
return domain.MessageList{}, nil
|
||||
}
|
||||
return s.messages.SearchPrivateMedia(ctx, userID, peerID, req)
|
||||
}
|
||||
|
||||
// CountPrivateMediaCategories 返回某私聊会话按基础媒体类别聚合的精确计数。
|
||||
func (s *Service) CountPrivateMediaCategories(ctx context.Context, userID, peerID int64) (domain.MediaCategoryCounts, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 || peerID == 0 {
|
||||
return domain.MediaCategoryCounts{}, nil
|
||||
}
|
||||
return s.cachedPrivateMediaCounts(ctx, userID, peerID)
|
||||
}
|
||||
|
||||
// ReadHistory 将当前账号某个 peer 的 inbox 标记为已读,并为发送方生成 outbox 已读回执。
|
||||
func (s *Service) ReadHistory(ctx context.Context, userID int64, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) {
|
||||
if s == nil || userID == 0 {
|
||||
|
|
@ -156,6 +308,34 @@ func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req dom
|
|||
return s.messages.SetMessageReactions(ctx, req)
|
||||
}
|
||||
|
||||
// VoteMessagePoll 给私聊消息上的 poll 投票(options 为空 = 撤票)。
|
||||
func (s *Service) VoteMessagePoll(ctx context.Context, userID int64, req domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = userID
|
||||
}
|
||||
if req.UserID != userID || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID {
|
||||
return domain.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
return s.messages.VoteMessagePoll(ctx, req)
|
||||
}
|
||||
|
||||
// CloseMessagePoll 关闭私聊消息上的 poll(仅 poll 创建者)。
|
||||
func (s *Service) CloseMessagePoll(ctx context.Context, userID int64, req domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = userID
|
||||
}
|
||||
if req.UserID != userID || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID {
|
||||
return domain.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
return s.messages.CloseMessagePoll(ctx, req)
|
||||
}
|
||||
|
||||
// GetMessageReactions returns reaction summaries for visible private messages.
|
||||
func (s *Service) GetMessageReactions(ctx context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
|
|
@ -186,6 +366,35 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit
|
|||
return s.messages.EditMessage(ctx, req)
|
||||
}
|
||||
|
||||
// PinPrivateMessage 翻转当前账号可见私聊消息的置顶状态;非 pm_oneside
|
||||
// 时同步翻转对端视角。
|
||||
func (s *Service) PinPrivateMessage(ctx context.Context, userID int64, req domain.PinPrivateMessageRequest) (domain.PinPrivateMessageResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.PinPrivateMessageResult{OwnerUserID: userID}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
if req.OwnerUserID != userID {
|
||||
return domain.PinPrivateMessageResult{OwnerUserID: userID}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
return s.messages.PinPrivateMessage(ctx, req)
|
||||
}
|
||||
|
||||
// UnpinAllPrivateMessages 清空当前账号与某私聊 peer 的全部置顶。
|
||||
func (s *Service) UnpinAllPrivateMessages(ctx context.Context, userID int64, req domain.UnpinAllPrivateMessagesRequest) (domain.PinPrivateMessageResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.PinPrivateMessageResult{OwnerUserID: userID}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
if req.OwnerUserID != userID {
|
||||
return domain.PinPrivateMessageResult{OwnerUserID: userID}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
return s.messages.UnpinAllPrivateMessages(ctx, req)
|
||||
}
|
||||
|
||||
// DeleteMessages 删除当前账号视角下的一组消息;revoke 时同步删除对端私聊盒子。
|
||||
func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
|
|
@ -208,6 +417,240 @@ func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.De
|
|||
return s.messages.DeleteHistory(ctx, req)
|
||||
}
|
||||
|
||||
// GetSavedDialogs 返回收藏夹子会话分页(messages.getSavedDialogs)。
|
||||
func (s *Service) GetSavedDialogs(ctx context.Context, userID int64, filter domain.SavedDialogsFilter) (domain.SavedDialogList, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.SavedDialogList{Full: true}, nil
|
||||
}
|
||||
return s.messages.ListSavedDialogs(ctx, userID, filter)
|
||||
}
|
||||
|
||||
// GetPinnedSavedDialogs 返回全部置顶收藏夹子会话(messages.getPinnedSavedDialogs)。
|
||||
func (s *Service) GetPinnedSavedDialogs(ctx context.Context, userID int64) (domain.SavedDialogList, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.SavedDialogList{Full: true}, nil
|
||||
}
|
||||
return s.messages.ListPinnedSavedDialogs(ctx, userID)
|
||||
}
|
||||
|
||||
// GetSavedDialogsByPeers 返回指定收藏夹子会话(messages.getSavedDialogsByID)。
|
||||
func (s *Service) GetSavedDialogsByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.SavedDialogList, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 || len(peers) == 0 {
|
||||
return domain.SavedDialogList{Full: true}, nil
|
||||
}
|
||||
return s.messages.ListSavedDialogsByPeers(ctx, userID, peers)
|
||||
}
|
||||
|
||||
// ToggleSavedDialogPin 翻转收藏夹子会话置顶状态,返回是否实际变化。
|
||||
func (s *Service) ToggleSavedDialogPin(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return s.messages.ToggleSavedDialogPin(ctx, userID, peer, pinned)
|
||||
}
|
||||
|
||||
// ReorderPinnedSavedDialogs 全量重排收藏夹置顶顺序。
|
||||
func (s *Service) ReorderPinnedSavedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.messages.ReorderPinnedSavedDialogs(ctx, userID, order, force)
|
||||
}
|
||||
|
||||
// DeleteSavedHistory 删除收藏夹一个子会话的消息(单批)。
|
||||
func (s *Service) DeleteSavedHistory(ctx context.Context, userID int64, req domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.DeleteSavedHistoryResult{}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
return s.messages.DeleteSavedHistory(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) ScheduleMessage(ctx context.Context, userID int64, req domain.ScheduleMessageRequest) (domain.ScheduledMessage, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.ScheduledMessage{}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
scheduled, ok := s.messages.(store.ScheduledMessageStore)
|
||||
if !ok {
|
||||
return domain.ScheduledMessage{}, nil
|
||||
}
|
||||
return scheduled.CreateScheduledMessage(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) ListScheduledMessages(ctx context.Context, userID int64, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.ScheduledMessageList{}, nil
|
||||
}
|
||||
if filter.OwnerUserID == 0 {
|
||||
filter.OwnerUserID = userID
|
||||
}
|
||||
scheduled, ok := s.messages.(store.ScheduledMessageStore)
|
||||
if !ok {
|
||||
return domain.ScheduledMessageList{}, nil
|
||||
}
|
||||
return scheduled.ListScheduledMessages(ctx, filter)
|
||||
}
|
||||
|
||||
func (s *Service) EditScheduledMessage(ctx context.Context, userID int64, req domain.EditScheduledMessageRequest) (domain.ScheduledMessage, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.ScheduledMessage{}, nil
|
||||
}
|
||||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
scheduled, ok := s.messages.(store.ScheduledMessageStore)
|
||||
if !ok {
|
||||
return domain.ScheduledMessage{}, nil
|
||||
}
|
||||
return scheduled.EditScheduledMessage(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) GetScheduledMessages(ctx context.Context, userID int64, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.ScheduledMessageList{}, nil
|
||||
}
|
||||
if filter.OwnerUserID == 0 {
|
||||
filter.OwnerUserID = userID
|
||||
}
|
||||
scheduled, ok := s.messages.(store.ScheduledMessageStore)
|
||||
if !ok {
|
||||
return domain.ScheduledMessageList{}, nil
|
||||
}
|
||||
return scheduled.GetScheduledMessages(ctx, filter)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteScheduledMessages(ctx context.Context, userID int64, filter domain.ScheduledMessageFilter, date int) ([]domain.ScheduledMessage, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if filter.OwnerUserID == 0 {
|
||||
filter.OwnerUserID = userID
|
||||
}
|
||||
scheduled, ok := s.messages.(store.ScheduledMessageStore)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return scheduled.DeleteScheduledMessages(ctx, filter, date)
|
||||
}
|
||||
|
||||
func (s *Service) ClaimScheduledMessages(ctx context.Context, userID int64, claim domain.ScheduledMessageClaim) ([]domain.ScheduledMessage, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if claim.OwnerUserID == 0 {
|
||||
claim.OwnerUserID = userID
|
||||
}
|
||||
scheduled, ok := s.messages.(store.ScheduledMessageStore)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return scheduled.ClaimScheduledMessages(ctx, claim)
|
||||
}
|
||||
|
||||
func (s *Service) ClaimDueScheduledMessages(ctx context.Context, now, limit, leaseSeconds int) ([]domain.ScheduledMessage, error) {
|
||||
if s == nil || s.messages == nil {
|
||||
return nil, nil
|
||||
}
|
||||
scheduled, ok := s.messages.(store.ScheduledMessageStore)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return scheduled.ClaimDueScheduledMessages(ctx, now, limit, leaseSeconds)
|
||||
}
|
||||
|
||||
func (s *Service) MarkScheduledMessageSent(ctx context.Context, ownerUserID int64, id, sentMessageID, date int) error {
|
||||
if s == nil || s.messages == nil {
|
||||
return nil
|
||||
}
|
||||
scheduled, ok := s.messages.(store.ScheduledMessageStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return scheduled.MarkScheduledMessageSent(ctx, ownerUserID, id, sentMessageID, date)
|
||||
}
|
||||
|
||||
func (s *Service) ReleaseScheduledMessage(ctx context.Context, ownerUserID int64, id int, errText string) error {
|
||||
if s == nil || s.messages == nil {
|
||||
return nil
|
||||
}
|
||||
scheduled, ok := s.messages.(store.ScheduledMessageStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return scheduled.ReleaseScheduledMessage(ctx, ownerUserID, id, errText)
|
||||
}
|
||||
|
||||
func (s *Service) HasScheduledMessages(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
scheduled, ok := s.messages.(store.ScheduledMessageStore)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
return scheduled.HasScheduledMessages(ctx, userID, peer)
|
||||
}
|
||||
|
||||
func (s *Service) GetPrivateHistoryTTL(ctx context.Context, userID int64, peer domain.Peer) (int, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ttl, ok := s.messages.(store.HistoryTTLStore)
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
return ttl.GetPrivateHistoryTTL(ctx, userID, peer)
|
||||
}
|
||||
|
||||
func (s *Service) SetPrivateHistoryTTL(ctx context.Context, userID int64, peer domain.Peer, period int) error {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
ttl, ok := s.messages.(store.HistoryTTLStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return ttl.SetPrivateHistoryTTL(ctx, userID, peer, period)
|
||||
}
|
||||
|
||||
func (s *Service) DefaultHistoryTTL(ctx context.Context, userID int64) (int, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ttl, ok := s.messages.(store.HistoryTTLStore)
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
return ttl.DefaultHistoryTTL(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) SetDefaultHistoryTTL(ctx context.Context, userID int64, period int) error {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
ttl, ok := s.messages.(store.HistoryTTLStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return ttl.SetDefaultHistoryTTL(ctx, userID, period)
|
||||
}
|
||||
|
||||
func (s *Service) ClaimExpiredPrivateMessages(ctx context.Context, now, limit int) ([]domain.DeleteMessagesRequest, error) {
|
||||
if s == nil || s.messages == nil {
|
||||
return nil, nil
|
||||
}
|
||||
ttl, ok := s.messages.(store.HistoryTTLStore)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return ttl.ClaimExpiredPrivateMessages(ctx, now, limit)
|
||||
}
|
||||
|
||||
func (s *Service) list(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
return domain.MessageList{}, nil
|
||||
|
|
@ -230,3 +673,26 @@ func (s *Service) projectMessageUsers(ctx context.Context, userID int64, list do
|
|||
list.Users = users
|
||||
return list, nil
|
||||
}
|
||||
|
||||
type privateUnreadReactionsStore interface {
|
||||
ListUnreadReactionMessages(ctx context.Context, ownerUserID int64, peer domain.Peer, limit int) ([]domain.Message, error)
|
||||
ReadPeerReactions(ctx context.Context, ownerUserID int64, peer domain.Peer) (int, error)
|
||||
}
|
||||
|
||||
// ListUnreadReactionMessages 返回私聊 peer 下带未读 reaction 的消息。
|
||||
func (s *Service) ListUnreadReactionMessages(ctx context.Context, userID int64, peer domain.Peer, limit int) ([]domain.Message, error) {
|
||||
store, ok := s.messages.(privateUnreadReactionsStore)
|
||||
if !ok || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return store.ListUnreadReactionMessages(ctx, userID, peer, limit)
|
||||
}
|
||||
|
||||
// ReadPeerReactions 清理私聊 peer 下的全部未读 reaction。
|
||||
func (s *Service) ReadPeerReactions(ctx context.Context, userID int64, peer domain.Peer) (int, error) {
|
||||
store, ok := s.messages.(privateUnreadReactionsStore)
|
||||
if !ok || userID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return store.ReadPeerReactions(ctx, userID, peer)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,51 @@ package messages
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/app/account"
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestServiceSendPrivateTextHonorsSendPermissionGate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := &gateMessageStore{}
|
||||
svc := NewService(store, nil, WithSendPermissionChecker(denySendChecker{}))
|
||||
if _, err := svc.SendPrivateText(ctx, 1001, domain.SendPrivateTextRequest{
|
||||
SenderUserID: 1001,
|
||||
RecipientUserID: 1002,
|
||||
RandomID: 1,
|
||||
Message: "blocked",
|
||||
}); !errors.Is(err, domain.ErrUserSendRestricted) {
|
||||
t.Fatalf("SendPrivateText err=%v, want ErrUserSendRestricted", err)
|
||||
}
|
||||
if store.sends != 0 {
|
||||
t.Fatalf("store sends=%d, want 0", store.sends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceForwardPrivateMessagesHonorsSendPermissionGate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := &gateMessageStore{}
|
||||
svc := NewService(store, nil, WithSendPermissionChecker(denySendChecker{}))
|
||||
if _, err := svc.ForwardPrivateMessages(ctx, 1001, domain.ForwardPrivateMessagesRequest{
|
||||
OwnerUserID: 1001,
|
||||
FromPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
|
||||
ToUserID: 1003,
|
||||
MessageIDs: []int{1},
|
||||
RandomIDs: []int64{2},
|
||||
}); !errors.Is(err, domain.ErrUserSendRestricted) {
|
||||
t.Fatalf("ForwardPrivateMessages err=%v, want ErrUserSendRestricted", err)
|
||||
}
|
||||
if store.forwards != 0 {
|
||||
t.Fatalf("store forwards=%d, want 0", store.forwards)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
|
|
@ -58,6 +97,281 @@ func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBusinessAutomationGreetingSendsQuickReplyWithoutLoop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 2001
|
||||
const customerID int64 = 2002
|
||||
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
business := memory.NewPasswordStore()
|
||||
accountSvc := account.NewService(business, account.WithBusinessAutomation(business))
|
||||
|
||||
ownerShortcutID := saveBusinessQuickReply(t, ctx, accountSvc, ownerID, "hello", "owner hello", 100)
|
||||
customerShortcutID := saveBusinessQuickReply(t, ctx, accountSvc, customerID, "hello", "customer hello", 101)
|
||||
if _, err := accountSvc.UpdateBusinessGreetingMessage(ctx, ownerID, &domain.BusinessGreetingMessage{
|
||||
ShortcutID: ownerShortcutID,
|
||||
Recipients: businessAutomationAllRecipients(),
|
||||
NoActivityDays: 7,
|
||||
}); err != nil {
|
||||
t.Fatalf("update owner greeting: %v", err)
|
||||
}
|
||||
if _, err := accountSvc.UpdateBusinessGreetingMessage(ctx, customerID, &domain.BusinessGreetingMessage{
|
||||
ShortcutID: customerShortcutID,
|
||||
Recipients: businessAutomationAllRecipients(),
|
||||
NoActivityDays: 7,
|
||||
}); err != nil {
|
||||
t.Fatalf("update customer greeting: %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(messages, dialogs, WithBusinessAutomation(business))
|
||||
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: customerID,
|
||||
RecipientUserID: ownerID,
|
||||
RandomID: 10001,
|
||||
Message: "hi",
|
||||
Date: 1_700_000_000,
|
||||
}); err != nil {
|
||||
t.Fatalf("send first message: %v", err)
|
||||
}
|
||||
|
||||
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "owner hello"); got != 1 {
|
||||
t.Fatalf("customer owner hello count = %d, want 1", got)
|
||||
}
|
||||
if got := countBusinessMessagesByBody(t, ctx, messages, ownerID, customerID, "owner hello"); got != 1 {
|
||||
t.Fatalf("owner outgoing hello count = %d, want 1", got)
|
||||
}
|
||||
if got := countBusinessMessagesByBody(t, ctx, messages, ownerID, customerID, "customer hello"); got != 0 {
|
||||
t.Fatalf("recursive customer hello count = %d, want 0", got)
|
||||
}
|
||||
|
||||
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: customerID,
|
||||
RecipientUserID: ownerID,
|
||||
RandomID: 10002,
|
||||
Message: "again",
|
||||
Date: 1_700_000_060,
|
||||
}); err != nil {
|
||||
t.Fatalf("send second message: %v", err)
|
||||
}
|
||||
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "owner hello"); got != 1 {
|
||||
t.Fatalf("owner hello after second incoming = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessAutomationAwayHonorsOnlineStateAndCooldown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 2101
|
||||
const customerID int64 = 2102
|
||||
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
business := memory.NewPasswordStore()
|
||||
accountSvc := account.NewService(business, account.WithBusinessAutomation(business))
|
||||
|
||||
shortcutID := saveBusinessQuickReply(t, ctx, accountSvc, ownerID, "away", "away reply", 200)
|
||||
if _, err := accountSvc.UpdateBusinessAwayMessage(ctx, ownerID, &domain.BusinessAwayMessage{
|
||||
ShortcutID: shortcutID,
|
||||
Schedule: domain.BusinessAwaySchedule{Kind: domain.BusinessAwayScheduleAlways},
|
||||
Recipients: businessAutomationAllRecipients(),
|
||||
OfflineOnly: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("update away: %v", err)
|
||||
}
|
||||
|
||||
online := businessAutomationOnline{ownerID: true}
|
||||
svc := NewService(messages, dialogs, WithBusinessAutomation(business, WithBusinessAutomationOnlineChecker(online)))
|
||||
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: customerID,
|
||||
RecipientUserID: ownerID,
|
||||
RandomID: 20001,
|
||||
Message: "online?",
|
||||
Date: 1_700_010_000,
|
||||
}); err != nil {
|
||||
t.Fatalf("send while online: %v", err)
|
||||
}
|
||||
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "away reply"); got != 0 {
|
||||
t.Fatalf("away while online count = %d, want 0", got)
|
||||
}
|
||||
|
||||
online[ownerID] = false
|
||||
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: customerID,
|
||||
RecipientUserID: ownerID,
|
||||
RandomID: 20002,
|
||||
Message: "offline?",
|
||||
Date: 1_700_010_060,
|
||||
}); err != nil {
|
||||
t.Fatalf("send while offline: %v", err)
|
||||
}
|
||||
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: customerID,
|
||||
RecipientUserID: ownerID,
|
||||
RandomID: 20003,
|
||||
Message: "still offline?",
|
||||
Date: 1_700_010_120,
|
||||
}); err != nil {
|
||||
t.Fatalf("send during cooldown: %v", err)
|
||||
}
|
||||
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "away reply"); got != 1 {
|
||||
t.Fatalf("away reply count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessAutomationReplyProviderCanReplaceTemplate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 2201
|
||||
const customerID int64 = 2202
|
||||
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
business := memory.NewPasswordStore()
|
||||
accountSvc := account.NewService(business, account.WithBusinessAutomation(business))
|
||||
|
||||
shortcutID := saveBusinessQuickReply(t, ctx, accountSvc, ownerID, "hello", "template reply", 300)
|
||||
if _, err := accountSvc.UpdateBusinessGreetingMessage(ctx, ownerID, &domain.BusinessGreetingMessage{
|
||||
ShortcutID: shortcutID,
|
||||
Recipients: businessAutomationAllRecipients(),
|
||||
NoActivityDays: 7,
|
||||
}); err != nil {
|
||||
t.Fatalf("update greeting: %v", err)
|
||||
}
|
||||
svc := NewService(messages, dialogs, WithBusinessAutomation(business, WithBusinessAutomationReplyProvider(staticBusinessAutomationProvider{message: "ai reply"})))
|
||||
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: customerID,
|
||||
RecipientUserID: ownerID,
|
||||
RandomID: 30001,
|
||||
Message: "hi",
|
||||
Date: 1_700_020_000,
|
||||
}); err != nil {
|
||||
t.Fatalf("send first message: %v", err)
|
||||
}
|
||||
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "ai reply"); got != 1 {
|
||||
t.Fatalf("provider reply count = %d, want 1", got)
|
||||
}
|
||||
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "template reply"); got != 0 {
|
||||
t.Fatalf("template reply count = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessAutomationEchoProviderEchoesTriggerText(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 2301
|
||||
const customerID int64 = 2302
|
||||
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
business := memory.NewPasswordStore()
|
||||
accountSvc := account.NewService(business, account.WithBusinessAutomation(business))
|
||||
|
||||
shortcutID := saveBusinessQuickReply(t, ctx, accountSvc, ownerID, "hello", "template reply", 400)
|
||||
if _, err := accountSvc.UpdateBusinessGreetingMessage(ctx, ownerID, &domain.BusinessGreetingMessage{
|
||||
ShortcutID: shortcutID,
|
||||
Recipients: businessAutomationAllRecipients(),
|
||||
NoActivityDays: 7,
|
||||
}); err != nil {
|
||||
t.Fatalf("update greeting: %v", err)
|
||||
}
|
||||
svc := NewService(messages, dialogs, WithBusinessAutomation(business, WithBusinessAutomationReplyProvider(NewEchoBusinessAutomationProvider())))
|
||||
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: customerID,
|
||||
RecipientUserID: ownerID,
|
||||
RandomID: 40001,
|
||||
Message: "echo this",
|
||||
Date: 1_700_030_000,
|
||||
}); err != nil {
|
||||
t.Fatalf("send first message: %v", err)
|
||||
}
|
||||
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "echo this"); got != 2 {
|
||||
t.Fatalf("echo body count in customer history = %d, want 2 (outgoing + echo reply)", got)
|
||||
}
|
||||
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "template reply"); got != 0 {
|
||||
t.Fatalf("template reply count = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectedBusinessBotEchoHonorsPauseAndDisable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 2401
|
||||
const customerID int64 = 2402
|
||||
const botID int64 = 2403
|
||||
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
business := memory.NewPasswordStore()
|
||||
accountSvc := account.NewService(business, account.WithBusinessAutomation(business))
|
||||
if _, err := accountSvc.SaveConnectedBusinessBot(ctx, ownerID, domain.ConnectedBusinessBot{
|
||||
BotUserID: botID,
|
||||
Recipients: domain.BusinessBotRecipients{ExcludeSelected: true},
|
||||
Rights: domain.BusinessBotRights{Reply: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("save connected bot: %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(messages, dialogs, WithBusinessAutomation(business, WithBusinessAutomationReplyProvider(NewEchoBusinessAutomationProvider())))
|
||||
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: customerID,
|
||||
RecipientUserID: ownerID,
|
||||
RandomID: 50001,
|
||||
Message: "connected echo",
|
||||
Date: 1_700_050_000,
|
||||
}); err != nil {
|
||||
t.Fatalf("send connected echo: %v", err)
|
||||
}
|
||||
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "connected echo"); got != 2 {
|
||||
t.Fatalf("connected echo customer count = %d, want 2 (original + echo)", got)
|
||||
}
|
||||
assertMessageViaBot(t, ctx, messages, customerID, ownerID, "connected echo", botID)
|
||||
|
||||
if _, err := accountSvc.SetConnectedBusinessBotPaused(ctx, ownerID, customerID, true); err != nil {
|
||||
t.Fatalf("pause connected bot: %v", err)
|
||||
}
|
||||
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: customerID,
|
||||
RecipientUserID: ownerID,
|
||||
RandomID: 50002,
|
||||
Message: "paused echo",
|
||||
Date: 1_700_050_060,
|
||||
}); err != nil {
|
||||
t.Fatalf("send paused echo: %v", err)
|
||||
}
|
||||
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "paused echo"); got != 1 {
|
||||
t.Fatalf("paused echo customer count = %d, want 1", got)
|
||||
}
|
||||
|
||||
if _, err := accountSvc.SetConnectedBusinessBotPaused(ctx, ownerID, customerID, false); err != nil {
|
||||
t.Fatalf("unpause connected bot: %v", err)
|
||||
}
|
||||
if _, err := accountSvc.DisableConnectedBusinessBotForPeer(ctx, ownerID, customerID); err != nil {
|
||||
t.Fatalf("disable connected bot peer: %v", err)
|
||||
}
|
||||
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: customerID,
|
||||
RecipientUserID: ownerID,
|
||||
RandomID: 50003,
|
||||
Message: "disabled echo",
|
||||
Date: 1_700_050_120,
|
||||
}); err != nil {
|
||||
t.Fatalf("send disabled echo: %v", err)
|
||||
}
|
||||
if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "disabled echo"); got != 1 {
|
||||
t.Fatalf("disabled echo customer count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEchoBusinessAutomationProviderSkipsEmptyText(t *testing.T) {
|
||||
msgs, err := NewEchoBusinessAutomationProvider().BusinessAutomationReplies(context.Background(), BusinessAutomationReplyInput{
|
||||
TriggerMessage: domain.Message{Body: " \t\n"},
|
||||
Now: 1_700_040_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BusinessAutomationReplies: %v", err)
|
||||
}
|
||||
if len(msgs) != 0 {
|
||||
t.Fatalf("messages = %+v, want none", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func findUser(t *testing.T, users []domain.User, id int64) domain.User {
|
||||
t.Helper()
|
||||
for _, user := range users {
|
||||
|
|
@ -69,12 +383,191 @@ func findUser(t *testing.T, users []domain.User, id int64) domain.User {
|
|||
return domain.User{}
|
||||
}
|
||||
|
||||
func TestCountPrivateMediaCategoriesCachesByReadModelHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
const peerID int64 = 1002
|
||||
key := store.ReadModelKey{Model: readmodel.ModelPrivateMediaCounts, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: peerID}
|
||||
versions := &fakeMessageReadModelVersions{hashes: map[store.ReadModelKey]int64{key: 101}}
|
||||
counting := &countingPrivateMediaStore{counts: domain.MediaCategoryCounts{
|
||||
domain.MediaCategoryPhoto: 3,
|
||||
domain.MediaCategoryVideo: 2,
|
||||
}}
|
||||
svc := NewService(counting, nil, WithReadModelVersions(versions))
|
||||
|
||||
first, err := svc.CountPrivateMediaCategories(ctx, ownerID, peerID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountPrivateMediaCategories first: %v", err)
|
||||
}
|
||||
first[domain.MediaCategoryPhoto] = 99
|
||||
second, err := svc.CountPrivateMediaCategories(ctx, ownerID, peerID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountPrivateMediaCategories second: %v", err)
|
||||
}
|
||||
if counting.countPrivateMediaCalls != 1 {
|
||||
t.Fatalf("count calls = %d, want 1", counting.countPrivateMediaCalls)
|
||||
}
|
||||
if got := second[domain.MediaCategoryPhoto]; got != 3 {
|
||||
t.Fatalf("cached photo count = %d, want 3", got)
|
||||
}
|
||||
|
||||
svc.InvalidatePrivateMediaCountReadModel(ownerID, peerID)
|
||||
if _, err := svc.CountPrivateMediaCategories(ctx, ownerID, peerID); err != nil {
|
||||
t.Fatalf("CountPrivateMediaCategories after explicit invalidation: %v", err)
|
||||
}
|
||||
if counting.countPrivateMediaCalls != 2 {
|
||||
t.Fatalf("count calls after explicit invalidation = %d, want 2", counting.countPrivateMediaCalls)
|
||||
}
|
||||
|
||||
versions.hashes[key] = 202
|
||||
counting.counts[domain.MediaCategoryPhoto] = 4
|
||||
third, err := svc.CountPrivateMediaCategories(ctx, ownerID, peerID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountPrivateMediaCategories third: %v", err)
|
||||
}
|
||||
if counting.countPrivateMediaCalls != 3 {
|
||||
t.Fatalf("count calls after hash change = %d, want 3", counting.countPrivateMediaCalls)
|
||||
}
|
||||
if got := third[domain.MediaCategoryPhoto]; got != 4 {
|
||||
t.Fatalf("reloaded photo count = %d, want 4", got)
|
||||
}
|
||||
}
|
||||
|
||||
type projectionMessageStore struct {
|
||||
list domain.MessageList
|
||||
}
|
||||
|
||||
type denySendChecker struct{}
|
||||
|
||||
func (denySendChecker) CanSendMessages(context.Context, int64) error {
|
||||
return domain.ErrUserSendRestricted
|
||||
}
|
||||
|
||||
type gateMessageStore struct {
|
||||
projectionMessageStore
|
||||
sends int
|
||||
forwards int
|
||||
}
|
||||
|
||||
func (s *gateMessageStore) SendPrivateText(context.Context, domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
|
||||
s.sends++
|
||||
return domain.SendPrivateTextResult{}, nil
|
||||
}
|
||||
|
||||
func (s *gateMessageStore) ForwardPrivateMessages(context.Context, domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) {
|
||||
s.forwards++
|
||||
return domain.ForwardPrivateMessagesResult{}, nil
|
||||
}
|
||||
|
||||
type countingPrivateMediaStore struct {
|
||||
projectionMessageStore
|
||||
counts domain.MediaCategoryCounts
|
||||
countPrivateMediaCalls int
|
||||
}
|
||||
|
||||
func (s *countingPrivateMediaStore) CountPrivateMediaCategories(context.Context, int64, int64) (domain.MediaCategoryCounts, error) {
|
||||
s.countPrivateMediaCalls++
|
||||
out := make(domain.MediaCategoryCounts, len(s.counts))
|
||||
for category, count := range s.counts {
|
||||
out[category] = count
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakeMessageReadModelVersions struct {
|
||||
hashes map[store.ReadModelKey]int64
|
||||
}
|
||||
|
||||
func (f *fakeMessageReadModelVersions) ReadModelHash(_ context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) {
|
||||
hash := f.hashes[store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}]
|
||||
return hash, hash != 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeMessageReadModelVersions) ReadModelHashes(_ context.Context, keys []store.ReadModelKey) (map[store.ReadModelKey]int64, error) {
|
||||
out := make(map[store.ReadModelKey]int64, len(keys))
|
||||
for _, key := range keys {
|
||||
if hash := f.hashes[key]; hash != 0 {
|
||||
out[key] = hash
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type messageProfilePhotos map[int64]domain.ProfilePhotoRef
|
||||
|
||||
type businessAutomationOnline map[int64]bool
|
||||
|
||||
func (o businessAutomationOnline) IsUserOnline(userID int64) bool {
|
||||
return o[userID]
|
||||
}
|
||||
|
||||
type staticBusinessAutomationProvider struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (p staticBusinessAutomationProvider) BusinessAutomationReplies(context.Context, BusinessAutomationReplyInput) ([]domain.QuickReplyMessage, error) {
|
||||
return []domain.QuickReplyMessage{{ID: 1, Message: p.message}}, nil
|
||||
}
|
||||
|
||||
func businessAutomationAllRecipients() domain.BusinessRecipients {
|
||||
return domain.BusinessRecipients{
|
||||
ExistingChats: true,
|
||||
NewChats: true,
|
||||
Contacts: true,
|
||||
NonContacts: true,
|
||||
}
|
||||
}
|
||||
|
||||
func saveBusinessQuickReply(t *testing.T, ctx context.Context, svc *account.Service, ownerID int64, shortcut, message string, randomID int64) int {
|
||||
t.Helper()
|
||||
mutation, err := svc.SaveQuickReplyText(ctx, ownerID, shortcut, domain.QuickReplyMessage{
|
||||
RandomID: randomID,
|
||||
Date: 1_700_000_000,
|
||||
Message: message,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("save quick reply %s: %v", shortcut, err)
|
||||
}
|
||||
return mutation.ShortcutID
|
||||
}
|
||||
|
||||
func countBusinessMessagesByBody(t *testing.T, ctx context.Context, messages *memory.MessageStore, ownerID, peerID int64, body string) int {
|
||||
t.Helper()
|
||||
list, err := messages.ListByUser(ctx, ownerID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID},
|
||||
Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list messages: %v", err)
|
||||
}
|
||||
count := 0
|
||||
for _, msg := range list.Messages {
|
||||
if msg.Body == body {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func assertMessageViaBot(t *testing.T, ctx context.Context, messages *memory.MessageStore, ownerID, peerID int64, body string, botID int64) {
|
||||
t.Helper()
|
||||
list, err := messages.ListByUser(ctx, ownerID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID},
|
||||
Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list messages: %v", err)
|
||||
}
|
||||
for _, msg := range list.Messages {
|
||||
if msg.Body == body && msg.ViaBotID == botID {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("message body %q via bot %d not found in %+v", body, botID, list.Messages)
|
||||
}
|
||||
|
||||
func (p messageProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
out := make(map[int64]domain.ProfilePhotoRef, len(ids))
|
||||
for _, id := range ids {
|
||||
|
|
@ -117,14 +610,54 @@ func (s projectionMessageStore) GetMessageReactions(context.Context, domain.Priv
|
|||
return domain.PrivateMessageReactionsResult{}, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) VoteMessagePoll(context.Context, domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) {
|
||||
return domain.PrivateMessagePollResult{}, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) CloseMessagePoll(context.Context, domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) {
|
||||
return domain.PrivateMessagePollResult{}, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) EditMessage(context.Context, domain.EditMessageRequest) (domain.EditMessageResult, error) {
|
||||
return domain.EditMessageResult{}, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) PinPrivateMessage(context.Context, domain.PinPrivateMessageRequest) (domain.PinPrivateMessageResult, error) {
|
||||
return domain.PinPrivateMessageResult{}, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) UnpinAllPrivateMessages(context.Context, domain.UnpinAllPrivateMessagesRequest) (domain.PinPrivateMessageResult, error) {
|
||||
return domain.PinPrivateMessageResult{}, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) DeleteMessages(context.Context, domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) {
|
||||
return domain.DeleteMessagesResult{}, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) ListSavedDialogs(context.Context, int64, domain.SavedDialogsFilter) (domain.SavedDialogList, error) {
|
||||
return domain.SavedDialogList{}, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) ListPinnedSavedDialogs(context.Context, int64) (domain.SavedDialogList, error) {
|
||||
return domain.SavedDialogList{}, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) ListSavedDialogsByPeers(context.Context, int64, []domain.Peer) (domain.SavedDialogList, error) {
|
||||
return domain.SavedDialogList{}, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) ToggleSavedDialogPin(context.Context, int64, domain.Peer, bool) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) ReorderPinnedSavedDialogs(context.Context, int64, []domain.Peer, bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) DeleteSavedHistory(context.Context, domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error) {
|
||||
return domain.DeleteSavedHistoryResult{}, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) DeleteHistory(context.Context, domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) {
|
||||
return domain.DeleteMessagesResult{}, nil
|
||||
}
|
||||
|
|
@ -136,3 +669,11 @@ func (s projectionMessageStore) GetByIDs(context.Context, int64, []int) (domain.
|
|||
func (s projectionMessageStore) ListByUser(context.Context, int64, domain.MessageFilter) (domain.MessageList, error) {
|
||||
return s.list, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) SearchPrivateMedia(context.Context, int64, int64, domain.MediaSearchRequest) (domain.MessageList, error) {
|
||||
return domain.MessageList{}, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) CountPrivateMediaCategories(context.Context, int64, int64) (domain.MediaCategoryCounts, error) {
|
||||
return domain.MediaCategoryCounts{}, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue