232 lines
7.1 KiB
Go
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())
|
|
}
|