spark-server/internal/module/squarebot/logic/service.go
DESKTOP-L8OUL9F\gfl0034 5fda6be8ec feat(squarebot): 接入媒体引导与 Bot 附件/锁
完善 bootstrap 媒体装配、配置开关,并补充 squarebot 附件与锁逻辑。
2026-09-24 23:37:44 +08:00

219 lines
5.3 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
comp *MediaCompressor
store ObjectStore
botCompress bool
}
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)
images := s.attachMedia(ctx, item)
view, err := s.square.PublishBotMedia(ctx, bot.ID, &squarelogic.BotPublishInput{
Text: text, SourceURL: item.Link, TTL: s.opts.TTL, Images: images,
})
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),
zap.Int("images", len(images)),
)
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
}