PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / 3.3.1
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder v3.3.1
3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 2.10.1 All 138 releases
bit-form / includes / Core / Integration / Telegram / RecordApiHelper.php

RecordApiHelper.php in Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder 3.3.1, at includes/Core/Integration/Telegram/RecordApiHelper.php

389 lines 11.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Telegram Record Api
5 *
6 */
7
8 namespace BitCode\BitForm\Core\Integration\Telegram;
9
10 use BitCode\BitForm\Core\Util\ApiResponse as UtilApiResponse;
11 use BitCode\BitForm\Core\Util\FieldValueHandler;
12 use BitCode\BitForm\Core\Util\HttpHelper;
13 use WP_Error;
14
15 /**
16 * Provide functionality for Record insert,upsert
17 */
18 class RecordApiHelper
19 {
20 /** Telegram caption cap; a longer body must go as its own sendMessage (limit 4096). */
21 private const CAPTION_LIMIT = 1024;
22
23 private $_defaultHeader;
24 private $_integrationID;
25 private $_logID;
26 private $_logResponse;
27 private $_entryID;
28 private $_apiEndPoint;
29
30 public function __construct($apiEndPoint, $integId, $logID, $entryID)
31 {
32 $this->_defaultHeader['Content-Type'] = 'application/x-www-form-urlencoded';
33 $this->_integrationID = $integId;
34 $this->_logID = $logID;
35 $this->_logResponse = new UtilApiResponse();
36 $this->_entryID = $entryID;
37 $this->_apiEndPoint = $apiEndPoint;
38 }
39
40 public function sendMessages($data)
41 {
42 $insertRecordEndpoint = $this->_apiEndPoint . '/sendMessage';
43 return HttpHelper::post($insertRecordEndpoint, $data, $this->_defaultHeader);
44 }
45
46 public function executeRecordApi($integrationDetails, $fieldValues, $formID, $entryID)
47 {
48 // Without $formID, ${bf_all_data} and the repeater tags resolve to ''.
49 $msg = FieldValueHandler::replaceFieldWithValue($integrationDetails->body, $fieldValues, $formID);
50 $messagesBody = self::normalizeMessageBody($msg, $integrationDetails->parse_mode);
51 $type = 'insert';
52
53 $files = self::collectAttachments($integrationDetails, $fieldValues);
54 $responses = [];
55
56 // Ride along as the caption when it fits, so users still get one notification.
57 $caption = '';
58 if (!empty($files) && '' !== trim($messagesBody) && mb_strlen($messagesBody) <= self::CAPTION_LIMIT) {
59 $caption = $messagesBody;
60 $messagesBody = '';
61 }
62
63 if ('' !== trim($messagesBody)) {
64 $responses[] = $this->sendMessages([
65 'chat_id' => $integrationDetails->chat_id,
66 'text' => $messagesBody,
67 'parse_mode' => $integrationDetails->parse_mode,
68 ]);
69 }
70
71 if (!empty($files)) {
72 $filesApiHelper = new FilesApiHelper($formID, $entryID);
73 // ten items max per group, and incompatible types cannot share one
74 $batches = $filesApiHelper->buildMediaBatches(array_values($files));
75
76 if (empty($batches)) {
77 // the body was folded into the caption above — don't lose it too
78 if ('' !== trim($caption)) {
79 $responses[] = $this->sendMessages([
80 'chat_id' => $integrationDetails->chat_id,
81 'text' => $caption,
82 'parse_mode' => $integrationDetails->parse_mode,
83 ]);
84 }
85 $responses[] = new WP_Error('TELEGRAM_FILE_NOT_FOUND', __('None of the Telegram attachments could be read.', 'bit-form'));
86 }
87
88 foreach ($batches as $index => $batch) {
89 // first send only, or the body repeats once per batch
90 $batchCaption = 0 === $index ? $caption : '';
91
92 if (1 === count($batch)) {
93 // uploadFiles() picks sendPhoto/sendAudio/sendVideo/sendDocument by MIME
94 $responses[] = $filesApiHelper->uploadFiles($this->_apiEndPoint, [
95 'chat_id' => $integrationDetails->chat_id,
96 'parse_mode' => $integrationDetails->parse_mode,
97 'caption' => $batchCaption,
98 'photo' => $batch[0]['url'],
99 ]);
100 continue;
101 }
102
103 $responses[] = $filesApiHelper->uploadMultipleFiles($this->_apiEndPoint, [
104 'chat_id' => $integrationDetails->chat_id,
105 'parse_mode' => $integrationDetails->parse_mode,
106 'caption' => $batchCaption,
107 'media' => $batch,
108 ]);
109 }
110 }
111
112 if (empty($responses)) {
113 $responses[] = new WP_Error('TELEGRAM_EMPTY_REQUEST', __('Telegram integration has nothing to send: message body and attachments are both empty.', 'bit-form'));
114 }
115
116 $entryDetails = [
117 'formId' => $formID,
118 'entryId' => $entryID,
119 'fieldValues' => $fieldValues
120 ];
121
122 $failure = null;
123 $decoded = [];
124
125 foreach ($responses as $response) {
126 $response = self::decodeResponse($response);
127 $decoded[] = $response;
128
129 if (is_null($failure) && !self::isSuccessful($response)) {
130 $failure = $response;
131 }
132 }
133
134 $recordApiResponse = 1 === count($decoded) ? $decoded[0] : $decoded;
135
136 if (is_null($failure)) {
137 $this->_logResponse->apiResponse($this->_logID, $this->_integrationID, ['type' => 'record', 'type_name' => $type], 'success', $recordApiResponse, $entryDetails);
138 return $recordApiResponse;
139 }
140
141 $this->_logResponse->apiResponse($this->_logID, $this->_integrationID, ['type' => 'record', 'type_name' => $type], 'error', self::describeFailure($failure), $entryDetails);
142
143 return $failure instanceof WP_Error ? $failure : $recordApiResponse;
144 }
145
146 /**
147 * Collect the mapped attachment file URLs out of the submitted values.
148 *
149 * @return array
150 */
151 private static function collectAttachments($integrationDetails, $fieldValues)
152 {
153 if (empty($integrationDetails->actions->attachments)) {
154 return [];
155 }
156
157 $attachment = (array) $integrationDetails->actions->attachments;
158 $files = [];
159
160 foreach ($fieldValues as $fieldKey => $fieldValue) {
161 if (!in_array($fieldKey, $attachment)) {
162 continue;
163 }
164 if (is_array($fieldValue)) {
165 $files = array_merge($files, $fieldValue);
166 } elseif (!empty($fieldValue)) {
167 $files[] = $fieldValue;
168 }
169 }
170
171 return array_values(array_filter($files, function ($file) {
172 return is_string($file) && '' !== $file;
173 }));
174 }
175
176 /**
177 * Telegram's HTML parse mode allows only a small tag whitelist; <p>/<br> are not
178 * in it and fail the call with "Unsupported start tag".
179 *
180 * @return string
181 */
182 private static function normalizeMessageBody($msg, $parseMode)
183 {
184 if (!is_string($msg)) {
185 return '';
186 }
187
188 $msg = self::normalizeAnchors($msg);
189 $msg = self::flattenTableMarkup($msg);
190 $msg = self::flattenListMarkup($msg);
191
192 $msg = preg_replace('#<br\s*/?>#i', "\n", $msg);
193 $msg = preg_replace('#</p>\s*<p[^>]*>#i', "\n\n", $msg);
194 $msg = preg_replace('#</?p[^>]*>#i', '', $msg);
195
196 if ('HTML' !== strtoupper((string) $parseMode)) {
197 return self::tidyWhitespace(wp_strip_all_tags($msg));
198 }
199
200 return self::tidyWhitespace(wp_kses($msg, [
201 'b' => [],
202 'strong' => [],
203 'i' => [],
204 'em' => [],
205 'u' => [],
206 'ins' => [],
207 's' => [],
208 'strike' => [],
209 'del' => [],
210 'a' => ['href' => []],
211 'code' => ['class' => []],
212 'pre' => [],
213 'span' => ['class' => []],
214 'tg-spoiler' => [],
215 'blockquote' => [],
216 ]));
217 }
218
219 /**
220 * FieldValueHandler::anchorMarkup() wraps list values in an <a href>, so a checkbox
221 * option becomes href="Option 2" and Telegram fails the message on the bad protocol.
222 *
223 * @param string $msg
224 *
225 * @return string
226 */
227 private static function normalizeAnchors($msg)
228 {
229 if (false === stripos($msg, '<a')) {
230 return $msg;
231 }
232
233 return preg_replace_callback(
234 '#<a\b[^>]*href\s*=\s*([\'"])(.*?)\1[^>]*>(.*?)</a>#is',
235 function ($matches) {
236 $href = trim(html_entity_decode($matches[2], ENT_QUOTES));
237 $text = $matches[3];
238
239 if (!preg_match('#^(?:https?://|tg://|mailto:)#i', $href)) {
240 return $text;
241 }
242
243 return '<a href="' . esc_url($href) . '">' . $text . '</a>';
244 },
245 $msg
246 );
247 }
248
249 /**
250 * ${bf_all_data} renders an HTML <table>, which Telegram rejects. Turn each row
251 * into a "Label: value" (or "a | b | c") line instead.
252 *
253 * @param string $msg
254 *
255 * @return string
256 */
257 private static function flattenTableMarkup($msg)
258 {
259 if (false === stripos($msg, '<table')) {
260 return $msg;
261 }
262
263 // Innermost table first: repeater sub-tables sit inside a parent cell.
264 $innerMostTable = '#<table[^>]*>((?:(?!<table[^>]*>).)*?)</table>#is';
265 $guard = 0;
266
267 while ($guard++ < 20 && preg_match($innerMostTable, $msg)) {
268 $msg = preg_replace_callback($innerMostTable, function ($matches) {
269 return "\n" . self::flattenTableRows($matches[1]);
270 }, $msg, 1);
271 }
272
273 // Unbalanced markup must not reach Telegram as tags.
274 return preg_replace('#</?(?:table|thead|tbody|tfoot|tr|th|td)[^>]*>#i', "\n", $msg);
275 }
276
277 /**
278 * @param string $tableHtml
279 *
280 * @return string
281 */
282 private static function flattenTableRows($tableHtml)
283 {
284 preg_match_all('#<tr[^>]*>(.*?)</tr>#is', $tableHtml, $rows);
285 $lines = [];
286
287 foreach ($rows[1] as $row) {
288 preg_match_all('#<t[dh][^>]*>(.*?)</t[dh]>#is', $row, $cellMatches);
289 $cells = [];
290
291 foreach ($cellMatches[1] as $cell) {
292 $cell = preg_replace('#</li>\s*<li[^>]*>#i', ', ', $cell);
293 $cell = preg_replace('#</?(?:ul|ol|li)[^>]*>#i', ' ', $cell);
294 $cell = preg_replace('#<br\s*/?>#i', ', ', $cell);
295 // Keep newlines: an already-flattened nested table lives inside this cell.
296 $cell = preg_replace('#[ \t\r]+#', ' ', $cell);
297 $cells[] = trim($cell);
298 }
299
300 if (empty($cells) || '' === trim(implode('', $cells))) {
301 continue;
302 }
303
304 $lines[] = 2 === count($cells) ? "{$cells[0]}: {$cells[1]}" : implode(' | ', $cells);
305 }
306
307 return empty($lines) ? '' : implode("\n", $lines) . "\n";
308 }
309
310 /**
311 * @param string $msg
312 *
313 * @return string
314 */
315 private static function flattenListMarkup($msg)
316 {
317 $msg = preg_replace('#<li[^>]*>#i', '', $msg);
318 $msg = preg_replace('#</li>#i', "\n", $msg);
319
320 return preg_replace('#</?(?:ul|ol)[^>]*>#i', "\n", $msg);
321 }
322
323 /**
324 * @param string $msg
325 *
326 * @return string
327 */
328 private static function tidyWhitespace($msg)
329 {
330 $msg = preg_replace('#[ \t]+\n#', "\n", $msg);
331 $msg = preg_replace('#\n[ \t]+#', "\n", $msg);
332 $msg = preg_replace('#\n{3,}#', "\n\n", $msg);
333
334 return trim($msg);
335 }
336
337 /**
338 * @return mixed WP_Error, decoded object/array, or the raw body
339 */
340 private static function decodeResponse($response)
341 {
342 if (is_wp_error($response)) {
343 return $response;
344 }
345
346 if (is_string($response)) {
347 $json = json_decode($response);
348 return is_null($json) ? $response : $json;
349 }
350
351 return $response;
352 }
353
354 private static function isSuccessful($response)
355 {
356 if (is_wp_error($response) || empty($response)) {
357 return false;
358 }
359
360 return is_object($response) && isset($response->ok) && $response->ok;
361 }
362
363 /**
364 * Never hand a falsy value to the logger: ApiResponse::apiResponse() drops the row.
365 *
366 * @return mixed
367 */
368 private static function describeFailure($failure)
369 {
370 if (is_wp_error($failure)) {
371 return [
372 'ok' => false,
373 'error_code' => $failure->get_error_code(),
374 'description' => $failure->get_error_message(),
375 ];
376 }
377
378 if (empty($failure)) {
379 return [
380 'ok' => false,
381 'error_code' => 'TELEGRAM_EMPTY_RESPONSE',
382 'description' => __('Telegram returned an empty response.', 'bit-form'),
383 ];
384 }
385
386 return $failure;
387 }
388 }
389