merged from gramsrv upstream

This commit is contained in:
onysd 2026-09-01 12:06:31 +03:00
parent 79c64ee916
commit 21a0856587
651 changed files with 54774 additions and 4590 deletions

View file

@ -72,6 +72,10 @@ func (s *Service) prepareBusinessAutomation(ctx context.Context, req domain.Send
if !s.shouldConsiderBusinessAutomation(req) {
return businessAutomationContext{}, false
}
hasAutomation, err := s.business.store.HasBusinessAutomation(ctx, req.RecipientUserID)
if err != nil || !hasAutomation {
return businessAutomationContext{}, false
}
out := businessAutomationContext{
ownerUserID: req.RecipientUserID,
customerUserID: req.SenderUserID,

View file

@ -11,17 +11,22 @@ import (
// Service 提供消息历史、搜索与已读业务。
type Service struct {
messages store.MessageStore
dialogs store.DialogStore
contacts store.ContactStore
photos userprojection.ProfilePhotoProvider
privacy userprojection.PrivacyEvaluator
freezes userprojection.AccountFreezeProvider
versions store.ReadModelVersionStore
projector *userprojection.Projector
botResponder BotResponder
sendGate SendPermissionChecker
business *businessAutomationConfig
messages store.MessageStore
dialogs store.DialogStore
contacts store.ContactStore
photos userprojection.ProfilePhotoProvider
privacy userprojection.PrivacyEvaluator
freezes userprojection.AccountFreezeProvider
versions store.ReadModelVersionStore
projector *userprojection.Projector
// viewerProjectionComplete is true only when every viewer-scoped user
// overlay used by the shared RPC Users service is configured here too.
// A partially configured service may still project the dependencies it has,
// but RPC must not trust that partial envelope as authoritative.
viewerProjectionComplete bool
botResponder BotResponder
sendGate SendPermissionChecker
business *businessAutomationConfig
privateMediaCountCache *privateMediaCountReadModelCache
}
@ -37,7 +42,7 @@ 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)
OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message, session domain.ClientSessionMetadata)
}
// Option adjusts optional message service dependencies.
@ -92,9 +97,18 @@ func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...
userprojection.WithPrivacyEvaluator(s.privacy),
userprojection.WithAccountFreezeProvider(s.freezes),
)
s.viewerProjectionComplete = s.contacts != nil && s.photos != nil && s.privacy != nil && s.freezes != nil
return s
}
// ProjectsMessageUsersForViewer reports that history/search results returned by
// this service have already passed through the viewer-specific user projection
// boundary. RPC may reuse that envelope and resolve only nested message refs;
// raw stores and test doubles do not implicitly gain this trust marker.
func (s *Service) ProjectsMessageUsersForViewer() bool {
return s != nil && s.viewerProjectionComplete
}
// SendPrivateText 发送一条私聊文本消息。
func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
if s == nil || s.messages == nil || userID == 0 {
@ -140,7 +154,7 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.
// 兜错,不回传失败。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)
s.botResponder.OnPrivateMessage(ctx, req.RecipientUserID, res.RecipientMessage, req.OriginClientSession)
}
return res, err
}
@ -349,7 +363,11 @@ func (s *Service) SearchPrivateMedia(ctx context.Context, userID, peerID int64,
if s == nil || s.messages == nil || userID == 0 || peerID == 0 {
return domain.MessageList{}, nil
}
return s.messages.SearchPrivateMedia(ctx, userID, peerID, req)
list, err := s.messages.SearchPrivateMedia(ctx, userID, peerID, req)
if err != nil {
return domain.MessageList{}, err
}
return s.projectMessageUsers(ctx, userID, list)
}
// CountPrivateMediaCategories 返回某私聊会话按基础媒体类别聚合的精确计数。

View file

@ -104,6 +104,9 @@ func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) {
friendID: {PhotoID: 9101, DCID: 2, Stripped: []byte{5, 6}},
strangerID: {PhotoID: 9102, DCID: 4},
}))
if svc.ProjectsMessageUsersForViewer() {
t.Fatal("partially configured message projector must not claim a complete viewer envelope")
}
list, err := svc.GetHistory(ctx, ownerID, domain.MessageFilter{Limit: 10})
if err != nil {
@ -127,6 +130,62 @@ func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) {
if self.Phone != "15550000001" {
t.Fatalf("self phone = %q, want preserved", self.Phone)
}
media, err := svc.SearchPrivateMedia(ctx, ownerID, friendID, domain.MediaSearchRequest{Limit: 10})
if err != nil {
t.Fatalf("SearchPrivateMedia: %v", err)
}
mediaFriend := findUser(t, media.Users, friendID)
if !mediaFriend.Contact || mediaFriend.FirstName != "Remark" || mediaFriend.Phone != "15550000002" || mediaFriend.PhotoID != 9101 {
t.Fatalf("shared-media friend projection = %+v, want the same viewer projection as history", mediaFriend)
}
}
func TestServiceMarksOnlyFullyConfiguredViewerProjectionComplete(t *testing.T) {
store := projectionMessageStore{}
svc := NewService(store, nil,
WithContactStore(memory.NewContactStore()),
WithPhotoProvider(messageProfilePhotos{}),
WithPrivacyEvaluator(messageProjectionPrivacy{}),
WithAccountFreezeProvider(messageProjectionFreezes{}),
)
if !svc.ProjectsMessageUsersForViewer() {
t.Fatal("fully configured message projector must advertise a complete viewer envelope")
}
}
func TestSendPrivateTextWithoutBusinessAutomationSkipsDialogAndContactReads(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 2001
const customerID int64 = 2002
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
business := &countingBusinessAutomationStore{BusinessAutomationStore: memory.NewPasswordStore()}
countingDialogs := &countingBusinessDialogStore{DialogStore: dialogs}
countingContacts := &countingBusinessContactStore{ContactStore: memory.NewContactStore()}
svc := NewService(
messages,
countingDialogs,
WithBusinessAutomation(business),
WithContactStore(countingContacts),
)
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
SenderUserID: customerID,
RecipientUserID: ownerID,
RandomID: 9001,
Message: "ordinary message",
Date: 1_700_000_000,
}); err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
if business.hasCalls != 1 {
t.Fatalf("HasBusinessAutomation calls = %d, want one lightweight gate", business.hasCalls)
}
if countingDialogs.listByPeersCalls != 0 || countingContacts.getCalls != 0 {
t.Fatalf("business detail reads dialogs/contacts = %d/%d, want 0/0 without automation", countingDialogs.listByPeersCalls, countingContacts.getCalls)
}
}
func TestBusinessAutomationGreetingSendsQuickReplyWithoutLoop(t *testing.T) {
@ -567,6 +626,36 @@ type staticBusinessAutomationProvider struct {
message string
}
type countingBusinessAutomationStore struct {
store.BusinessAutomationStore
hasCalls int
}
func (s *countingBusinessAutomationStore) HasBusinessAutomation(ctx context.Context, userID int64) (bool, error) {
s.hasCalls++
return s.BusinessAutomationStore.HasBusinessAutomation(ctx, userID)
}
type countingBusinessDialogStore struct {
store.DialogStore
listByPeersCalls int
}
func (s *countingBusinessDialogStore) ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
s.listByPeersCalls++
return s.DialogStore.ListByPeers(ctx, userID, peers)
}
type countingBusinessContactStore struct {
store.ContactStore
getCalls int
}
func (s *countingBusinessContactStore) Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, error) {
s.getCalls++
return s.ContactStore.Get(ctx, userID, contactUserID)
}
func (p staticBusinessAutomationProvider) BusinessAutomationReplies(context.Context, BusinessAutomationReplyInput) ([]domain.QuickReplyMessage, error) {
return []domain.QuickReplyMessage{{ID: 1, Message: p.message}}, nil
}
@ -751,9 +840,21 @@ func (s projectionMessageStore) ListByUser(context.Context, int64, domain.Messag
}
func (s projectionMessageStore) SearchPrivateMedia(context.Context, int64, int64, domain.MediaSearchRequest) (domain.MessageList, error) {
return domain.MessageList{}, nil
return s.list, nil
}
func (s projectionMessageStore) CountPrivateMediaCategories(context.Context, int64, int64) (domain.MediaCategoryCounts, error) {
return domain.MediaCategoryCounts{}, nil
}
type messageProjectionPrivacy struct{}
func (messageProjectionPrivacy) CanSee(context.Context, int64, int64, domain.PrivacyKey) (bool, error) {
return true, nil
}
type messageProjectionFreezes struct{}
func (messageProjectionFreezes) AccountFreezes(context.Context, []int64) (map[int64]domain.AccountFreeze, error) {
return map[int64]domain.AccountFreeze{}, nil
}