418 lines
14 KiB
Go
418 lines
14 KiB
Go
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
|
|
}
|