PluginProbe
WPBot – AI ChatBot for Live Support, Lead Generation, WordPress Automation, AI Services / 8.7.7
WPBot – AI ChatBot for Live Support, Lead Generation, WordPress Automation, AI Services v8.7.7
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 8.5.5 8.5.4 8.5.3 8.5.2 All 533 releases
chatbot / addons / automator / includes / engine / workflow-runner.php

workflow-runner.php in WPBot – AI ChatBot for Live Support, Lead Generation, WordPress Automation, AI Services 8.7.7, at addons/automator/includes/engine/workflow-runner.php

548 lines 19.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
548