chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue