214 lines
5.2 KiB
Go
214 lines
5.2 KiB
Go
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
|
|
}
|