PluginProbe
WPBot – AI ChatBot for Live Support, Lead Generation, WordPress Automation, AI Services / 8.8.1
WPBot – AI ChatBot for Live Support, Lead Generation, WordPress Automation, AI Services v8.8.1
8.8.1 8.8.0 8.7.9 8.7.8 8.7.7 8.7.6 8.7.5 8.7.4 8.7.3 8.7.2 8.7.1 8.7.0 8.6.9 8.6.8 8.6.7 8.6.6 8.6.5 8.6.4 8.6.2 8.6.1 8.6.0 8.5.9 8.5.8 8.5.7 8.5.6 All 537 releases
← All changes | addons/automator/includes/engine/workflow-runner.php +547 -547 8.8.0 → 8.8.1 View file →
@@ -1,547 +1,547 @@
1 -<?php
2 -/**
3 - * Workflow Runner Engine
4 - *
5 - * @package WPbot_Automator
6 - */
7 -
8 -namespace WPbot_Automator\Engine;
9 -
10 -use WPbot_Automator\Core\Database;
11 -use WPbot_Automator\Core\Registry;
12 -
13 -if ( ! defined( 'ABSPATH' ) ) {
14 - exit;
15 -}
16 -
17 -/**
18 - * Class Workflow_Runner
19 - */
20 -class Workflow_Runner {
21 -
22 - /**
23 - * Run workflows matching a trigger
24 - *
25 - * @param string $trigger_id Full trigger ID (e.g., 'wordpress_core:user_register').
26 - * @param array $trigger_data Data from the trigger.
27 - */
28 - public static function run_workflows_for_trigger( $trigger_id, $trigger_data ) {
29 - // error_log( sprintf( 'WPbot Automator - Workflow_Runner::run_workflows_for_trigger called with trigger data: %s', $trigger_id ) );
30 - // error_log( sprintf( 'WPbot Automator - Workflow_Runner::run_workflows_for_trigger: %s', $trigger_id ) );
31 - global $wpdb;
32 - $table = Database::get_workflows_table();
33 -
34 - // Check total count in table.
35 - $total_count = $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
36 - //error_log( sprintf( 'WPbot Automator - Total workflows in table: %s', $total_count ) );
37 -
38 - // Extract sub-trigger ID for better matching (e.g., 'wpforms_submit' from 'wpforms:wpforms_submit').
39 - $trigger_id_str = is_array( $trigger_id ) ? wp_json_encode( $trigger_id ) : (string) $trigger_id;
40 - $sub_id = ( strpos( $trigger_id_str, ':' ) !== false ) ? explode( ':', $trigger_id_str )[1] : $trigger_id_str;
41 -
42 - // Force string conversion and ensure scalar before passing to prepare.
43 - $trigger_id_param = is_scalar( $trigger_id_str ) ? strval( $trigger_id_str ) : wp_json_encode( $trigger_id_str );
44 - $sub_id_param = is_scalar( $sub_id ) ? strval( $sub_id ) : wp_json_encode( $sub_id );
45 -
46 - // // error_log( sprintf( 'WPbot Automator - Querying workflows for: %s and %s in table %s', $trigger_id_param, $sub_id_param, $table ) );
47 -
48 - $workflows = $wpdb->get_results(
49 - $wpdb->prepare(
50 - "SELECT * FROM {$table} WHERE status = 'active' AND (workflow_data LIKE %s OR workflow_data LIKE %s)",
51 - '%' . $wpdb->esc_like( $trigger_id_param ) . '%',
52 - '%' . $wpdb->esc_like( $sub_id_param ) . '%'
53 - ),
54 - ARRAY_A
55 - );
56 -
57 - // // error_log( sprintf( 'WPbot Automator - Found %d matching workflows', count( $workflows ) ) );
58 -
59 - if ( empty( $workflows ) ) {
60 - return;
61 - }
62 -
63 - foreach ( $workflows as $workflow ) {
64 - self::execute_workflow( $workflow, $trigger_data, $trigger_id );
65 - }
66 - }
67 -
68 - /**
69 - * Execute a single workflow
70 - *
71 - * @param array $workflow Workflow row from DB.
72 - * @param array $trigger_data Data from the trigger.
73 - * @param string $trigger_id The ID of the trigger that fired.
74 - */
75 - public static function execute_workflow( $workflow, $trigger_data, $trigger_id = '' ) {
76 - // error_log( 'WPbot Automator - execute_workflow starting for ID: ' . ( $workflow['id'] ?? 'unknown' ) );
77 - $data = json_decode( $workflow['workflow_data'], true );
78 - if ( ! $data || empty( $data['nodes'] ) ) {
79 - return $trigger_data;
80 - }
81 -
82 - $nodes = $data['nodes'];
83 - $connections = isset( $data['connections'] ) ? $data['connections'] : array();
84 -
85 - // Find starting node (trigger) that matches the trigger that fired.
86 - $trigger_node = null;
87 - foreach ( $nodes as $node ) {
88 - if ( 'trigger' === $node['type'] ) {
89 - $node_action_id = isset( $node['data']['actionId'] ) ? $node['data']['actionId'] : '';
90 - $node_app_id = isset( $node['appData']['id'] ) ? $node['appData']['id'] : '';
91 -
92 - // Try to match by full ID or action ID.
93 - if ( $trigger_id ) {
94 - // Special handling for Webhooks: if target_node_id is provided, it MUST match the node ID.
95 - if ( isset( $trigger_data['target_node_id'] ) ) {
96 - if ( $node['id'] === $trigger_data['target_node_id'] ) {
97 - $trigger_node = $node;
98 - break;
99 - }
100 - continue; // Skip other triggers in this workflow if we have a specific target.
101 - }
102 -
103 - // // error_log( sprintf( 'WPbot Automator - Comparing node: ActionID=%s, AppID=%s with TriggerID=%s', $node_action_id, $node_app_id, $trigger_id ) );
104 - if ( $node_action_id === $trigger_id || ( $node_app_id . ':' . $node_action_id ) === $trigger_id ) {
105 - $trigger_node = $node;
106 - break;
107 - }
108 - // Partial match for sub-id.
109 - $sub_id = strpos( $trigger_id, ':' ) !== false ? explode( ':', $trigger_id )[1] : $trigger_id;
110 - if ( $node_action_id === $sub_id ) {
111 - // // error_log( 'WPbot Automator - Matched by sub_id: ' . $sub_id );
112 - $trigger_node = $node;
113 - break;
114 - }
115 - } else {
116 - // Fallback to first trigger if no trigger_id provided.
117 - $trigger_node = $node;
118 - break;
119 - }
120 - }
121 - }
122 -
123 - if ( ! $trigger_node ) {
124 - // // error_log( sprintf( 'WPbot Automator - Workflow %s: No matching trigger node found for %s', $workflow['id'], $trigger_id ) );
125 - // // error_log( 'WPbot Automator - Nodes in workflow: ' . wp_json_encode( $nodes ) );
126 - return $trigger_data;
127 - }
128 -
129 - // Filter by trigger configuration (e.g., Specific Product).
130 - if ( ! empty( $trigger_node['data']['config'] ) ) {
131 - $config = $trigger_node['data']['config'];
132 -
133 - // Specific Product filter for WooCommerce.
134 - if ( ! empty( $config['product_id'] ) ) {
135 - $selected_product_id = (int) $config['product_id'];
136 - $order_product_ids = isset( $trigger_data['product_ids'] ) ? array_map( 'intval', $trigger_data['product_ids'] ) : array();
137 -
138 - if ( ! in_array( $selected_product_id, $order_product_ids, true ) ) {
139 - // // error_log( sprintf( 'WPbot Automator - Workflow %s: Product ID %d not found in order products.', $workflow['id'], $selected_product_id ) );
140 - return $trigger_data;
141 - }
142 - }
143 -
144 - // WooCommerce order status filter: only proceed when new_status matches.
145 - if ( ! empty( $config['new_status'] ) ) {
146 - $required_status = sanitize_key( $config['new_status'] );
147 - $actual_status = isset( $trigger_data['new_status'] ) ? sanitize_key( $trigger_data['new_status'] ) : '';
148 - if ( $required_status !== $actual_status ) {
149 - // error_log( sprintf( 'WPbot Automator - Workflow %s: Skipped — new_status "%s" does not match required "%s".', $workflow['id'], $actual_status, $required_status ) );
150 - return $trigger_data;
151 - }
152 - }
153 - }
154 -
155 -
156 - // // error_log( sprintf( 'WPbot Automator - Workflow %s: Starting execution from node %s', $workflow['id'], $trigger_node['id'] ) );
157 -
158 - // ── Inject nested app_id.sub_id into trigger_data for token parsing ──
159 - $trigger_app_id = isset( $trigger_node['data']['appId'] ) ? sanitize_key( $trigger_node['data']['appId'] ) : ( strpos( (string) $trigger_id, ':' ) !== false ? explode( ':', (string) $trigger_id )[0] : $trigger_id );
160 - $trigger_sub_id = isset( $trigger_node['data']['actionId'] ) ? sanitize_key( $trigger_node['data']['actionId'] ) : ( strpos( (string) $trigger_id, ':' ) !== false ? explode( ':', (string) $trigger_id )[1] : $trigger_id );
161 -
162 - if ( ! empty( $trigger_app_id ) && ! empty( $trigger_sub_id ) ) {
163 - if ( ! isset( $trigger_data[ $trigger_app_id ] ) ) {
164 - $trigger_data[ $trigger_app_id ] = array();
165 - }
166 - $temp_data = $trigger_data; // Avoid infinite recursion
167 - unset( $temp_data[ $trigger_app_id ] );
168 - $trigger_data[ $trigger_app_id ][ $trigger_sub_id ] = $temp_data;
169 - }
170 -
171 - // Find following actions.
172 - $current_node_id = $trigger_node['id'];
173 - $executed_nodes = array( $current_node_id );
174 -
175 - return self::run_next_nodes( $current_node_id, $nodes, $connections, $trigger_data, $executed_nodes, $workflow['id'], $trigger_id );
176 - }
177 -
178 - /**
179 - * Recursively run next nodes in the flow
180 - */
181 - private static function run_next_nodes( $current_node_id, $nodes, $connections, $trigger_data, &$executed_nodes, $workflow_id = 0, $trigger_id = '' ) {
182 - // error_log( sprintf( 'WPbot Automator - $current_node_id %s', $current_node_id ) );
183 - // Find connections originating from current node.
184 - foreach ( $connections as $connection ) {
185 - if ( isset( $connection['from'] ) && $connection['from'] === $current_node_id ) {
186 - $target_node_id = $connection['to'];
187 -
188 - // Prevent infinite loops.
189 - if ( in_array( $target_node_id, $executed_nodes ) ) {
190 - continue;
191 - }
192 -
193 - // Find target node.
194 - $target_node = null;
195 - foreach ( $nodes as $node ) {
196 - if ( $node['id'] === $target_node_id ) {
197 - $target_node = $node;
198 - break;
199 - }
200 - }
201 -
202 - if ( ! $target_node || ( 'action' !== $target_node['type'] && 'trigger' !== $target_node['type'] ) ) {
203 - continue;
204 - }
205 -
206 - $app_id = isset( $target_node['appData']['id'] ) ? $target_node['appData']['id'] : '';
207 - if ( ! $app_id && strpos( $target_node_id, '-' ) !== false ) {
208 - $app_id = explode( '-', $target_node_id )[0];
209 - }
210 -
211 - // // error_log( sprintf( 'WPbot Automator - Workflow %s: Attempting to run node %s (App: %s)', $workflow_id, $target_node_id, $app_id ) );
212 -
213 - // ── ITERATOR HANDLING ────────────────────────────────────────
214 - if ( 'iterator' === $app_id ) {
215 - $iterator_node_id = $target_node_id;
216 - $executed_nodes[] = $iterator_node_id;
217 -
218 - self::log_execution( $workflow_id, $trigger_id, $app_id, array( 'success' => true, 'message' => 'Iterator started.' ) );
219 -
220 - // Get array key from config.
221 - $cfg = isset( $target_node['data']['config'] ) ? $target_node['data']['config'] : array();
222 - $array_key = isset( $cfg['array_key'] ) ? trim( $cfg['array_key'] ) : 'items';
223 - $items = isset( $trigger_data[ $array_key ] ) ? $trigger_data[ $array_key ] : array();
224 -
225 - // Collect all nodes between iterator and iterator_end.
226 - $loop_node_ids = array();
227 - $iterator_end_id = null;
228 - self::collect_loop_nodes( $iterator_node_id, $nodes, $connections, $loop_node_ids, $iterator_end_id );
229 -
230 - // Loop through each item.
231 - if ( ! empty( $items ) && is_array( $items ) ) {
232 - foreach ( $items as $index => $item ) {
233 - // Merge the current item into trigger data so actions can use {item.name} etc.
234 - $loop_data = $trigger_data;
235 - if ( is_array( $item ) ) {
236 - foreach ( $item as $k => $v ) {
237 - $loop_data[ 'item.' . $k ] = $v;
238 - $loop_data[ 'item_' . $k ] = $v;
239 - }
240 - } else {
241 - $loop_data['item'] = $item;
242 - $loop_data['item_value'] = $item;
243 - }
244 - $loop_data['item_index'] = $index;
245 -
246 - // Execute loop body nodes in order.
247 - $loop_executed = array();
248 - self::run_loop_nodes( $iterator_node_id, $loop_node_ids, $iterator_end_id, $nodes, $connections, $loop_data, $loop_executed, $workflow_id, $trigger_id );
249 - }
250 - }
251 -
252 - // Continue from iterator_end node.
253 - if ( $iterator_end_id ) {
254 - $executed_nodes[] = $iterator_end_id;
255 - $trigger_data = self::run_next_nodes( $iterator_end_id, $nodes, $connections, $trigger_data, $executed_nodes, $workflow_id, $trigger_id );
256 - }
257 -
258 - return $trigger_data; // Don't continue with the standard path inside the loop.
259 - }
260 -
261 - // ── ITERATOR_END: skip (handled inside loop) ─────────────────
262 - if ( 'iterator_end' === $app_id ) {
263 - continue;
264 - }
265 -
266 - // ── STANDARD ACTION ──────────────────────────────────────────
267 - $action = Registry::get_action( $app_id );
268 -
269 - if ( $action ) {
270 - $node_data = isset( $target_node['data'] ) ? $target_node['data'] : array();
271 - $node_data['node_id'] = $target_node_id;
272 -
273 - // ── Build downstream context for Delay (and similar) actions ──
274 - // Collect every node reachable from this node so the delay action
275 - // knows what to resume with.
276 - $downstream_nodes = self::collect_downstream_nodes( $target_node_id, $nodes, $connections );
277 - $node_data['__workflow_context'] = array(
278 - 'workflow_id' => $workflow_id,
279 - 'trigger_id' => $trigger_id,
280 - 'remaining_nodes' => $downstream_nodes,
281 - 'connections' => $connections,
282 - );
283 -
284 - $result = $action->execute( $node_data, $trigger_data );
285 -
286 - // ── Halt flag: Delay action signals us to stop this branch ──
287 - if ( is_array( $result ) && ! empty( $result['__halt'] ) ) {
288 - self::log_execution( $workflow_id, $trigger_id, $app_id, $result );
289 - return $trigger_data; // Stop synchronous execution; WP Cron resumes later.
290 - }
291 -
292 - if ( is_array( $result ) && isset( $result['__return_data'] ) ) {
293 - return $result['__return_data']; // Immediate return from sub-workflow
294 - }
295 -
296 - if ( is_array( $result ) && isset( $result['success'] ) && $result['success'] ) {
297 - $trigger_data = array_merge( $trigger_data, $result );
298 -
299 - // Also store every result key prefixed with the node ID so that
300 - // downstream token references like {node-action-1.faq_title} resolve.
301 - foreach ( $result as $rkey => $rval ) {
302 - if ( is_string( $rval ) || is_numeric( $rval ) || is_bool( $rval ) ) {
303 - $trigger_data[ $target_node_id . '.' . $rkey ] = (string) $rval;
304 - }
305 - }
306 - }
307 -
308 - self::log_execution( $workflow_id, $trigger_id, $app_id, $result );
309 -
310 - if ( is_array( $result ) && isset( $result['success'] ) && ! $result['success'] ) {
311 - //error_log( sprintf( 'WPbot Automator - Workflow %s: Stopping branch execution because node %s failed.', $workflow_id, $target_node_id ) );
312 - continue; // Stop this branch.
313 - }
314 -
315 - $executed_nodes[] = $target_node_id;
316 - $trigger_data = self::run_next_nodes( $target_node_id, $nodes, $connections, $trigger_data, $executed_nodes, $workflow_id, $trigger_id );
317 - }
318 - }
319 - }
320 - return $trigger_data;
321 - }
322 -
323 -
324 - /**
325 - * Collect all node IDs between an iterator and its matching iterator_end.
326 - */
327 - private static function collect_loop_nodes( $iterator_node_id, $nodes, $connections, &$loop_node_ids, &$iterator_end_id ) {
328 - $visited = array();
329 - $queue = array( $iterator_node_id );
330 -
331 - while ( ! empty( $queue ) ) {
332 - $current = array_shift( $queue );
333 -
334 - foreach ( $connections as $connection ) {
335 - if ( isset( $connection['from'] ) && $connection['from'] === $current ) {
336 - $next_id = $connection['to'];
337 -
338 - if ( in_array( $next_id, $visited ) ) {
339 - continue;
340 - }
341 - $visited[] = $next_id;
342 -
343 - // Find node.
344 - $next_node = null;
345 - foreach ( $nodes as $n ) {
346 - if ( $n['id'] === $next_id ) {
347 - $next_node = $n;
348 - break;
349 - }
350 - }
351 -
352 - if ( ! $next_node ) {
353 - continue;
354 - }
355 -
356 - $next_app_id = isset( $next_node['appData']['id'] ) ? $next_node['appData']['id'] : '';
357 -
358 - if ( 'iterator_end' === $next_app_id ) {
359 - $iterator_end_id = $next_id;
360 - } else {
361 - $loop_node_ids[] = $next_id;
362 - $queue[] = $next_id;
363 - }
364 - }
365 - }
366 - }
367 - }
368 -
369 - /**
370 - * Run the nodes inside an iterator loop body.
371 - */
372 - private static function run_loop_nodes( $iterator_node_id, $loop_node_ids, $iterator_end_id, $nodes, $connections, &$loop_data, &$loop_executed, $workflow_id, $trigger_id ) {
373 - // Find first node after the iterator.
374 - foreach ( $connections as $connection ) {
375 - if ( isset( $connection['from'] ) && $connection['from'] === $iterator_node_id ) {
376 - $first_node_id = $connection['to'];
377 -
378 - if ( 'iterator_end' === self::get_node_app_id( $first_node_id, $nodes ) ) {
379 - continue;
380 - }
381 -
382 - self::run_loop_body_node( $first_node_id, $loop_node_ids, $iterator_end_id, $nodes, $connections, $loop_data, $loop_executed, $workflow_id, $trigger_id );
383 - }
384 - }
385 - }
386 -
387 - /**
388 - * Run a single node in the loop body and recurse.
389 - */
390 - private static function run_loop_body_node( $node_id, $loop_node_ids, $iterator_end_id, $nodes, $connections, &$loop_data, &$loop_executed, $workflow_id, $trigger_id ) {
391 - if ( in_array( $node_id, $loop_executed ) ) {
392 - return;
393 - }
394 -
395 - $app_id = self::get_node_app_id( $node_id, $nodes );
396 - if ( 'iterator_end' === $app_id ) {
397 - return;
398 - }
399 -
400 - $loop_executed[] = $node_id;
401 - $action = Registry::get_action( $app_id );
402 -
403 - if ( $action ) {
404 - $target_node = null;
405 - foreach ( $nodes as $n ) {
406 - if ( $n['id'] === $node_id ) {
407 - $target_node = $n;
408 - break;
409 - }
410 - }
411 - $node_data = isset( $target_node['data'] ) ? $target_node['data'] : array();
412 - $result = $action->execute( $node_data, $loop_data );
413 -
414 - if ( is_array( $result ) && isset( $result['success'] ) && $result['success'] ) {
415 - $loop_data = array_merge( $loop_data, $result );
416 - }
417 -
418 - self::log_execution( $workflow_id, $trigger_id, $app_id, $result );
419 -
420 - if ( is_array( $result ) && isset( $result['success'] ) && ! $result['success'] ) {
421 - //error_log( sprintf( 'WPbot Automator - Workflow %s: Stopping loop branch execution because node %s failed.', $workflow_id, $node_id ) );
422 - return; // Stop this loop iteration's branch.
423 - }
424 - }
425 -
426 - // Continue to next loop body node.
427 - foreach ( $connections as $connection ) {
428 - if ( isset( $connection['from'] ) && $connection['from'] === $node_id ) {
429 - $next_id = $connection['to'];
430 - if ( 'iterator_end' === self::get_node_app_id( $next_id, $nodes ) ) {
431 - return; // Reached end of loop body.
432 - }
433 - self::run_loop_body_node( $next_id, $loop_node_ids, $iterator_end_id, $nodes, $connections, $loop_data, $loop_executed, $workflow_id, $trigger_id );
434 - }
435 - }
436 - }
437 -
438 - /**
439 - * Helper: get the app ID for a given node ID.
440 - */
441 - private static function get_node_app_id( $node_id, $nodes ) {
442 - foreach ( $nodes as $node ) {
443 - if ( $node['id'] === $node_id ) {
444 - $app_id = isset( $node['appData']['id'] ) ? $node['appData']['id'] : '';
445 - if ( ! $app_id && strpos( $node_id, '-' ) !== false ) {
446 - $app_id = explode( '-', $node_id )[0];
447 - }
448 - return $app_id;
449 - }
450 - }
451 - return '';
452 - }
453 -
454 - /**
455 - * Collect all downstream node objects reachable from $start_node_id via connections.
456 - *
457 - * Used by the Delay action to persist what needs to be resumed after the
458 - * delay fires. Returns an ordered array of node objects (not just IDs).
459 - *
460 - * @param string $start_node_id The node immediately *after* the delay node.
461 - * @param array $nodes All nodes in the workflow.
462 - * @param array $connections All connections in the workflow.
463 - * @return array Ordered list of node objects downstream of $start_node_id.
464 - */
465 - private static function collect_downstream_nodes( $start_node_id, $nodes, $connections ) {
466 - $visited = array();
467 - $queue = array();
468 - $result_nodes = array();
469 -
470 - // Seed: find immediate successors of the start node.
471 - foreach ( $connections as $conn ) {
472 - if ( isset( $conn['from'] ) && $conn['from'] === $start_node_id ) {
473 - if ( ! in_array( $conn['to'], $visited, true ) ) {
474 - $queue[] = $conn['to'];
475 - $visited[] = $conn['to'];
476 - }
477 - }
478 - }
479 -
480 - while ( ! empty( $queue ) ) {
481 - $current_id = array_shift( $queue );
482 -
483 - // Find the node object.
484 - foreach ( $nodes as $node ) {
485 - if ( $node['id'] === $current_id ) {
486 - $result_nodes[] = $node;
487 - break;
488 - }
489 - }
490 -
491 - // Traverse outward.
492 - foreach ( $connections as $conn ) {
493 - if ( isset( $conn['from'] ) && $conn['from'] === $current_id ) {
494 - if ( ! in_array( $conn['to'], $visited, true ) ) {
495 - $queue[] = $conn['to'];
496 - $visited[] = $conn['to'];
497 - }
498 - }
499 - }
500 - }
501 -
502 - return $result_nodes;
503 - }
504 -
505 -
506 - /**
507 - * Log execution results
508 - */
509 - public static function log_execution( $workflow_id, $trigger_id, $action_id, $result ) {
510 - $status = 'success';
511 - $error_message = '';
512 - $log_data = '';
513 -
514 - if ( is_array( $result ) ) {
515 - if ( isset( $result['success'] ) && ! $result['success'] ) {
516 - $status = 'failed';
517 - }
518 - $error_message = isset( $result['message'] ) ? $result['message'] : '';
519 - $log_data = wp_json_encode( $result );
520 - } elseif ( false === $result ) {
521 - $status = 'failed';
522 - $error_message = 'Action returned false';
523 - } else {
524 - $log_data = (string) $result;
525 - }
526 -
527 - Database::add_log( array(
528 - 'workflow_id' => $workflow_id,
529 - 'trigger_type' => (string) $trigger_id,
530 - 'action_id' => (string) $action_id,
531 - 'status' => $status,
532 - 'log_data' => $log_data,
533 - 'error_message' => $error_message,
534 - ) );
535 - }
536 -
537 - public static function debug_dump_all_workflows() {
538 - global $wpdb;
539 - $table = \WPbot_Automator\Core\Database::get_workflows_table();
540 - $workflows = $wpdb->get_results( "SELECT * FROM {$table}" );
541 - //error_log( 'WPbot Automator - DEBUG DUMP ALL WORKFLOWS' );
542 - foreach ( $workflows as $workflow ) {
543 - //error_log( sprintf( 'Workflow ID: %d, Name: %s, Status: %s', $workflow->id, $workflow->name, $workflow->status ) );
544 - //error_log( 'Data: ' . $workflow->workflow_data );
545 - }
546 - }
547 -}
1 +<?php
2 +/**
3 + * Workflow Runner Engine
4 + *
5 + * @package WPbot_Automator
6 + */
7 +
8 +namespace WPbot_Automator\Engine;
9 +
10 +use WPbot_Automator\Core\Database;
11 +use WPbot_Automator\Core\Registry;
12 +
13 +if ( ! defined( 'ABSPATH' ) ) {
14 + exit;
15 +}
16 +
17 +/**
18 + * Class Workflow_Runner
19 + */
20 +class Workflow_Runner {
21 +
22 + /**
23 + * Run workflows matching a trigger
24 + *
25 + * @param string $trigger_id Full trigger ID (e.g., 'wordpress_core:user_register').
26 + * @param array $trigger_data Data from the trigger.
27 + */
28 + public static function run_workflows_for_trigger( $trigger_id, $trigger_data ) {
29 + // error_log( sprintf( 'WPbot Automator - Workflow_Runner::run_workflows_for_trigger called with trigger data: %s', $trigger_id ) );
30 + // error_log( sprintf( 'WPbot Automator - Workflow_Runner::run_workflows_for_trigger: %s', $trigger_id ) );
31 + global $wpdb;
32 + $table = Database::get_workflows_table();
33 +
34 + // Check total count in table.
35 + $total_count = $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
36 + //error_log( sprintf( 'WPbot Automator - Total workflows in table: %s', $total_count ) );
37 +
38 + // Extract sub-trigger ID for better matching (e.g., 'wpforms_submit' from 'wpforms:wpforms_submit').
39 + $trigger_id_str = is_array( $trigger_id ) ? wp_json_encode( $trigger_id ) : (string) $trigger_id;
40 + $sub_id = ( strpos( $trigger_id_str, ':' ) !== false ) ? explode( ':', $trigger_id_str )[1] : $trigger_id_str;
41 +
42 + // Force string conversion and ensure scalar before passing to prepare.
43 + $trigger_id_param = is_scalar( $trigger_id_str ) ? strval( $trigger_id_str ) : wp_json_encode( $trigger_id_str );
44 + $sub_id_param = is_scalar( $sub_id ) ? strval( $sub_id ) : wp_json_encode( $sub_id );
45 +
46 + // // error_log( sprintf( 'WPbot Automator - Querying workflows for: %s and %s in table %s', $trigger_id_param, $sub_id_param, $table ) );
47 +
48 + $workflows = $wpdb->get_results(
49 + $wpdb->prepare(
50 + "SELECT * FROM {$table} WHERE status = 'active' AND (workflow_data LIKE %s OR workflow_data LIKE %s)",
51 + '%' . $wpdb->esc_like( $trigger_id_param ) . '%',
52 + '%' . $wpdb->esc_like( $sub_id_param ) . '%'
53 + ),
54 + ARRAY_A
55 + );
56 +
57 + // // error_log( sprintf( 'WPbot Automator - Found %d matching workflows', count( $workflows ) ) );
58 +
59 + if ( empty( $workflows ) ) {
60 + return;
61 + }
62 +
63 + foreach ( $workflows as $workflow ) {
64 + self::execute_workflow( $workflow, $trigger_data, $trigger_id );
65 + }
66 + }
67 +
68 + /**
69 + * Execute a single workflow
70 + *
71 + * @param array $workflow Workflow row from DB.
72 + * @param array $trigger_data Data from the trigger.
73 + * @param string $trigger_id The ID of the trigger that fired.
74 + */
75 + public static function execute_workflow( $workflow, $trigger_data, $trigger_id = '' ) {
76 + // error_log( 'WPbot Automator - execute_workflow starting for ID: ' . ( $workflow['id'] ?? 'unknown' ) );
77 + $data = json_decode( $workflow['workflow_data'], true );
78 + if ( ! $data || empty( $data['nodes'] ) ) {
79 + return $trigger_data;
80 + }
81 +
82 + $nodes = $data['nodes'];
83 + $connections = isset( $data['connections'] ) ? $data['connections'] : array();
84 +
85 + // Find starting node (trigger) that matches the trigger that fired.
86 + $trigger_node = null;
87 + foreach ( $nodes as $node ) {
88 + if ( 'trigger' === $node['type'] ) {
89 + $node_action_id = isset( $node['data']['actionId'] ) ? $node['data']['actionId'] : '';
90 + $node_app_id = isset( $node['appData']['id'] ) ? $node['appData']['id'] : '';
91 +
92 + // Try to match by full ID or action ID.
93 + if ( $trigger_id ) {
94 + // Special handling for Webhooks: if target_node_id is provided, it MUST match the node ID.
95 + if ( isset( $trigger_data['target_node_id'] ) ) {
96 + if ( $node['id'] === $trigger_data['target_node_id'] ) {
97 + $trigger_node = $node;
98 + break;
99 + }
100 + continue; // Skip other triggers in this workflow if we have a specific target.
101 + }
102 +
103 + // // error_log( sprintf( 'WPbot Automator - Comparing node: ActionID=%s, AppID=%s with TriggerID=%s', $node_action_id, $node_app_id, $trigger_id ) );
104 + if ( $node_action_id === $trigger_id || ( $node_app_id . ':' . $node_action_id ) === $trigger_id ) {
105 + $trigger_node = $node;
106 + break;
107 + }
108 + // Partial match for sub-id.
109 + $sub_id = strpos( $trigger_id, ':' ) !== false ? explode( ':', $trigger_id )[1] : $trigger_id;
110 + if ( $node_action_id === $sub_id ) {
111 + // // error_log( 'WPbot Automator - Matched by sub_id: ' . $sub_id );
112 + $trigger_node = $node;
113 + break;
114 + }
115 + } else {
116 + // Fallback to first trigger if no trigger_id provided.
117 + $trigger_node = $node;
118 + break;
119 + }
120 + }
121 + }
122 +
123 + if ( ! $trigger_node ) {
124 + // // error_log( sprintf( 'WPbot Automator - Workflow %s: No matching trigger node found for %s', $workflow['id'], $trigger_id ) );
125 + // // error_log( 'WPbot Automator - Nodes in workflow: ' . wp_json_encode( $nodes ) );
126 + return $trigger_data;
127 + }
128 +
129 + // Filter by trigger configuration (e.g., Specific Product).
130 + if ( ! empty( $trigger_node['data']['config'] ) ) {
131 + $config = $trigger_node['data']['config'];
132 +
133 + // Specific Product filter for WooCommerce.
134 + if ( ! empty( $config['product_id'] ) ) {
135 + $selected_product_id = (int) $config['product_id'];
136 + $order_product_ids = isset( $trigger_data['product_ids'] ) ? array_map( 'intval', $trigger_data['product_ids'] ) : array();
137 +
138 + if ( ! in_array( $selected_product_id, $order_product_ids, true ) ) {
139 + // // error_log( sprintf( 'WPbot Automator - Workflow %s: Product ID %d not found in order products.', $workflow['id'], $selected_product_id ) );
140 + return $trigger_data;
141 + }
142 + }
143 +
144 + // WooCommerce order status filter: only proceed when new_status matches.
145 + if ( ! empty( $config['new_status'] ) ) {
146 + $required_status = sanitize_key( $config['new_status'] );
147 + $actual_status = isset( $trigger_data['new_status'] ) ? sanitize_key( $trigger_data['new_status'] ) : '';
148 + if ( $required_status !== $actual_status ) {
149 + // error_log( sprintf( 'WPbot Automator - Workflow %s: Skipped — new_status "%s" does not match required "%s".', $workflow['id'], $actual_status, $required_status ) );
150 + return $trigger_data;
151 + }
152 + }
153 + }
154 +
155 +
156 + // // error_log( sprintf( 'WPbot Automator - Workflow %s: Starting execution from node %s', $workflow['id'], $trigger_node['id'] ) );
157 +
158 + // ── Inject nested app_id.sub_id into trigger_data for token parsing ──
159 + $trigger_app_id = isset( $trigger_node['data']['appId'] ) ? sanitize_key( $trigger_node['data']['appId'] ) : ( strpos( (string) $trigger_id, ':' ) !== false ? explode( ':', (string) $trigger_id )[0] : $trigger_id );
160 + $trigger_sub_id = isset( $trigger_node['data']['actionId'] ) ? sanitize_key( $trigger_node['data']['actionId'] ) : ( strpos( (string) $trigger_id, ':' ) !== false ? explode( ':', (string) $trigger_id )[1] : $trigger_id );
161 +
162 + if ( ! empty( $trigger_app_id ) && ! empty( $trigger_sub_id ) ) {
163 + if ( ! isset( $trigger_data[ $trigger_app_id ] ) ) {
164 + $trigger_data[ $trigger_app_id ] = array();
165 + }
166 + $temp_data = $trigger_data; // Avoid infinite recursion
167 + unset( $temp_data[ $trigger_app_id ] );
168 + $trigger_data[ $trigger_app_id ][ $trigger_sub_id ] = $temp_data;
169 + }
170 +
171 + // Find following actions.
172 + $current_node_id = $trigger_node['id'];
173 + $executed_nodes = array( $current_node_id );
174 +
175 + return self::run_next_nodes( $current_node_id, $nodes, $connections, $trigger_data, $executed_nodes, $workflow['id'], $trigger_id );
176 + }
177 +
178 + /**
179 + * Recursively run next nodes in the flow
180 + */
181 + private static function run_next_nodes( $current_node_id, $nodes, $connections, $trigger_data, &$executed_nodes, $workflow_id = 0, $trigger_id = '' ) {
182 + // error_log( sprintf( 'WPbot Automator - $current_node_id %s', $current_node_id ) );
183 + // Find connections originating from current node.
184 + foreach ( $connections as $connection ) {
185 + if ( isset( $connection['from'] ) && $connection['from'] === $current_node_id ) {
186 + $target_node_id = $connection['to'];
187 +
188 + // Prevent infinite loops.
189 + if ( in_array( $target_node_id, $executed_nodes ) ) {
190 + continue;
191 + }
192 +
193 + // Find target node.
194 + $target_node = null;
195 + foreach ( $nodes as $node ) {
196 + if ( $node['id'] === $target_node_id ) {
197 + $target_node = $node;
198 + break;
199 + }
200 + }
201 +
202 + if ( ! $target_node || ( 'action' !== $target_node['type'] && 'trigger' !== $target_node['type'] ) ) {
203 + continue;
204 + }
205 +
206 + $app_id = isset( $target_node['appData']['id'] ) ? $target_node['appData']['id'] : '';
207 + if ( ! $app_id && strpos( $target_node_id, '-' ) !== false ) {
208 + $app_id = explode( '-', $target_node_id )[0];
209 + }
210 +
211 + // // error_log( sprintf( 'WPbot Automator - Workflow %s: Attempting to run node %s (App: %s)', $workflow_id, $target_node_id, $app_id ) );
212 +
213 + // ── ITERATOR HANDLING ────────────────────────────────────────
214 + if ( 'iterator' === $app_id ) {
215 + $iterator_node_id = $target_node_id;
216 + $executed_nodes[] = $iterator_node_id;
217 +
218 + self::log_execution( $workflow_id, $trigger_id, $app_id, array( 'success' => true, 'message' => 'Iterator started.' ) );
219 +
220 + // Get array key from config.
221 + $cfg = isset( $target_node['data']['config'] ) ? $target_node['data']['config'] : array();
222 + $array_key = isset( $cfg['array_key'] ) ? trim( $cfg['array_key'] ) : 'items';
223 + $items = isset( $trigger_data[ $array_key ] ) ? $trigger_data[ $array_key ] : array();
224 +
225 + // Collect all nodes between iterator and iterator_end.
226 + $loop_node_ids = array();
227 + $iterator_end_id = null;
228 + self::collect_loop_nodes( $iterator_node_id, $nodes, $connections, $loop_node_ids, $iterator_end_id );
229 +
230 + // Loop through each item.
231 + if ( ! empty( $items ) && is_array( $items ) ) {
232 + foreach ( $items as $index => $item ) {
233 + // Merge the current item into trigger data so actions can use {item.name} etc.
234 + $loop_data = $trigger_data;
235 + if ( is_array( $item ) ) {
236 + foreach ( $item as $k => $v ) {
237 + $loop_data[ 'item.' . $k ] = $v;
238 + $loop_data[ 'item_' . $k ] = $v;
239 + }
240 + } else {
241 + $loop_data['item'] = $item;
242 + $loop_data['item_value'] = $item;
243 + }
244 + $loop_data['item_index'] = $index;
245 +
246 + // Execute loop body nodes in order.
247 + $loop_executed = array();
248 + self::run_loop_nodes( $iterator_node_id, $loop_node_ids, $iterator_end_id, $nodes, $connections, $loop_data, $loop_executed, $workflow_id, $trigger_id );
249 + }
250 + }
251 +
252 + // Continue from iterator_end node.
253 + if ( $iterator_end_id ) {
254 + $executed_nodes[] = $iterator_end_id;
255 + $trigger_data = self::run_next_nodes( $iterator_end_id, $nodes, $connections, $trigger_data, $executed_nodes, $workflow_id, $trigger_id );
256 + }
257 +
258 + return $trigger_data; // Don't continue with the standard path inside the loop.
259 + }
260 +
261 + // ── ITERATOR_END: skip (handled inside loop) ─────────────────
262 + if ( 'iterator_end' === $app_id ) {
263 + continue;
264 + }
265 +
266 + // ── STANDARD ACTION ──────────────────────────────────────────
267 + $action = Registry::get_action( $app_id );
268 +
269 + if ( $action ) {
270 + $node_data = isset( $target_node['data'] ) ? $target_node['data'] : array();
271 + $node_data['node_id'] = $target_node_id;
272 +
273 + // ── Build downstream context for Delay (and similar) actions ──
274 + // Collect every node reachable from this node so the delay action
275 + // knows what to resume with.
276 + $downstream_nodes = self::collect_downstream_nodes( $target_node_id, $nodes, $connections );
277 + $node_data['__workflow_context'] = array(
278 + 'workflow_id' => $workflow_id,
279 + 'trigger_id' => $trigger_id,
280 + 'remaining_nodes' => $downstream_nodes,
281 + 'connections' => $connections,
282 + );
283 +
284 + $result = $action->execute( $node_data, $trigger_data );
285 +
286 + // ── Halt flag: Delay action signals us to stop this branch ──
287 + if ( is_array( $result ) && ! empty( $result['__halt'] ) ) {
288 + self::log_execution( $workflow_id, $trigger_id, $app_id, $result );
289 + return $trigger_data; // Stop synchronous execution; WP Cron resumes later.
290 + }
291 +
292 + if ( is_array( $result ) && isset( $result['__return_data'] ) ) {
293 + return $result['__return_data']; // Immediate return from sub-workflow
294 + }
295 +
296 + if ( is_array( $result ) && isset( $result['success'] ) && $result['success'] ) {
297 + $trigger_data = array_merge( $trigger_data, $result );
298 +
299 + // Also store every result key prefixed with the node ID so that
300 + // downstream token references like {node-action-1.faq_title} resolve.
301 + foreach ( $result as $rkey => $rval ) {
302 + if ( is_string( $rval ) || is_numeric( $rval ) || is_bool( $rval ) ) {
303 + $trigger_data[ $target_node_id . '.' . $rkey ] = (string) $rval;
304 + }
305 + }
306 + }
307 +
308 + self::log_execution( $workflow_id, $trigger_id, $app_id, $result );
309 +
310 + if ( is_array( $result ) && isset( $result['success'] ) && ! $result['success'] ) {
311 + //error_log( sprintf( 'WPbot Automator - Workflow %s: Stopping branch execution because node %s failed.', $workflow_id, $target_node_id ) );
312 + continue; // Stop this branch.
313 + }
314 +
315 + $executed_nodes[] = $target_node_id;
316 + $trigger_data = self::run_next_nodes( $target_node_id, $nodes, $connections, $trigger_data, $executed_nodes, $workflow_id, $trigger_id );
317 + }
318 + }
319 + }
320 + return $trigger_data;
321 + }
322 +
323 +
324 + /**
325 + * Collect all node IDs between an iterator and its matching iterator_end.
326 + */
327 + private static function collect_loop_nodes( $iterator_node_id, $nodes, $connections, &$loop_node_ids, &$iterator_end_id ) {
328 + $visited = array();
329 + $queue = array( $iterator_node_id );
330 +
331 + while ( ! empty( $queue ) ) {
332 + $current = array_shift( $queue );
333 +
334 + foreach ( $connections as $connection ) {
335 + if ( isset( $connection['from'] ) && $connection['from'] === $current ) {
336 + $next_id = $connection['to'];
337 +
338 + if ( in_array( $next_id, $visited ) ) {
339 + continue;
340 + }
341 + $visited[] = $next_id;
342 +
343 + // Find node.
344 + $next_node = null;
345 + foreach ( $nodes as $n ) {
346 + if ( $n['id'] === $next_id ) {
347 + $next_node = $n;
348 + break;
349 + }
350 + }
351 +
352 + if ( ! $next_node ) {
353 + continue;
354 + }
355 +
356 + $next_app_id = isset( $next_node['appData']['id'] ) ? $next_node['appData']['id'] : '';
357 +
358 + if ( 'iterator_end' === $next_app_id ) {
359 + $iterator_end_id = $next_id;
360 + } else {
361 + $loop_node_ids[] = $next_id;
362 + $queue[] = $next_id;
363 + }
364 + }
365 + }
366 + }
367 + }
368 +
369 + /**
370 + * Run the nodes inside an iterator loop body.
371 + */
372 + private static function run_loop_nodes( $iterator_node_id, $loop_node_ids, $iterator_end_id, $nodes, $connections, &$loop_data, &$loop_executed, $workflow_id, $trigger_id ) {
373 + // Find first node after the iterator.
374 + foreach ( $connections as $connection ) {
375 + if ( isset( $connection['from'] ) && $connection['from'] === $iterator_node_id ) {
376 + $first_node_id = $connection['to'];
377 +
378 + if ( 'iterator_end' === self::get_node_app_id( $first_node_id, $nodes ) ) {
379 + continue;
380 + }
381 +
382 + self::run_loop_body_node( $first_node_id, $loop_node_ids, $iterator_end_id, $nodes, $connections, $loop_data, $loop_executed, $workflow_id, $trigger_id );
383 + }
384 + }
385 + }
386 +
387 + /**
388 + * Run a single node in the loop body and recurse.
389 + */
390 + private static function run_loop_body_node( $node_id, $loop_node_ids, $iterator_end_id, $nodes, $connections, &$loop_data, &$loop_executed, $workflow_id, $trigger_id ) {
391 + if ( in_array( $node_id, $loop_executed ) ) {
392 + return;
393 + }
394 +
395 + $app_id = self::get_node_app_id( $node_id, $nodes );
396 + if ( 'iterator_end' === $app_id ) {
397 + return;
398 + }
399 +
400 + $loop_executed[] = $node_id;
401 + $action = Registry::get_action( $app_id );
402 +
403 + if ( $action ) {
404 + $target_node = null;
405 + foreach ( $nodes as $n ) {
406 + if ( $n['id'] === $node_id ) {
407 + $target_node = $n;
408 + break;
409 + }
410 + }
411 + $node_data = isset( $target_node['data'] ) ? $target_node['data'] : array();
412 + $result = $action->execute( $node_data, $loop_data );
413 +
414 + if ( is_array( $result ) && isset( $result['success'] ) && $result['success'] ) {
415 + $loop_data = array_merge( $loop_data, $result );
416 + }
417 +
418 + self::log_execution( $workflow_id, $trigger_id, $app_id, $result );
419 +
420 + if ( is_array( $result ) && isset( $result['success'] ) && ! $result['success'] ) {
421 + //error_log( sprintf( 'WPbot Automator - Workflow %s: Stopping loop branch execution because node %s failed.', $workflow_id, $node_id ) );
422 + return; // Stop this loop iteration's branch.
423 + }
424 + }
425 +
426 + // Continue to next loop body node.
427 + foreach ( $connections as $connection ) {
428 + if ( isset( $connection['from'] ) && $connection['from'] === $node_id ) {
429 + $next_id = $connection['to'];
430 + if ( 'iterator_end' === self::get_node_app_id( $next_id, $nodes ) ) {
431 + return; // Reached end of loop body.
432 + }
433 + self::run_loop_body_node( $next_id, $loop_node_ids, $iterator_end_id, $nodes, $connections, $loop_data, $loop_executed, $workflow_id, $trigger_id );
434 + }
435 + }
436 + }
437 +
438 + /**
439 + * Helper: get the app ID for a given node ID.
440 + */
441 + private static function get_node_app_id( $node_id, $nodes ) {
442 + foreach ( $nodes as $node ) {
443 + if ( $node['id'] === $node_id ) {
444 + $app_id = isset( $node['appData']['id'] ) ? $node['appData']['id'] : '';
445 + if ( ! $app_id && strpos( $node_id, '-' ) !== false ) {
446 + $app_id = explode( '-', $node_id )[0];
447 + }
448 + return $app_id;
449 + }
450 + }
451 + return '';
452 + }
453 +
454 + /**
455 + * Collect all downstream node objects reachable from $start_node_id via connections.
456 + *
457 + * Used by the Delay action to persist what needs to be resumed after the
458 + * delay fires. Returns an ordered array of node objects (not just IDs).
459 + *
460 + * @param string $start_node_id The node immediately *after* the delay node.
461 + * @param array $nodes All nodes in the workflow.
462 + * @param array $connections All connections in the workflow.
463 + * @return array Ordered list of node objects downstream of $start_node_id.
464 + */
465 + private static function collect_downstream_nodes( $start_node_id, $nodes, $connections ) {
466 + $visited = array();
467 + $queue = array();
468 + $result_nodes = array();
469 +
470 + // Seed: find immediate successors of the start node.
471 + foreach ( $connections as $conn ) {
472 + if ( isset( $conn['from'] ) && $conn['from'] === $start_node_id ) {
473 + if ( ! in_array( $conn['to'], $visited, true ) ) {
474 + $queue[] = $conn['to'];
475 + $visited[] = $conn['to'];
476 + }
477 + }
478 + }
479 +
480 + while ( ! empty( $queue ) ) {
481 + $current_id = array_shift( $queue );
482 +
483 + // Find the node object.
484 + foreach ( $nodes as $node ) {
485 + if ( $node['id'] === $current_id ) {
486 + $result_nodes[] = $node;
487 + break;
488 + }
489 + }
490 +
491 + // Traverse outward.
492 + foreach ( $connections as $conn ) {
493 + if ( isset( $conn['from'] ) && $conn['from'] === $current_id ) {
494 + if ( ! in_array( $conn['to'], $visited, true ) ) {
495 + $queue[] = $conn['to'];
496 + $visited[] = $conn['to'];
497 + }
498 + }
499 + }
500 + }
501 +
502 + return $result_nodes;
503 + }
504 +
505 +
506 + /**
507 + * Log execution results
508 + */
509 + public static function log_execution( $workflow_id, $trigger_id, $action_id, $result ) {
510 + $status = 'success';
511 + $error_message = '';
512 + $log_data = '';
513 +
514 + if ( is_array( $result ) ) {
515 + if ( isset( $result['success'] ) && ! $result['success'] ) {
516 + $status = 'failed';
517 + }
518 + $error_message = isset( $result['message'] ) ? $result['message'] : '';
519 + $log_data = wp_json_encode( $result );
520 + } elseif ( false === $result ) {
521 + $status = 'failed';
522 + $error_message = 'Action returned false';
523 + } else {
524 + $log_data = (string) $result;
525 + }
526 +
527 + Database::add_log( array(
528 + 'workflow_id' => $workflow_id,
529 + 'trigger_type' => (string) $trigger_id,
530 + 'action_id' => (string) $action_id,
531 + 'status' => $status,
532 + 'log_data' => $log_data,
533 + 'error_message' => $error_message,
534 + ) );
535 + }
536 +
537 + public static function debug_dump_all_workflows() {
538 + global $wpdb;
539 + $table = \WPbot_Automator\Core\Database::get_workflows_table();
540 + $workflows = $wpdb->get_results( "SELECT * FROM {$table}" );
541 + //error_log( 'WPbot Automator - DEBUG DUMP ALL WORKFLOWS' );
542 + foreach ( $workflows as $workflow ) {
543 + //error_log( sprintf( 'Workflow ID: %d, Name: %s, Status: %s', $workflow->id, $workflow->name, $workflow->status ) );
544 + //error_log( 'Data: ' . $workflow->workflow_data );
545 + }
546 + }
547 +}