chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View 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
}

View 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
}

View 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
}

View file

@ -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)
}

View file

@ -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
}