package logic import ( "context" "strings" "time" "unicode/utf8" "spark-server/internal/module/square/entity" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/i18n" "spark-server/internal/pkg/reqctx" ) // BotPublishInput is an internal publish from the media bot worker (no rate limit). type BotPublishInput struct { Text string SourceURL string TTL time.Duration } // PublishBotMedia publishes a public text post as a bot user with TTL and URL dedupe. func (s *Service) PublishBotMedia(ctx context.Context, userID int64, in *BotPublishInput) (*View, error) { if !s.ready() || userID <= 0 { return nil, errs.ErrUnauthorized } if in == nil { return nil, errs.ErrInvalidArgument } text := strings.TrimSpace(in.Text) url := strings.TrimSpace(in.SourceURL) if text == "" || url == "" { return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareEmpty, nil)) } if utf8.RuneCountInString(text) > entity.MaxTextRunes { return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareTextInvalid, nil)) } ttl := in.TTL if ttl <= 0 { ttl = 15 * 24 * time.Hour } content := &entity.Content{} raw, err := content.Marshal() if err != nil { return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareContentInvalid, nil)) } id, err := s.ids.NextID() if err != nil { return nil, err } now := time.Now() post := &entity.Post{ ID: id, UserID: userID, Kind: entity.KindText, Text: text, Content: raw, Visibility: entity.VisibilityPublic, AuditStatus: entity.AuditApproved, Status: entity.StatusNormal, Source: entity.SourceBotRSS, SourceURL: url, ExpiresAtMS: now.Add(ttl).UnixMilli(), CreatedAtMS: now.UnixMilli(), } if err := s.repo.CreateBotPost(ctx, post); err != nil { return nil, err } return s.viewOf(ctx, userID, post, true) } // ExpireBotPosts soft-deletes expired bot posts and best-effort deletes media. func (s *Service) ExpireBotPosts(ctx context.Context, now time.Time, limit int) (int, error) { if !s.ready() { return 0, nil } if now.IsZero() { now = time.Now() } if limit <= 0 { limit = 200 } posts, err := s.repo.SoftDeleteExpiredBotPosts(ctx, now.UnixMilli(), limit) if err != nil { return 0, err } for _, post := range posts { s.deleteMedia(ctx, post) } return len(posts), nil }