Passed
Push — master ( c61e61...a7f47b )
by
unknown
13:11
created

Bbb::regenerateRecording()   B

Complexity

Conditions 11
Paths 8

Size

Total Lines 50
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 11
eloc 28
nc 8
nop 2
dl 0
loc 50
rs 7.3166
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
use Chamilo\CoreBundle\Entity\ConferenceActivity;
6
use Chamilo\CoreBundle\Entity\ConferenceMeeting;
7
use Chamilo\CoreBundle\Entity\ConferenceRecording;
8
use Chamilo\CoreBundle\Enums\ActionIcon;
9
use Chamilo\CoreBundle\Enums\ObjectIcon;
10
use Chamilo\CoreBundle\Enums\StateIcon;
11
use Chamilo\CoreBundle\Repository\ConferenceActivityRepository;
12
use Chamilo\CoreBundle\Repository\ConferenceMeetingRepository;
13
use Chamilo\CoreBundle\Repository\ConferenceRecordingRepository;
14
use Chamilo\CoreBundle\Entity\User;
15
16
/**
17
 * Class Bbb
18
 * This script initiates a video conference session, calling the BigBlueButton
19
 * API BigBlueButton-Chamilo connector class
20
 */
21
class Bbb
22
{
23
    public $url;
24
    public $salt;
25
    public $api;
26
    public $userCompleteName = '';
27
    public $protocol = 'http://';
28
    public $debug = false;
29
    public $logoutUrl = '';
30
    public $pluginEnabled = false;
31
    public $enableGlobalConference = false;
32
    public $enableGlobalConferencePerUser = false;
33
    public $isGlobalConference = false;
34
    public $groupSupport = false;
35
    public $userSupport = false;
36
    public $accessUrl = 1;
37
    public $userId = 0;
38
    public $plugin;
39
    private $courseCode;
40
    private $courseId;
41
    private $sessionId;
42
    private $groupId;
43
    private $maxUsersLimit;
44
    private $urlWithProtocol = '';
45
46
    /**
47
     * Constructor (generates a connection to the API and the Chamilo settings
48
     * required for the connection to the video conference server)
49
     *
50
     * @param string $host
51
     * @param string $salt
52
     * @param bool   $isGlobalConference
53
     * @param int    $isGlobalPerUser
54
     */
55
    public function __construct(
56
        $host = '',
57
        $salt = '',
58
        $isGlobalConference = false,
59
        $isGlobalPerUser = 0
60
    ) {
61
        $this->courseCode = api_get_course_id();
62
        $this->courseId = api_get_course_int_id();
63
        $this->sessionId = api_get_session_id();
64
        $this->groupId = api_get_group_id();
65
66
        // Initialize video server settings from global settings
67
        $this->plugin = BbbPlugin::create();
68
        $bbbPluginEnabled = $this->plugin->get('tool_enable');
69
70
        $bbb_host = !empty($host) ? $host : $this->plugin->get('host');
71
        $bbb_salt = !empty($salt) ? $salt : $this->plugin->get('salt');
72
73
        $this->table = Database::get_main_table('conference_meeting');
74
        $this->enableGlobalConference = $this->plugin->get('enable_global_conference') === 'true';
75
        $this->isGlobalConference = (bool) $isGlobalConference;
76
77
        $columns = Database::listTableColumns($this->table);
78
        $this->groupSupport = isset($columns['group_id']) ? true : false;
79
        $this->userSupport = isset($columns['user_id']) ? true : false;
80
        $this->accessUrl = api_get_current_access_url_id();
81
82
        $this->enableGlobalConferencePerUser = false;
83
        if ($this->userSupport && !empty($isGlobalPerUser)) {
84
            $this->enableGlobalConferencePerUser = $this->plugin->get('enable_global_conference_per_user') === 'true';
85
            $this->userId = $isGlobalPerUser;
86
        }
87
88
        if ($this->groupSupport) {
89
            // Plugin check
90
            $this->groupSupport = $this->plugin->get('enable_conference_in_course_groups') === 'true' ? true : false;
91
            if ($this->groupSupport) {
92
                // Platform check
93
                $bbbSetting = api_get_plugin_setting('bbb', 'enable_conference_in_course_groups') === 'true';
94
                if ($bbbSetting) {
95
                    // Course check
96
                    $courseInfo = api_get_course_info();
97
                    if ($courseInfo) {
98
                        $this->groupSupport = api_get_course_plugin_setting(
99
                                'bbb',
100
                                'bbb_enable_conference_in_groups',
101
                                $courseInfo
102
                            ) === '1';
103
                    }
104
                }
105
            }
106
        }
107
        $this->maxUsersLimit = $this->plugin->get('max_users_limit');
108
109
        if ($bbbPluginEnabled === 'true') {
110
            $userInfo = api_get_user_info();
111
            if (empty($userInfo) && !empty($isGlobalPerUser)) {
112
                // If we are following a link to a global "per user" conference
113
                // then generate a random guest name to join the conference
114
                // because there is no part of the process where we give a name
115
                //$this->userCompleteName = 'Guest'.rand(1000, 9999);
116
            } else {
117
                $this->userCompleteName = $userInfo['complete_name'];
118
            }
119
120
            if (api_is_anonymous()) {
121
                $this->userCompleteName = get_lang('Guest').'_'.rand(1000, 9999);
122
            }
123
124
            $this->salt = $bbb_salt;
125
            if (!empty($bbb_host)) {
126
                if (substr($bbb_host, -1, 1) !== '/') {
127
                    $bbb_host .= '/';
128
                }
129
                $this->url = $bbb_host;
130
                if (!preg_match('#/bigbluebutton/$#', $bbb_host)) {
131
                    $this->url = $bbb_host.'bigbluebutton/';
132
                }
133
            }
134
            $info = parse_url($bbb_host);
135
136
            if (isset($info['scheme'])) {
137
                $this->protocol = $info['scheme'].'://';
138
                $this->url = str_replace($this->protocol, '', $this->url);
139
                $urlWithProtocol = $bbb_host;
140
            } else {
141
                // We assume it's an http, if user wants to use https, the host *must* include the protocol.
142
                $this->protocol = 'http://';
143
                $urlWithProtocol = $this->protocol.$bbb_host;
144
            }
145
146
            // Normalize final URL with protocol to the /bigbluebutton/ endpoint
147
            if (!str_ends_with($urlWithProtocol, '/bigbluebutton/')) {
148
                $urlWithProtocol = rtrim($urlWithProtocol, '/').'/bigbluebutton/';
149
            }
150
            $this->urlWithProtocol = $urlWithProtocol;
151
152
            // Define constants safely (optional legacy compatibility)
153
            if (!defined('CONFIG_SERVER_BASE_URL')) {
154
                define('CONFIG_SERVER_BASE_URL', $this->url);
155
            }
156
            if (!defined('CONFIG_SERVER_PROTOCOL')) {
157
                define('CONFIG_SERVER_PROTOCOL', $this->protocol);
158
            }
159
            if (!defined('CONFIG_SERVER_URL_WITH_PROTOCOL')) {
160
                define('CONFIG_SERVER_URL_WITH_PROTOCOL', $this->urlWithProtocol);
161
            }
162
            if (!defined('CONFIG_SECURITY_SALT')) {
163
                // IMPORTANT: use $this->salt, not $this->securitySalt
164
                define('CONFIG_SECURITY_SALT', (string) $this->salt);
165
            }
166
167
            // Initialize API object only if we have both URL and salt
168
            if (!empty($this->urlWithProtocol) && !empty($this->salt)) {
169
                $this->api = new BigBlueButtonBN($this->urlWithProtocol, $this->salt);
170
                $this->pluginEnabled = true;
171
            } else {
172
                $this->api = null;
173
                $this->pluginEnabled = false;
174
            }
175
176
            $this->logoutUrl = $this->getListingUrl();
177
        }
178
    }
179
180
    private function ensureApi(): bool
181
    {
182
        if ($this->api instanceof BigBlueButtonBN) {
183
            return true;
184
        }
185
        $url = $this->urlWithProtocol ?: (defined('CONFIG_SERVER_URL_WITH_PROTOCOL') ? constant('CONFIG_SERVER_URL_WITH_PROTOCOL') : '');
186
        $salt = $this->salt ?: (defined('CONFIG_SECURITY_SALT') ? constant('CONFIG_SECURITY_SALT') : '');
187
188
        if ($url && $salt) {
189
            $this->api = new BigBlueButtonBN($url, $salt);
190
            return $this->api instanceof BigBlueButtonBN;
191
        }
192
193
        return false;
194
    }
195
196
    /**
197
     * @param int $courseId  Optional. Course ID.
198
     * @param int $sessionId Optional. Session ID.
199
     * @param int $groupId   Optional. Group ID.
200
     *
201
     * @return string
202
     */
203
    public function getListingUrl($courseId = 0, $sessionId = 0, $groupId = 0)
204
    {
205
        return api_get_path(WEB_PLUGIN_PATH).'Bbb/listing.php?'
206
            .$this->getUrlParams($courseId, $sessionId, $groupId);
207
    }
208
209
    /**
210
     * @param int $courseId  Optional. Course ID.
211
     * @param int $sessionId Optional. Session ID.
212
     * @param int $groupId   Optional. Group ID.
213
     *
214
     * @return string
215
     */
216
    public function getUrlParams($courseId = 0, $sessionId = 0, $groupId = 0)
217
    {
218
        if (empty($this->courseId) && !$courseId) {
219
            if ($this->isGlobalConferencePerUserEnabled()) {
220
                return 'global=1&user_id='.$this->userId;
221
            }
222
223
            if ($this->isGlobalConference()) {
224
                return 'global=1';
225
            }
226
227
            return '';
228
        }
229
230
        $defaultCourseId = (int) $this->courseId;
231
        if (!empty($courseId)) {
232
            $defaultCourseId = (int) $courseId;
233
        }
234
235
        return http_build_query(
236
            [
237
                'cid' => $defaultCourseId,
238
                'sid' => (int) $sessionId ?: $this->sessionId,
239
                'gid' => (int) $groupId ?: $this->groupId,
240
            ]
241
        );
242
    }
243
244
    /**
245
     * @return bool
246
     */
247
    public function isGlobalConferencePerUserEnabled()
248
    {
249
        return $this->enableGlobalConferencePerUser;
250
    }
251
252
    /**
253
     * @return bool
254
     */
255
    public function isGlobalConference()
256
    {
257
        if ($this->isGlobalConferenceEnabled() === false) {
258
            return false;
259
        }
260
261
        return (bool) $this->isGlobalConference;
262
    }
263
264
    /**
265
     * @return bool
266
     */
267
    public function isGlobalConferenceEnabled()
268
    {
269
        return $this->enableGlobalConference;
270
    }
271
272
    /**
273
     * @param array $userInfo
274
     *
275
     * @return bool
276
     */
277
    private static function normalizeSettingToArray($value): array
278
    {
279
        if (is_array($value)) {
280
            return $value;
281
        }
282
        if (is_string($value)) {
283
            $value = trim($value);
284
            if ($value === '') {
285
                return [];
286
            }
287
            $decoded = json_decode($value, true);
288
            if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
289
                return $decoded;
290
            }
291
            return array_values(
292
                array_filter(
293
                    array_map('trim', explode(',', $value)),
294
                    'strlen'
295
                )
296
            );
297
        }
298
        return [];
299
    }
300
301
    /**
302
     * Gets the global limit of users in a video-conference room.
303
     * This value can be overridden by course-specific values
304
     * @return  int Maximum number of users set globally
305
     */
306
    public function getMaxUsersLimit()
307
    {
308
        $limit = $this->maxUsersLimit;
309
        if ($limit <= 0) {
310
            $limit = 0;
311
        }
312
        $courseLimit = 0;
313
        $sessionLimit = 0;
314
315
        // Check course extra field
316
        if (!empty($this->courseId)) {
317
            $extraField = new ExtraField('course');
318
            $fieldIdList = $extraField->get_all(
319
                array('variable = ?' => 'plugin_bbb_course_users_limit')
320
            );
321
322
            if (!empty($fieldIdList)) {
323
                $fieldId = $fieldIdList[0]['id'] ?? null;
324
                if ($fieldId) {
325
                    $extraValue = new ExtraFieldValue('course');
326
                    $value = $extraValue->get_values_by_handler_and_field_id($this->courseId, $fieldId);
327
                    if (!empty($value['value'])) {
328
                        $courseLimit = (int) $value['value'];
329
                    }
330
                }
331
            }
332
        }
333
334
        // Check session extra field
335
        if (!empty($this->sessionId)) {
336
            $extraField = new ExtraField('session');
337
            $fieldIdList = $extraField->get_all(
338
                array('variable = ?' => 'plugin_bbb_session_users_limit')
339
            );
340
341
            if (!empty($fieldIdList)) {
342
                $fieldId = $fieldIdList[0]['id'] ?? null;
343
                if ($fieldId) {
344
                    $extraValue = new ExtraFieldValue('session');
345
                    $value = $extraValue->get_values_by_handler_and_field_id($this->sessionId, $fieldId);
346
                    if (!empty($value['value'])) {
347
                        $sessionLimit = (int) $value['value'];
348
                    }
349
                }
350
            }
351
        }
352
353
        if (!empty($sessionLimit)) {
354
            return $sessionLimit;
355
        } elseif (!empty($courseLimit)) {
356
            return $courseLimit;
357
        }
358
359
        return (int) $limit;
360
    }
361
362
    /**
363
     * Sets the global limit of users in a video-conference room.
364
     *
365
     * @param int Maximum number of users (globally)
366
     */
367
    public function setMaxUsersLimit($max)
368
    {
369
        if ($max < 0) {
370
            $max = 0;
371
        }
372
        $this->maxUsersLimit = (int) $max;
373
    }
374
375
    /**
376
     * See this file in you BBB to set up default values
377
     *
378
     * @param array $params Array of parameters that will be completed if not containing all expected variables
379
     *
380
     * /var/lib/tomcat6/webapps/bigbluebutton/WEB-INF/classes/bigbluebutton.properties
381
     *
382
     * More record information:
383
     * http://code.google.com/p/bigbluebutton/wiki/RecordPlaybackSpecification
384
     *
385
     * Default maximum number of users a meeting can have.
386
     * Doesn't get enforced yet but is the default value when the create
387
     * API doesn't pass a value.
388
     * defaultMaxUsers=20
389
     *
390
     * Default duration of the meeting in minutes.
391
     * Current default is 0 (meeting doesn't end).
392
     * defaultMeetingDuration=0
393
     *
394
     * Remove the meeting from memory when the end API is called.
395
     * This allows 3rd-party apps to recycle the meeting right-away
396
     * instead of waiting for the meeting to expire (see below).
397
     * removeMeetingWhenEnded=false
398
     *
399
     * The number of minutes before the system removes the meeting from memory.
400
     * defaultMeetingExpireDuration=1
401
     *
402
     * The number of minutes the system waits when a meeting is created and when
403
     * a user joins. If after this period, a user hasn't joined, the meeting is
404
     * removed from memory.
405
     * defaultMeetingCreateJoinDuration=5
406
     *
407
     * @return mixed
408
     */
409
    public function createMeeting($params)
410
    {
411
        error_log('BBB API createMeeting');
412
        // Ensure API is available
413
        if (!$this->ensureApi()) {
414
            if ($this->debug) {
415
                error_log('BBB API not initialized in createMeeting().');
416
            }
417
            return false;
418
        }
419
420
        // Normalize input
421
        $params = is_array($params) ? $params : [];
422
423
        // Context defaults
424
        $params['c_id']       = $params['c_id']       ?? api_get_course_int_id();
425
        $params['session_id'] = $params['session_id'] ?? api_get_session_id();
426
427
        if ($this->hasGroupSupport()) {
428
            $params['group_id'] = $params['group_id'] ?? api_get_group_id();
429
        }
430
431
        if ($this->isGlobalConferencePerUserEnabled() && !empty($this->userId)) {
432
            $params['user_id'] = (int) $this->userId;
433
        }
434
435
        // Passwords
436
        $params['attendee_pw']  = $params['attendee_pw']  ?? $this->getUserMeetingPassword();
437
        $params['moderator_pw'] = $params['moderator_pw'] ?? $this->getModMeetingPassword();
438
        $attendeePassword  = (string) $params['attendee_pw'];
439
        $moderatorPassword = (string) $params['moderator_pw'];
440
441
        // Recording and limits
442
        $params['record'] = api_get_course_plugin_setting('bbb', 'big_blue_button_record_and_store') == 1 ? 1 : 0;
443
        $max = api_get_course_plugin_setting('bbb', 'big_blue_button_max_students_allowed');
444
        $max = isset($max) ? (int) $max : -1;
445
446
        // Meeting identifiers
447
        $params['status']     = 1;
448
        $params['remote_id']  = $params['remote_id']  ?? uniqid(true, true);
449
        $params['voice_bridge']= $params['voice_bridge'] ?? rand(10000, 99999);
450
        $params['created_at'] = $params['created_at'] ?? api_get_utc_datetime();
451
        $params['access_url'] = $params['access_url'] ?? $this->accessUrl;
452
453
        // Persist meeting entity
454
        $em = Database::getManager();
455
        $meeting = new ConferenceMeeting();
456
457
        $meeting
458
            ->setCourse(api_get_course_entity($params['c_id']))
459
            ->setSession(api_get_session_entity($params['session_id']))
460
            ->setAccessUrl(api_get_url_entity($params['access_url']))
461
            ->setGroup($this->hasGroupSupport() ? api_get_group_entity($params['group_id']) : null)
462
            ->setUser($this->isGlobalConferencePerUserEnabled() ? api_get_user_entity($params['user_id'] ?? 0) : null)
463
            ->setRemoteId($params['remote_id'])
464
            ->setTitle($params['meeting_name'] ?? $this->getCurrentVideoConferenceName())
465
            ->setAttendeePw($attendeePassword)
466
            ->setModeratorPw($moderatorPassword)
467
            ->setRecord((bool) $params['record'])
468
            ->setStatus($params['status'])
469
            ->setVoiceBridge($params['voice_bridge'])
470
            ->setWelcomeMsg($params['welcome_msg'] ?? null)
471
            ->setVisibility(1)
472
            ->setHasVideoM4v(false)
473
            ->setServiceProvider('bbb');
474
475
        $em->persist($meeting);
476
        $em->flush();
477
478
        $id = $meeting->getId();
479
480
        Event::addEvent(
0 ignored issues
show
Bug introduced by
The method addEvent() does not exist on Event. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

480
        Event::/** @scrutinizer ignore-call */ 
481
               addEvent(

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
481
            'bbb_create_meeting',
482
            'meeting_id',
483
            $id,
484
            null,
485
            api_get_user_id(),
486
            api_get_course_int_id(),
487
            api_get_session_id()
488
        );
489
490
        // Compute BBB params
491
        $meetingName = $meeting->getTitle();
492
        $record      = $meeting->isRecord() ? 'true' : 'false';
493
        $duration    = 300;
494
        $meetingDuration = (int) $this->plugin->get('meeting_duration');
495
        if (!empty($meetingDuration)) {
496
            $duration = $meetingDuration;
497
        }
498
499
        // Normalize optional "documents" to avoid undefined index warnings
500
        $documents = [];
501
        if (!empty($params['documents']) && is_array($params['documents'])) {
502
            foreach ($params['documents'] as $doc) {
503
                if (!is_array($doc)) {
504
                    continue;
505
                }
506
                $url = $doc['url'] ?? null;
507
                if (!$url) {
508
                    continue;
509
                }
510
                $documents[] = [
511
                    'url'          => (string) $url,
512
                    'filename'     => $doc['filename'] ?? (basename(parse_url($url, PHP_URL_PATH) ?: 'document')),
513
                    'downloadable' => array_key_exists('downloadable', $doc) ? (bool) $doc['downloadable'] : true,
514
                    'removable'    => array_key_exists('removable', $doc) ? (bool) $doc['removable'] : true,
515
                ];
516
            }
517
        }
518
519
        $bbbParams = [
520
            'meetingId'       => $meeting->getRemoteId(),
521
            'meetingName'     => $meetingName,
522
            'attendeePw'      => $attendeePassword,
523
            'moderatorPw'     => $moderatorPassword,
524
            'welcomeMsg'      => $meeting->getWelcomeMsg(),
525
            'dialNumber'      => '',
526
            'voiceBridge'     => $meeting->getVoiceBridge(),
527
            'webVoice'        => '',
528
            'logoutUrl'       => $this->logoutUrl . '&action=logout&remote_id=' . $meeting->getRemoteId(),
529
            'maxParticipants' => $max,
530
            'record'          => $record,
531
            'duration'        => $duration,
532
            'documents'       => $documents, // safe default (empty array) if none provided
533
        ];
534
535
        // Try to create meeting until success (kept as in original logic)
536
        $status = false;
537
        while ($status === false) {
538
            $result = $this->api->createMeetingWithXmlResponseArray($bbbParams);
539
540
            if ((string)($result['returncode'] ?? '') === 'SUCCESS') {
541
                if ($this->plugin->get('allow_regenerate_recording') === 'true' && !empty($result['internalMeetingID'])) {
542
                    $meeting->setInternalMeetingId($result['internalMeetingID']);
543
                    $em->flush();
544
                }
545
546
                // Register webhook if enabled (no DB tables, pure BBB hooks API)
547
                try {
548
                    if ($this->plugin->webhooksEnabled()) {
549
                        $scope = $this->plugin->webhooksScope(); // 'per_meeting' | 'global'
550
                        if ($scope === 'per_meeting') {
551
                            error_log('[BBB hooks] register per-meeting');
552
                            $this->registerWebhookForMeeting($meeting->getRemoteId());
553
                        } else {
554
                            error_log('[BBB hooks] ensure global');
555
                            $this->ensureGlobalWebhook();
556
                        }
557
                    } else {
558
                        error_log('[BBB hooks] disabled by config');
559
                    }
560
                } catch (\Throwable $e) {
561
                    error_log('[BBB hooks] registration error: '.$e->getMessage());
562
                }
563
564
                return $this->joinMeeting($meetingName, true);
565
            }
566
567
            if ((string)($result['returncode'] ?? '') === 'FAILED') {
568
                if ((int)($result['httpCode'] ?? 0) === 413) {
569
                    if ($this->debug) {
570
                        error_log('BBB createMeeting failed (413): payload too large');
571
                    }
572
                } else if ($this->debug) {
573
                    error_log('BBB createMeeting failed: '.json_encode($result));
574
                }
575
                break;
576
            }
577
578
            if ($this->debug) {
579
                error_log('BBB createMeeting unexpected response: '.print_r($result, true));
0 ignored issues
show
Bug introduced by
Are you sure print_r($result, true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

579
                error_log('BBB createMeeting unexpected response: './** @scrutinizer ignore-type */ print_r($result, true));
Loading history...
580
            }
581
            break;
582
        }
583
584
        return false;
585
    }
586
587
    /**
588
     * @return bool
589
     */
590
    public function hasGroupSupport()
591
    {
592
        return $this->groupSupport;
593
    }
594
595
    /**
596
     * Gets the password for a specific meeting for the current user
597
     *
598
     * @param string $courseCode
599
     *
600
     * @return string A moderator password if user is teacher, or the course code otherwise
601
     *
602
     */
603
    public function getUserMeetingPassword($courseCode = null)
604
    {
605
        if ($this->isGlobalConferencePerUserEnabled()) {
606
            return 'url_'.$this->userId.'_'.api_get_current_access_url_id();
607
        }
608
609
        if ($this->isGlobalConference()) {
610
            return 'url_'.api_get_current_access_url_id();
611
        }
612
613
        return empty($courseCode) ? api_get_course_id() : $courseCode;
614
    }
615
616
    /**
617
     * Generated a moderator password for the meeting.
618
     *
619
     * @param string $courseCode
620
     *
621
     * @return string A password for the moderation of the videoconference
622
     */
623
    public function getModMeetingPassword($courseCode = null)
624
    {
625
        if ($this->isGlobalConferencePerUserEnabled()) {
626
            return 'url_'.$this->userId.'_'.api_get_current_access_url_id().'_mod';
627
        }
628
629
        if ($this->isGlobalConference()) {
630
            return 'url_'.api_get_current_access_url_id().'_mod';
631
        }
632
633
        $courseCode = empty($courseCode) ? api_get_course_id() : $courseCode;
634
635
        return $courseCode.'mod';
636
    }
637
638
    /**
639
     * @return string
640
     */
641
    public function getCurrentVideoConferenceName()
642
    {
643
        if ($this->isGlobalConferencePerUserEnabled()) {
644
            return 'url_'.$this->userId.'_'.api_get_current_access_url_id();
645
        }
646
647
        if ($this->isGlobalConference()) {
648
            return 'url_'.api_get_current_access_url_id();
649
        }
650
651
        if ($this->hasGroupSupport()) {
652
            return api_get_course_id().'-'.api_get_session_id().'-'.api_get_group_id();
653
        }
654
655
        return api_get_course_id().'-'.api_get_session_id();
656
    }
657
658
    /**
659
     * Returns a meeting "join" URL
660
     *
661
     * @param string The name of the meeting (usually the course code)
662
     *
663
     * @return mixed The URL to join the meeting, or false on error
664
     * @todo implement moderator pass
665
     * @assert ('') === false
666
     * @assert ('abcdefghijklmnopqrstuvwxyzabcdefghijklmno') === false
667
     */
668
    public function joinMeeting($meetingName)
669
    {
670
        if (!$this->ensureApi()) {
671
            if ($this->debug) {
672
                error_log('BBB API not initialized in joinMeeting().');
673
            }
674
            return false;
675
        }
676
        if ($this->debug) {
677
            error_log("joinMeeting: $meetingName");
678
        }
679
680
        if (empty($meetingName)) {
681
            return false;
682
        }
683
684
        $manager = $this->isConferenceManager();
685
        $pass = $manager ? $this->getModMeetingPassword() : $this->getUserMeetingPassword();
686
687
        $meetingData = Database::getManager()
688
            ->getRepository(ConferenceMeeting::class)
689
            ->findOneBy([
690
                'title' => $meetingName,
691
                'status' => 1,
692
                'accessUrl' => api_get_url_entity($this->accessUrl),
693
            ]);
694
695
        if (empty($meetingData)) {
696
            if ($this->debug) {
697
                error_log("meeting does not exist: $meetingName");
698
            }
699
700
            return false;
701
        }
702
703
        $params = [
704
            'meetingId' => $meetingData->getRemoteId(),
705
            'password' => $this->getModMeetingPassword(),
706
        ];
707
708
        $meetingInfoExists = false;
709
        $meetingIsRunningInfo = $this->getMeetingInfo($params);
710
        if ($this->debug) {
711
            error_log('Searching meeting with params:');
712
            error_log(print_r($params, 1));
713
            error_log('Result:');
714
            error_log(print_r($meetingIsRunningInfo, 1));
715
        }
716
717
        if ($meetingIsRunningInfo === false) {
718
            $params['meetingId'] = $meetingData->getId();
719
            $meetingIsRunningInfo = $this->getMeetingInfo($params);
720
            if ($this->debug) {
721
                error_log('Searching meetingId with params:');
722
                error_log(print_r($params, 1));
723
                error_log('Result:');
724
                error_log(print_r($meetingIsRunningInfo, 1));
725
            }
726
        }
727
728
        $meetingInfoExists = false;
729
730
        if (is_array($meetingIsRunningInfo)) {
731
            $returncode = (string)($meetingIsRunningInfo['returncode'] ?? '');
732
            $meetingName = $meetingIsRunningInfo['meetingName'] ?? null;
733
734
            if ($returncode === 'SUCCESS' && !empty($meetingName)) {
735
                $meetingInfoExists = true;
736
            }
737
        } else {
738
            if (!empty($this->debug)) {
739
                error_log('[BBB] meetingIsRunning returned non-array: '.gettype($meetingIsRunningInfo));
740
            }
741
        }
742
743
        if ($this->debug) {
744
            error_log("meeting is running: " . intval($meetingInfoExists));
745
        }
746
747
        if ($meetingInfoExists) {
748
            $joinParams = [
749
                'meetingId' => $meetingData->getRemoteId(),
750
                'username' => $this->userCompleteName,
751
                'password' => $pass,
752
                'userID' => api_get_user_id(),
753
                'moderatorPw' => $this->getModMeetingPassword(),
754
                'userID'      => api_get_user_id(),
755
                'webVoiceConf' => '',
756
            ];
757
            $url = $this->api->getJoinMeetingURL($joinParams);
758
            if (preg_match('#^https?://#i', $url)) {
759
                return $url;
760
            }
761
762
            return $this->protocol . $url;
763
        }
764
765
        return false;
766
    }
767
768
769
    /**
770
     * Checks whether a user is teacher in the current course
771
     * @return bool True if the user can be considered a teacher in this course, false otherwise
772
     */
773
    public function isConferenceManager()
774
    {
775
        if (api_is_coach() || api_is_platform_admin(false, true)) {
776
            return true;
777
        }
778
779
        if ($this->isGlobalConferencePerUserEnabled()) {
780
            $currentUserId = api_get_user_id();
781
            if ($this->userId === $currentUserId) {
782
                return true;
783
            } else {
784
                return false;
785
            }
786
        }
787
788
        $courseInfo = api_get_course_info();
789
        $groupId = api_get_group_id();
790
        if (!empty($groupId) && !empty($courseInfo)) {
791
            $groupEnabled = api_get_course_plugin_setting('bbb', 'bbb_enable_conference_in_groups') === '1';
792
            if ($groupEnabled) {
793
                $studentCanStartConference = api_get_course_plugin_setting(
794
                        'bbb',
795
                        'big_blue_button_students_start_conference_in_groups'
796
                    ) === '1';
797
798
                if ($studentCanStartConference) {
799
                    $isSubscribed = GroupManager::is_user_in_group(
800
                        api_get_user_id(),
801
                        GroupManager::get_group_properties($groupId)
802
                    );
803
                    if ($isSubscribed) {
804
                        return true;
805
                    }
806
                }
807
            }
808
        }
809
810
        if (!empty($courseInfo)) {
811
            return api_is_course_admin();
812
        }
813
814
        return false;
815
    }
816
817
    /**
818
     * Get information about the given meeting
819
     *
820
     * @param array ...?
821
     *
822
     * @return mixed Array of information on success, false on error
823
     * @assert (array()) === false
824
     */
825
    public function getMeetingInfo($params)
826
    {
827
        try {
828
            // Guard against null API
829
            if (!$this->ensureApi()) {
830
                if ($this->debug) {
831
                    error_log('BBB API not initialized (missing URL or salt).');
832
                }
833
                return false;
834
            }
835
836
            $result = $this->api->getMeetingInfoWithXmlResponseArray($params);
837
            if ($result == null) {
838
                if ($this->debug) {
839
                    error_log("Failed to get any response. Maybe we can't contact the BBB server.");
840
                }
841
                return false;
842
            }
843
844
            return $result;
845
        } catch (\Throwable $e) {
846
            if ($this->debug) {
847
                error_log('BBB getMeetingInfo error: '.$e->getMessage());
848
            }
849
            return false;
850
        }
851
    }
852
853
854
    /**
855
     * Returns per-participant aggregates for a given meeting (durations + metrics).
856
     * - Sums all closed intervals (inAt..outAt) plus any open interval up to "now".
857
     * - Merges JSON metrics across rows:
858
     *   * Sums integer counters and "totals.*_seconds"
859
     *   * Keeps last non-empty scalar for other leaves
860
     */
861
    public function getMeetingParticipantInfo($meetingId, $userId): array
862
    {
863
        $em = Database::getManager();
864
        /** @var ConferenceActivityRepository $repo */
865
        $repo = $em->getRepository(ConferenceActivity::class);
866
867
        $meeting = $em->getRepository(ConferenceMeeting::class)->find((int)$meetingId);
868
        $user    = $em->getRepository(User::class)->find((int)$userId);
869
870
        if (!$meeting || !$user) {
871
            return [];
872
        }
873
874
        $rows = $repo->createQueryBuilder('a')
875
            ->where('a.meeting = :meeting')
876
            ->andWhere('a.participant = :user')
877
            ->setParameter('meeting', $meeting)
878
            ->setParameter('user', $user)
879
            ->orderBy('a.id', 'ASC')
880
            ->getQuery()->getResult();
881
882
        if (!$rows) {
883
            return [];
884
        }
885
886
        $now = new \DateTimeImmutable();
887
        $onlineSeconds = 0;
888
        $lastInAt = null;
889
        $lastOutAt = null;
890
891
        // Merge metrics across rows
892
        $mergedMetrics = [];
893
894
        foreach ($rows as $a) {
895
            $in  = $a->getInAt();
896
            $out = $a->getOutAt();
897
898
            if ($in instanceof \DateTimeInterface) {
899
                $lastInAt = $in;
900
            }
901
            if ($out instanceof \DateTimeInterface) {
902
                $lastOutAt = $out;
903
            }
904
905
            // Duration: if outAt missing or equals inAt (open), count until now
906
            $start = $in instanceof \DateTimeInterface ? \DateTimeImmutable::createFromMutable($in) : null;
907
            $end   = $out instanceof \DateTimeInterface ? \DateTimeImmutable::createFromMutable($out) : null;
908
909
            if ($start) {
910
                $effectiveEnd = ($end && $end > $start) ? $end : $now;
911
                $delta = max(0, $effectiveEnd->getTimestamp() - $start->getTimestamp());
912
                $onlineSeconds += $delta;
913
            }
914
915
            // Merge metrics JSON
916
            if (method_exists($a, 'getMetrics')) {
917
                $metrics = $a->getMetrics() ?? [];
918
                $mergedMetrics = $this->mergeMetrics($mergedMetrics, $metrics);
919
            }
920
        }
921
922
        return [
923
            'meeting_id'      => (int)$meeting->getId(),
924
            'participant_id'  => (int)$user->getId(),
925
            'online_seconds'  => (int)$onlineSeconds,
926
            'in_at_last'      => $lastInAt ? $lastInAt->format('Y-m-d H:i:s') : null,
927
            'out_at_last'     => $lastOutAt ? $lastOutAt->format('Y-m-d H:i:s') : null,
928
            'metrics'         => $mergedMetrics,
929
        ];
930
    }
931
932
    /**
933
     * Merge two metrics arrays:
934
     * - If both leaves are numeric -> sum them
935
     * - If keys start with "totals." and end with "_seconds" and values are numeric -> sum
936
     * - If arrays -> deep merge with same rules
937
     * - Otherwise -> prefer right-side (latest non-empty)
938
     */
939
    private function mergeMetrics(array $left, array $right, string $prefix = ''): array
940
    {
941
        foreach ($right as $k => $rv) {
942
            $path = $prefix === '' ? $k : $prefix.'.'.$k;
943
944
            if (!array_key_exists($k, $left)) {
945
                $left[$k] = $rv;
946
                continue;
947
            }
948
949
            $lv = $left[$k];
950
951
            // Both arrays: deep merge
952
            if (is_array($lv) && is_array($rv)) {
953
                $left[$k] = $this->mergeMetrics($lv, $rv, $path);
954
                continue;
955
            }
956
957
            // Both numeric: sum
958
            if (is_numeric($lv) && is_numeric($rv)) {
959
                $left[$k] = 0 + $lv + $rv;
960
                continue;
961
            }
962
963
            // totals.* seconds heuristic
964
            $isTotalsSeconds = (str_starts_with($path, 'totals.') && str_ends_with($k, '_seconds'));
965
            if ($isTotalsSeconds && is_numeric($lv) && is_numeric($rv)) {
966
                $left[$k] = 0 + $lv + $rv;
967
                continue;
968
            }
969
970
            // Fallback: keep the most recent non-empty
971
            $left[$k] = $rv !== null && $rv !== '' ? $rv : $lv;
972
        }
973
974
        return $left;
975
    }
976
977
    /**
978
     * Save a participant in a meeting room
979
     *
980
     * @param int $meetingId
981
     * @param int $participantId
982
     *
983
     * @return false|int The last inserted ID. Otherwise return false
984
     */
985
    public function saveParticipant(int $meetingId, int $participantId): false|int
986
    {
987
        $em = Database::getManager();
988
989
        /** @var ConferenceActivityRepository $repo */
990
        $repo = $em->getRepository(ConferenceActivity::class);
991
992
        $meeting = $em->getRepository(ConferenceMeeting::class)->find($meetingId);
993
        $user = api_get_user_entity($participantId);
994
995
        if (!$meeting || !$user) {
996
            return false;
997
        }
998
999
        $existing = $repo->createQueryBuilder('a')
1000
            ->where('a.meeting = :meeting')
1001
            ->andWhere('a.participant = :participant')
1002
            ->andWhere('a.close = :open')
1003
            ->setParameter('meeting', $meeting)
1004
            ->setParameter('participant', $user)
1005
            ->setParameter('open', \BbbPlugin::ROOM_OPEN)
1006
            ->getQuery()
1007
            ->getResult();
1008
1009
        foreach ($existing as $activity) {
1010
            if ($activity->getInAt() != $activity->getOutAt()) {
1011
                $activity->setClose(\BbbPlugin::ROOM_CLOSE);
1012
            } else {
1013
                $activity->setOutAt(new \DateTime());
1014
                $activity->setClose(\BbbPlugin::ROOM_CLOSE);
1015
            }
1016
            $em->persist($activity);
1017
        }
1018
1019
        $newActivity = new ConferenceActivity();
1020
        $newActivity->setMeeting($meeting);
1021
        $newActivity->setParticipant($user);
1022
        $newActivity->setInAt(new \DateTime());
1023
        $newActivity->setOutAt(new \DateTime());
1024
        $newActivity->setClose(\BbbPlugin::ROOM_OPEN);
1025
1026
        $em->persist($newActivity);
1027
        $em->flush();
1028
1029
        return $newActivity->getId();
1030
    }
1031
1032
    /**
1033
     * Tells whether the given meeting exists and is running
1034
     * (using course code as name)
1035
     *
1036
     * @param string $meetingName Meeting name (usually the course code)
1037
     *
1038
     * @return bool True if meeting exists, false otherwise
1039
     * @assert ('') === false
1040
     * @assert ('abcdefghijklmnopqrstuvwxyzabcdefghijklmno') === false
1041
     */
1042
    public function meetingExists($meetingName)
1043
    {
1044
        $meetingData = $this->getMeetingByName($meetingName);
1045
1046
        return !empty($meetingData);
1047
    }
1048
1049
    /**
1050
     * @param string $meetingName
1051
     *
1052
     * @return array
1053
     */
1054
    public function getMeetingByName($meetingName)
1055
    {
1056
        if (empty($meetingName)) {
1057
            return [];
1058
        }
1059
1060
        $courseEntity = api_get_course_entity();
1061
        $sessionEntity = api_get_session_entity();
1062
        $accessUrlEntity = api_get_url_entity($this->accessUrl);
1063
1064
        $criteria = [
1065
            'course' => $courseEntity,
1066
            'session' => $sessionEntity,
1067
            'title' => $meetingName,
1068
            'status' => 1,
1069
            'accessUrl' => $accessUrlEntity,
1070
        ];
1071
1072
        if ($this->hasGroupSupport()) {
1073
            $groupEntity = api_get_group_entity(api_get_group_id());
1074
            $criteria['group'] = $groupEntity;
1075
        }
1076
1077
        $meeting = Database::getManager()
1078
            ->getRepository(ConferenceMeeting::class)
1079
            ->findOneBy($criteria);
1080
1081
        if ($this->debug) {
1082
            error_log('meeting_exists '.print_r($meeting ? ['id' => $meeting->getId()] : [], 1));
0 ignored issues
show
Bug introduced by
Are you sure print_r($meeting ? array...>getId()) : array(), 1) of type string|true can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1082
            error_log('meeting_exists './** @scrutinizer ignore-type */ print_r($meeting ? ['id' => $meeting->getId()] : [], 1));
Loading history...
1083
        }
1084
1085
        if (!$meeting) {
1086
            return [];
1087
        }
1088
1089
        return [
1090
            'id' => $meeting->getId(),
1091
            'c_id' => $meeting->getCourse()?->getId(),
1092
            'session_id' => $meeting->getSession()?->getId(),
1093
            'meeting_name' => $meeting->getTitle(),
1094
            'status' => $meeting->getStatus(),
1095
            'access_url' => $meeting->getAccessUrl()?->getId(),
1096
            'group_id' => $meeting->getGroup()?->getIid(),
1097
            'remote_id' => $meeting->getRemoteId(),
1098
            'moderator_pw' => $meeting->getModeratorPw(),
1099
            'attendee_pw' => $meeting->getAttendeePw(),
1100
            'created_at' => $meeting->getCreatedAt()->format('Y-m-d H:i:s'),
1101
            'closed_at' => $meeting->getClosedAt()?->format('Y-m-d H:i:s'),
1102
            'visibility' => $meeting->getVisibility(),
1103
            'video_url' => $meeting->getVideoUrl(),
1104
            'has_video_m4v' => $meeting->isHasVideoM4v(),
1105
            'record' => $meeting->isRecord(),
1106
            'internal_meeting_id' => $meeting->getInternalMeetingId(),
1107
        ];
1108
    }
1109
1110
    /**
1111
     * Gets a list from the database of all meetings attached to a course with the given status
1112
     * @param int $courseId
1113
     * @param int $sessionId
1114
     * @param int $status 0 for closed meetings, 1 for open meetings
1115
     *
1116
     * @return array
1117
     */
1118
    public function getAllMeetingsInCourse($courseId, $sessionId, $status)
1119
    {
1120
        $em = Database::getManager();
1121
        $courseEntity = api_get_course_entity($courseId);
1122
        $sessionEntity = api_get_session_entity($sessionId);
1123
1124
        $meetings = $em->getRepository(ConferenceMeeting::class)->findBy([
1125
            'course' => $courseEntity,
1126
            'session' => $sessionEntity,
1127
            'status' => $status,
1128
        ]);
1129
1130
        $results = [];
1131
        foreach ($meetings as $meeting) {
1132
            $results[] = [
1133
                'id' => $meeting->getId(),
1134
                'c_id' => $meeting->getCourse()?->getId(),
1135
                'session_id' => $meeting->getSession()?->getId(),
1136
                'meeting_name' => $meeting->getTitle(),
1137
                'status' => $meeting->getStatus(),
1138
                'access_url' => $meeting->getAccessUrl()?->getId(),
1139
                'group_id' => $meeting->getGroup()?->getIid(),
1140
                'remote_id' => $meeting->getRemoteId(),
1141
                'moderator_pw' => $meeting->getModeratorPw(),
1142
                'attendee_pw' => $meeting->getAttendeePw(),
1143
                'created_at' => $meeting->getCreatedAt()->format('Y-m-d H:i:s'),
1144
                'closed_at' => $meeting->getClosedAt()?->format('Y-m-d H:i:s'),
1145
                'visibility' => $meeting->getVisibility(),
1146
                'video_url' => $meeting->getVideoUrl(),
1147
                'has_video_m4v' => $meeting->isHasVideoM4v(),
1148
                'record' => $meeting->isRecord(),
1149
                'internal_meeting_id' => $meeting->getInternalMeetingId(),
1150
            ];
1151
        }
1152
1153
        return $results;
1154
    }
1155
1156
    /**
1157
     * Gets all the course meetings saved in the plugin_bbb_meeting table and
1158
     * generate actionable links (join/close/delete/etc)
1159
     *
1160
     * @param int   $courseId
1161
     * @param int   $sessionId
1162
     * @param int   $groupId
1163
     * @param bool  $isAdminReport Optional. Set to true then the report is for admins
1164
     * @param array $dateRange     Optional
1165
     *
1166
     * @return array Array of current open meeting rooms
1167
     * @throws Exception
1168
     */
1169
    public function getMeetings(
1170
        $courseId = 0,
1171
        $sessionId = 0,
1172
        $groupId = 0,
1173
        $isAdminReport = false,
1174
        $dateRange = []
1175
    ) {
1176
        if (!$this->ensureApi()) {
1177
            if ($this->debug) {
1178
                error_log('BBB API not initialized in getMeetings().');
1179
            }
1180
            return [];
1181
        }
1182
1183
        $em = Database::getManager();
1184
        $repo = $em->getRepository(ConferenceMeeting::class);
1185
        $manager = $this->isConferenceManager();
1186
        $isGlobal = $this->isGlobalConference();
1187
        $meetings = [];
1188
1189
        if (!empty($dateRange)) {
1190
            $dateStart = (new \DateTime($dateRange['search_meeting_start']))->setTime(0, 0, 0);
1191
            $dateEnd = (new \DateTime($dateRange['search_meeting_end']))->setTime(23, 59, 59);
1192
            $meetings = $repo->findByDateRange($dateStart, $dateEnd);
1193
        } elseif ($this->isGlobalConference()) {
1194
            $meetings = $repo->findBy([
1195
                'course' => null,
1196
                'user' => api_get_user_entity($this->userId),
1197
                'accessUrl' => api_get_url_entity($this->accessUrl),
1198
            ]);
1199
        } elseif ($this->isGlobalConferencePerUserEnabled()) {
1200
            $meetings = $repo->findBy([
1201
                'course' => api_get_course_entity($courseId),
1202
                'session' => api_get_session_entity($sessionId),
1203
                'user' => api_get_user_entity($this->userId),
1204
                'accessUrl' => api_get_url_entity($this->accessUrl),
1205
            ]);
1206
        } else {
1207
            $criteria = [
1208
                'course' => api_get_course_entity($courseId),
1209
                'session' => api_get_session_entity($sessionId),
1210
                'accessUrl' => api_get_url_entity($this->accessUrl),
1211
            ];
1212
            if ($this->hasGroupSupport() && $groupId) {
1213
                $criteria['group'] = api_get_group_entity($groupId);
1214
            }
1215
            $meetings = $repo->findBy($criteria, ['createdAt' => 'ASC']);
1216
        }
1217
1218
        $result = [];
1219
        foreach ($meetings as $meeting) {
1220
            $meetingArray = $this->convertMeetingToArray($meeting);
1221
            $recordLink = $this->plugin->get_lang('NoRecording');
1222
            $meetingBBB = $this->getMeetingInfo([
1223
                'meetingId' => $meeting->getRemoteId(),
1224
                'password' => $manager ? $meeting->getModeratorPw() : $meeting->getAttendeePw(),
1225
            ]);
1226
1227
            if (!$meetingBBB && $meeting->getId()) {
1228
                $meetingBBB = $this->getMeetingInfo([
1229
                    'meetingId' => $meeting->getId(),
1230
                    'password' => $manager ? $meeting->getModeratorPw() : $meeting->getAttendeePw(),
1231
                ]);
1232
            }
1233
1234
            if (!$meeting->isVisible() && !$manager) {
1235
                continue;
1236
            }
1237
1238
            $meetingBBB['end_url'] = $this->endUrl(['id' => $meeting->getId()]);
1239
            if (isset($meetingBBB['returncode']) && (string) $meetingBBB['returncode'] === 'FAILED') {
1240
                if ($meeting->getStatus() === 1 && $manager) {
1241
                    $this->endMeeting($meeting->getId(), $meeting->getCourse()?->getCode());
1242
                }
1243
            } else {
1244
                $meetingBBB['add_to_calendar_url'] = $this->addToCalendarUrl($meetingArray);
1245
            }
1246
1247
            if ($meeting->isRecord()) {
1248
                $recordings = $this->api->getRecordingsWithXmlResponseArray(['meetingId' => $meeting->getRemoteId()]);
1249
                if (!empty($recordings) && (!isset($recordings['messageKey']) || $recordings['messageKey'] !== 'noRecordings')) {
1250
                    $record = end($recordings);
1251
                    if (isset($record['playbackFormat'])) {
1252
                        $recordLink = array();
1253
                        foreach ($record['playbackFormat'] as $format) {
1254
                            $this->insertMeetingFormat(intval($meeting->getId()), $format->type->__toString(), $format->url->__toString());
1255
                            $recordLink['record'][] = 1;
1256
                            $recordLink[] = Display::url(
1257
                                $this->plugin->get_lang($format->type->__toString()),
1258
                                $format->url->__toString(),
1259
                                ['target' => '_blank', 'class' => 'btn btn--plain']
1260
                            );
1261
                        }
1262
                        $this->updateMeetingVideoUrl($meeting->getId(), $record['playbackFormatUrl']);
1263
                    }
1264
                }
1265
            }
1266
1267
            $actionLinks = $this->getActionLinks($meetingArray, $record ?? [], $isGlobal, $isAdminReport);
1268
1269
            $item = array_merge($meetingArray, [
1270
                'go_url' => '',
1271
                'show_links' => $recordLink,
1272
                'action_links' => implode(PHP_EOL, $actionLinks),
1273
                'publish_url' => $this->publishUrl(['id' => $meeting->getId()]),
1274
                'unpublish_url' => $this->unPublishUrl(['id' => $meeting->getId()]),
1275
            ]);
1276
1277
            if ($meeting->getStatus() === 1) {
1278
                $joinParams = [
1279
                    'meetingId' => $meeting->getRemoteId(),
1280
                    'username' => $this->userCompleteName,
1281
                    'password' => $manager ? $meeting->getModeratorPw() : $meeting->getAttendeePw(),
1282
                    'createTime' => '',
1283
                    'userID' => '',
1284
                    'webVoiceConf' => '',
1285
                ];
1286
                $item['go_url'] = $this->protocol.$this->api->getJoinMeetingURL($joinParams);
1287
            }
1288
1289
            $result[] = array_merge($item, $meetingBBB);
1290
        }
1291
1292
        return $result;
1293
    }
1294
    
1295
    /**
1296
     * @param int $meetingId
1297
     * @param string $formatType
1298
     * @param string $resourceUrl
1299
     *
1300
     * @return bool|int
1301
     */
1302
    public function insertMeetingFormat(int $meetingId, string $formatType, string $resourceUrl)
1303
    {
1304
        $em = Database::getManager();
1305
        $sm = $em->getConnection()->getSchemaManager();
1306
        if ($sm->tablesExist('conference_recording')) {
1307
            $params = [
1308
                'format_type = ? and meeting_id = ? and resource_url = ?' => [$formatType, $meetingId, $resourceUrl],
1309
            ];
1310
            $result = Database::select(
1311
                'id',
1312
                'conference_recording',
1313
                ['where' => $params]
1314
            );
1315
            if (empty($result)) {
1316
                return Database::insert(
1317
                    'conference_recording',
1318
                    [
1319
                        'format_type' => $formatType,
1320
                        'resource_url' => $resourceUrl,
1321
                        'meeting_id' => $meetingId
1322
                    ]
1323
                );
1324
            }
1325
        }
1326
    }
1327
1328
    private function convertMeetingToArray(ConferenceMeeting $meeting): array
1329
    {
1330
        return [
1331
            'id' => $meeting->getId(),
1332
            'remote_id' => $meeting->getRemoteId(),
1333
            'internal_meeting_id' => $meeting->getInternalMeetingId(),
1334
            'meeting_name' => $meeting->getTitle(),
1335
            'status' => $meeting->getStatus(),
1336
            'visibility' => $meeting->getVisibility(),
1337
            'created_at' => $meeting->getCreatedAt() instanceof \DateTime ? $meeting->getCreatedAt()->format('Y-m-d H:i:s') : '',
0 ignored issues
show
introduced by
$meeting->getCreatedAt() is always a sub-type of DateTime.
Loading history...
1338
            'closed_at' => $meeting->getClosedAt() instanceof \DateTime ? $meeting->getClosedAt()->format('Y-m-d H:i:s') : '',
1339
            'record' => $meeting->isRecord() ? 1 : 0,
1340
            'c_id' => $meeting->getCourse()?->getId() ?? 0,
1341
            'session_id' => $meeting->getSession()?->getId() ?? 0,
1342
            'group_id' => $meeting->getGroup()?->getIid() ?? 0,
1343
            'course' => $meeting->getCourse(),
1344
            'session' => $meeting->getSession(),
1345
            'title' => $meeting->getTitle(),
1346
        ];
1347
    }
1348
1349
    public function getMeetingsLight(
1350
        $courseId = 0,
1351
        $sessionId = 0,
1352
        $groupId = 0,
1353
        $dateRange = []
1354
    ): array {
1355
        $em = Database::getManager();
1356
        $repo = $em->getRepository(ConferenceMeeting::class);
1357
        $meetings = [];
1358
1359
        if (!empty($dateRange)) {
1360
            $dateStart = (new \DateTime($dateRange['search_meeting_start']))->setTime(0, 0, 0);
1361
            $dateEnd = (new \DateTime($dateRange['search_meeting_end']))->setTime(23, 59, 59);
1362
            $meetings = $repo->findByDateRange($dateStart, $dateEnd);
1363
        } else {
1364
            $criteria = [
1365
                'course' => api_get_course_entity($courseId),
1366
                'session' => api_get_session_entity($sessionId),
1367
                'accessUrl' => api_get_url_entity($this->accessUrl),
1368
            ];
1369
            if ($this->hasGroupSupport() && $groupId) {
1370
                $criteria['group'] = api_get_group_entity($groupId);
1371
            }
1372
            $meetings = $repo->findBy($criteria, ['createdAt' => 'DESC']);
1373
        }
1374
1375
        $result = [];
1376
        foreach ($meetings as $meeting) {
1377
            $meetingArray = $this->convertMeetingToArray($meeting);
1378
1379
            $item = array_merge($meetingArray, [
1380
                'go_url' => '',
1381
                'show_links' => $this->plugin->get_lang('NoRecording'),
1382
                'action_links' => '',
1383
                'publish_url' => $this->publishUrl(['id' => $meeting->getId()]),
1384
                'unpublish_url' => $this->unPublishUrl(['id' => $meeting->getId()]),
1385
            ]);
1386
1387
            $result[] = $item;
1388
        }
1389
1390
        return $result;
1391
    }
1392
1393
    /**
1394
     * @param array $meeting
1395
     *
1396
     * @return string
1397
     */
1398
    public function endUrl($meeting)
1399
    {
1400
        if (!isset($meeting['id'])) {
1401
            return '';
1402
        }
1403
1404
        return api_get_path(WEB_PLUGIN_PATH).'Bbb/listing.php?'.$this->getUrlParams().'&action=end&id='.$meeting['id'];
1405
    }
1406
1407
    /**
1408
     * Closes a meeting (usually when the user click on the close button from
1409
     * the conferences listing.
1410
     *
1411
     * @param string The internal ID of the meeting (id field for this meeting)
1412
     * @param string $courseCode
1413
     *
1414
     * @return void
1415
     * @assert (0) === false
1416
     */
1417
    public function endMeeting($id, $courseCode = null)
1418
    {
1419
        if (empty($id)) {
1420
            return false;
1421
        }
1422
1423
        $em = Database::getManager();
1424
1425
        /** @var ConferenceMeetingRepository $repo */
1426
        $repo = $em->getRepository(ConferenceMeeting::class);
1427
1428
        $meetingData = $repo->findOneAsArrayById((int) $id);
1429
        if (!$meetingData) {
1430
            return false;
1431
        }
1432
1433
        $manager = $this->isConferenceManager();
1434
        $pass = $manager ? $meetingData['moderatorPw'] : $meetingData['attendeePw'];
1435
1436
        Event::addEvent(
1437
            'bbb_end_meeting',
1438
            'meeting_id',
1439
            (int) $id,
1440
            null,
1441
            api_get_user_id(),
1442
            api_get_course_int_id(),
1443
            api_get_session_id()
1444
        );
1445
1446
        $endParams = [
1447
            'meetingId' => $meetingData['remoteId'],
1448
            'password' => $pass,
1449
        ];
1450
        $this->api->endMeetingWithXmlResponseArray($endParams);
1451
1452
        $repo->closeMeeting((int) $id, new \DateTime());
1453
1454
        /** @var ConferenceActivityRepository $activityRepo */
1455
        $activityRepo = $em->getRepository(ConferenceActivity::class);
1456
1457
        $activities = $activityRepo->findOpenWithSameInAndOutTime((int) $id);
1458
1459
        foreach ($activities as $activity) {
1460
            $activity->setOutAt(new \DateTime());
1461
            $activity->setClose(BbbPlugin::ROOM_CLOSE);
1462
            $em->persist($activity);
1463
        }
1464
1465
        $activityRepo->closeAllByMeetingId((int) $id);
1466
1467
        $em->flush();
1468
1469
        return true;
1470
    }
1471
1472
    /**
1473
     * @param array $meeting
1474
     * @param array $record
1475
     *
1476
     * @return string
1477
     */
1478
    public function addToCalendarUrl($meeting, $record = []): string
1479
    {
1480
        $url = isset($record['playbackFormatUrl']) ? $record['playbackFormatUrl'] : '';
1481
1482
        return api_get_path(WEB_PLUGIN_PATH).'Bbb/listing.php?'.$this->getUrlParams(
1483
            ).'&action=add_to_calendar&id='.$meeting['id'].'&start='.api_strtotime($meeting['created_at']).'&url='.$url;
1484
    }
1485
1486
    /**
1487
     * @param int    $meetingId
1488
     * @param string $videoUrl
1489
     *
1490
     * @return bool|int
1491
     */
1492
    public function updateMeetingVideoUrl(int $meetingId, string $videoUrl): void
1493
    {
1494
        $em = Database::getManager();
1495
        /** @var ConferenceMeetingRepository $repo */
1496
        $repo = $em->getRepository(ConferenceMeeting::class);
1497
        $repo->updateVideoUrl($meetingId, $videoUrl);
1498
    }
1499
1500
    /**
1501
     * Force the course, session and/or group IDs
1502
     *
1503
     * @param string $courseCode
1504
     * @param int    $sessionId
1505
     * @param int    $groupId
1506
     */
1507
    public function forceCIdReq($courseCode, $sessionId = 0, $groupId = 0)
1508
    {
1509
        $this->courseCode = $courseCode;
1510
        $this->sessionId = (int) $sessionId;
1511
        $this->groupId = (int) $groupId;
1512
    }
1513
1514
    /**
1515
     * @param array $meetingInfo
1516
     * @param array $recordInfo
1517
     * @param bool  $isGlobal
1518
     * @param bool  $isAdminReport
1519
     *
1520
     * @return array
1521
     */
1522
    private function getActionLinks(
1523
        $meetingInfo,
1524
        $recordInfo,
1525
        $isGlobal = false,
1526
        $isAdminReport = false
1527
    ) {
1528
        $isVisible = $meetingInfo['visibility'] != 0;
1529
        $linkVisibility = $isVisible
1530
            ? Display::url(
1531
                Display::getMdiIcon(StateIcon::ACTIVE, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Make invisible')),
1532
                $this->unPublishUrl($meetingInfo)
1533
            )
1534
            : Display::url(
1535
                Display::getMdiIcon(StateIcon::INACTIVE, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Make visible')),
1536
                $this->publishUrl($meetingInfo)
1537
            );
1538
1539
        $links = [];
1540
        if ($this->plugin->get('allow_regenerate_recording') === 'true' && $meetingInfo['record'] == 1) {
1541
            if (!empty($recordInfo)) {
1542
                $links[] = Display::url(
1543
                    Display::getMdiIcon(ActionIcon::REFRESH, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('RegenerateRecord')),
1544
                    $this->regenerateRecordUrl($meetingInfo, $recordInfo)
1545
                );
1546
            } else {
1547
                $links[] = Display::url(
1548
                    Display::getMdiIcon(ActionIcon::REFRESH, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('RegenerateRecord')),
1549
                    $this->regenerateRecordUrlFromMeeting($meetingInfo)
1550
                );
1551
            }
1552
        }
1553
1554
        if (empty($recordInfo)) {
1555
            if (!$isAdminReport) {
1556
                if ($meetingInfo['status'] == 0) {
1557
                    $links[] = Display::url(
1558
                        Display::getMdiIcon(ActionIcon::DELETE, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Delete')),
1559
                        $this->deleteRecordUrl($meetingInfo)
1560
                    );
1561
                    $links[] = $linkVisibility;
1562
                }
1563
1564
                return $links;
1565
            } else {
1566
                $links[] = Display::url(
1567
                    Display::getMdiIcon(ObjectIcon::HOME, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Go to the course')),
1568
                    $this->getListingUrl($meetingInfo['c_id'], $meetingInfo['session_id'], $meetingInfo['group_id'])
1569
                );
1570
1571
                return $links;
1572
            }
1573
        }
1574
1575
        if (!$isGlobal) {
1576
            $links[] = Display::url(
1577
                Display::getMdiIcon(ObjectIcon::LINK, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('UrlMeetingToShare')),
1578
                $this->copyToRecordToLinkTool($meetingInfo)
1579
            );
1580
            $links[] = Display::url(
1581
                Display::getMdiIcon(ObjectIcon::AGENDA, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Add to calendar')),
1582
                $this->addToCalendarUrl($meetingInfo, $recordInfo)
1583
            );
1584
        }
1585
1586
        $hide = $this->plugin->get('disable_download_conference_link') === 'true' ? true : false;
1587
1588
        if ($hide == false) {
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like you are loosely comparing two booleans. Considering using the strict comparison === instead.

When comparing two booleans, it is generally considered safer to use the strict comparison operator.

Loading history...
1589
            if ($meetingInfo['has_video_m4v']) {
1590
                $links[] = Display::url(
1591
                    Display::getMdiIcon(ActionIcon::SAVE_FORM, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Download file')),
1592
                    $recordInfo['playbackFormatUrl'].'/capture.m4v',
1593
                    ['target' => '_blank']
1594
                );
1595
            } else {
1596
                $links[] = Display::url(
1597
                    Display::getMdiIcon(ActionIcon::SAVE_FORM, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Download file')),
1598
                    '#',
1599
                    [
1600
                        'id' => "btn-check-meeting-video-{$meetingInfo['id']}",
1601
                        'class' => 'check-meeting-video',
1602
                        'data-id' => $meetingInfo['id'],
1603
                    ]
1604
                );
1605
            }
1606
        }
1607
1608
1609
        if (!$isAdminReport) {
1610
            $links[] = Display::url(
1611
                Display::getMdiIcon(ActionIcon::DELETE, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Delete')),
1612
                $this->deleteRecordUrl($meetingInfo)
1613
            );
1614
            $links[] = $linkVisibility;
1615
        } else {
1616
            $links[] = Display::url(
1617
                Display::getMdiIcon(ObjectIcon::HOME, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Go to the course')),
1618
                $this->getListingUrl($meetingInfo['c_id'], $meetingInfo['session_id'], $meetingInfo['group_id'])
1619
            );
1620
        }
1621
1622
1623
        return $links;
1624
    }
1625
1626
    /**
1627
     * @param array $meeting
1628
     *
1629
     * @return string
1630
     */
1631
    public function unPublishUrl($meeting)
1632
    {
1633
        if (!isset($meeting['id'])) {
1634
            return null;
1635
        }
1636
1637
        return api_get_path(WEB_PLUGIN_PATH).'Bbb/listing.php?'.$this->getUrlParams(
1638
            ).'&action=unpublish&id='.$meeting['id'];
1639
    }
1640
1641
    /**
1642
     * @param array $meeting
1643
     *
1644
     * @return string
1645
     */
1646
    public function publishUrl($meeting)
1647
    {
1648
        if (!isset($meeting['id'])) {
1649
            return '';
1650
        }
1651
1652
        return api_get_path(WEB_PLUGIN_PATH).'Bbb/listing.php?'.$this->getUrlParams(
1653
            ).'&action=publish&id='.$meeting['id'];
1654
    }
1655
1656
    /**
1657
     * @param array $meeting
1658
     * @param array $recordInfo
1659
     *
1660
     * @return string
1661
     */
1662
    public function regenerateRecordUrl($meeting, $recordInfo)
1663
    {
1664
        if ($this->plugin->get('allow_regenerate_recording') !== 'true') {
1665
            return '';
1666
        }
1667
1668
        if (!isset($meeting['id'])) {
1669
            return '';
1670
        }
1671
1672
        if (empty($recordInfo) || (!empty($recordInfo['recordId']) && !isset($recordInfo['recordId']))) {
1673
            return '';
1674
        }
1675
1676
        return api_get_path(WEB_PLUGIN_PATH).'Bbb/listing.php?'.$this->getUrlParams().
1677
            '&action=regenerate_record&id='.$meeting['id'].'&record_id='.$recordInfo['recordId'];
1678
    }
1679
1680
    /**
1681
     * @param array $meeting
1682
     *
1683
     * @return string
1684
     */
1685
    public function regenerateRecordUrlFromMeeting($meeting)
1686
    {
1687
        if ($this->plugin->get('allow_regenerate_recording') !== 'true') {
1688
            return '';
1689
        }
1690
1691
        if (!isset($meeting['id'])) {
1692
            return '';
1693
        }
1694
1695
        return api_get_path(WEB_PLUGIN_PATH).'Bbb/listing.php?'.$this->getUrlParams().
1696
            '&action=regenerate_record&id='.$meeting['id'];
1697
    }
1698
1699
    /**
1700
     * @param array $meeting
1701
     *
1702
     * @return string
1703
     */
1704
    public function deleteRecordUrl($meeting)
1705
    {
1706
        if (!isset($meeting['id'])) {
1707
            return '';
1708
        }
1709
1710
        return api_get_path(WEB_PLUGIN_PATH).'Bbb/listing.php?'.$this->getUrlParams(
1711
            ).'&action=delete_record&id='.$meeting['id'];
1712
    }
1713
1714
    /**
1715
     * @param array $meeting
1716
     *
1717
     * @return string
1718
     */
1719
    public function copyToRecordToLinkTool($meeting)
1720
    {
1721
        if (!isset($meeting['id'])) {
1722
            return '';
1723
        }
1724
1725
        return api_get_path(WEB_PLUGIN_PATH).
1726
            'Bbb/listing.php?'.$this->getUrlParams().'&action=copy_record_to_link_tool&id='.$meeting['id'];
1727
    }
1728
1729
    /**
1730
     * Function disabled
1731
     */
1732
    public function publishMeeting($id)
1733
    {
1734
        if (empty($id)) {
1735
            return false;
1736
        }
1737
1738
        $em = Database::getManager();
1739
        /** @var ConferenceMeetingRepository $repo */
1740
        $repo = $em->getRepository(ConferenceMeeting::class);
1741
1742
        $meeting = $repo->find($id);
1743
        if (!$meeting) {
1744
            return false;
1745
        }
1746
1747
        $meeting->setVisibility(1);
1748
        $em->flush();
1749
1750
        return true;
1751
    }
1752
1753
    /**
1754
     * Function disabled
1755
     */
1756
    public function unpublishMeeting($id)
1757
    {
1758
        if (empty($id)) {
1759
            return false;
1760
        }
1761
1762
        $em = Database::getManager();
1763
        /** @var ConferenceMeetingRepository $repo */
1764
        $repo = $em->getRepository(ConferenceMeeting::class);
1765
1766
        $meeting = $repo->find($id);
1767
        if (!$meeting) {
1768
            return false;
1769
        }
1770
1771
        $meeting->setVisibility(0);
1772
        $em->flush();
1773
1774
        return true;
1775
    }
1776
1777
    /**
1778
     * Get users online in the current course room.
1779
     *
1780
     * @return int The number of users currently connected to the videoconference
1781
     * @assert () > -1
1782
     */
1783
    public function getUsersOnlineInCurrentRoom()
1784
    {
1785
        $courseId = api_get_course_int_id();
1786
        $sessionId = api_get_session_id();
1787
1788
        $em = Database::getManager();
1789
        $repo = $em->getRepository(ConferenceMeeting::class);
1790
1791
        $qb = $repo->createQueryBuilder('m')
1792
            ->where('m.status = 1')
1793
            ->andWhere('m.accessUrl = :accessUrl')
1794
            ->setParameter('accessUrl', $this->accessUrl)
1795
            ->setMaxResults(1);
1796
1797
        if ($this->hasGroupSupport()) {
1798
            $groupId = api_get_group_id();
1799
            $qb->andWhere('m.course = :courseId')
1800
                ->andWhere('m.session = :sessionId')
1801
                ->andWhere('m.group = :groupId')
1802
                ->setParameter('courseId', $courseId)
1803
                ->setParameter('sessionId', $sessionId)
1804
                ->setParameter('groupId', $groupId);
1805
        } elseif ($this->isGlobalConferencePerUserEnabled()) {
1806
            $qb->andWhere('m.user = :userId')
1807
                ->setParameter('userId', $this->userId);
1808
        } else {
1809
            $qb->andWhere('m.course = :courseId')
1810
                ->andWhere('m.session = :sessionId')
1811
                ->setParameter('courseId', $courseId)
1812
                ->setParameter('sessionId', $sessionId);
1813
        }
1814
1815
        $meetingData = $qb->getQuery()->getOneOrNullResult();
1816
1817
        if (!$meetingData) {
1818
            return 0;
1819
        }
1820
        $pass = $meetingData->getModeratorPw();
1821
        $info = $this->getMeetingInfo([
1822
            'meetingId' => $meetingData->getRemoteId(),
1823
            'password' => $pass,
1824
        ]);
1825
        if ($info === false) {
1826
            $info = $this->getMeetingInfo([
1827
                'meetingId' => $meetingData->getId(),
1828
                'password' => $pass,
1829
            ]);
1830
        }
1831
1832
        if (!empty($info) && isset($info['participantCount'])) {
1833
            return (int) $info['participantCount'];
1834
        }
1835
1836
        return 0;
1837
    }
1838
1839
    /**
1840
     * @param int    $id
1841
     * @param string $recordId
1842
     *
1843
     * @return bool
1844
     */
1845
    public function regenerateRecording($id, $recordId = '')
1846
    {
1847
        if ($this->plugin->get('allow_regenerate_recording') !== 'true') {
1848
            return false;
1849
        }
1850
1851
        if (empty($id)) {
1852
            return false;
1853
        }
1854
1855
        $em = Database::getManager();
1856
        /** @var ConferenceMeetingRepository $repo */
1857
        $repo = $em->getRepository(ConferenceMeeting::class);
1858
1859
        $meetingData = $repo->findOneAsArrayById((int) $id);
1860
        if (!$meetingData) {
1861
            return false;
1862
        }
1863
1864
        Event::addEvent(
1865
            'bbb_regenerate_record',
1866
            'record_id',
1867
            (int) $recordId,
1868
            null,
1869
            api_get_user_id(),
1870
            api_get_course_int_id(),
1871
            api_get_session_id()
1872
        );
1873
1874
        /** @var ConferenceRecordingRepository $recordingRepo */
1875
        $recordingRepo = $em->getRepository(ConferenceRecordingRepository::class);
1876
        $recordings = $recordingRepo->findByMeetingRemoteId($meetingData['remoteId']);
1877
1878
        if (!empty($recordings) && isset($recordings['messageKey']) && $recordings['messageKey'] === 'noRecordings') {
1879
            if (!empty($meetingData['internalMeetingId'])) {
1880
                return $this->api->generateRecording(['recordId' => $meetingData['internalMeetingId']]);
1881
            }
1882
1883
            return false;
1884
        }
1885
1886
        if (!empty($recordings['records'])) {
1887
            foreach ($recordings['records'] as $record) {
1888
                if ($recordId == $record['recordId']) {
1889
                    return $this->api->generateRecording(['recordId' => $recordId]);
1890
                }
1891
            }
1892
        }
1893
1894
        return false;
1895
    }
1896
1897
    /**
1898
     * Deletes a recording of a meeting
1899
     *
1900
     * @param int $id ID of the recording
1901
     *
1902
     * @return bool
1903
     *
1904
     * @assert () === false
1905
     * @todo Also delete links and agenda items created from this recording
1906
     */
1907
    public function deleteRecording($id)
1908
    {
1909
        if (empty($id)) {
1910
            return false;
1911
        }
1912
1913
        $em = Database::getManager();
1914
1915
        /** @var ConferenceMeetingRepository $meetingRepo */
1916
        $meetingRepo = $em->getRepository(ConferenceMeeting::class);
1917
        $meetingData = $meetingRepo->findOneAsArrayById((int) $id);
1918
        if (!$meetingData) {
1919
            return false;
1920
        }
1921
1922
        Event::addEvent(
1923
            'bbb_delete_record',
1924
            'meeting_id',
1925
            $id,
1926
            null,
1927
            api_get_user_id(),
1928
            api_get_course_int_id(),
1929
            api_get_session_id()
1930
        );
1931
1932
        $delete = false;
1933
        $recordings = [];
1934
1935
        if (!empty($meetingData['remoteId'])) {
1936
            Event::addEvent(
1937
                'bbb_delete_record',
1938
                'remote_id',
1939
                $meetingData['remoteId'],
1940
                null,
1941
                api_get_user_id(),
1942
                api_get_course_int_id(),
1943
                api_get_session_id()
1944
            );
1945
1946
            /** @var ConferenceRecordingRepository $recordingRepo */
1947
            $recordingRepo = $em->getRepository(ConferenceRecording::class);
1948
            $recordings = $recordingRepo->findByMeetingRemoteId($meetingData['remoteId']);
1949
        }
1950
1951
        if (!empty($recordings) && isset($recordings['messageKey']) && $recordings['messageKey'] === 'noRecordings') {
1952
            $delete = true;
1953
        } elseif (!empty($recordings['records'])) {
1954
            $recordsToDelete = [];
1955
            foreach ($recordings['records'] as $record) {
1956
                $recordsToDelete[] = $record['recordId'];
1957
            }
1958
1959
            if (!empty($recordsToDelete)) {
1960
                $recordingParams = ['recordId' => implode(',', $recordsToDelete)];
1961
                Event::addEvent(
1962
                    'bbb_delete_record',
1963
                    'record_id_list',
1964
                    implode(',', $recordsToDelete),
1965
                    null,
1966
                    api_get_user_id(),
1967
                    api_get_course_int_id(),
1968
                    api_get_session_id()
1969
                );
1970
1971
                $result = $this->api->deleteRecordingsWithXmlResponseArray($recordingParams);
1972
1973
                if (!empty($result) && isset($result['deleted']) && $result['deleted'] === 'true') {
1974
                    $delete = true;
1975
                }
1976
            }
1977
        }
1978
1979
        if (!$delete) {
1980
            $delete = true;
1981
        }
1982
1983
        if ($delete) {
1984
            /** @var ConferenceActivityRepository $activityRepo */
1985
            $activityRepo = $em->getRepository(ConferenceActivity::class);
1986
            $activityRepo->closeAllByMeetingId((int) $id);
1987
1988
            $meeting = $meetingRepo->find((int) $id);
1989
            if ($meeting) {
1990
                $em->remove($meeting);
1991
            }
1992
1993
            $em->flush();
1994
        }
1995
1996
        return $delete;
1997
    }
1998
1999
    /**
2000
     * Creates a link in the links tool from the given videoconference recording
2001
     *
2002
     * @param int $id ID of the item in the plugin_bbb_meeting table
2003
     * @param string Hash identifying the recording, as provided by the API
2004
     *
2005
     * @return mixed ID of the newly created link, or false on error
2006
     * @assert (null, null) === false
2007
     * @assert (1, null) === false
2008
     * @assert (null, 'abcdefabcdefabcdefabcdef') === false
2009
     */
2010
    public function copyRecordingToLinkTool($id)
2011
    {
2012
        if (empty($id)) {
2013
            return false;
2014
        }
2015
2016
        $em = Database::getManager();
2017
        /** @var ConferenceMeetingRepository $repo */
2018
        $repo = $em->getRepository(ConferenceMeeting::class);
2019
2020
        $meetingData = $repo->findOneAsArrayById((int) $id);
2021
        if (!$meetingData || empty($meetingData['remoteId'])) {
2022
            return false;
2023
        }
2024
2025
        $records = $this->api->getRecordingsWithXmlResponseArray([
2026
            'meetingId' => $meetingData['remoteId']
2027
        ]);
2028
2029
        if (!empty($records)) {
2030
            if (isset($records['message']) && !empty($records['message'])) {
2031
                if ($records['messageKey'] == 'noRecordings') {
2032
                    return false;
2033
                }
2034
            } else {
2035
                $record = $records[0];
2036
                if (is_array($record) && isset($record['recordId'])) {
2037
                    $url = $record['playbackFormatUrl'];
2038
                    $link = new \Link();
2039
                    $params = [
2040
                        'url' => $url,
2041
                        'title' => $meetingData['title'],
2042
                    ];
2043
                    $id = $link->save($params);
2044
2045
                    return $id;
2046
                }
2047
            }
2048
        }
2049
2050
        return false;
2051
    }
2052
2053
    /**
2054
     * Checks if the video conference server is running.
2055
     * Function currently disabled (always returns 1)
2056
     * @return bool True if server is running, false otherwise
2057
     * @assert () === false
2058
     */
2059
    public function isServerRunning()
2060
    {
2061
        return true;
2062
        //return BigBlueButtonBN::isServerRunning($this->protocol.$this->url);
2063
    }
2064
2065
    /**
2066
     * Checks if the video conference plugin is properly configured
2067
     * @return bool True if plugin has a host and a salt, false otherwise
2068
     * @assert () === false
2069
     */
2070
    public function isServerConfigured()
2071
    {
2072
        $host = $this->plugin->get('host');
2073
2074
        if (empty($host)) {
2075
            return false;
2076
        }
2077
2078
        $salt = $this->plugin->get('salt');
2079
2080
        if (empty($salt)) {
2081
            return false;
2082
        }
2083
2084
        return true;
2085
        //return BigBlueButtonBN::isServerRunning($this->protocol.$this->url);
2086
    }
2087
2088
    /**
2089
     * Get active session in the all platform
2090
     */
2091
    public function getActiveSessionsCount(): int
2092
    {
2093
        $em = Database::getManager();
2094
        $qb = $em->createQueryBuilder();
2095
2096
        $qb->select('COUNT(m.id)')
2097
            ->from(ConferenceMeeting::class, 'm')
2098
            ->where('m.status = :status')
2099
            ->andWhere('m.accessUrl = :accessUrl')
2100
            ->setParameter('status', 1)
2101
            ->setParameter('accessUrl', $this->accessUrl);
2102
2103
        return (int) $qb->getQuery()->getSingleScalarResult();
2104
    }
2105
2106
    /**
2107
     * Get active session in the all platform
2108
     */
2109
    public function getActiveSessions(): array
2110
    {
2111
        $em = Database::getManager();
2112
        $repo = $em->getRepository(ConferenceMeeting::class);
2113
2114
        $qb = $repo->createQueryBuilder('m')
2115
            ->where('m.status = :status')
2116
            ->andWhere('m.accessUrl = :accessUrl')
2117
            ->setParameter('status', 1)
2118
            ->setParameter('accessUrl', $this->accessUrl);
2119
2120
        return $qb->getQuery()->getArrayResult();
2121
    }
2122
2123
    /**
2124
     * @param string $url
2125
     */
2126
    public function redirectToBBB($url)
2127
    {
2128
        if (file_exists(__DIR__.'/../config.vm.php')) {
2129
            // Using VM
2130
            echo Display::url($this->plugin->get_lang('ClickToContinue'), $url);
2131
            exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
2132
        } else {
2133
            // Classic
2134
            header("Location: $url");
2135
            exit;
2136
        }
2137
    }
2138
2139
    /**
2140
     * @return string
2141
     */
2142
    public function getConferenceUrl()
2143
    {
2144
        return api_get_path(WEB_PLUGIN_PATH).'Bbb/start.php?launch=1&'.$this->getUrlParams();
2145
    }
2146
2147
    /**
2148
     * Get the meeting info from DB by its name
2149
     *
2150
     * @param string $name
2151
     *
2152
     * @return array
2153
     */
2154
    public function findMeetingByName(string $name): ?array
2155
    {
2156
        $em = Database::getManager();
2157
        /** @var ConferenceMeetingRepository $repo */
2158
        $repo = $em->getRepository(ConferenceMeeting::class);
2159
2160
        // Selecciona campos que necesitamos, incluyendo remoteId
2161
        $qb = $repo->createQueryBuilder('m')
2162
            ->select('m.id AS id, m.title AS title, m.remoteId AS remote_id, m.createdAt AS created_at')
2163
            ->where('m.title = :name')
2164
            ->andWhere('m.accessUrl = :accessUrl')
2165
            ->setParameter('name', $name)
2166
            ->setParameter('accessUrl', api_get_url_entity($this->accessUrl))
2167
            ->setMaxResults(1);
2168
2169
        $row = $qb->getQuery()->getArrayResult();
2170
        if (!$row) {
2171
            return null;
2172
        }
2173
2174
        // Normalización mínima de fechas a string (opcional)
2175
        if (!empty($row[0]['created_at']) && $row[0]['created_at'] instanceof \DateTimeInterface) {
2176
            $row[0]['created_at'] = $row[0]['created_at']->format('Y-m-d H:i:s');
2177
        }
2178
2179
        return $row[0];
2180
    }
2181
2182
    /**
2183
     * Get the meeting info from DB by its name
2184
     *
2185
     * @param int $id
2186
     *
2187
     * @return array
2188
     */
2189
    public function getMeeting(int $id): ?array
2190
    {
2191
        $em = Database::getManager();
2192
        /** @var ConferenceMeetingRepository $repo */
2193
        $repo = $em->getRepository(ConferenceMeeting::class);
2194
2195
        return $repo->findOneAsArrayById($id);
2196
    }
2197
2198
    /**
2199
     * Get the meeting info.
2200
     *
2201
     * @param int $id
2202
     *
2203
     * @return array
2204
     */
2205
    public function getMeetingByRemoteId(string $id): ?array
2206
    {
2207
        $em = Database::getManager();
2208
        /** @var ConferenceMeetingRepository $repo */
2209
        $repo = $em->getRepository(ConferenceMeeting::class);
2210
2211
        return $repo->findOneByRemoteIdAndAccessUrl($id, $this->accessUrl);
2212
    }
2213
2214
    /**
2215
     * @param int $meetingId
2216
     *
2217
     * @return array
2218
     */
2219
    public function findConnectedMeetingParticipants(int $meetingId): array
2220
    {
2221
        $em = Database::getManager();
2222
        /** @var ConferenceActivityRepository $repo */
2223
        $repo = $em->getRepository(ConferenceActivity::class);
2224
2225
        $activities = $repo->createQueryBuilder('a')
2226
            ->where('a.meeting = :meetingId')
2227
            ->andWhere('a.inAt IS NOT NULL')
2228
            ->setParameter('meetingId', $meetingId)
2229
            ->getQuery()
2230
            ->getResult();
2231
2232
        $participantIds = [];
2233
        $return = [];
2234
2235
        foreach ($activities as $activity) {
2236
            $participant = $activity->getParticipant();
2237
            $participantId = $participant?->getId();
2238
2239
            if (!$participantId || in_array($participantId, $participantIds, true)) {
2240
                continue;
2241
            }
2242
            $participantIds[] = $participantId;
2243
2244
            // Reuse aggregator for each user
2245
            $agg = $this->getMeetingParticipantInfo($meetingId, $participantId);
2246
2247
            $return[] = [
2248
                'id'             => $activity->getId(),
2249
                'meeting_id'     => $meetingId,
2250
                'participant'    => api_get_user_entity($participantId),
2251
                'in_at'          => $activity->getInAt()?->format('Y-m-d H:i:s'),
2252
                'out_at'         => $activity->getOutAt()?->format('Y-m-d H:i:s'),
2253
                'online_seconds' => $agg['online_seconds'] ?? 0,
2254
                'metrics'        => $agg['metrics'] ?? [],
2255
            ];
2256
        }
2257
2258
        return $return;
2259
    }
2260
2261
    /**
2262
     * Check if the meeting has a capture.m4v video file. If exists then the has_video_m4v field is updated
2263
     *
2264
     * @param int $meetingId
2265
     *
2266
     * @return bool
2267
     */
2268
    public function checkDirectMeetingVideoUrl(int $meetingId): bool
2269
    {
2270
        $em = Database::getManager();
2271
        /** @var ConferenceMeetingRepository $repo */
2272
        $repo = $em->getRepository(ConferenceMeeting::class);
2273
2274
        $meetingInfo = $repo->findOneAsArrayById($meetingId);
2275
2276
        if (empty($meetingInfo) || !isset($meetingInfo['videoUrl'])) {
2277
            return false;
2278
        }
2279
2280
        $hasCapture = SocialManager::verifyUrl($meetingInfo['videoUrl'].'/capture.m4v');
2281
2282
        if ($hasCapture) {
2283
            $qb = $em->createQueryBuilder();
2284
            $qb->update(ConferenceMeeting::class, 'm')
2285
                ->set('m.hasVideoM4v', ':value')
2286
                ->where('m.id = :id')
2287
                ->setParameter('value', true)
2288
                ->setParameter('id', $meetingId)
2289
                ->getQuery()
2290
                ->execute();
2291
2292
            return true;
2293
        }
2294
2295
        return false;
2296
    }
2297
2298
    public static function showGlobalConferenceLink($userInfo)
2299
    {
2300
        if (empty($userInfo)) {
2301
            return false;
2302
        }
2303
2304
        $setting = api_get_plugin_setting('bbb', 'enable_global_conference');
2305
        $settingLink = api_get_plugin_setting('bbb', 'enable_global_conference_link');
2306
2307
        if ($setting === 'true' && $settingLink === 'true') {
2308
            $allowedRoles = api_get_plugin_setting('bbb', 'global_conference_allow_roles');
2309
            $allowedRoles = self::normalizeSettingToArray($allowedRoles);
2310
2311
            if (api_is_platform_admin()) {
2312
                $userInfo['status'] = PLATFORM_ADMIN;
2313
            }
2314
2315
            if (!empty($allowedRoles)) {
2316
                $needle = (string) $userInfo['status'];
2317
                $haystack = array_map('strval', $allowedRoles);
2318
2319
                if (!in_array($needle, $haystack, true)) {
2320
                    return false;
2321
                }
2322
            }
2323
2324
            return true;
2325
        }
2326
2327
        return false;
2328
    }
2329
2330
    /**
2331
     * Build the callback URL for BBB webhooks, signed with HMAC using the plugin salt.
2332
     */
2333
    private function buildWebhookCallbackUrl(?string $meetingId = null): string
2334
    {
2335
        $base = rtrim(api_get_path(WEB_PLUGIN_PATH), '/').'/Bbb/webhook.php';
2336
2337
        $au  = (int) $this->accessUrl;              // current access_url_id
2338
        $mid = (string) ($meetingId ?? '');         // meetingID (empty if global)
2339
2340
        $algo   = $this->plugin->webhooksHashAlgo(); // 'sha256' | 'sha1'
2341
        $secret = (string) $this->salt;
2342
2343
        // Stable signature without timestamp (MUST match webhook.php)
2344
        $payload = $au.'|'.$mid;
2345
        $sig     = hash_hmac($algo, $payload, $secret);
2346
2347
        $qs = [
2348
            'au'  => $au,
2349
            'mid' => $mid,
2350
            'sig' => $sig,
2351
        ];
2352
2353
        $url = $base.'?'.http_build_query($qs);
2354
2355
        error_log('[BBB hooks] callbackURL='.$url);
2356
2357
        return $url;
2358
    }
2359
2360
    /**
2361
     * Call to the BBB hooks API (GET with sha1(call+query+salt) checksum).
2362
     * Returns a normalized array. Uses cURL with timeouts and TLS verification.
2363
     */
2364
    private function bbbHooksRequest(string $call, array $params): array
2365
    {
2366
        try {
2367
            $baseRaw = $this->urlWithProtocol ?: '';
2368
            if ($baseRaw === '') {
2369
                return ['returncode' => 'FAILED', 'messageKey' => 'missingServerUrl'];
2370
            }
2371
2372
            $base = rtrim($baseRaw, '/').'/';
2373
            $apiBase = $base . 'api/';
2374
2375
            $query    = http_build_query($params);
2376
            $checksum = sha1($call . $query . $this->salt);
2377
            $url      = $apiBase . $call . '?' . $query . '&checksum=' . $checksum;
2378
2379
            error_log('[BBB hooks] CALL='.$call.' URL='.$url);
2380
2381
            $ch = curl_init($url);
2382
            curl_setopt_array($ch, [
2383
                CURLOPT_RETURNTRANSFER => true,
2384
                CURLOPT_TIMEOUT        => 8,
2385
                CURLOPT_CONNECTTIMEOUT => 3,
2386
                CURLOPT_FOLLOWLOCATION => false,
2387
                CURLOPT_SSL_VERIFYPEER => true,
2388
                CURLOPT_SSL_VERIFYHOST => 2,
2389
                CURLOPT_USERAGENT      => 'Chamilo-BBB-Webhooks/1.0',
2390
            ]);
2391
            $body = curl_exec($ch);
2392
            $err  = curl_error($ch);
2393
            $info = curl_getinfo($ch);
2394
            curl_close($ch);
2395
2396
            error_log('[BBB hooks] RAW='.substr($body, 0, 2000));
2397
2398
            if ($body === false || !$body) {
2399
                $this->dlog('bbbHooksRequest: empty body', ['curl_err' => $err]);
2400
                return ['returncode' => 'FAILED', 'messageKey' => 'noResponse', 'error' => $err ?: ''];
2401
            }
2402
2403
            $xml = @simplexml_load_string($body);
2404
            if (!$xml) {
2405
                $this->dlog('bbbHooksRequest: invalid XML body', ['body_sample' => substr($body, 0, 200)]);
2406
                return ['returncode' => 'FAILED', 'messageKey' => 'invalidXML'];
2407
            }
2408
2409
            $out = ['returncode' => (string)($xml->returncode ?? '')];
2410
            if (isset($xml->messageKey)) { $out['messageKey'] = (string)$xml->messageKey; }
2411
            if (isset($xml->message))    { $out['message']    = (string)$xml->message; }
2412
2413
            if ($call === 'hooks/list' && isset($xml->hooks)) {
2414
                $out['hooks'] = [];
2415
                foreach (($xml->hooks->hook ?? []) as $h) {
2416
                    $out['hooks'][] = [
2417
                        'id'          => isset($h->id) ? (string)$h->id : '',
2418
                        'callbackURL' => isset($h->callbackURL) ? (string)$h->callbackURL : '',
2419
                        'meetingID'   => isset($h->meetingID) ? (string)$h->meetingID : '',
2420
                    ];
2421
                }
2422
            }
2423
            if ($call === 'hooks/create' && isset($xml->hookID)) {
2424
                $out['hookID'] = (string)$xml->hookID;
2425
            }
2426
2427
            $this->dlog('bbbHooksRequest: parsed response', $out);
2428
            return $out;
2429
2430
        } catch (\Throwable $e) {
2431
            $this->dlog('bbbHooksRequest: exception', ['error' => $e->getMessage()]);
2432
            return ['returncode' => 'FAILED', 'messageKey' => 'exception'];
2433
        }
2434
    }
2435
2436
    /**
2437
     * Registers one webhook per meeting (idempotent).
2438
     * If a hook with that meeting ID already exists, don't create another one.
2439
     */
2440
    private function registerWebhookForMeeting(string $meetingId): void
2441
    {
2442
        if ($meetingId === '') {
2443
            $this->dlog('registerWebhookForMeeting: empty meetingId');
2444
            return;
2445
        }
2446
2447
        $this->dlog('registerWebhookForMeeting: start', ['meetingId' => $meetingId]);
2448
2449
        $list = $this->bbbHooksRequest('hooks/list', []);
2450
        if (($list['returncode'] ?? '') === 'SUCCESS') {
2451
            foreach (($list['hooks'] ?? []) as $h) {
2452
                if (($h['meetingID'] ?? '') === $meetingId) {
2453
                    $this->dlog('registerWebhookForMeeting: already registered', $h);
2454
                    return;
2455
                }
2456
            }
2457
        } else {
2458
            $this->dlog('registerWebhookForMeeting: hooks/list failed', $list);
2459
        }
2460
2461
        $callback = $this->buildWebhookCallbackUrl($meetingId);
2462
        $params = ['callbackURL' => $callback, 'meetingID' => $meetingId];
2463
2464
        $events = trim((string)$this->plugin->webhooksEventFilter());
2465
        if ($events !== '') {
2466
            $events = implode(',', array_map('trim', explode(',', strtolower($events))));
2467
            $params['events'] = $events;
2468
        }
2469
2470
        $this->dlog('registerWebhookForMeeting: creating hook', $params);
2471
        $res = $this->bbbHooksRequest('hooks/create', $params);
2472
2473
        if (($res['returncode'] ?? '') !== 'SUCCESS') {
2474
            $this->dlog('registerWebhookForMeeting: create failed', $res);
2475
        } else {
2476
            $this->dlog('registerWebhookForMeeting: create success', $res);
2477
        }
2478
    }
2479
2480
    /** Ensure webhooks when joining an already-existing meeting */
2481
    public function ensureWebhooksOnJoin(string $remoteId): void
2482
    {
2483
        try {
2484
            if (!$this->plugin->webhooksEnabled()) {
2485
                if ($this->debug) { error_log('[BBB] ensureWebhooksOnJoin: webhooks disabled'); }
2486
                return;
2487
            }
2488
            $scope = $this->plugin->webhooksScope(); // 'per_meeting' | 'global'
2489
            if ($this->debug) { error_log('[BBB] ensureWebhooksOnJoin: scope='.$scope.' remoteId='.$remoteId); }
2490
2491
            if ($scope === 'per_meeting') {
2492
                if ($remoteId === '') {
2493
                    if ($this->debug) { error_log('[BBB] ensureWebhooksOnJoin: empty remoteId'); }
2494
                    return;
2495
                }
2496
                // private helper already logs internals
2497
                $this->registerWebhookForMeeting($remoteId);
2498
            } else {
2499
                $this->ensureGlobalWebhook();
2500
            }
2501
        } catch (\Throwable $e) {
2502
            if ($this->debug) { error_log('[BBB] ensureWebhooksOnJoin: exception '.$e->getMessage()); }
2503
        }
2504
    }
2505
2506
    /**
2507
     * Ensures a single global webhook (without a meeting ID).
2508
     * Query hooks/list and create it only if it doesn't exist with the same callbackURL.
2509
     */
2510
    private function ensureGlobalWebhook(): void
2511
    {
2512
        $callback = $this->buildWebhookCallbackUrl(null);
2513
        $this->dlog('ensureGlobalWebhook: start', ['callback' => $callback]);
2514
2515
        $list = $this->bbbHooksRequest('hooks/list', []);
2516
        if (($list['returncode'] ?? '') !== 'SUCCESS') {
2517
            $this->dlog('ensureGlobalWebhook: hooks/list failed', $list);
2518
            return;
2519
        }
2520
2521
        foreach (($list['hooks'] ?? []) as $h) {
2522
            if (($h['callbackURL'] ?? '') === $callback && ($h['meetingID'] ?? '') === '') {
2523
                $this->dlog('ensureGlobalWebhook: global hook already exists', $h);
2524
                return;
2525
            }
2526
        }
2527
2528
        $params = ['callbackURL' => $callback];
2529
        $events = trim((string)$this->plugin->webhooksEventFilter());
2530
        if ($events !== '') {
2531
            $events = implode(',', array_map('trim', explode(',', strtolower($events))));
2532
            $params['events'] = $events;
2533
        }
2534
2535
        $this->dlog('ensureGlobalWebhook: creating', $params);
2536
        $res = $this->bbbHooksRequest('hooks/create', $params);
2537
        if (($res['returncode'] ?? '') !== 'SUCCESS') {
2538
            $this->dlog('ensureGlobalWebhook: create failed', $res);
2539
        } else {
2540
            $this->dlog('ensureGlobalWebhook: create success', $res);
2541
        }
2542
    }
2543
2544
    public function ensureGlobalHook(): void
2545
    {
2546
        try {
2547
            error_log('[BBB hooks] ensureGlobalHook() called');
2548
            $this->ensureGlobalWebhook();
2549
            error_log('[BBB hooks] ensureGlobalHook() done');
2550
        } catch (\Throwable $e) {
2551
            error_log('[BBB hooks] ensureGlobalHook() error: '.$e->getMessage());
2552
        }
2553
    }
2554
2555
    public function ensureHookForMeeting(string $remoteId): void
2556
    {
2557
        try {
2558
            if ($remoteId === '') {
2559
                error_log('[BBB hooks] ensureHookForMeeting(): empty remoteId');
2560
                return;
2561
            }
2562
            error_log('[BBB hooks] ensureHookForMeeting('.$remoteId.') called');
2563
            $this->registerWebhookForMeeting($remoteId);
2564
            error_log('[BBB hooks] ensureHookForMeeting('.$remoteId.') done');
2565
        } catch (\Throwable $e) {
2566
            error_log('[BBB hooks] ensureHookForMeeting('.$remoteId.') error: '.$e->getMessage());
2567
        }
2568
    }
2569
2570
    private function dlog(string $msg, array $ctx = []): void
2571
    {
2572
        if (!$this->debug) { return; }
2573
        if (!empty($ctx)) {
2574
            $msg .= ' | ' . json_encode($ctx, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
2575
        }
2576
        error_log('[BBB] ' . $msg);
2577
    }
2578
2579
}
2580