Files
creator-hub/internal/creator/logic.go
T

232 lines
7.1 KiB
Go

package creator
import (
"errors"
"math"
"strings"
"time"
)
var (
ErrInvalid = errors.New("invalid creator input")
ErrConflict = errors.New("creator resource conflicts with existing state")
ErrNotFound = errors.New("creator resource not found")
ErrUnavailable = errors.New("creator capability is unavailable")
ErrUncertain = errors.New("creator operation result is uncertain")
)
func ValidatePlatform(platform string) bool {
return platform == PlatformDouyin || platform == PlatformXiaohongshu
}
const maxDurationSeconds = int64(math.MaxInt64 / int64(time.Second))
func ValidateSettings(input SettingsUpdate) error {
if input.LookbackDays <= 0 || int64(input.LookbackDays) > maxDurationSeconds/(24*60*60) ||
input.NewWorkIntervalSeconds <= 0 || input.NewWorkIntervalSeconds > maxDurationSeconds ||
input.MetricInitialIntervalSeconds <= 0 || input.MetricInitialIntervalSeconds > maxDurationSeconds ||
math.IsNaN(input.MetricMultiplier) || math.IsInf(input.MetricMultiplier, 0) || input.MetricMultiplier <= 1 ||
input.MetricMaxIntervalSeconds < input.MetricInitialIntervalSeconds || input.MetricMaxIntervalSeconds > maxDurationSeconds ||
input.MetricAgeSeconds <= 0 || input.MetricAgeSeconds > maxDurationSeconds {
return ErrInvalid
}
return nil
}
// NextMetricAt returns the next scheduled point strictly after now. A point
// exactly at now is considered covered by the current collection and is not
// returned again.
func NextMetricAt(publishedAt, now time.Time, initial, max time.Duration, multiplier float64, monitoringAge time.Duration) (time.Time, string) {
publishedAt, now = publishedAt.UTC(), now.UTC()
if publishedAt.IsZero() || !publishedAt.Before(now) || initial <= 0 || max < initial || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) || multiplier <= 1 || monitoringAge <= 0 {
return time.Time{}, "published_at_pending_verification"
}
if !now.Before(publishedAt.Add(monitoringAge)) {
return time.Time{}, "monitoring_age_reached"
}
elapsed := now.Sub(publishedAt)
interval := initial
total := initial
for total <= elapsed {
candidate := float64(interval) * multiplier
var next time.Duration
if math.IsNaN(candidate) || math.IsInf(candidate, 0) || candidate < float64(interval) || candidate > float64(max) {
next = max
} else {
next = time.Duration(candidate)
if next < interval {
next = max
}
}
interval = next
if total > time.Duration(math.MaxInt64)-interval {
return time.Time{}, "monitoring_age_reached"
}
total += interval
}
candidate := publishedAt.Add(total)
if !candidate.Before(publishedAt.Add(monitoringAge)) {
return time.Time{}, "monitoring_age_reached"
}
return candidate, ""
}
func MetricSchedule(publishedAt time.Time, initial, max time.Duration, multiplier float64, count int) []time.Time {
if count <= 0 || publishedAt.IsZero() || initial <= 0 || max < initial || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) || multiplier <= 1 {
return []time.Time{}
}
points := make([]time.Time, 0, count)
interval, total := initial, initial
for len(points) < count {
points = append(points, publishedAt.UTC().Add(total))
candidate := float64(interval) * multiplier
var next time.Duration
if math.IsNaN(candidate) || math.IsInf(candidate, 0) || candidate < float64(interval) || candidate > float64(max) {
next = max
} else {
next = time.Duration(candidate)
if next < interval {
next = max
}
}
interval = next
if total > time.Duration(math.MaxInt64)-interval {
break
}
total += interval
}
return points
}
// TrimKeyword removes only the edge whitespace allowed by the product rule.
// It intentionally does not normalize case, Unicode, full-width characters,
// or whitespace inside a keyword.
func TrimKeyword(value string) string { return strings.Trim(value, " \t\r\n") }
func NormalizeKeywords(values []string) ([]string, error) {
result := make([]string, len(values))
for index, value := range values {
result[index] = TrimKeyword(value)
if result[index] == "" {
return nil, ErrInvalid
}
}
return result, nil
}
func ValidateRuleInput(input LeadRuleInput) (LeadRuleInput, error) {
input.Name = strings.TrimSpace(input.Name)
input.Topic = strings.TrimSpace(input.Topic)
input.AIRequirement = strings.TrimSpace(input.AIRequirement)
input.SourceType = strings.TrimSpace(input.SourceType)
if input.SourceType == "" {
input.SourceType = "all"
}
if input.Name == "" || input.Topic == "" || input.AIRequirement == "" ||
(input.SourceType != "all" && input.SourceType != SourceOwned && input.SourceType != SourceCompetitor) {
return LeadRuleInput{}, ErrInvalid
}
var err error
input.IncludeKeywords, err = NormalizeKeywords(input.IncludeKeywords)
if err != nil || len(input.IncludeKeywords) == 0 {
return LeadRuleInput{}, ErrInvalid
}
input.ExcludeKeywords, err = NormalizeKeywords(input.ExcludeKeywords)
if err != nil {
return LeadRuleInput{}, ErrInvalid
}
return input, nil
}
func MatchKeywords(content string, include, exclude []string) (bool, []string) {
matched := make([]string, 0, len(include))
for _, keyword := range include {
if strings.Contains(content, keyword) {
matched = append(matched, keyword)
}
}
if len(matched) == 0 {
return false, matched
}
for _, keyword := range exclude {
if strings.Contains(content, keyword) {
return false, matched
}
}
return true, matched
}
func ValidEventType(eventType string) bool {
switch eventType {
case "comment", "like", "repost", "follow", "dm":
return true
default:
return false
}
}
func ValidMessageType(messageType string) bool {
switch messageType {
case MessageTypeText, MessageTypeImage, MessageTypeVoice, MessageTypeVideo, MessageTypeSticker, MessageTypeUnknown:
return true
default:
return false
}
}
func ValidAction(action string) bool {
switch action {
case ActionDM, ActionReplyComment, ActionLikeComment, ActionLikeWork, ActionFollow, ActionRepost:
return true
default:
return false
}
}
func ActionRequiresText(action string) bool {
return action == ActionDM || action == ActionReplyComment || action == ActionRepost
}
func validCooldownSeconds(seconds int64) bool {
return seconds > 0 && seconds <= maxDurationSeconds
}
func ActionTargetValid(action string, interactorUID, commentID, workID, targetType string) bool {
if interactorUID == "" {
return false
}
switch action {
case ActionDM, ActionFollow:
return true
case ActionReplyComment, ActionLikeComment:
return commentID != ""
case ActionLikeWork:
return workID != ""
case ActionRepost:
return workID != ""
default:
return false
}
}
func CanWrite(profile AccountProfile, automatic bool, action string) error {
if !ValidAction(action) {
return ErrInvalid
}
if profile.BusinessStatus != "normal" {
if automatic || profile.BusinessStatus == "banned" || profile.BusinessStatus == "deleted" ||
(action == ActionReplyComment || action == ActionDM || action == ActionRepost) {
return ErrConflict
}
}
if profile.LoginStatus != "logged_in" || profile.AuthorizationStatus != "authorized" {
return ErrConflict
}
return nil
}
func InWindow(publishedAt, start, end time.Time) bool {
return !publishedAt.IsZero() && !publishedAt.Before(start.UTC()) && !publishedAt.After(end.UTC())
}