package logic import ( "context" "errors" "math/rand" "net/http" "strings" "time" "go.uber.org/zap" squarelogic "spark-server/internal/module/square/logic" userentity "spark-server/internal/module/user/entity" "spark-server/internal/pkg/errs" ) // Options configures the square media bot worker. type Options struct { Enabled bool Interval time.Duration TTL time.Duration Feeds []string BotIndexes []int HTTPClient *http.Client } // Users resolves seeded robot accounts. type Users interface { GetByEmail(ctx context.Context, email string) (*userentity.User, error) } // Square publishes and expires bot posts. type Square interface { PublishBotMedia(ctx context.Context, userID int64, in *squarelogic.BotPublishInput) (*squarelogic.View, error) ExpireBotPosts(ctx context.Context, now time.Time, limit int) (int, error) } // Locker is an optional distributed tick lock (e.g. Redis SETNX). type Locker interface { TryLock(ctx context.Context, key string, ttl time.Duration) (bool, error) } // Service runs one RSS publish + TTL cleanup tick. type Service struct { log *zap.Logger square Square users Users locker Locker opts Options client *http.Client rand *rand.Rand lockKey string } func NewService(log *zap.Logger, square Square, users Users, locker Locker, opts Options) *Service { if log == nil { log = zap.NewNop() } opts = normalizeOptions(opts) client := opts.HTTPClient if client == nil { client = &http.Client{Timeout: rssTimeout} } return &Service{ log: log, square: square, users: users, locker: locker, opts: opts, client: client, rand: rand.New(rand.NewSource(time.Now().UnixNano())), lockKey: "squarebot:tick", } } func normalizeOptions(o Options) Options { if o.Interval <= 0 { o.Interval = 5 * time.Minute } if o.TTL <= 0 { o.TTL = 15 * 24 * time.Hour } if len(o.Feeds) == 0 { o.Feeds = DefaultFeeds() } if len(o.BotIndexes) == 0 { o.BotIndexes = defaultBotIndexes() } return o } // DefaultFeeds are public overseas mainstream media RSS endpoints (title/summary/link only). func DefaultFeeds() []string { return []string{ "https://feeds.bbci.co.uk/news/world/rss.xml", "https://www.theguardian.com/world/rss", "https://feeds.npr.org/1004/rss.xml", "https://www.aljazeera.com/xml/rss/all.xml", "https://rss.nytimes.com/services/xml/rss/nyt/World.xml", "https://www.reutersagency.com/feed/?best-topics=world-including-us&post_type=best", } } func defaultBotIndexes() []int { out := make([]int, 0, userentity.BotCount) for i := 1; i <= userentity.BotCount; i++ { out = append(out, i) } return out } func (s *Service) Enabled() bool { return s != nil && s.opts.Enabled && s.square != nil && s.users != nil } func (s *Service) Interval() time.Duration { if s == nil { return 5 * time.Minute } return s.opts.Interval } // Tick expires old bot posts then publishes one new RSS item via a random bot. func (s *Service) Tick(ctx context.Context) error { if !s.Enabled() { return nil } if s.locker != nil { ok, err := s.locker.TryLock(ctx, s.lockKey, s.opts.Interval) if err != nil { s.log.Warn("squarebot lock", zap.Error(err)) } else if !ok { return nil } } if n, err := s.square.ExpireBotPosts(ctx, time.Now(), 200); err != nil { s.log.Warn("squarebot expire", zap.Error(err)) } else if n > 0 { s.log.Info("squarebot expired", zap.Int("count", n)) } bot, err := s.pickBot(ctx) if err != nil { return err } item, err := s.pickItem(ctx) if err != nil { return err } text := FormatPostText(item) view, err := s.square.PublishBotMedia(ctx, bot.ID, &squarelogic.BotPublishInput{ Text: text, SourceURL: item.Link, TTL: s.opts.TTL, }) if err != nil { if errors.Is(err, errs.ErrDuplicate) { s.log.Info("squarebot skip duplicate", zap.String("url", item.Link)) return nil } return err } s.log.Info("squarebot posted", zap.Int64("bot_uid", bot.ID), zap.String("bot", bot.Nickname), zap.Int64("post_id", view.ID), zap.String("url", item.Link), ) return nil } func (s *Service) pickBot(ctx context.Context) (*userentity.User, error) { indexes := append([]int(nil), s.opts.BotIndexes...) s.rand.Shuffle(len(indexes), func(i, j int) { indexes[i], indexes[j] = indexes[j], indexes[i] }) var last error for _, idx := range indexes { email := userentity.BotEmail(idx) if email == "" { continue } u, err := s.users.GetByEmail(ctx, email) if err != nil { last = err continue } if u != nil && u.ID > 0 { return u, nil } } if last != nil { return nil, last } return nil, errs.ErrNotFound } func (s *Service) pickItem(ctx context.Context) (FeedItem, error) { feeds := append([]string(nil), s.opts.Feeds...) s.rand.Shuffle(len(feeds), func(i, j int) { feeds[i], feeds[j] = feeds[j], feeds[i] }) var last error for _, feed := range feeds { feed = strings.TrimSpace(feed) if feed == "" { continue } items, err := FetchFeed(ctx, s.client, feed) if err != nil { last = err s.log.Warn("squarebot feed", zap.String("feed", feed), zap.Error(err)) continue } if len(items) == 0 { continue } return items[s.rand.Intn(len(items))], nil } if last != nil { return FeedItem{}, last } return FeedItem{}, errs.ErrNotFound }