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

1300 lines
56 KiB
Go

package creator
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"strings"
"time"
)
func validateStrategyInput(input StrategyInput) (StrategyInput, error) {
input.Action = strings.TrimSpace(input.Action)
input.TargetType = strings.TrimSpace(input.TargetType)
if input.Position <= 0 || !ValidAction(input.Action) || len(input.EventTypes) == 0 {
return StrategyInput{}, ErrInvalid
}
for i, eventType := range input.EventTypes {
input.EventTypes[i] = strings.TrimSpace(eventType)
if !ValidEventType(input.EventTypes[i]) || input.EventTypes[i] == "dm" {
return StrategyInput{}, ErrInvalid
}
}
for i, text := range input.CandidateTexts {
input.CandidateTexts[i] = strings.TrimSpace(text)
if input.CandidateTexts[i] == "" {
return StrategyInput{}, ErrInvalid
}
}
if input.Action == ActionLikeComment && input.TargetType != "comment" {
return StrategyInput{}, ErrInvalid
}
if (input.Action == ActionLikeWork || input.Action == ActionRepost) && input.TargetType != "work" {
return StrategyInput{}, ErrInvalid
}
if input.Action != ActionLikeComment && input.Action != ActionLikeWork && input.Action != ActionRepost {
input.TargetType = "user"
}
if len(input.CandidateTexts) > 20 {
return StrategyInput{}, ErrInvalid
}
return input, nil
}
func encodeStrategyLists(input StrategyInput) (string, string, error) {
events, err := jsonText(input.EventTypes)
if err != nil {
return "", "", err
}
texts, err := jsonText(input.CandidateTexts)
if err != nil {
return "", "", err
}
return events, texts, nil
}
func scanStrategy(scanner interface{ Scan(...any) error }) (Strategy, error) {
var result Strategy
var events, texts []byte
if err := scanner.Scan(&result.ID, &result.BigAccountID, &result.ExecutionAccountID, &result.Position, &result.Enabled, &events, &result.Action, &result.TargetType, &texts, &result.CreatedAt, &result.UpdatedAt); err != nil {
return Strategy{}, err
}
var err error
result.EventTypes, err = decodeStringList(events)
if err != nil {
return Strategy{}, err
}
result.CandidateTexts, err = decodeStringList(texts)
if err != nil {
return Strategy{}, err
}
return result, nil
}
const strategySelect = `SELECT id,big_account_id,execution_account_id,position,enabled,event_types,action,target_type,candidate_texts,created_at,updated_at FROM creator_strategy`
func (s *Store) validateEnabledStrategy(ctx context.Context, bigAccountID string, input StrategyInput) error {
if !input.Enabled || !ActionRequiresText(input.Action) || len(input.CandidateTexts) > 0 {
return nil
}
if strings.TrimSpace(input.Action) == "" {
return ErrInvalid
}
if strings.TrimSpace(bigAccountID) == "" {
return ErrInvalid
}
big, err := s.GetAccountProfile(ctx, bigAccountID)
if err != nil {
return err
}
if strings.TrimSpace(big.ReplyRequirements) == "" {
return ErrInvalid
}
settings, err := s.GetSettings(ctx)
if err != nil {
return err
}
if !settings.AIConfigured || settings.AIProvider != "bailian" || strings.TrimSpace(settings.AIModel) == "" {
return ErrUnavailable
}
return nil
}
func (s *Store) CreateStrategy(ctx context.Context, bigAccountID string, input StrategyInput) (Strategy, error) {
input, err := validateStrategyInput(input)
if err != nil {
return Strategy{}, err
}
if err := s.validateEnabledStrategy(ctx, bigAccountID, input); err != nil {
return Strategy{}, err
}
big, err := s.GetAccountProfile(ctx, bigAccountID)
if err != nil {
return Strategy{}, err
}
if !big.BigAccount {
return Strategy{}, ErrConflict
}
small, err := s.GetAccountProfile(ctx, input.ExecutionAccountID)
if err != nil {
return Strategy{}, err
}
if big.Platform != small.Platform {
return Strategy{}, ErrConflict
}
if big.AuthorizationKind != SourceOwned || small.AuthorizationKind != SourceOwned {
return Strategy{}, ErrInvalid
}
if err := s.requireRelation(ctx, bigAccountID, input.ExecutionAccountID); err != nil {
return Strategy{}, err
}
events, texts, err := encodeStrategyLists(input)
if err != nil {
return Strategy{}, err
}
id := newID("strategy")
if _, err := s.db.ExecContext(ctx, `INSERT INTO creator_strategy (id,big_account_id,execution_account_id,position,enabled,event_types,action,target_type,candidate_texts) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9::jsonb)`, id, bigAccountID, input.ExecutionAccountID, input.Position, input.Enabled, events, input.Action, input.TargetType, texts); err != nil {
return Strategy{}, databaseError(err)
}
if input.Enabled {
if err := s.InvalidateListener(ctx, bigAccountID, "策略新增"); err != nil {
return Strategy{}, err
}
}
return s.GetStrategy(ctx, id)
}
func (s *Store) requireRelation(ctx context.Context, bigID, smallID string) error {
var exists bool
if err := s.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_relation WHERE big_account_id=$1 AND small_account_id=$2)`, bigID, smallID).Scan(&exists); err != nil {
return databaseError(err)
}
if !exists {
return ErrConflict
}
return nil
}
func (s *Store) GetStrategy(ctx context.Context, id string) (Strategy, error) {
result, err := scanStrategy(s.db.QueryRowContext(ctx, strategySelect+` WHERE id=$1`, id))
return result, rowError(err)
}
func (s *Store) ListStrategyTraces(ctx context.Context, eventID string) ([]StrategyTrace, error) {
eventID = strings.TrimSpace(eventID)
if eventID == "" {
return nil, ErrInvalid
}
rows, err := s.db.QueryContext(ctx, `SELECT event_id,strategy_id,position,outcome,reason,created_at FROM creator_event_strategy_trace WHERE event_id=$1 ORDER BY position,id`, eventID)
if err != nil {
return nil, databaseError(err)
}
defer rows.Close()
result := make([]StrategyTrace, 0)
for rows.Next() {
var trace StrategyTrace
if err := rows.Scan(&trace.EventID, &trace.StrategyID, &trace.Position, &trace.Outcome, &trace.Reason, &trace.CreatedAt); err != nil {
return nil, err
}
result = append(result, trace)
}
return result, rows.Err()
}
func (s *Store) ListStrategies(ctx context.Context, bigID string) ([]Strategy, error) {
query := strategySelect
args := []any{}
if bigID != "" {
query += ` WHERE big_account_id=$1`
args = append(args, bigID)
}
query += ` ORDER BY position,id`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, databaseError(err)
}
defer rows.Close()
result := make([]Strategy, 0)
for rows.Next() {
item, err := scanStrategy(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func (s *Store) UpdateStrategy(ctx context.Context, id string, input StrategyInput) (Strategy, error) {
strategy, err := s.GetStrategy(ctx, id)
if err != nil {
return Strategy{}, err
}
input, err = validateStrategyInput(input)
if err != nil {
return Strategy{}, err
}
if err := s.validateEnabledStrategy(ctx, strategy.BigAccountID, input); err != nil {
return Strategy{}, err
}
big, err := s.GetAccountProfile(ctx, strategy.BigAccountID)
if err != nil {
return Strategy{}, err
}
small, err := s.GetAccountProfile(ctx, input.ExecutionAccountID)
if err != nil {
return Strategy{}, err
}
if big.Platform != small.Platform {
return Strategy{}, ErrConflict
}
if big.AuthorizationKind != SourceOwned || small.AuthorizationKind != SourceOwned {
return Strategy{}, ErrInvalid
}
events, texts, err := encodeStrategyLists(input)
if err != nil {
return Strategy{}, err
}
if err := s.requireRelation(ctx, strategy.BigAccountID, input.ExecutionAccountID); err != nil {
return Strategy{}, err
}
if _, err := s.db.ExecContext(ctx, `UPDATE creator_strategy SET execution_account_id=$2,position=$3,enabled=$4,event_types=$5::jsonb,action=$6,target_type=$7,candidate_texts=$8::jsonb,updated_at=now() WHERE id=$1`, id, input.ExecutionAccountID, input.Position, input.Enabled, events, input.Action, input.TargetType, texts); err != nil {
return Strategy{}, databaseError(err)
}
if err := s.InvalidateListener(ctx, strategy.BigAccountID, "策略修改"); err != nil {
return Strategy{}, err
}
return s.GetStrategy(ctx, id)
}
func (s *Store) SetStrategyEnabled(ctx context.Context, id string, enabled bool) (Strategy, error) {
strategy, err := s.GetStrategy(ctx, id)
if err != nil {
return Strategy{}, err
}
if enabled {
if err := s.validateEnabledStrategy(ctx, strategy.BigAccountID, StrategyInput{Enabled: true, Action: strategy.Action, CandidateTexts: strategy.CandidateTexts}); err != nil {
return Strategy{}, err
}
}
if _, err := s.db.ExecContext(ctx, `UPDATE creator_strategy SET enabled=$2,updated_at=now() WHERE id=$1`, id, enabled); err != nil {
return Strategy{}, databaseError(err)
}
if err := s.InvalidateListener(ctx, strategy.BigAccountID, "策略启停变更"); err != nil {
return Strategy{}, err
}
return s.GetStrategy(ctx, id)
}
func (s *Store) DeleteStrategy(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM creator_strategy WHERE id=$1`, id)
return databaseError(err)
}
func scanEvent(scanner interface{ Scan(...any) error }) (InteractionEvent, error) {
var result InteractionEvent
var platformAt, gatewayReceivedAt, receivedAt, startedAt, finishedAt, displayedAt sql.NullTime
var commentID, workID, messageType, messageText, strategyID, executionID sql.NullString
if err := scanner.Scan(&result.ID, &result.Platform, &result.Generation, &result.ReceivingAccountID, &result.EventKey, &result.EventType, &result.InteractorUID, &commentID, &workID, &messageType, &messageText, &platformAt, &gatewayReceivedAt, &receivedAt, &startedAt, &finishedAt, &displayedAt, &result.State, &result.Reason, &strategyID, &executionID); err != nil {
return InteractionEvent{}, err
}
result.CommentID, result.WorkID, result.MessageType, result.MessageText = commentID.String, workID.String, messageType.String, messageText.String
result.StrategyID, result.ExecutionAccountID = strategyID.String, executionID.String
result.PlatformEventAt, result.GatewayReceivedAt, result.ProcessingStartedAt, result.ProcessingFinishedAt, result.DisplayedAt = nullableTime(platformAt), nullableTime(gatewayReceivedAt), nullableTime(startedAt), nullableTime(finishedAt), nullableTime(displayedAt)
if receivedAt.Valid {
result.ReceivedAt = receivedAt.Time.UTC()
}
return result, nil
}
const eventSelect = `SELECT id,platform,generation,receiving_account_id,event_key,event_type,interactor_uid,comment_id,work_id,message_type,message_text,platform_event_at,gateway_received_at,received_at,processing_started_at,processing_finished_at,displayed_at,state,reason,strategy_id,execution_account_id FROM creator_event`
func (s *Store) GetEvent(ctx context.Context, id string) (InteractionEvent, error) {
result, err := scanEvent(s.db.QueryRowContext(ctx, eventSelect+` WHERE id=$1`, id))
return result, rowError(err)
}
func (s *Store) ListEvents(ctx context.Context, accountID string) ([]InteractionEvent, error) {
query := eventSelect
args := []any{}
if accountID != "" {
query += ` WHERE receiving_account_id=$1`
args = append(args, accountID)
}
query += ` ORDER BY received_at DESC,id`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, databaseError(err)
}
defer rows.Close()
result := make([]InteractionEvent, 0)
for rows.Next() {
item, err := scanEvent(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func (s *Store) RecordEvent(ctx context.Context, input InteractionEvent) (AutomaticResult, error) {
input.Platform, input.Generation, input.ReceivingAccountID, input.EventKey, input.EventType, input.InteractorUID, input.CommentID, input.WorkID, input.MessageType, input.MessageText = strings.TrimSpace(input.Platform), strings.TrimSpace(input.Generation), strings.TrimSpace(input.ReceivingAccountID), strings.TrimSpace(input.EventKey), strings.TrimSpace(input.EventType), strings.TrimSpace(input.InteractorUID), strings.TrimSpace(input.CommentID), strings.TrimSpace(input.WorkID), strings.TrimSpace(input.MessageType), strings.TrimSpace(input.MessageText)
if input.MessageType == "" {
input.MessageType = MessageTypeText
}
if !ValidatePlatform(input.Platform) || len(input.Generation) > 500 || input.ReceivingAccountID == "" || input.EventKey == "" || !ValidEventType(input.EventType) || !ValidMessageType(input.MessageType) || len(input.MessageText) > 100000 {
return AutomaticResult{}, ErrInvalid
}
profile, err := s.GetAccountProfile(ctx, input.ReceivingAccountID)
if err != nil {
return AutomaticResult{}, err
}
if profile.Platform != input.Platform {
return AutomaticResult{}, ErrInvalid
}
id := newID("event")
state := "received"
reason := ""
if input.Baseline {
state = "baseline"
reason = input.BaselineReason
if reason == "" {
reason = "监听基线"
}
}
if !input.Baseline && input.EventType != "dm" && input.InteractorUID == "" {
state = "blocked"
reason = "缺少互动用户 UID"
}
receivedAt := input.ReceivedAt.UTC()
if receivedAt.IsZero() {
receivedAt = time.Now().UTC()
}
var returnedID string
var inserted bool
err = s.db.QueryRowContext(ctx, `INSERT INTO creator_event (id,platform,generation,receiving_account_id,event_key,event_type,interactor_uid,comment_id,work_id,message_type,message_text,platform_event_at,gateway_received_at,received_at,state,reason) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT (platform,receiving_account_id,event_key) DO NOTHING RETURNING id,(xmax=0)`, id, input.Platform, input.Generation, input.ReceivingAccountID, input.EventKey, input.EventType, input.InteractorUID, input.CommentID, input.WorkID, input.MessageType, input.MessageText, input.PlatformEventAt, input.GatewayReceivedAt, receivedAt, state, reason).Scan(&returnedID, &inserted)
if errors.Is(err, sql.ErrNoRows) {
existingErr := s.db.QueryRowContext(ctx, `SELECT id FROM creator_event WHERE platform=$1 AND receiving_account_id=$2 AND event_key=$3`, input.Platform, input.ReceivingAccountID, input.EventKey).Scan(&returnedID)
if existingErr != nil {
return AutomaticResult{}, rowError(existingErr)
}
event, getErr := s.GetEvent(ctx, returnedID)
return AutomaticResult{Event: event, Duplicate: true}, getErr
}
if err != nil {
return AutomaticResult{}, databaseError(err)
}
event, err := s.GetEvent(ctx, returnedID)
return AutomaticResult{Event: event, Duplicate: !inserted}, err
}
func (s *Store) markEvent(ctx context.Context, eventID, state, reason, strategyID, executionID string, started, finished, displayed *time.Time) error {
if state != "received" && state != "baseline" && state != "ignored" && state != "unmatched" && state != "blocked" && state != "processing" && state != "succeeded" && state != "failed" && state != "uncertain" {
return ErrInvalid
}
_, err := s.db.ExecContext(ctx, `UPDATE creator_event SET state=$2,reason=$3,strategy_id=NULLIF($4,''),execution_account_id=NULLIF($5,''),processing_started_at=COALESCE($6,processing_started_at),processing_finished_at=COALESCE($7,processing_finished_at),displayed_at=COALESCE($8,displayed_at) WHERE id=$1`, eventID, state, reason, strategyID, executionID, started, finished, displayed)
return databaseError(err)
}
func selectCandidate(values []string) (string, error) {
if len(values) == 0 {
return "", nil
}
if len(values) == 1 {
return values[0], nil
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(values))))
if err != nil {
return "", fmt.Errorf("choose automatic response candidate: %w", err)
}
return values[n.Int64()], nil
}
func (s *Store) ProcessAutomaticEvent(ctx context.Context, input InteractionEvent, executor ActionExecutor, generator TextGenerator) (AutomaticResult, error) {
recorded, err := s.RecordEvent(ctx, input)
if err != nil {
return recorded, err
}
eventID := recorded.Event.ID
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return AutomaticResult{}, fmt.Errorf("begin automatic event: %w", err)
}
defer tx.Rollback()
var storedPlatform, storedGeneration, storedReceivingAccountID, storedEventKey, storedEventType, storedInteractorUID, storedCommentID, storedWorkID, eventState string
if err := tx.QueryRowContext(ctx, `SELECT platform,generation,receiving_account_id,event_key,event_type,interactor_uid,comment_id,work_id,state FROM creator_event WHERE id=$1 FOR UPDATE`, eventID).Scan(&storedPlatform, &storedGeneration, &storedReceivingAccountID, &storedEventKey, &storedEventType, &storedInteractorUID, &storedCommentID, &storedWorkID, &eventState); err != nil {
return AutomaticResult{}, rowError(err)
}
if eventState != "received" {
tx.Rollback()
event, getErr := s.GetEvent(ctx, eventID)
return AutomaticResult{Event: event, Duplicate: recorded.Duplicate}, getErr
}
input.Platform = storedPlatform
input.Generation = storedGeneration
input.ReceivingAccountID = storedReceivingAccountID
input.EventKey = storedEventKey
input.EventType = storedEventType
input.InteractorUID = storedInteractorUID
input.CommentID = storedCommentID
input.WorkID = storedWorkID
var bigProfile AccountProfile
if err := s.scanAccountTx(ctx, tx, input.ReceivingAccountID, &bigProfile); err != nil {
return AutomaticResult{}, err
}
if storedGeneration != "" {
var listenerStatus, listenerGeneration string
var listenerInvalidated bool
listenerErr := tx.QueryRowContext(ctx, `SELECT status,generation,invalidated FROM creator_listener_state WHERE account_id=$1`, input.ReceivingAccountID).Scan(&listenerStatus, &listenerGeneration, &listenerInvalidated)
if listenerErr != nil && !errors.Is(listenerErr, sql.ErrNoRows) {
return AutomaticResult{}, databaseError(listenerErr)
}
if listenerErr == nil && (listenerStatus != "ready" || listenerGeneration != storedGeneration || listenerInvalidated) {
if _, updateErr := tx.ExecContext(ctx, `UPDATE creator_event SET state='blocked',reason='监听代际未就绪' WHERE id=$1 AND state='received'`, eventID); updateErr != nil {
return AutomaticResult{}, databaseError(updateErr)
}
if err := tx.Commit(); err != nil {
return AutomaticResult{}, err
}
event, getErr := s.GetEvent(ctx, eventID)
return AutomaticResult{Event: event}, getErr
}
}
if !bigProfile.BigAccount {
if _, err := tx.ExecContext(ctx, `UPDATE creator_event SET state='ignored', reason='大号模式未开启' WHERE id=$1`, eventID); err != nil {
return AutomaticResult{}, databaseError(err)
}
if err := tx.Commit(); err != nil {
return AutomaticResult{}, fmt.Errorf("commit ignored event: %w", err)
}
event, err := s.GetEvent(ctx, eventID)
return AutomaticResult{Event: event}, err
}
var chosen Strategy
var execution AccountProfile
reason := ""
strategies, err := s.listStrategiesTx(ctx, tx, input.ReceivingAccountID)
if err != nil {
return AutomaticResult{}, err
}
tracePosition := 0
trace := func(strategyID, outcome, traceReason string) error {
tracePosition++
_, traceErr := tx.ExecContext(ctx, `INSERT INTO creator_event_strategy_trace (event_id,strategy_id,position,outcome,reason) VALUES ($1,$2,$3,$4,$5)`, eventID, strategyID, tracePosition, outcome, traceReason)
return databaseError(traceErr)
}
for _, strategy := range strategies {
if !strategy.Enabled {
if err := trace(strategy.ID, "skipped", "策略未启用"); err != nil {
return AutomaticResult{}, err
}
continue
}
if !contains(strategy.EventTypes, input.EventType) {
if err := trace(strategy.ID, "skipped", "事件类型不匹配"); err != nil {
return AutomaticResult{}, err
}
continue
}
var profile AccountProfile
if err := s.scanAccountTx(ctx, tx, strategy.ExecutionAccountID, &profile); err != nil {
reason = "执行账号不可用"
if traceErr := trace(strategy.ID, "skipped", reason); traceErr != nil {
return AutomaticResult{}, traceErr
}
continue
}
var related bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_relation WHERE big_account_id=$1 AND small_account_id=$2)`, input.ReceivingAccountID, strategy.ExecutionAccountID).Scan(&related); err != nil {
return AutomaticResult{}, databaseError(err)
}
if !related {
reason = "执行账号未绑定"
if traceErr := trace(strategy.ID, "skipped", reason); traceErr != nil {
return AutomaticResult{}, traceErr
}
continue
}
if err := CanWrite(profile, true, strategy.Action); err != nil {
reason = "执行账号不可用"
if traceErr := trace(strategy.ID, "skipped", reason); traceErr != nil {
return AutomaticResult{}, traceErr
}
continue
}
if !ActionTargetValid(strategy.Action, input.InteractorUID, input.CommentID, input.WorkID, strategy.TargetType) {
reason = "事件缺少动作目标"
if traceErr := trace(strategy.ID, "skipped", reason); traceErr != nil {
return AutomaticResult{}, traceErr
}
continue
}
if ActionRequiresText(strategy.Action) && len(strategy.CandidateTexts) == 0 && strings.TrimSpace(bigProfile.ReplyRequirements) == "" {
reason = "缺少候选文本和大号 AI 回复要求"
if traceErr := trace(strategy.ID, "skipped", reason); traceErr != nil {
return AutomaticResult{}, traceErr
}
continue
}
chosen, execution = strategy, profile
if err := trace(strategy.ID, "selected", ""); err != nil {
return AutomaticResult{}, err
}
break
}
if chosen.ID == "" {
if reason == "" {
reason = "没有匹配策略"
}
if _, err := tx.ExecContext(ctx, `UPDATE creator_event SET state='unmatched',reason=$2 WHERE id=$1`, eventID, reason); err != nil {
return AutomaticResult{}, databaseError(err)
}
if err := tx.Commit(); err != nil {
return AutomaticResult{}, err
}
event, getErr := s.GetEvent(ctx, eventID)
if getErr != nil {
return AutomaticResult{}, getErr
}
return AutomaticResult{Event: event}, nil
}
now := time.Now().UTC()
cooldownSeconds := bigProfile.CooldownSeconds
if !validCooldownSeconds(cooldownSeconds) {
return AutomaticResult{}, ErrInvalid
}
expires := now.Add(time.Duration(cooldownSeconds) * time.Second)
var cooldownID string
scanErr := tx.QueryRowContext(ctx, `INSERT INTO creator_cooldown (big_account_id,interactor_uid,event_id,strategy_id,execution_account_id,started_at,expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (big_account_id,interactor_uid) DO UPDATE SET event_id=EXCLUDED.event_id,strategy_id=EXCLUDED.strategy_id,execution_account_id=EXCLUDED.execution_account_id,started_at=EXCLUDED.started_at,expires_at=EXCLUDED.expires_at WHERE creator_cooldown.expires_at <= $6 RETURNING event_id`, input.ReceivingAccountID, input.InteractorUID, eventID, chosen.ID, execution.ID, now, expires).Scan(&cooldownID)
if errors.Is(scanErr, sql.ErrNoRows) {
if _, err := tx.ExecContext(ctx, `UPDATE creator_event SET state='blocked',reason='自动响应冷却中' WHERE id=$1`, eventID); err != nil {
return AutomaticResult{}, databaseError(err)
}
if err := tx.Commit(); err != nil {
return AutomaticResult{}, err
}
event, getErr := s.GetEvent(ctx, eventID)
if getErr != nil {
return AutomaticResult{}, getErr
}
return AutomaticResult{Event: event}, nil
}
if scanErr != nil {
return AutomaticResult{}, databaseError(scanErr)
}
text, selectionErr := selectCandidate(chosen.CandidateTexts)
if selectionErr != nil {
if _, updateErr := tx.ExecContext(ctx, `UPDATE creator_event SET state='failed', reason=$2, strategy_id=$3, execution_account_id=$4 WHERE id=$1`, eventID, selectionErr.Error(), chosen.ID, execution.ID); updateErr != nil {
return AutomaticResult{}, databaseError(updateErr)
}
if commitErr := tx.Commit(); commitErr != nil {
return AutomaticResult{}, commitErr
}
event, getErr := s.GetEvent(ctx, eventID)
if getErr != nil {
return AutomaticResult{}, getErr
}
return AutomaticResult{Event: event}, selectionErr
}
opInput := OperationInput{IdempotencyKey: "event:" + input.Platform + ":" + input.ReceivingAccountID + ":" + input.EventKey, Source: "automatic", Action: chosen.Action, Platform: input.Platform, AccountID: execution.ID, TargetUID: input.InteractorUID, TargetCommentID: input.CommentID, TargetWorkID: input.WorkID, Text: text, EventID: eventID, StrategyID: chosen.ID}
opID := newID("operation")
hash := operationHash(opInput)
if _, err := tx.ExecContext(ctx, `INSERT INTO creator_operation (id,idempotency_key,source,action,platform,account_id,target_uid,target_comment_id,target_work_id,text,event_id,strategy_id,request_hash,state) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'processing')`, opID, opInput.IdempotencyKey, opInput.Source, opInput.Action, opInput.Platform, opInput.AccountID, opInput.TargetUID, opInput.TargetCommentID, opInput.TargetWorkID, opInput.Text, opInput.EventID, opInput.StrategyID, hash); err != nil {
return AutomaticResult{}, databaseError(err)
}
if _, err := tx.ExecContext(ctx, `UPDATE creator_event SET state='processing',reason='',strategy_id=$2,execution_account_id=$3,processing_started_at=$4 WHERE id=$1`, eventID, chosen.ID, execution.ID, now); err != nil {
return AutomaticResult{}, databaseError(err)
}
if err := tx.Commit(); err != nil {
return AutomaticResult{}, fmt.Errorf("commit automatic event: %w", err)
}
if ActionRequiresText(chosen.Action) && strings.TrimSpace(text) == "" {
if generator == nil {
result := ActionResult{State: "blocked", Reason: "AI 生成不可用"}
if err := s.UpdateOperationResult(ctx, opID, result); err != nil {
return AutomaticResult{}, fmt.Errorf("save unavailable automatic operation: %w", err)
}
if err := s.markEvent(ctx, eventID, result.State, result.Reason, chosen.ID, execution.ID, nil, ptrTime(time.Now().UTC()), nil); err != nil {
return AutomaticResult{}, fmt.Errorf("save unavailable automatic event: %w", err)
}
event, getErr := s.GetEvent(ctx, eventID)
if getErr != nil {
return AutomaticResult{}, getErr
}
op, getErr := s.GetOperation(ctx, opID)
if getErr != nil {
return AutomaticResult{}, getErr
}
return AutomaticResult{Event: event, Operation: &op}, ErrUnavailable
}
generated, generateErr := generator.Generate(ctx, bigProfile.ReplyRequirements, input.EventType)
if generateErr != nil {
result := ActionResult{State: "failed", Reason: generateErr.Error()}
if err := s.UpdateOperationResult(ctx, opID, result); err != nil {
return AutomaticResult{}, fmt.Errorf("save failed automatic operation: %w", err)
}
if err := s.markEvent(ctx, eventID, result.State, result.Reason, chosen.ID, execution.ID, nil, ptrTime(time.Now().UTC()), nil); err != nil {
return AutomaticResult{}, fmt.Errorf("save failed automatic event: %w", err)
}
event, getErr := s.GetEvent(ctx, eventID)
if getErr != nil {
return AutomaticResult{}, getErr
}
op, getErr := s.GetOperation(ctx, opID)
if getErr != nil {
return AutomaticResult{}, getErr
}
return AutomaticResult{Event: event, Operation: &op}, generateErr
}
text = strings.TrimSpace(generated)
if text == "" {
result := ActionResult{State: "failed", Reason: "AI 返回空内容"}
if err := s.UpdateOperationResult(ctx, opID, result); err != nil {
return AutomaticResult{}, fmt.Errorf("save empty automatic operation: %w", err)
}
if err := s.markEvent(ctx, eventID, result.State, result.Reason, chosen.ID, execution.ID, nil, ptrTime(time.Now().UTC()), nil); err != nil {
return AutomaticResult{}, fmt.Errorf("save empty automatic event: %w", err)
}
event, getErr := s.GetEvent(ctx, eventID)
if getErr != nil {
return AutomaticResult{}, getErr
}
op, getErr := s.GetOperation(ctx, opID)
if getErr != nil {
return AutomaticResult{}, getErr
}
return AutomaticResult{Event: event, Operation: &op}, ErrInvalid
}
hash = operationHash(OperationInput{IdempotencyKey: opInput.IdempotencyKey, Source: opInput.Source, Action: opInput.Action, Platform: opInput.Platform, AccountID: opInput.AccountID, TargetUID: opInput.TargetUID, TargetCommentID: opInput.TargetCommentID, TargetWorkID: opInput.TargetWorkID, Text: text, EventID: opInput.EventID, StrategyID: opInput.StrategyID})
updated, updateErr := s.db.ExecContext(ctx, `UPDATE creator_operation SET text=$2,request_hash=$3,updated_at=now() WHERE id=$1 AND state='processing'`, opID, text, hash)
if updateErr != nil {
return AutomaticResult{}, databaseError(updateErr)
}
if affected, affectedErr := updated.RowsAffected(); affectedErr != nil {
return AutomaticResult{}, affectedErr
} else if affected != 1 {
event, getErr := s.GetEvent(ctx, eventID)
if getErr != nil {
return AutomaticResult{}, getErr
}
op, getErr := s.GetOperation(ctx, opID)
if getErr != nil {
return AutomaticResult{}, getErr
}
return AutomaticResult{Event: event, Operation: &op}, nil
}
}
started := now
// Conditions may change while the operation waits for the account executor.
// Re-check immediately before the platform write; a stale queued operation is blocked, never sent.
// The lock is deliberately acquired after receipt/operation persistence so ingestion
// never waits on a slow platform write, while one execution account remains serial.
executionLock := s.automaticExecutionLock(execution.ID)
executionLock.Lock()
defer executionLock.Unlock()
releaseExecutionLock, err := s.acquireAutomaticExecutionLock(ctx, execution.ID)
if err != nil {
return AutomaticResult{}, err
}
defer releaseExecutionLock()
var liveOperationID string
if err := s.db.QueryRowContext(ctx, `UPDATE creator_operation SET updated_at=now() WHERE id=$1 AND state='processing' RETURNING id`, opID).Scan(&liveOperationID); errors.Is(err, sql.ErrNoRows) {
event, getErr := s.GetEvent(ctx, eventID)
if getErr != nil {
return AutomaticResult{}, getErr
}
op, getErr := s.GetOperation(ctx, opID)
if getErr != nil {
return AutomaticResult{}, getErr
}
return AutomaticResult{Event: event, Operation: &op}, nil
} else if err != nil {
return AutomaticResult{}, databaseError(err)
}
result := ActionResult{}
_, checkErr := s.AccountWriteCheck(ctx, execution.ID, true, chosen.Action)
if checkErr == nil {
currentBig, bigErr := s.GetAccountProfile(ctx, input.ReceivingAccountID)
if bigErr != nil {
checkErr = bigErr
} else if !currentBig.BigAccount || currentBig.BusinessStatus != "normal" || currentBig.LoginStatus != "logged_in" || currentBig.AuthorizationStatus != "authorized" {
checkErr = ErrConflict
}
}
if checkErr == nil {
var related bool
checkErr = s.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_relation WHERE big_account_id=$1 AND small_account_id=$2)`, input.ReceivingAccountID, execution.ID).Scan(&related)
if checkErr == nil && !related {
checkErr = ErrConflict
}
}
if checkErr == nil {
latestStrategy, strategyErr := s.GetStrategy(ctx, chosen.ID)
if strategyErr != nil {
checkErr = strategyErr
} else if !latestStrategy.Enabled || latestStrategy.Action != chosen.Action || latestStrategy.ExecutionAccountID != execution.ID || !contains(latestStrategy.EventTypes, input.EventType) {
checkErr = ErrConflict
}
}
if checkErr == nil && storedGeneration != "" {
var listenerStatus, listenerGeneration string
var listenerInvalidated bool
listenerErr := s.db.QueryRowContext(ctx, `SELECT status,generation,invalidated FROM creator_listener_state WHERE account_id=$1`, input.ReceivingAccountID).Scan(&listenerStatus, &listenerGeneration, &listenerInvalidated)
if listenerErr != nil && !errors.Is(listenerErr, sql.ErrNoRows) {
checkErr = databaseError(listenerErr)
} else if listenerErr == nil && (listenerStatus != "ready" || listenerGeneration != storedGeneration || listenerInvalidated) {
checkErr = ErrConflict
}
}
if checkErr != nil {
result = actionPreconditionResult(checkErr, "写入前条件已变化")
} else if executor == nil {
result = ActionResult{State: "uncertain", Reason: "平台执行器不可用"}
} else {
result, err = executor.Execute(ctx, ActionRequest{OperationID: opID, Action: chosen.Action, Platform: input.Platform, AccountID: execution.ID, TargetUID: input.InteractorUID, TargetCommentID: input.CommentID, TargetWorkID: input.WorkID, Text: text})
result = normalizeActionResult(result, err)
}
if result.State != "succeeded" && result.State != "failed" && result.State != "uncertain" && result.State != "blocked" {
result.State = "uncertain"
if result.Reason == "" {
result.Reason = "执行器未返回明确结果"
}
}
if err := s.UpdateOperationResult(ctx, opID, result); err != nil {
return AutomaticResult{}, fmt.Errorf("save automatic operation result: %w", err)
}
finished := time.Now().UTC()
if err := s.markEvent(ctx, eventID, result.State, result.Reason, chosen.ID, execution.ID, &started, &finished, nil); err != nil {
return AutomaticResult{}, fmt.Errorf("save automatic event result: %w", err)
}
event, err := s.GetEvent(ctx, eventID)
if err != nil {
return AutomaticResult{}, err
}
op, err := s.GetOperation(ctx, opID)
if err != nil {
return AutomaticResult{}, err
}
return AutomaticResult{Event: event, Operation: &op}, nil
}
const staleProcessingAfter = 2 * time.Minute
func (s *Store) RecoverStaleProcessing(ctx context.Context, now time.Time) (int, error) {
if now.IsZero() {
return 0, ErrInvalid
}
cutoff := now.UTC().Add(-staleProcessingAfter)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, databaseError(err)
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, `
SELECT id,event_id
FROM creator_operation
WHERE state='processing' AND updated_at < $1
FOR UPDATE SKIP LOCKED`, cutoff)
if err != nil {
return 0, databaseError(err)
}
const reason = "处理者失联,平台写入结果不明"
stale := make([]struct{ operationID, eventID string }, 0)
for rows.Next() {
var item struct{ operationID, eventID string }
if err := rows.Scan(&item.operationID, &item.eventID); err != nil {
rows.Close()
return 0, databaseError(err)
}
stale = append(stale, item)
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, databaseError(err)
}
if err := rows.Close(); err != nil {
return 0, databaseError(err)
}
for _, item := range stale {
if _, err := tx.ExecContext(ctx, `UPDATE creator_operation SET state='uncertain',reason=$2,updated_at=$3 WHERE id=$1 AND state='processing'`, item.operationID, reason, now.UTC()); err != nil {
return 0, databaseError(err)
}
if item.eventID != "" {
if _, err := tx.ExecContext(ctx, `UPDATE creator_event SET state='uncertain',reason=$2,processing_finished_at=COALESCE(processing_finished_at,$3) WHERE id=$1 AND state='processing'`, item.eventID, reason, now.UTC()); err != nil {
return 0, databaseError(err)
}
}
}
if _, err := tx.ExecContext(ctx, `UPDATE creator_event SET state='uncertain',reason=$2,processing_finished_at=COALESCE(processing_finished_at,$3) WHERE state='processing' AND processing_started_at IS NOT NULL AND processing_started_at < $1 AND NOT EXISTS (SELECT 1 FROM creator_operation WHERE event_id=creator_event.id AND state='processing')`, cutoff, reason, now.UTC()); err != nil {
return 0, databaseError(err)
}
receivedRecovered, err := tx.ExecContext(ctx, `UPDATE creator_event SET state='uncertain',reason='事件已收到但未开始处理,未补发',processing_finished_at=COALESCE(processing_finished_at,$2) WHERE state='received' AND received_at < $1`, cutoff, now.UTC())
if err != nil {
return 0, databaseError(err)
}
receivedCount, err := receivedRecovered.RowsAffected()
if err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, databaseError(err)
}
return len(stale) + int(receivedCount), nil
}
func contains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func (s *Store) scanAccountTx(ctx context.Context, tx *sql.Tx, id string, result *AccountProfile) error {
var checkedAt sql.NullTime
if err := tx.QueryRowContext(ctx, accountProfileQuery(), id).Scan(&result.ID, &result.Name, &result.Platform, &result.PlatformAccountKey, &result.AuthorizationKind, &result.AuthorizationStatus, &result.RuntimeStatus, &result.LoginUsername, &result.PasswordConfigured, &result.RealNameStatus, &result.RealName, &result.IdentityNumber, &result.Note, &result.BusinessStatus, &result.BigAccount, &result.ReplyRequirements, &result.LoginStatus, &result.LoginReason, &checkedAt, &result.CooldownSeconds, &result.UpdatedAt); err != nil {
return rowError(err)
}
result.LoginCheckedAt = nullableTime(checkedAt)
return nil
}
func (s *Store) listStrategiesTx(ctx context.Context, tx *sql.Tx, bigID string) ([]Strategy, error) {
rows, err := tx.QueryContext(ctx, strategySelect+` WHERE big_account_id=$1 ORDER BY position,id`, bigID)
if err != nil {
return nil, databaseError(err)
}
defer rows.Close()
result := make([]Strategy, 0)
for rows.Next() {
item, err := scanStrategy(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func operationHash(input OperationInput) string {
encoded, _ := json.Marshal(input)
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:])
}
func scanOperation(scanner interface{ Scan(...any) error }) (Operation, error) {
var result Operation
var evidence, verificationEvidence []byte
var eventID, strategyID sql.NullString
if err := scanner.Scan(&result.ID, &result.IdempotencyKey, &result.Source, &result.Action, &result.Platform, &result.AccountID, &result.TargetUID, &result.TargetCommentID, &result.TargetWorkID, &result.Text, &eventID, &strategyID, &result.State, &evidence, &result.Reason, &result.VerificationState, &verificationEvidence, &result.VerifiedAt, &result.CreatedAt, &result.UpdatedAt); err != nil {
return Operation{}, err
}
result.EventID, result.StrategyID = eventID.String, strategyID.String
result.Evidence = map[string]string{}
if len(evidence) > 0 {
if err := json.Unmarshal(evidence, &result.Evidence); err != nil {
return Operation{}, err
}
}
if result.VerificationState == "" {
result.VerificationState = "not_verified"
}
result.VerificationProof = map[string]string{}
if len(verificationEvidence) > 0 {
if err := json.Unmarshal(verificationEvidence, &result.VerificationProof); err != nil {
return Operation{}, err
}
}
return result, nil
}
const operationSelect = `SELECT id,idempotency_key,source,action,platform,account_id,target_uid,target_comment_id,target_work_id,text,event_id,strategy_id,state,evidence,reason,verification_state,verification_evidence,verified_at,created_at,updated_at FROM creator_operation`
func (s *Store) GetOperation(ctx context.Context, id string) (Operation, error) {
result, err := scanOperation(s.db.QueryRowContext(ctx, operationSelect+` WHERE id=$1`, id))
return result, rowError(err)
}
func (s *Store) ListOperations(ctx context.Context, accountID string) ([]Operation, error) {
query := operationSelect
args := []any{}
if accountID != "" {
query += ` WHERE account_id=$1`
args = append(args, accountID)
}
query += ` ORDER BY created_at DESC,id`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, databaseError(err)
}
defer rows.Close()
result := make([]Operation, 0)
for rows.Next() {
item, err := scanOperation(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func actionPreconditionResult(err error, reason string) ActionResult {
if errors.Is(err, ErrInvalid) || errors.Is(err, ErrConflict) || errors.Is(err, ErrNotFound) {
return ActionResult{State: "blocked", Reason: reason}
}
return ActionResult{State: "uncertain", Reason: "写入前检查失败: " + err.Error()}
}
func normalizeActionResult(result ActionResult, execErr error) ActionResult {
if execErr != nil {
if result.State != "failed" && result.State != "blocked" {
result.State = "uncertain"
}
if result.Reason == "" {
result.Reason = execErr.Error()
}
}
if result.State != "succeeded" && result.State != "failed" && result.State != "uncertain" && result.State != "blocked" {
result.State = "uncertain"
if result.Reason == "" {
result.Reason = "执行器未返回明确结果"
}
}
return result
}
func (s *Store) validateOperationTarget(ctx context.Context, input *OperationInput) error {
if input.TargetCommentID != "" {
var platform, authorUID, commentWorkID string
if err := s.db.QueryRowContext(ctx, `SELECT platform,author_uid,work_id FROM creator_comment WHERE id=$1`, input.TargetCommentID).Scan(&platform, &authorUID, &commentWorkID); err != nil {
return rowError(err)
}
if platform != input.Platform || authorUID != input.TargetUID || input.TargetWorkID != "" && commentWorkID != input.TargetWorkID {
return ErrInvalid
}
if input.TargetWorkID == "" {
input.TargetWorkID = commentWorkID
}
}
if input.TargetWorkID != "" {
var platform string
if err := s.db.QueryRowContext(ctx, `SELECT platform FROM creator_work WHERE id=$1`, input.TargetWorkID).Scan(&platform); err != nil {
return rowError(err)
}
if platform != input.Platform {
return ErrInvalid
}
}
return nil
}
func (s *Store) CreateOperation(ctx context.Context, input OperationInput) (Operation, bool, error) {
input.IdempotencyKey = strings.TrimSpace(input.IdempotencyKey)
input.Source = strings.TrimSpace(input.Source)
input.Action = strings.TrimSpace(input.Action)
input.Platform = strings.TrimSpace(input.Platform)
input.AccountID = strings.TrimSpace(input.AccountID)
input.TargetUID = strings.TrimSpace(input.TargetUID)
input.TargetCommentID = strings.TrimSpace(input.TargetCommentID)
input.TargetWorkID = strings.TrimSpace(input.TargetWorkID)
input.Text = strings.TrimSpace(input.Text)
if input.IdempotencyKey == "" || input.Source != "manual" || !ValidAction(input.Action) || !ValidatePlatform(input.Platform) || input.AccountID == "" || !ActionTargetValid(input.Action, input.TargetUID, input.TargetCommentID, input.TargetWorkID, "") || ActionRequiresText(input.Action) && input.Text == "" {
return Operation{}, false, ErrInvalid
}
hash := operationHash(input)
var returnedID, existingHash string
lookupErr := s.db.QueryRowContext(ctx, `SELECT id,request_hash FROM creator_operation WHERE idempotency_key=$1`, input.IdempotencyKey).Scan(&returnedID, &existingHash)
if lookupErr == nil {
if existingHash != hash {
return Operation{}, false, ErrConflict
}
op, err := s.GetOperation(ctx, returnedID)
return op, false, err
}
if !errors.Is(lookupErr, sql.ErrNoRows) {
return Operation{}, false, databaseError(lookupErr)
}
profile, err := s.AccountWriteCheck(ctx, input.AccountID, false, input.Action)
if err != nil {
return Operation{}, false, err
}
if profile.Platform != input.Platform {
return Operation{}, false, ErrInvalid
}
if err := s.validateOperationTarget(ctx, &input); err != nil {
return Operation{}, false, err
}
id := newID("operation")
var inserted bool
err = s.db.QueryRowContext(ctx, `INSERT INTO creator_operation (id,idempotency_key,source,action,platform,account_id,target_uid,target_comment_id,target_work_id,text,event_id,strategy_id,request_hash) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id,(xmax=0)`, id, input.IdempotencyKey, input.Source, input.Action, input.Platform, input.AccountID, input.TargetUID, input.TargetCommentID, input.TargetWorkID, input.Text, input.EventID, input.StrategyID, hash).Scan(&returnedID, &inserted)
if errors.Is(err, sql.ErrNoRows) {
if err := s.db.QueryRowContext(ctx, `SELECT id,request_hash FROM creator_operation WHERE idempotency_key=$1`, input.IdempotencyKey).Scan(&returnedID, &existingHash); err != nil {
return Operation{}, false, rowError(err)
}
if existingHash != hash {
return Operation{}, false, ErrConflict
}
op, err := s.GetOperation(ctx, returnedID)
return op, false, err
}
if err != nil {
return Operation{}, false, databaseError(err)
}
op, err := s.GetOperation(ctx, returnedID)
return op, inserted, err
}
func (s *Store) UpdateOperationResult(ctx context.Context, id string, result ActionResult) error {
if result.State != "succeeded" && result.State != "failed" && result.State != "uncertain" && result.State != "blocked" {
return ErrInvalid
}
evidence, err := jsonText(result.Evidence)
if err != nil {
return err
}
verificationState := "uncertain"
if result.State == "succeeded" || result.State == "failed" || result.State == "blocked" {
verificationState = result.State
}
updated, err := s.db.ExecContext(ctx, `UPDATE creator_operation SET state=$2,evidence=$3::jsonb,reason=$4,verification_state=$5,verification_evidence=$3::jsonb,verified_at=CASE WHEN $5 IN ('succeeded','failed','blocked') THEN now() ELSE NULL END,updated_at=now() WHERE id=$1 AND state='processing'`, id, result.State, evidence, result.Reason, verificationState)
if err != nil {
return databaseError(err)
}
affected, err := updated.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return ErrConflict
}
return nil
}
func (s *Store) persistDirectMessageResult(ctx context.Context, op Operation, result ActionResult) error {
if op.Action != ActionDM {
return nil
}
messageState := result.State
if messageState == "blocked" {
messageState = "failed"
}
messageAt := time.Now().UTC()
platformMessageKey := result.Evidence["message_server_id"]
if strings.TrimSpace(platformMessageKey) == "" {
platformMessageKey = "operation:" + op.ID
}
_, _, err := s.SaveMessage(ctx, MessageInput{Platform: op.Platform, AccountID: op.AccountID, PeerUID: op.TargetUID, PlatformMessageKey: platformMessageKey, OperationID: op.ID, Direction: "outbound", MessageType: "text", Text: op.Text, SentState: messageState, MessageAt: &messageAt})
return err
}
func (s *Store) ExecuteManualOperation(ctx context.Context, id string, executor ActionExecutor) (Operation, error) {
op, err := s.GetOperation(ctx, id)
if err != nil {
return Operation{}, err
}
if op.Source != "manual" {
return Operation{}, ErrInvalid
}
if op.State != "created" {
return op, nil
}
var claimedID string
if err := s.db.QueryRowContext(ctx, `UPDATE creator_operation SET state='processing',updated_at=now() WHERE id=$1 AND state='created' RETURNING id`, id).Scan(&claimedID); errors.Is(err, sql.ErrNoRows) {
return s.GetOperation(ctx, id)
} else if err != nil {
return Operation{}, databaseError(err)
}
executionLock := s.automaticExecutionLock(op.AccountID)
executionLock.Lock()
defer executionLock.Unlock()
releaseExecutionLock, err := s.acquireAutomaticExecutionLock(ctx, op.AccountID)
if err != nil {
return Operation{}, err
}
defer releaseExecutionLock()
if err := s.db.QueryRowContext(ctx, `UPDATE creator_operation SET updated_at=now() WHERE id=$1 AND state='processing' RETURNING id`, id).Scan(&claimedID); errors.Is(err, sql.ErrNoRows) {
return s.GetOperation(ctx, id)
} else if err != nil {
return Operation{}, databaseError(err)
}
if _, checkErr := s.AccountWriteCheck(ctx, op.AccountID, false, op.Action); checkErr != nil {
result := actionPreconditionResult(checkErr, "写入前条件已变化")
if updateErr := s.UpdateOperationResult(ctx, id, result); updateErr != nil {
return Operation{}, updateErr
}
if messageErr := s.persistDirectMessageResult(ctx, op, result); messageErr != nil {
return Operation{}, fmt.Errorf("persist direct message result: %w", messageErr)
}
return s.GetOperation(ctx, id)
}
if checkErr := s.validateOperationTarget(ctx, &OperationInput{Platform: op.Platform, AccountID: op.AccountID, Action: op.Action, TargetUID: op.TargetUID, TargetCommentID: op.TargetCommentID, TargetWorkID: op.TargetWorkID}); checkErr != nil {
result := actionPreconditionResult(checkErr, "写入目标已变化")
if updateErr := s.UpdateOperationResult(ctx, id, result); updateErr != nil {
return Operation{}, updateErr
}
if messageErr := s.persistDirectMessageResult(ctx, op, result); messageErr != nil {
return Operation{}, fmt.Errorf("persist direct message result: %w", messageErr)
}
return s.GetOperation(ctx, id)
}
if executor == nil {
result := ActionResult{State: "uncertain", Reason: "平台执行器不可用"}
if updateErr := s.UpdateOperationResult(ctx, id, result); updateErr != nil {
return Operation{}, updateErr
}
if messageErr := s.persistDirectMessageResult(ctx, op, result); messageErr != nil {
return Operation{}, fmt.Errorf("persist direct message result: %w", messageErr)
}
return s.GetOperation(ctx, id)
}
result, execErr := executor.Execute(ctx, ActionRequest{OperationID: claimedID, Action: op.Action, Platform: op.Platform, AccountID: op.AccountID, TargetUID: op.TargetUID, TargetCommentID: op.TargetCommentID, TargetWorkID: op.TargetWorkID, Text: op.Text})
result = normalizeActionResult(result, execErr)
if err := s.UpdateOperationResult(ctx, id, result); err != nil {
return Operation{}, err
}
if err := s.persistDirectMessageResult(ctx, op, result); err != nil {
return Operation{}, fmt.Errorf("persist direct message result: %w", err)
}
return s.GetOperation(ctx, id)
}
func scanConversation(scanner interface{ Scan(...any) error }) (Conversation, error) {
var c Conversation
var last, synced sql.NullTime
if err := scanner.Scan(&c.ID, &c.Platform, &c.AccountID, &c.PeerUID, &c.PeerName, &last, &c.HistoryCursor, &c.HistoryHasMore, &synced); err != nil {
return Conversation{}, err
}
c.LastMessageAt, c.HistorySyncedAt = nullableTime(last), nullableTime(synced)
return c, nil
}
const conversationSelect = `SELECT id,platform,account_id,peer_uid,peer_name,last_message_at,history_cursor,history_has_more,history_synced_at FROM creator_conversation`
func (s *Store) UpsertConversation(ctx context.Context, input MessageInput) (Conversation, error) {
if !ValidatePlatform(input.Platform) || input.AccountID == "" || input.PeerUID == "" {
return Conversation{}, ErrInvalid
}
var accountPlatform string
if err := s.db.QueryRowContext(ctx, `SELECT platform FROM social_account WHERE id=$1`, input.AccountID).Scan(&accountPlatform); err != nil {
return Conversation{}, rowError(err)
}
if accountPlatform != input.Platform {
return Conversation{}, ErrInvalid
}
id := newID("conversation")
var returned string
if err := s.db.QueryRowContext(ctx, `INSERT INTO creator_conversation (id,platform,account_id,peer_uid,peer_name,last_message_at) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (account_id,peer_uid) DO UPDATE SET peer_name=CASE WHEN EXCLUDED.peer_name='' THEN creator_conversation.peer_name ELSE EXCLUDED.peer_name END, last_message_at=CASE WHEN EXCLUDED.last_message_at IS NULL THEN creator_conversation.last_message_at WHEN creator_conversation.last_message_at IS NULL OR EXCLUDED.last_message_at > creator_conversation.last_message_at THEN EXCLUDED.last_message_at ELSE creator_conversation.last_message_at END RETURNING id`, id, input.Platform, input.AccountID, input.PeerUID, input.PeerName, input.MessageAt).Scan(&returned); err != nil {
return Conversation{}, databaseError(err)
}
return s.GetConversation(ctx, returned)
}
func (s *Store) GetConversation(ctx context.Context, id string) (Conversation, error) {
result, err := scanConversation(s.db.QueryRowContext(ctx, conversationSelect+` WHERE id=$1`, id))
return result, rowError(err)
}
func (s *Store) UpdateConversationHistoryCursor(ctx context.Context, id, cursor string, hasMore bool) error {
id, cursor = strings.TrimSpace(id), strings.TrimSpace(cursor)
if id == "" || len(cursor) > 500 {
return ErrInvalid
}
if _, err := s.db.ExecContext(ctx, `UPDATE creator_conversation SET history_cursor=$2,history_has_more=$3,history_synced_at=now() WHERE id=$1`, id, cursor, hasMore); err != nil {
return databaseError(err)
}
return nil
}
func (s *Store) ListConversations(ctx context.Context, accountID string) ([]Conversation, error) {
query := conversationSelect
args := []any{}
if accountID != "" {
query += ` WHERE account_id=$1`
args = append(args, accountID)
}
query += ` ORDER BY last_message_at DESC NULLS LAST,id`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, databaseError(err)
}
defer rows.Close()
result := make([]Conversation, 0)
for rows.Next() {
item, err := scanConversation(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func (s *Store) SaveMessage(ctx context.Context, input MessageInput) (Message, bool, error) {
input.PlatformMessageKey = strings.TrimSpace(input.PlatformMessageKey)
input.Direction = strings.TrimSpace(input.Direction)
input.MessageType = strings.TrimSpace(input.MessageType)
if input.PlatformMessageKey == "" || len(input.PlatformMessageKey) > 500 || input.Direction != "inbound" && input.Direction != "outbound" || input.MessageType == "" || len(input.MessageType) > 100 || len(input.Text) > 100000 {
return Message{}, false, ErrInvalid
}
state := input.SentState
if state == "" {
state = "received"
}
if state != "received" && state != "pending" && state != "succeeded" && state != "failed" && state != "uncertain" {
return Message{}, false, ErrInvalid
}
input.SentState = state
conversation, err := s.UpsertConversation(ctx, input)
if err != nil {
return Message{}, false, err
}
id := newID("message")
var returned string
var inserted bool
err = s.db.QueryRowContext(ctx, `INSERT INTO creator_message (id,conversation_id,platform_message_key,operation_id,direction,message_type,text,sent_state,message_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (conversation_id,platform_message_key) DO NOTHING RETURNING id,(xmax=0)`, id, conversation.ID, input.PlatformMessageKey, nullableString(input.OperationID), input.Direction, input.MessageType, input.Text, state, input.MessageAt).Scan(&returned, &inserted)
if errors.Is(err, sql.ErrNoRows) {
if err := s.db.QueryRowContext(ctx, `SELECT id FROM creator_message WHERE conversation_id=$1 AND platform_message_key=$2`, conversation.ID, input.PlatformMessageKey).Scan(&returned); err != nil {
return Message{}, false, rowError(err)
}
message, err := s.GetMessage(ctx, returned)
return message, false, err
}
if err != nil {
return Message{}, false, databaseError(err)
}
message, err := s.GetMessage(ctx, returned)
return message, inserted, err
}
func scanMessage(scanner interface{ Scan(...any) error }) (Message, error) {
var m Message
var operationID sql.NullString
var at sql.NullTime
if err := scanner.Scan(&m.ID, &m.ConversationID, &m.PlatformMessageKey, &operationID, &m.Direction, &m.MessageType, &m.Text, &m.SentState, &at, &m.CreatedAt); err != nil {
return Message{}, err
}
m.OperationID, m.MessageAt = operationID.String, nullableTime(at)
return m, nil
}
const messageSelect = `SELECT id,conversation_id,platform_message_key,operation_id,direction,message_type,text,sent_state,message_at,created_at FROM creator_message`
func (s *Store) GetMessage(ctx context.Context, id string) (Message, error) {
result, err := scanMessage(s.db.QueryRowContext(ctx, messageSelect+` WHERE id=$1`, id))
return result, rowError(err)
}
func (s *Store) ListEventsPage(ctx context.Context, accountID string, page, pageSize int) (Page[InteractionEvent], error) {
items, err := s.ListEvents(ctx, accountID)
if err != nil {
return Page[InteractionEvent]{}, err
}
return slicePage(items, page, pageSize)
}
func (s *Store) ListMessagesPage(ctx context.Context, conversationID string, page, pageSize int) (Page[Message], error) {
items, err := s.ListMessages(ctx, conversationID)
if err != nil {
return Page[Message]{}, err
}
return slicePage(items, page, pageSize)
}
func (s *Store) LinkMessageOperation(ctx context.Context, messageID, platformMessageKey string) error {
messageID, platformMessageKey = strings.TrimSpace(messageID), strings.TrimSpace(platformMessageKey)
if messageID == "" || platformMessageKey == "" {
return ErrInvalid
}
_, err := s.db.ExecContext(ctx, `UPDATE creator_message message SET operation_id=(SELECT operation.id FROM creator_operation operation WHERE operation.evidence->>'message_server_id'=$2 ORDER BY operation.updated_at DESC LIMIT 1) WHERE message.id=$1 AND message.operation_id IS NULL`, messageID, platformMessageKey)
return databaseError(err)
}
func (s *Store) ListMessages(ctx context.Context, conversationID string) ([]Message, error) {
rows, err := s.db.QueryContext(ctx, messageSelect+` WHERE conversation_id=$1 ORDER BY message_at NULLS LAST,created_at,id`, conversationID)
if err != nil {
return nil, databaseError(err)
}
defer rows.Close()
result := make([]Message, 0)
for rows.Next() {
item, err := scanMessage(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}