feat(squarebot): 接入媒体引导与 Bot 附件/锁
完善 bootstrap 媒体装配、配置开关,并补充 squarebot 附件与锁逻辑。
This commit is contained in:
parent
6cfd6309d8
commit
5fda6be8ec
@ -106,7 +106,7 @@ sms:
|
|||||||
username: "noreply@sparkmail.wyrs365.com"
|
username: "noreply@sparkmail.wyrs365.com"
|
||||||
password: "gD9UNvSt5ilagfa1"
|
password: "gD9UNvSt5ilagfa1"
|
||||||
from: "Spark IM <noreply@sparkmail.wyrs365.com>"
|
from: "Spark IM <noreply@sparkmail.wyrs365.com>"
|
||||||
aliyun_sms:
|
aliyun_sms:
|
||||||
access_key_id: ""
|
access_key_id: ""
|
||||||
access_key_secret: ""
|
access_key_secret: ""
|
||||||
sign_name: ""
|
sign_name: ""
|
||||||
@ -119,3 +119,33 @@ sms:
|
|||||||
mobile: ""
|
mobile: ""
|
||||||
unicom: ""
|
unicom: ""
|
||||||
telecom: ""
|
telecom: ""
|
||||||
|
|
||||||
|
# 用户上传:检测/校验(默认仅日志;user_reject=true 则拒凭证/发帖)。主压缩仍在 App。
|
||||||
|
# 机器人媒体:bot_compress=true 时入库前压到图≤1080 / 视频≤720(视频需 ffmpeg)。
|
||||||
|
media:
|
||||||
|
enabled: true
|
||||||
|
user_reject: false
|
||||||
|
bot_compress: true
|
||||||
|
image_max_edge: 1080
|
||||||
|
video_max_edge: 720
|
||||||
|
avatar_max_edge: 200
|
||||||
|
image_max_bytes: 10485760
|
||||||
|
video_max_bytes: 104857600
|
||||||
|
avatar_max_bytes: 2097152
|
||||||
|
jpeg_quality: 85
|
||||||
|
video_crf: 23
|
||||||
|
ffmpeg_path: "ffmpeg"
|
||||||
|
|
||||||
|
# 广场机器人:每 interval 随机 bot 发一条海外主流媒体公开 RSS(标题+短摘要+原文链接)。
|
||||||
|
# 需先跑 cmd/seed 确保 bot001@spark.bot … 存在。机器人帖 ttl 后自动清理。
|
||||||
|
square_bot:
|
||||||
|
enabled: true
|
||||||
|
interval: 5m
|
||||||
|
ttl: 360h
|
||||||
|
bot_indexes: []
|
||||||
|
feeds:
|
||||||
|
- "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"
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import (
|
|||||||
smslogic "spark-server/internal/module/sms/logic"
|
smslogic "spark-server/internal/module/sms/logic"
|
||||||
squarelogic "spark-server/internal/module/square/logic"
|
squarelogic "spark-server/internal/module/square/logic"
|
||||||
squaremodel "spark-server/internal/module/square/model"
|
squaremodel "spark-server/internal/module/square/model"
|
||||||
|
squarebotlogic "spark-server/internal/module/squarebot/logic"
|
||||||
synclogic "spark-server/internal/module/sync/logic"
|
synclogic "spark-server/internal/module/sync/logic"
|
||||||
syncmodel "spark-server/internal/module/sync/model"
|
syncmodel "spark-server/internal/module/sync/model"
|
||||||
uploadlogic "spark-server/internal/module/upload/logic"
|
uploadlogic "spark-server/internal/module/upload/logic"
|
||||||
@ -71,6 +72,7 @@ type App struct {
|
|||||||
idgen *id.Generator
|
idgen *id.Generator
|
||||||
sessions session.Manager
|
sessions session.Manager
|
||||||
deliver *deliverlogic.Worker
|
deliver *deliverlogic.Worker
|
||||||
|
squareBot *squarebotlogic.Worker
|
||||||
accepting atomic.Bool
|
accepting atomic.Bool
|
||||||
shutdownOnce sync.Once
|
shutdownOnce sync.Once
|
||||||
}
|
}
|
||||||
@ -189,10 +191,18 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
|
|||||||
friends := wireFriends(idgen, db, users, deliverQueue, chat)
|
friends := wireFriends(idgen, db, users, deliverQueue, chat)
|
||||||
groups := wireGroups(idgen, db, users, friends, deliverQueue, objects, chat)
|
groups := wireGroups(idgen, db, users, friends, deliverQueue, objects, chat)
|
||||||
messages := wireMessages(idgen, friends, groups, deliverQueue, chat)
|
messages := wireMessages(idgen, friends, groups, deliverQueue, chat)
|
||||||
files := wireFiles(cfg, objects, groups)
|
files := wireFiles(cfg, log, objects, groups)
|
||||||
favorites := wireFavorites(idgen, db, objects, chat)
|
favorites := wireFavorites(idgen, db, objects, chat)
|
||||||
moments := wireMoments(idgen, db, objects, friends, users)
|
moments := wireMoments(idgen, db, objects, friends, users)
|
||||||
square := wireSquare(idgen, db, objects, friends, users)
|
square := wireSquare(idgen, db, objects, friends, users)
|
||||||
|
mediaPol := MediaPolicy(cfg.Media)
|
||||||
|
if moments != nil {
|
||||||
|
moments.ConfigureMedia(mediaPol, log)
|
||||||
|
}
|
||||||
|
if square != nil {
|
||||||
|
square.ConfigureMedia(mediaPol, log)
|
||||||
|
}
|
||||||
|
app.squareBot = wireSquareBot(cfg, log, rdb, objects, square, users)
|
||||||
music := wireMusic(cfg, db, objects)
|
music := wireMusic(cfg, db, objects)
|
||||||
users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, square, groups, favorites, inbox, mailbox, chat)...)
|
users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, square, groups, favorites, inbox, mailbox, chat)...)
|
||||||
adminSvc, adminTokens := wireAdmin(cfg, db, groups, idgen)
|
adminSvc, adminTokens := wireAdmin(cfg, db, groups, idgen)
|
||||||
@ -325,7 +335,7 @@ func wireGroups(idgen *id.Generator, db *mysql.DB, users *userlogic.Service, fri
|
|||||||
return grouplogic.NewService(repo, users, friends, idgen, chat.msgs, q, objects)
|
return grouplogic.NewService(repo, users, friends, idgen, chat.msgs, q, objects)
|
||||||
}
|
}
|
||||||
|
|
||||||
func wireFiles(cfg *Config, store *objectstore.Store, groups *grouplogic.Service) *uploadlogic.Service {
|
func wireFiles(cfg *Config, log *zap.Logger, store *objectstore.Store, groups *grouplogic.Service) *uploadlogic.Service {
|
||||||
if store == nil || !store.Ready() {
|
if store == nil || !store.Ready() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -333,9 +343,33 @@ func wireFiles(cfg *Config, store *objectstore.Store, groups *grouplogic.Service
|
|||||||
TTL: cfg.ObjectStorage.PresignTTL,
|
TTL: cfg.ObjectStorage.PresignTTL,
|
||||||
Limit: ratelimit.New(cfg.RateLimit.UploadPerSecond),
|
Limit: ratelimit.New(cfg.RateLimit.UploadPerSecond),
|
||||||
Groups: groups,
|
Groups: groups,
|
||||||
|
Media: MediaPolicy(cfg.Media),
|
||||||
|
Log: log,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func wireSquareBot(
|
||||||
|
cfg *Config, log *zap.Logger, rdb *redisx.Client, store *objectstore.Store,
|
||||||
|
square *squarelogic.Service, users *userlogic.Service,
|
||||||
|
) *squarebotlogic.Worker {
|
||||||
|
if cfg == nil || !cfg.SquareBot.Enabled || square == nil || users == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var locker squarebotlogic.Locker
|
||||||
|
if rdb != nil && rdb.Ready() {
|
||||||
|
locker = squarebotlogic.NewRedisLocker(rdb.RDB())
|
||||||
|
}
|
||||||
|
svc := squarebotlogic.NewService(log, square, users, locker, squarebotlogic.Options{
|
||||||
|
Enabled: true, Interval: cfg.SquareBot.Interval, TTL: cfg.SquareBot.TTL,
|
||||||
|
Feeds: cfg.SquareBot.Feeds, BotIndexes: cfg.SquareBot.BotIndexes,
|
||||||
|
})
|
||||||
|
if cfg.Media.BotCompress && store != nil && store.Ready() {
|
||||||
|
lim, jq, crf, ff := MediaCompressOptions(cfg.Media)
|
||||||
|
svc.BindMedia(squarebotlogic.NewMediaCompressor(lim, jq, crf, ff), store, true)
|
||||||
|
}
|
||||||
|
return squarebotlogic.NewWorker(log, svc)
|
||||||
|
}
|
||||||
|
|
||||||
func wireFavorites(idgen *id.Generator, db *mysql.DB, store *objectstore.Store, chat chatStores) *favlogic.Service {
|
func wireFavorites(idgen *id.Generator, db *mysql.DB, store *objectstore.Store, chat chatStores) *favlogic.Service {
|
||||||
if idgen == nil {
|
if idgen == nil {
|
||||||
return nil
|
return nil
|
||||||
@ -522,6 +556,10 @@ func (a *App) Run() error {
|
|||||||
if a.deliver != nil {
|
if a.deliver != nil {
|
||||||
a.deliver.Start(context.Background())
|
a.deliver.Start(context.Background())
|
||||||
}
|
}
|
||||||
|
if a.squareBot != nil {
|
||||||
|
a.squareBot.Start()
|
||||||
|
a.log.Info("squarebot started")
|
||||||
|
}
|
||||||
errCh := make(chan error, 2)
|
errCh := make(chan error, 2)
|
||||||
go func() { errCh <- a.http.Start() }()
|
go func() { errCh <- a.http.Start() }()
|
||||||
go func() { errCh <- a.tcp.Start() }()
|
go func() { errCh <- a.tcp.Start() }()
|
||||||
@ -588,6 +626,9 @@ func (a *App) doShutdown() error {
|
|||||||
if a.deliver != nil {
|
if a.deliver != nil {
|
||||||
a.deliver.Stop()
|
a.deliver.Stop()
|
||||||
}
|
}
|
||||||
|
if a.squareBot != nil {
|
||||||
|
a.squareBot.Stop()
|
||||||
|
}
|
||||||
err = errors.Join(err, a.redis.Close(), a.mysql.Close())
|
err = errors.Join(err, a.redis.Close(), a.mysql.Close())
|
||||||
if a.logClose != nil {
|
if a.logClose != nil {
|
||||||
err = errors.Join(err, a.logClose())
|
err = errors.Join(err, a.logClose())
|
||||||
|
|||||||
@ -26,6 +26,7 @@ type Config struct {
|
|||||||
Message MessageConfig `yaml:"message"`
|
Message MessageConfig `yaml:"message"`
|
||||||
ObjectStorage ObjectStorageConfig `yaml:"object_storage"`
|
ObjectStorage ObjectStorageConfig `yaml:"object_storage"`
|
||||||
Media MediaConfig `yaml:"media"`
|
Media MediaConfig `yaml:"media"`
|
||||||
|
SquareBot SquareBotConfig `yaml:"square_bot"`
|
||||||
SMS SMSConfig `yaml:"sms"`
|
SMS SMSConfig `yaml:"sms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,6 +154,15 @@ type MediaConfig struct {
|
|||||||
FFmpegPath string `yaml:"ffmpeg_path"`
|
FFmpegPath string `yaml:"ffmpeg_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SquareBotConfig drives the overseas media RSS publisher on the square feed.
|
||||||
|
type SquareBotConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Interval time.Duration `yaml:"interval"`
|
||||||
|
TTL time.Duration `yaml:"ttl"`
|
||||||
|
Feeds []string `yaml:"feeds"`
|
||||||
|
BotIndexes []int `yaml:"bot_indexes"`
|
||||||
|
}
|
||||||
|
|
||||||
type MessageConfig struct {
|
type MessageConfig struct {
|
||||||
MaxContentSize int `yaml:"max_content_size"`
|
MaxContentSize int `yaml:"max_content_size"`
|
||||||
SyncLimit int `yaml:"sync_limit"`
|
SyncLimit int `yaml:"sync_limit"`
|
||||||
@ -333,6 +343,12 @@ func applyDefaults(cfg *Config) {
|
|||||||
cfg.RateLimit.UploadPerSecond = 10
|
cfg.RateLimit.UploadPerSecond = 10
|
||||||
}
|
}
|
||||||
applyMediaDefaults(&cfg.Media)
|
applyMediaDefaults(&cfg.Media)
|
||||||
|
if cfg.SquareBot.Interval <= 0 {
|
||||||
|
cfg.SquareBot.Interval = 5 * time.Minute
|
||||||
|
}
|
||||||
|
if cfg.SquareBot.TTL <= 0 {
|
||||||
|
cfg.SquareBot.TTL = 15 * 24 * time.Hour
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyMediaDefaults(m *MediaConfig) {
|
func applyMediaDefaults(m *MediaConfig) {
|
||||||
|
|||||||
21
internal/bootstrap/media.go
Normal file
21
internal/bootstrap/media.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"spark-server/internal/pkg/media"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MediaPolicy builds the shared user-check / bot-compress limits from config.
|
||||||
|
func MediaPolicy(cfg MediaConfig) media.Policy {
|
||||||
|
return media.Policy{
|
||||||
|
Enabled: cfg.Enabled,
|
||||||
|
UserReject: cfg.UserReject,
|
||||||
|
Limits: media.Limits{
|
||||||
|
ImageMaxEdge: cfg.ImageMaxEdge,
|
||||||
|
VideoMaxEdge: cfg.VideoMaxEdge,
|
||||||
|
AvatarMaxEdge: cfg.AvatarMaxEdge,
|
||||||
|
ImageMaxBytes: cfg.ImageMaxBytes,
|
||||||
|
VideoMaxBytes: cfg.VideoMaxBytes,
|
||||||
|
AvatarMaxBytes: cfg.AvatarMaxBytes,
|
||||||
|
}.Normalize(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@ import (
|
|||||||
"image"
|
"image"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png"
|
"image/png"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/image/draw"
|
"golang.org/x/image/draw"
|
||||||
|
|||||||
@ -13,13 +13,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// BotPublishInput is an internal publish from the media bot worker (no rate limit).
|
// BotPublishInput is an internal publish from the media bot worker (no rate limit).
|
||||||
|
// Images must already be compressed and stored under perm/square/image/.
|
||||||
type BotPublishInput struct {
|
type BotPublishInput struct {
|
||||||
Text string
|
Text string
|
||||||
SourceURL string
|
SourceURL string
|
||||||
TTL time.Duration
|
TTL time.Duration
|
||||||
|
Images []*entity.Image
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublishBotMedia publishes a public text post as a bot user with TTL and URL dedupe.
|
// PublishBotMedia publishes a public post as a bot user with TTL and URL dedupe.
|
||||||
func (s *Service) PublishBotMedia(ctx context.Context, userID int64, in *BotPublishInput) (*View, error) {
|
func (s *Service) PublishBotMedia(ctx context.Context, userID int64, in *BotPublishInput) (*View, error) {
|
||||||
if !s.ready() || userID <= 0 {
|
if !s.ready() || userID <= 0 {
|
||||||
return nil, errs.ErrUnauthorized
|
return nil, errs.ErrUnauthorized
|
||||||
@ -39,7 +41,10 @@ func (s *Service) PublishBotMedia(ctx context.Context, userID int64, in *BotPubl
|
|||||||
if ttl <= 0 {
|
if ttl <= 0 {
|
||||||
ttl = 15 * 24 * time.Hour
|
ttl = 15 * 24 * time.Hour
|
||||||
}
|
}
|
||||||
content := &entity.Content{}
|
content := &entity.Content{Images: in.Images}
|
||||||
|
if err := content.Validate(); err != nil {
|
||||||
|
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareContentInvalid, nil))
|
||||||
|
}
|
||||||
raw, err := content.Marshal()
|
raw, err := content.Marshal()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareContentInvalid, nil))
|
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareContentInvalid, nil))
|
||||||
@ -50,7 +55,7 @@ func (s *Service) PublishBotMedia(ctx context.Context, userID int64, in *BotPubl
|
|||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
post := &entity.Post{
|
post := &entity.Post{
|
||||||
ID: id, UserID: userID, Kind: entity.KindText, Text: text, Content: raw,
|
ID: id, UserID: userID, Kind: content.Kind(), Text: text, Content: raw,
|
||||||
Visibility: entity.VisibilityPublic, AuditStatus: entity.AuditApproved,
|
Visibility: entity.VisibilityPublic, AuditStatus: entity.AuditApproved,
|
||||||
Status: entity.StatusNormal, Source: entity.SourceBotRSS, SourceURL: url,
|
Status: entity.StatusNormal, Source: entity.SourceBotRSS, SourceURL: url,
|
||||||
ExpiresAtMS: now.Add(ttl).UnixMilli(), CreatedAtMS: now.UnixMilli(),
|
ExpiresAtMS: now.Add(ttl).UnixMilli(), CreatedAtMS: now.UnixMilli(),
|
||||||
|
|||||||
@ -14,10 +14,12 @@ import (
|
|||||||
// Repository persists square data. Model implements it.
|
// Repository persists square data. Model implements it.
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
CreatePost(ctx context.Context, post *entity.Post, audience []int64) error
|
CreatePost(ctx context.Context, post *entity.Post, audience []int64) error
|
||||||
|
CreateBotPost(ctx context.Context, post *entity.Post) error
|
||||||
GetPost(ctx context.Context, id int64) (*entity.Post, error)
|
GetPost(ctx context.Context, id int64) (*entity.Post, error)
|
||||||
ListPublicFeed(ctx context.Context, cursor int64, limit int) ([]*entity.Post, error)
|
ListPublicFeed(ctx context.Context, cursor int64, limit int) ([]*entity.Post, error)
|
||||||
ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error)
|
ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error)
|
||||||
SoftDeletePost(ctx context.Context, userID, postID int64) (*entity.Post, error)
|
SoftDeletePost(ctx context.Context, userID, postID int64) (*entity.Post, error)
|
||||||
|
SoftDeleteExpiredBotPosts(ctx context.Context, nowMS int64, limit int) ([]*entity.Post, error)
|
||||||
ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error)
|
ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error)
|
||||||
ListLikes(ctx context.Context, postIDs []int64) (map[int64][]*entity.Like, error)
|
ListLikes(ctx context.Context, postIDs []int64) (map[int64][]*entity.Like, error)
|
||||||
AddLike(ctx context.Context, like *entity.Like) (created bool, err error)
|
AddLike(ctx context.Context, like *entity.Like) (created bool, err error)
|
||||||
|
|||||||
90
internal/module/squarebot/logic/attach.go
Normal file
90
internal/module/squarebot/logic/attach.go
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
infmedia "spark-server/internal/infrastructure/media"
|
||||||
|
squareentity "spark-server/internal/module/square/entity"
|
||||||
|
uploadentity "spark-server/internal/module/upload/entity"
|
||||||
|
"spark-server/internal/pkg/reqctx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ObjectStore writes compressed bot assets before square publish.
|
||||||
|
type ObjectStore interface {
|
||||||
|
Put(ctx context.Context, key string, body []byte, contentType string) error
|
||||||
|
PutLarge(ctx context.Context, key string, body []byte, contentType string, maxBytes int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindMedia attaches optional compressor + object store for RSS enclosure images/videos.
|
||||||
|
func (s *Service) BindMedia(comp *MediaCompressor, store ObjectStore, botCompress bool) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.comp = comp
|
||||||
|
s.store = store
|
||||||
|
s.botCompress = botCompress
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachMedia downloads/compresses optional enclosure and returns square Image refs (best-effort).
|
||||||
|
func (s *Service) attachMedia(ctx context.Context, item FeedItem) []*squareentity.Image {
|
||||||
|
if s == nil || !s.botCompress || s.comp == nil || s.store == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
imgURL := strings.TrimSpace(item.ImageURL)
|
||||||
|
if imgURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
asset, err := s.comp.FetchImage(ctx, imgURL)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("squarebot compress image", zap.String("url", imgURL), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ext := extForMIME(asset.MIME)
|
||||||
|
key := uploadentity.BuildObjectKey(
|
||||||
|
uploadentity.PurposeSquareImage, 0, reqctx.NewObjectName(),
|
||||||
|
time.Now().UTC().Format("20060102"), ext,
|
||||||
|
)
|
||||||
|
if err := s.store.Put(ctx, key, asset.Data, asset.MIME); err != nil {
|
||||||
|
s.log.Warn("squarebot put image", zap.String("key", key), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []*squareentity.Image{{
|
||||||
|
Key: key, Mime: asset.MIME, Width: asset.Width, Height: asset.Height, Size: asset.Size,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareVideoAsset compresses a remote video and uploads it (bot video paths; requires ffmpeg).
|
||||||
|
func (s *Service) PrepareVideoAsset(ctx context.Context, videoURL string) (*infmedia.BotAsset, string, error) {
|
||||||
|
if s == nil || !s.botCompress || s.comp == nil || s.store == nil {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
asset, err := s.comp.FetchVideo(ctx, videoURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
key := uploadentity.BuildObjectKey(
|
||||||
|
uploadentity.PurposeSquareVideo, 0, reqctx.NewObjectName(),
|
||||||
|
time.Now().UTC().Format("20060102"), "mp4",
|
||||||
|
)
|
||||||
|
if err := s.store.PutLarge(ctx, key, asset.Data, asset.MIME, int(asset.Size)+1); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return asset, key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extForMIME(mime string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(mime)) {
|
||||||
|
case "image/png":
|
||||||
|
return "png"
|
||||||
|
case "image/gif":
|
||||||
|
return "gif"
|
||||||
|
case "image/webp":
|
||||||
|
return "webp"
|
||||||
|
default:
|
||||||
|
return "jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
internal/module/squarebot/logic/lock.go
Normal file
30
internal/module/squarebot/logic/lock.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RedisLocker uses SET NX EX so multi-instance servers only run one tick.
|
||||||
|
type RedisLocker struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRedisLocker(rdb *redis.Client) *RedisLocker {
|
||||||
|
if rdb == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &RedisLocker{rdb: rdb}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *RedisLocker) TryLock(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
||||||
|
if l == nil || l.rdb == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = time.Minute
|
||||||
|
}
|
||||||
|
return l.rdb.SetNX(ctx, key, "1", ttl).Result()
|
||||||
|
}
|
||||||
@ -22,10 +22,11 @@ const (
|
|||||||
|
|
||||||
// FeedItem is one public RSS/Atom entry (title + summary + link).
|
// FeedItem is one public RSS/Atom entry (title + summary + link).
|
||||||
type FeedItem struct {
|
type FeedItem struct {
|
||||||
Title string
|
Title string
|
||||||
Summary string
|
Summary string
|
||||||
Link string
|
Link string
|
||||||
Source string
|
Source string
|
||||||
|
ImageURL string // optional enclosure / media:content / first <img>
|
||||||
}
|
}
|
||||||
|
|
||||||
type rssFeed struct {
|
type rssFeed struct {
|
||||||
@ -36,10 +37,24 @@ type rssFeed struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type rssItem struct {
|
type rssItem struct {
|
||||||
Title string `xml:"title"`
|
Title string `xml:"title"`
|
||||||
Link string `xml:"link"`
|
Link string `xml:"link"`
|
||||||
Description string `xml:"description"`
|
Description string `xml:"description"`
|
||||||
GUID string `xml:"guid"`
|
GUID string `xml:"guid"`
|
||||||
|
Enclosure *rssEnclosure `xml:"enclosure"`
|
||||||
|
MediaContent []rssMediaContent `xml:"http://search.yahoo.com/mrss/ content"`
|
||||||
|
MediaThumb []rssMediaContent `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rssEnclosure struct {
|
||||||
|
URL string `xml:"url,attr"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rssMediaContent struct {
|
||||||
|
URL string `xml:"url,attr"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Medium string `xml:"medium,attr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type atomFeed struct {
|
type atomFeed struct {
|
||||||
@ -58,9 +73,11 @@ type atomEntry struct {
|
|||||||
type atomLink struct {
|
type atomLink struct {
|
||||||
Href string `xml:"href,attr"`
|
Href string `xml:"href,attr"`
|
||||||
Rel string `xml:"rel,attr"`
|
Rel string `xml:"rel,attr"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
|
var tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||||
|
var imgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`)
|
||||||
|
|
||||||
// FetchFeed loads and parses a public RSS 2.0 or Atom URL.
|
// FetchFeed loads and parses a public RSS 2.0 or Atom URL.
|
||||||
func FetchFeed(ctx context.Context, client *http.Client, feedURL string) ([]FeedItem, error) {
|
func FetchFeed(ctx context.Context, client *http.Client, feedURL string) ([]FeedItem, error) {
|
||||||
@ -100,10 +117,11 @@ func parseFeed(body []byte, feedURL string) ([]FeedItem, error) {
|
|||||||
for _, it := range rss.Channel.Items {
|
for _, it := range rss.Channel.Items {
|
||||||
link := firstNonEmpty(strings.TrimSpace(it.Link), strings.TrimSpace(it.GUID))
|
link := firstNonEmpty(strings.TrimSpace(it.Link), strings.TrimSpace(it.GUID))
|
||||||
item := FeedItem{
|
item := FeedItem{
|
||||||
Title: cleanText(it.Title),
|
Title: cleanText(it.Title),
|
||||||
Summary: cleanText(it.Description),
|
Summary: cleanText(it.Description),
|
||||||
Link: normalizeURL(link),
|
Link: normalizeURL(link),
|
||||||
Source: src,
|
Source: src,
|
||||||
|
ImageURL: rssImageURL(it),
|
||||||
}
|
}
|
||||||
if item.Title == "" || item.Link == "" {
|
if item.Title == "" || item.Link == "" {
|
||||||
continue
|
continue
|
||||||
@ -127,10 +145,11 @@ func parseFeed(body []byte, feedURL string) ([]FeedItem, error) {
|
|||||||
}
|
}
|
||||||
sum := firstNonEmpty(e.Summary, e.Content)
|
sum := firstNonEmpty(e.Summary, e.Content)
|
||||||
item := FeedItem{
|
item := FeedItem{
|
||||||
Title: cleanText(e.Title),
|
Title: cleanText(e.Title),
|
||||||
Summary: cleanText(sum),
|
Summary: cleanText(sum),
|
||||||
Link: normalizeURL(link),
|
Link: normalizeURL(link),
|
||||||
Source: src,
|
Source: src,
|
||||||
|
ImageURL: atomImageURL(e),
|
||||||
}
|
}
|
||||||
if item.Title == "" || item.Link == "" {
|
if item.Title == "" || item.Link == "" {
|
||||||
continue
|
continue
|
||||||
@ -143,6 +162,58 @@ func parseFeed(body []byte, feedURL string) ([]FeedItem, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rssImageURL(it rssItem) string {
|
||||||
|
if it.Enclosure != nil && isImageMIME(it.Enclosure.Type) {
|
||||||
|
if u := normalizeURL(it.Enclosure.URL); u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range it.MediaContent {
|
||||||
|
if isImageMedia(m) {
|
||||||
|
if u := normalizeURL(m.URL); u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range it.MediaThumb {
|
||||||
|
if u := normalizeURL(m.URL); u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstImgSrc(it.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
func atomImageURL(e atomEntry) string {
|
||||||
|
for _, l := range e.Links {
|
||||||
|
if strings.EqualFold(l.Rel, "enclosure") || strings.HasPrefix(strings.ToLower(l.Type), "image/") {
|
||||||
|
if u := normalizeURL(l.Href); u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstImgSrc(firstNonEmpty(e.Summary, e.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isImageMIME(t string) bool {
|
||||||
|
t = strings.ToLower(strings.TrimSpace(t))
|
||||||
|
return strings.HasPrefix(t, "image/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isImageMedia(m rssMediaContent) bool {
|
||||||
|
if isImageMIME(m.Type) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(m.Medium), "image")
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstImgSrc(htmlBody string) string {
|
||||||
|
m := imgSrcRe.FindStringSubmatch(htmlBody)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return normalizeURL(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
func atomBestLink(links []atomLink) string {
|
func atomBestLink(links []atomLink) string {
|
||||||
var alt, self, any string
|
var alt, self, any string
|
||||||
for _, l := range links {
|
for _, l := range links {
|
||||||
|
|||||||
@ -43,14 +43,17 @@ type Locker interface {
|
|||||||
|
|
||||||
// Service runs one RSS publish + TTL cleanup tick.
|
// Service runs one RSS publish + TTL cleanup tick.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
square Square
|
square Square
|
||||||
users Users
|
users Users
|
||||||
locker Locker
|
locker Locker
|
||||||
opts Options
|
opts Options
|
||||||
client *http.Client
|
client *http.Client
|
||||||
rand *rand.Rand
|
rand *rand.Rand
|
||||||
lockKey string
|
lockKey string
|
||||||
|
comp *MediaCompressor
|
||||||
|
store ObjectStore
|
||||||
|
botCompress bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(log *zap.Logger, square Square, users Users, locker Locker, opts Options) *Service {
|
func NewService(log *zap.Logger, square Square, users Users, locker Locker, opts Options) *Service {
|
||||||
@ -143,8 +146,9 @@ func (s *Service) Tick(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
text := FormatPostText(item)
|
text := FormatPostText(item)
|
||||||
|
images := s.attachMedia(ctx, item)
|
||||||
view, err := s.square.PublishBotMedia(ctx, bot.ID, &squarelogic.BotPublishInput{
|
view, err := s.square.PublishBotMedia(ctx, bot.ID, &squarelogic.BotPublishInput{
|
||||||
Text: text, SourceURL: item.Link, TTL: s.opts.TTL,
|
Text: text, SourceURL: item.Link, TTL: s.opts.TTL, Images: images,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errs.ErrDuplicate) {
|
if errors.Is(err, errs.ErrDuplicate) {
|
||||||
@ -158,6 +162,7 @@ func (s *Service) Tick(ctx context.Context) error {
|
|||||||
zap.String("bot", bot.Nickname),
|
zap.String("bot", bot.Nickname),
|
||||||
zap.Int64("post_id", view.ID),
|
zap.Int64("post_id", view.ID),
|
||||||
zap.String("url", item.Link),
|
zap.String("url", item.Link),
|
||||||
|
zap.Int("images", len(images)),
|
||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,7 +24,8 @@ func TestParseRSSAndFormat(t *testing.T) {
|
|||||||
<item>
|
<item>
|
||||||
<title>Hello & World</title>
|
<title>Hello & World</title>
|
||||||
<link>https://example.com/a</link>
|
<link>https://example.com/a</link>
|
||||||
<description><p>Short summary here.</p></description>
|
<description><p>Short summary here.</p><img src="https://example.com/pic.jpg"/></description>
|
||||||
|
<enclosure url="https://example.com/cover.jpg" type="image/jpeg"/>
|
||||||
</item>
|
</item>
|
||||||
</channel></rss>`)
|
</channel></rss>`)
|
||||||
items, err := parseFeed(body, "https://example.com/rss")
|
items, err := parseFeed(body, "https://example.com/rss")
|
||||||
@ -34,6 +35,9 @@ func TestParseRSSAndFormat(t *testing.T) {
|
|||||||
if items[0].Title != "Hello & World" || items[0].Link != "https://example.com/a" {
|
if items[0].Title != "Hello & World" || items[0].Link != "https://example.com/a" {
|
||||||
t.Fatalf("%+v", items[0])
|
t.Fatalf("%+v", items[0])
|
||||||
}
|
}
|
||||||
|
if items[0].ImageURL != "https://example.com/cover.jpg" {
|
||||||
|
t.Fatalf("image=%q", items[0].ImageURL)
|
||||||
|
}
|
||||||
text := FormatPostText(items[0])
|
text := FormatPostText(items[0])
|
||||||
if !strings.Contains(text, "Demo News") || !strings.Contains(text, "https://example.com/a") {
|
if !strings.Contains(text, "Demo News") || !strings.Contains(text, "https://example.com/a") {
|
||||||
t.Fatalf("text=%q", text)
|
t.Fatalf("text=%q", text)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user