288 lines
9.3 KiB
Go
288 lines
9.3 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/iamxvbaba/td/proto"
|
|
"github.com/iamxvbaba/td/tg"
|
|
"github.com/iamxvbaba/td/tlprofile"
|
|
"go.uber.org/zap"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
)
|
|
|
|
const (
|
|
defaultWelcomeDeliveryBatch = 100
|
|
defaultWelcomeDeliveryLease = 15 * time.Second
|
|
defaultWelcomeDeliveryInterval = 250 * time.Millisecond
|
|
defaultWelcomeDeliveryMaxRetry = time.Minute
|
|
defaultWelcomeDeliverySweep = time.Minute
|
|
)
|
|
|
|
var errWelcomeDeliveryMembershipSuperseded = errors.New("welcome delivery membership epoch is no longer active")
|
|
|
|
type WelcomeDeliveryDispatcher struct {
|
|
router *Router
|
|
store store.WelcomeMessageDeliveryStore
|
|
log *zap.Logger
|
|
owner string
|
|
batch int
|
|
lease time.Duration
|
|
nextSweep time.Time
|
|
}
|
|
|
|
func NewWelcomeDeliveryDispatcher(router *Router, deliveries store.WelcomeMessageDeliveryStore, log *zap.Logger) *WelcomeDeliveryDispatcher {
|
|
if log == nil {
|
|
log = zap.NewNop()
|
|
}
|
|
return &WelcomeDeliveryDispatcher{
|
|
router: router, store: deliveries, log: log,
|
|
owner: welcomeDeliveryOwner(), batch: defaultWelcomeDeliveryBatch,
|
|
lease: defaultWelcomeDeliveryLease,
|
|
}
|
|
}
|
|
|
|
func (d *WelcomeDeliveryDispatcher) Run(ctx context.Context) {
|
|
if d == nil || d.router == nil || d.store == nil {
|
|
return
|
|
}
|
|
runIdleBackoffLoop(ctx, defaultWelcomeDeliveryInterval, defaultIdleDispatchMaxInterval, d.DispatchOnce)
|
|
}
|
|
|
|
func (d *WelcomeDeliveryDispatcher) DispatchOnce(ctx context.Context) bool {
|
|
if d == nil || d.router == nil || d.store == nil {
|
|
return false
|
|
}
|
|
now := d.router.clock.Now()
|
|
deleted := 0
|
|
if d.nextSweep.IsZero() || !now.Before(d.nextSweep) {
|
|
var err error
|
|
deleted, err = d.store.DeleteExpiredWelcomeMessageDeliveries(ctx, now, d.batch*10)
|
|
switch {
|
|
case err != nil:
|
|
d.nextSweep = now.Add(defaultWelcomeDeliveryInterval)
|
|
d.log.Warn("delete expired welcome deliveries", zap.Error(err))
|
|
case deleted == d.batch*10:
|
|
d.nextSweep = now
|
|
default:
|
|
d.nextSweep = now.Add(defaultWelcomeDeliverySweep)
|
|
}
|
|
}
|
|
deliveries, err := d.store.ClaimWelcomeMessageDeliveries(ctx, d.owner, now, d.batch, d.lease)
|
|
if err != nil {
|
|
d.log.Warn("claim welcome deliveries", zap.Error(err))
|
|
return deleted > 0
|
|
}
|
|
for _, group := range groupWelcomeMessageDeliveries(deliveries) {
|
|
d.dispatchGroup(ctx, group)
|
|
}
|
|
return deleted > 0 || len(deliveries) > 0
|
|
}
|
|
|
|
func (d *WelcomeDeliveryDispatcher) dispatchGroup(ctx context.Context, deliveries []domain.WelcomeMessageDelivery) {
|
|
if len(deliveries) == 0 {
|
|
return
|
|
}
|
|
delivery := deliveries[0]
|
|
ids := welcomeDeliveryIDs(deliveries)
|
|
now := d.router.clock.Now()
|
|
if !delivery.ExpiresAt.After(now) {
|
|
return
|
|
}
|
|
if online, ok := d.router.deps.Sessions.(OnlineUserProvider); ok && !online.IsUserOnline(delivery.TargetUserID) {
|
|
d.retry(ctx, deliveries, now, "target has no online session")
|
|
return
|
|
}
|
|
binder, ok := d.router.deps.Sessions.(SemanticTransientSessionBinder)
|
|
if !ok {
|
|
d.retry(ctx, deliveries, now, "semantic transient session binder is unavailable")
|
|
return
|
|
}
|
|
updates, err := d.router.welcomeDeliveryUpdates(ctx, deliveries)
|
|
if errors.Is(err, errWelcomeDeliveryMembershipSuperseded) {
|
|
if _, ackErr := d.store.AckWelcomeMessageDeliveries(ctx, d.owner, ids, now); ackErr != nil {
|
|
d.log.Warn("discard superseded welcome delivery", zap.Int64("join_event_id", delivery.JoinEventID), zap.Error(ackErr))
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
d.retry(ctx, deliveries, now, err.Error())
|
|
return
|
|
}
|
|
sent, sendErr := binder.PushToUserTransientCompatible(
|
|
ctx, delivery.TargetUserID, tlprofile.SemanticTypeUpdateNewEphemeralMessage,
|
|
proto.MessageFromServer, updates, d.router.cfg.OutboundPushTimeout,
|
|
)
|
|
if sent <= 0 {
|
|
reason := "no ready exact profile can represent welcome delivery"
|
|
if sendErr != nil {
|
|
reason = sendErr.Error()
|
|
}
|
|
d.retry(ctx, deliveries, now, reason)
|
|
return
|
|
}
|
|
acked, err := d.store.AckWelcomeMessageDeliveries(ctx, d.owner, ids, now)
|
|
if err != nil || acked != len(ids) {
|
|
d.log.Warn("ack welcome delivery",
|
|
zap.Int64("join_event_id", delivery.JoinEventID), zap.Int64("target_user_id", delivery.TargetUserID),
|
|
zap.Int("templates", len(ids)), zap.Int("sent_sessions", sent), zap.Int("acked", acked), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
func (d *WelcomeDeliveryDispatcher) retry(ctx context.Context, deliveries []domain.WelcomeMessageDelivery, now time.Time, reason string) {
|
|
if len(deliveries) == 0 {
|
|
return
|
|
}
|
|
delivery := deliveries[0]
|
|
attempt := delivery.AttemptCount
|
|
expiresAt := delivery.ExpiresAt
|
|
for _, item := range deliveries[1:] {
|
|
if item.AttemptCount > attempt {
|
|
attempt = item.AttemptCount
|
|
}
|
|
if item.ExpiresAt.Before(expiresAt) {
|
|
expiresAt = item.ExpiresAt
|
|
}
|
|
}
|
|
delay := welcomeDeliveryRetryDelay(attempt)
|
|
next := now.Add(delay)
|
|
if next.After(expiresAt) {
|
|
next = expiresAt
|
|
}
|
|
ids := welcomeDeliveryIDs(deliveries)
|
|
updated, err := d.store.RetryWelcomeMessageDeliveries(ctx, d.owner, ids, next, reason)
|
|
if err != nil || updated != len(ids) {
|
|
d.log.Warn("retry welcome delivery",
|
|
zap.Int64("join_event_id", delivery.JoinEventID), zap.Int64("target_user_id", delivery.TargetUserID),
|
|
zap.Int("templates", len(ids)), zap.Int("updated", updated), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
func (r *Router) welcomeDeliveryUpdates(ctx context.Context, deliveries []domain.WelcomeMessageDelivery) (*tg.Updates, error) {
|
|
if r == nil || r.deps.Channels == nil {
|
|
return nil, errors.New("channel projection is unavailable")
|
|
}
|
|
if err := validateWelcomeDeliveryGroup(deliveries); err != nil {
|
|
return nil, err
|
|
}
|
|
delivery := deliveries[0]
|
|
view, err := r.deps.Channels.ResolveChannel(ctx, delivery.TargetUserID, delivery.ChannelID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if view.Self.Status != domain.ChannelMemberActive || view.Self.JoinedAt != delivery.JoinedAt {
|
|
return nil, errWelcomeDeliveryMembershipSuperseded
|
|
}
|
|
updates := make([]tg.UpdateClass, 0, len(deliveries))
|
|
for _, item := range deliveries {
|
|
wire := tg.EphemeralMessage{
|
|
Out: false, WelcomeTemplate: false,
|
|
InvertMedia: item.Content.InvertMedia, Noforwards: item.Content.NoForwards,
|
|
ID: item.EphemeralID,
|
|
FromID: tgPeer(domain.Peer{Type: domain.PeerTypeChannel, ID: item.ChannelID}),
|
|
PeerID: tgPeer(domain.Peer{Type: domain.PeerTypeChannel, ID: item.ChannelID}),
|
|
ReceiverID: 0,
|
|
Date: item.JoinedAt,
|
|
Message: item.Content.Message,
|
|
}
|
|
if len(item.Content.Entities) != 0 {
|
|
wire.SetEntities(tgMessageEntities(item.Content.Entities))
|
|
}
|
|
if item.Content.Media != nil && !item.Content.Media.IsZero() {
|
|
wire.SetMedia(tgMessageMedia(item.Content.Media))
|
|
}
|
|
if item.Content.ReplyMarkup != nil && !item.Content.ReplyMarkup.IsZero() {
|
|
wire.SetReplyMarkup(tgReplyMarkup(item.Content.ReplyMarkup))
|
|
}
|
|
rich, err := tgRichMessage(item.Content.RichMessage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("project welcome rich message: %w", err)
|
|
}
|
|
if rich != nil {
|
|
wire.SetRichMessage(*rich)
|
|
}
|
|
updates = append(updates, &tg.UpdateNewEphemeralMessage{Message: wire})
|
|
}
|
|
return &tg.Updates{
|
|
Updates: updates,
|
|
Chats: []tg.ChatClass{tgChannelChatForView(delivery.TargetUserID, view)},
|
|
Date: delivery.JoinedAt,
|
|
Seq: 0,
|
|
}, nil
|
|
}
|
|
|
|
func groupWelcomeMessageDeliveries(deliveries []domain.WelcomeMessageDelivery) [][]domain.WelcomeMessageDelivery {
|
|
groups := make([][]domain.WelcomeMessageDelivery, 0, len(deliveries))
|
|
positions := make(map[int64]int, len(deliveries))
|
|
for _, delivery := range deliveries {
|
|
position, ok := positions[delivery.JoinEventID]
|
|
if !ok {
|
|
position = len(groups)
|
|
positions[delivery.JoinEventID] = position
|
|
groups = append(groups, nil)
|
|
}
|
|
groups[position] = append(groups[position], delivery)
|
|
}
|
|
return groups
|
|
}
|
|
|
|
func validateWelcomeDeliveryGroup(deliveries []domain.WelcomeMessageDelivery) error {
|
|
if len(deliveries) == 0 || len(deliveries) > domain.MaxWelcomeMessagesPerPeer {
|
|
return errors.New("invalid welcome delivery group size")
|
|
}
|
|
first := deliveries[0]
|
|
seenTemplates := make(map[int]struct{}, len(deliveries))
|
|
seenEphemeral := make(map[int]struct{}, len(deliveries))
|
|
for _, delivery := range deliveries {
|
|
if delivery.JoinEventID != first.JoinEventID || delivery.ChannelID != first.ChannelID ||
|
|
delivery.TargetUserID != first.TargetUserID || delivery.JoinedAt != first.JoinedAt {
|
|
return errors.New("inconsistent welcome delivery group")
|
|
}
|
|
if _, duplicate := seenTemplates[delivery.TemplateID]; duplicate {
|
|
return errors.New("duplicate welcome delivery template")
|
|
}
|
|
if _, duplicate := seenEphemeral[delivery.EphemeralID]; duplicate {
|
|
return errors.New("duplicate welcome delivery ephemeral id")
|
|
}
|
|
seenTemplates[delivery.TemplateID] = struct{}{}
|
|
seenEphemeral[delivery.EphemeralID] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func welcomeDeliveryIDs(deliveries []domain.WelcomeMessageDelivery) []int64 {
|
|
ids := make([]int64, len(deliveries))
|
|
for i, delivery := range deliveries {
|
|
ids[i] = delivery.ID
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func welcomeDeliveryRetryDelay(attempt int) time.Duration {
|
|
if attempt < 1 {
|
|
attempt = 1
|
|
}
|
|
shift := attempt - 1
|
|
if shift > 6 {
|
|
shift = 6
|
|
}
|
|
delay := time.Second * time.Duration(1<<shift)
|
|
if delay > defaultWelcomeDeliveryMaxRetry {
|
|
return defaultWelcomeDeliveryMaxRetry
|
|
}
|
|
return delay
|
|
}
|
|
|
|
func welcomeDeliveryOwner() string {
|
|
var value [16]byte
|
|
if _, err := rand.Read(value[:]); err == nil {
|
|
return "welcome-" + hex.EncodeToString(value[:])
|
|
}
|
|
return fmt.Sprintf("welcome-%d", time.Now().UnixNano())
|
|
}
|