PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.83
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.83
51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 51.1.46 51.1.47 51.1.49 All 37 releases
king-addons / includes / widgets / Form_Builder / helpers / Integrations.php

Integrations.php in King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder 51.1.83, at includes/widgets/Form_Builder/helpers/Integrations.php

647 lines 20.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Form Builder integrations.
4 *
5 * One submit action that forwards a submission to whichever services the form
6 * has switched on: Slack, Telegram, Brevo, ConvertKit, ActiveCampaign, HubSpot,
7 * Klaviyo and Google Sheets.
8 *
9 * @package King_Addons
10 */
11
12 namespace King_Addons;
13
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 /**
19 * Sends a submission on to third-party services.
20 */
21 class Form_Integrations
22 {
23 /**
24 * Option prefix for the per-form configuration.
25 */
26 private const OPTION_PREFIX = 'king_addons_integrations_';
27
28 /**
29 * Services this action can talk to.
30 *
31 * @return array<string,string>
32 */
33 public static function services(): array
34 {
35 return [
36 'slack' => esc_html__('Slack', 'king-addons'),
37 'telegram' => esc_html__('Telegram', 'king-addons'),
38 'brevo' => esc_html__('Brevo', 'king-addons'),
39 'convertkit' => esc_html__('ConvertKit / Kit', 'king-addons'),
40 'activecampaign' => esc_html__('ActiveCampaign', 'king-addons'),
41 'hubspot' => esc_html__('HubSpot', 'king-addons'),
42 'klaviyo' => esc_html__('Klaviyo', 'king-addons'),
43 'google_sheets' => esc_html__('Google Sheets', 'king-addons'),
44 ];
45 }
46
47 /**
48 * Register the AJAX endpoint.
49 *
50 * @return void
51 */
52 public function __construct()
53 {
54 add_action('wp_ajax_king_addons_form_builder_integrations', [self::class, 'handle']);
55 add_action('wp_ajax_nopriv_king_addons_form_builder_integrations', [self::class, 'handle']);
56 }
57
58 /**
59 * Store a form's integration settings.
60 *
61 * The browser never sends these: like the email settings, they are written
62 * when the form renders and read back when a submission arrives, so an
63 * endpoint cannot be talked into posting somewhere else.
64 *
65 * @param string $form_id Form element id.
66 * @param array<string,mixed> $settings Widget settings.
67 *
68 * @return void
69 */
70 public static function save_settings(string $form_id, array $settings): void
71 {
72 $enabled = isset($settings['integration_services']) && is_array($settings['integration_services'])
73 ? array_values(array_intersect($settings['integration_services'], array_keys(self::services())))
74 : [];
75
76 $config = [
77 'enabled' => $enabled,
78 'slack_webhook' => (string) ($settings['slack_webhook_url'] ?? ''),
79 'slack_title' => (string) ($settings['slack_title'] ?? ''),
80 'telegram_token' => (string) ($settings['telegram_bot_token'] ?? ''),
81 'telegram_chat' => (string) ($settings['telegram_chat_id'] ?? ''),
82 'brevo_key' => (string) ($settings['brevo_api_key'] ?? ''),
83 'brevo_list' => (string) ($settings['brevo_list_id'] ?? ''),
84 'convertkit_key' => (string) ($settings['convertkit_api_key'] ?? ''),
85 'convertkit_form' => (string) ($settings['convertkit_form_id'] ?? ''),
86 'ac_url' => (string) ($settings['activecampaign_url'] ?? ''),
87 'ac_key' => (string) ($settings['activecampaign_api_key'] ?? ''),
88 'ac_list' => (string) ($settings['activecampaign_list_id'] ?? ''),
89 'hubspot_token' => (string) ($settings['hubspot_token'] ?? ''),
90 'klaviyo_key' => (string) ($settings['klaviyo_api_key'] ?? ''),
91 'klaviyo_list' => (string) ($settings['klaviyo_list_id'] ?? ''),
92 'sheets_url' => (string) ($settings['google_sheets_url'] ?? ''),
93 'sheets_secret' => (string) ($settings['google_sheets_secret'] ?? ''),
94 'email_field' => (string) ($settings['integration_email_field'] ?? ''),
95 'name_field' => (string) ($settings['integration_name_field'] ?? ''),
96 ];
97
98 update_option(self::OPTION_PREFIX . $form_id, $config, false);
99 }
100
101 /**
102 * The stored configuration for a form.
103 *
104 * @param string $form_id Form element id.
105 *
106 * @return array<string,mixed>
107 */
108 public static function get_settings(string $form_id): array
109 {
110 $config = get_option(self::OPTION_PREFIX . $form_id, []);
111
112 return is_array($config) ? $config : [];
113 }
114
115 /**
116 * Handle a submission.
117 *
118 * @return void
119 */
120 public static function handle(): void
121 {
122 $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
123 if (!wp_verify_nonce($nonce, 'king-addons-js')) {
124 wp_send_json_error(['message' => 'invalid-nonce', 'status' => 'error']);
125 }
126
127 if (class_exists('King_Addons\\Form_Builder_Security')) {
128 Form_Builder_Security::guard_spam();
129 }
130
131 $form_id = isset($_POST['king_addons_form_id'])
132 ? sanitize_text_field(wp_unslash($_POST['king_addons_form_id']))
133 : '';
134
135 if ('' === $form_id) {
136 wp_send_json_error(['message' => 'missing-form-id', 'status' => 'error']);
137 }
138
139 $config = self::get_settings($form_id);
140 if (empty($config['enabled'])) {
141 wp_send_json_success([
142 'action' => 'king_addons_form_builder_integrations',
143 'message' => esc_html__('No services are switched on for this form.', 'king-addons'),
144 'status' => 'success',
145 'results' => [],
146 ]);
147 }
148
149 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked above.
150 $raw = isset($_POST['form_content']) && is_array($_POST['form_content']) ? wp_unslash($_POST['form_content']) : [];
151 $fields = self::normalise_fields($raw);
152
153 $results = [];
154 foreach ((array) $config['enabled'] as $service) {
155 $results[$service] = self::dispatch((string) $service, $config, $fields);
156 }
157
158 $failed = array_keys(array_filter($results, static function ($ok) {
159 return true !== $ok;
160 }));
161
162 // The widget's front-end script decides success or failure from
163 // "status", and reports "message" next to the submission.
164 wp_send_json_success([
165 'action' => 'king_addons_form_builder_integrations',
166 'status' => empty($failed) ? 'success' : 'error',
167 'message' => empty($failed)
168 ? esc_html__('Sent to every connected service.', 'king-addons')
169 : sprintf(
170 /* translators: %s: comma separated list of services. */
171 esc_html__('These services did not accept the submission: %s', 'king-addons'),
172 implode(', ', $failed)
173 ),
174 'results' => $results,
175 'failed' => $failed,
176 ]);
177 }
178
179 /**
180 * Flatten what the browser sent into label => value pairs.
181 *
182 * @param array<mixed> $raw Raw form content.
183 *
184 * @return array<string,array{label:string,value:string,id:string}>
185 */
186 private static function normalise_fields(array $raw): array
187 {
188 $fields = [];
189
190 foreach ($raw as $key => $entry) {
191 if (!is_array($entry) || count($entry) < 2) {
192 continue;
193 }
194
195 $value = $entry[1];
196
197 if (is_array($value)) {
198 // Radio and checkbox groups arrive as [value, checked, name, id].
199 $picked = [];
200 foreach ($value as $option) {
201 if (is_array($option) && !empty($option[1]) && isset($option[0])) {
202 $picked[] = (string) $option[0];
203 } elseif (is_scalar($option)) {
204 $picked[] = (string) $option;
205 }
206 }
207 $value = implode(', ', $picked);
208 }
209
210 $id = str_replace('form_field-', '', sanitize_text_field((string) $key));
211
212 $fields[$id] = [
213 'id' => $id,
214 'label' => isset($entry[2]) && is_scalar($entry[2]) ? sanitize_text_field((string) $entry[2]) : $id,
215 'value' => sanitize_textarea_field((string) $value),
216 ];
217 }
218
219 return $fields;
220 }
221
222 /**
223 * Send to one service.
224 *
225 * @param string $service Service key.
226 * @param array<string,mixed> $config Stored config.
227 * @param array<string,array{label:string,value:string,id:string}> $fields Submitted fields.
228 *
229 * @return true|string True on success, otherwise a short reason.
230 */
231 private static function dispatch(string $service, array $config, array $fields)
232 {
233 switch ($service) {
234 case 'slack':
235 return self::send_slack($config, $fields);
236 case 'telegram':
237 return self::send_telegram($config, $fields);
238 case 'brevo':
239 return self::send_brevo($config, $fields);
240 case 'convertkit':
241 return self::send_convertkit($config, $fields);
242 case 'activecampaign':
243 return self::send_activecampaign($config, $fields);
244 case 'hubspot':
245 return self::send_hubspot($config, $fields);
246 case 'klaviyo':
247 return self::send_klaviyo($config, $fields);
248 case 'google_sheets':
249 return self::send_google_sheets($config, $fields);
250 }
251
252 return 'unknown-service';
253 }
254
255 /**
256 * The endpoint a service posts to.
257 *
258 * Filterable so a site can route through its own proxy, and so the
259 * integrations can be exercised against a stub.
260 *
261 * @param string $service Service key.
262 * @param string $url Default URL.
263 *
264 * @return string
265 */
266 private static function endpoint(string $service, string $url): string
267 {
268 /**
269 * Filters the endpoint one Form Builder integration posts to.
270 *
271 * @param string $url Endpoint URL.
272 * @param string $service Service key.
273 */
274 return (string) apply_filters('king_addons/form_builder/integration_endpoint', $url, $service);
275 }
276
277 /**
278 * The submission as plain "Label: value" lines.
279 *
280 * @param array<string,array{label:string,value:string,id:string}> $fields Fields.
281 *
282 * @return string
283 */
284 private static function as_text(array $fields): string
285 {
286 $lines = [];
287
288 foreach ($fields as $field) {
289 $label = '' !== $field['label'] ? $field['label'] : $field['id'];
290 $lines[] = $label . ': ' . $field['value'];
291 }
292
293 return implode("\n", $lines);
294 }
295
296 /**
297 * The value of a field the author nominated, by field ID.
298 *
299 * @param array<string,array{label:string,value:string,id:string}> $fields Fields.
300 * @param string $field_id Field ID.
301 *
302 * @return string
303 */
304 private static function value_of(array $fields, string $field_id): string
305 {
306 $field_id = trim($field_id);
307
308 if ('' !== $field_id && isset($fields[$field_id])) {
309 return $fields[$field_id]['value'];
310 }
311
312 foreach ($fields as $field) {
313 if (!is_array($field)) {
314 continue;
315 }
316 if ($field_id === (string) ($field['id'] ?? '')) {
317 return $field['value'];
318 }
319 }
320
321 return '';
322 }
323
324 /**
325 * The submitted email address.
326 *
327 * @param array<string,mixed> $config Config.
328 * @param array<string,array{label:string,value:string,id:string}> $fields Fields.
329 *
330 * @return string
331 */
332 private static function email_of(array $config, array $fields): string
333 {
334 $email = self::value_of($fields, (string) ($config['email_field'] ?? ''));
335
336 if ('' === $email) {
337 // Fall back to the first value that looks like an address.
338 foreach ($fields as $field) {
339 if (is_email($field['value'])) {
340 $email = $field['value'];
341 break;
342 }
343 }
344 }
345
346 return is_email($email) ? $email : '';
347 }
348
349 /**
350 * Post JSON and report whether the service accepted it.
351 *
352 * @param string $url Endpoint.
353 * @param array<string,mixed> $body Payload.
354 * @param array<string,string> $headers Headers.
355 *
356 * @return true|string
357 */
358 private static function post_json(string $url, array $body, array $headers = [])
359 {
360 if ('' === $url || !wp_http_validate_url($url)) {
361 return 'bad-url';
362 }
363
364 $response = wp_remote_post($url, [
365 'timeout' => 15,
366 'headers' => array_merge(['Content-Type' => 'application/json'], $headers),
367 'body' => wp_json_encode($body),
368 ]);
369
370 if (is_wp_error($response)) {
371 return 'request-failed';
372 }
373
374 $code = (int) wp_remote_retrieve_response_code($response);
375
376 return ($code >= 200 && $code < 300) ? true : 'http-' . $code;
377 }
378
379 /**
380 * Slack incoming webhook.
381 *
382 * @param array<string,mixed> $config Config.
383 * @param array<string,mixed> $fields Fields.
384 *
385 * @return true|string
386 */
387 private static function send_slack(array $config, array $fields)
388 {
389 $title = '' !== (string) ($config['slack_title'] ?? '')
390 ? (string) $config['slack_title']
391 : esc_html__('New form submission', 'king-addons');
392
393 return self::post_json(
394 self::endpoint('slack', (string) ($config['slack_webhook'] ?? '')),
395 ['text' => $title . "\n" . self::as_text($fields)]
396 );
397 }
398
399 /**
400 * Telegram bot message.
401 *
402 * @param array<string,mixed> $config Config.
403 * @param array<string,mixed> $fields Fields.
404 *
405 * @return true|string
406 */
407 private static function send_telegram(array $config, array $fields)
408 {
409 $token = trim((string) ($config['telegram_token'] ?? ''));
410 $chat = trim((string) ($config['telegram_chat'] ?? ''));
411
412 if ('' === $token || '' === $chat) {
413 return 'not-configured';
414 }
415
416 return self::post_json(
417 self::endpoint('telegram', 'https://api.telegram.org/bot' . rawurlencode($token) . '/sendMessage'),
418 ['chat_id' => $chat, 'text' => self::as_text($fields)]
419 );
420 }
421
422 /**
423 * Brevo contact.
424 *
425 * @param array<string,mixed> $config Config.
426 * @param array<string,mixed> $fields Fields.
427 *
428 * @return true|string
429 */
430 private static function send_brevo(array $config, array $fields)
431 {
432 $key = trim((string) ($config['brevo_key'] ?? ''));
433 $email = self::email_of($config, $fields);
434
435 if ('' === $key || '' === $email) {
436 return 'not-configured';
437 }
438
439 $body = ['email' => $email, 'updateEnabled' => true];
440
441 $list = trim((string) ($config['brevo_list'] ?? ''));
442 if ('' !== $list) {
443 $body['listIds'] = [(int) $list];
444 }
445
446 $name = self::value_of($fields, (string) ($config['name_field'] ?? ''));
447 if ('' !== $name) {
448 $body['attributes'] = ['FIRSTNAME' => $name];
449 }
450
451 return self::post_json(
452 self::endpoint('brevo', 'https://api.brevo.com/v3/contacts'),
453 $body,
454 ['api-key' => $key, 'accept' => 'application/json']
455 );
456 }
457
458 /**
459 * ConvertKit (Kit) subscriber.
460 *
461 * @param array<string,mixed> $config Config.
462 * @param array<string,mixed> $fields Fields.
463 *
464 * @return true|string
465 */
466 private static function send_convertkit(array $config, array $fields)
467 {
468 $key = trim((string) ($config['convertkit_key'] ?? ''));
469 $form = trim((string) ($config['convertkit_form'] ?? ''));
470 $email = self::email_of($config, $fields);
471
472 if ('' === $key || '' === $form || '' === $email) {
473 return 'not-configured';
474 }
475
476 $body = ['api_key' => $key, 'email' => $email];
477
478 $name = self::value_of($fields, (string) ($config['name_field'] ?? ''));
479 if ('' !== $name) {
480 $body['first_name'] = $name;
481 }
482
483 return self::post_json(
484 self::endpoint('convertkit', 'https://api.convertkit.com/v3/forms/' . rawurlencode($form) . '/subscribe'),
485 $body
486 );
487 }
488
489 /**
490 * ActiveCampaign contact.
491 *
492 * @param array<string,mixed> $config Config.
493 * @param array<string,mixed> $fields Fields.
494 *
495 * @return true|string
496 */
497 private static function send_activecampaign(array $config, array $fields)
498 {
499 $base = rtrim(trim((string) ($config['ac_url'] ?? '')), '/');
500 $key = trim((string) ($config['ac_key'] ?? ''));
501 $email = self::email_of($config, $fields);
502
503 if ('' === $base || '' === $key || '' === $email) {
504 return 'not-configured';
505 }
506
507 $body = ['contact' => ['email' => $email]];
508
509 $name = self::value_of($fields, (string) ($config['name_field'] ?? ''));
510 if ('' !== $name) {
511 $body['contact']['firstName'] = $name;
512 }
513
514 $created = self::post_json(
515 self::endpoint('activecampaign', $base . '/api/3/contacts'),
516 $body,
517 ['Api-Token' => $key]
518 );
519
520 $list = trim((string) ($config['ac_list'] ?? ''));
521 if (true !== $created || '' === $list) {
522 return $created;
523 }
524
525 // ActiveCampaign keeps list membership separate from the contact, so
526 // the list ID needs its own call.
527 return self::post_json(
528 self::endpoint('activecampaign_list', $base . '/api/3/contactLists'),
529 [
530 'contactList' => [
531 'list' => (int) $list,
532 'email' => $email,
533 'status' => 1,
534 ],
535 ],
536 ['Api-Token' => $key]
537 );
538 }
539
540 /**
541 * HubSpot contact.
542 *
543 * @param array<string,mixed> $config Config.
544 * @param array<string,mixed> $fields Fields.
545 *
546 * @return true|string
547 */
548 private static function send_hubspot(array $config, array $fields)
549 {
550 $token = trim((string) ($config['hubspot_token'] ?? ''));
551 $email = self::email_of($config, $fields);
552
553 if ('' === $token || '' === $email) {
554 return 'not-configured';
555 }
556
557 $properties = ['email' => $email];
558
559 $name = self::value_of($fields, (string) ($config['name_field'] ?? ''));
560 if ('' !== $name) {
561 $properties['firstname'] = $name;
562 }
563
564 return self::post_json(
565 self::endpoint('hubspot', 'https://api.hubapi.com/crm/v3/objects/contacts'),
566 ['properties' => $properties],
567 ['Authorization' => 'Bearer ' . $token]
568 );
569 }
570
571 /**
572 * Klaviyo profile.
573 *
574 * @param array<string,mixed> $config Config.
575 * @param array<string,mixed> $fields Fields.
576 *
577 * @return true|string
578 */
579 private static function send_klaviyo(array $config, array $fields)
580 {
581 $key = trim((string) ($config['klaviyo_key'] ?? ''));
582 $email = self::email_of($config, $fields);
583
584 if ('' === $key || '' === $email) {
585 return 'not-configured';
586 }
587
588 $attributes = ['email' => $email];
589
590 $name = self::value_of($fields, (string) ($config['name_field'] ?? ''));
591 if ('' !== $name) {
592 $attributes['first_name'] = $name;
593 }
594
595 $body = ['data' => ['type' => 'profile', 'attributes' => $attributes]];
596
597 $list = trim((string) ($config['klaviyo_list'] ?? ''));
598 if ('' !== $list) {
599 $body['data']['relationships'] = [
600 'lists' => ['data' => [['type' => 'list', 'id' => $list]]],
601 ];
602 }
603
604 return self::post_json(
605 self::endpoint('klaviyo', 'https://a.klaviyo.com/api/profiles/'),
606 $body,
607 [
608 'Authorization' => 'Klaviyo-API-Key ' . $key,
609 'revision' => '2024-10-15',
610 ]
611 );
612 }
613
614 /**
615 * Google Sheets, through an Apps Script web app.
616 *
617 * Google's own API needs OAuth, which a form widget cannot do on its own;
618 * a published Apps Script bound to the sheet is the usual way round it.
619 *
620 * @param array<string,mixed> $config Config.
621 * @param array<string,mixed> $fields Fields.
622 *
623 * @return true|string
624 */
625 private static function send_google_sheets(array $config, array $fields)
626 {
627 $url = trim((string) ($config['sheets_url'] ?? ''));
628
629 if ('' === $url) {
630 return 'not-configured';
631 }
632
633 $row = [];
634 foreach ($fields as $field) {
635 $row['' !== $field['label'] ? $field['label'] : $field['id']] = $field['value'];
636 }
637
638 $body = [
639 'secret' => (string) ($config['sheets_secret'] ?? ''),
640 'submitted_at' => current_time('mysql'),
641 'row' => $row,
642 ];
643
644 return self::post_json(self::endpoint('google_sheets', $url), $body);
645 }
646 }
647