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() }