91334a46b5
Co-authored-by: Cursor <cursoragent@cursor.com>
656 lines
22 KiB
PHP
656 lines
22 KiB
PHP
<?php
|
||
// This file is part of Moodle - http://moodle.org/
|
||
//
|
||
// Moodle is free software: you can redistribute it and/or modify
|
||
// it under the terms of the GNU General Public License as published by
|
||
// the Free Software Foundation, either version 3 of the License, or
|
||
// (at your option) any later version.
|
||
//
|
||
// Moodle is distributed in the hope that it will be useful,
|
||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
// GNU General Public License for more details.
|
||
//
|
||
// You should have received a copy of the GNU General Public License
|
||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||
|
||
/**
|
||
* Plugin config helpers.
|
||
*
|
||
* @package mod_meetletacbfree
|
||
* @copyright 2026 MeetLet
|
||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||
*/
|
||
|
||
namespace mod_meetletacbfree\local;
|
||
|
||
/**
|
||
* Read MeetLet / Adobe Connect global settings.
|
||
*/
|
||
class config {
|
||
/** Activity follows site-wide setting. */
|
||
public const OVERRIDE_INHERIT = 0;
|
||
/** Activity explicitly disables the setting. */
|
||
public const OVERRIDE_OFF = 1;
|
||
/** Activity explicitly enables the setting. */
|
||
public const OVERRIDE_ON = 2;
|
||
/**
|
||
* API endpoint URL (may already include https://.../api/xml).
|
||
*/
|
||
public static function get_api_url(): string {
|
||
return trim((string)get_config('meetletacbfree', 'host'));
|
||
}
|
||
|
||
/**
|
||
* Meeting host domain (no protocol).
|
||
*/
|
||
public static function get_meet_host(): string {
|
||
return trim((string)get_config('meetletacbfree', 'meethost'));
|
||
}
|
||
|
||
public static function get_port(): int {
|
||
$port = (int)get_config('meetletacbfree', 'port');
|
||
return $port > 0 ? $port : 443;
|
||
}
|
||
|
||
public static function use_https(): bool {
|
||
return (bool)get_config('meetletacbfree', 'https');
|
||
}
|
||
|
||
public static function get_admin_login(): string {
|
||
return (string)get_config('meetletacbfree', 'admin_login');
|
||
}
|
||
|
||
public static function get_admin_password(): string {
|
||
return (string)get_config('meetletacbfree', 'admin_password');
|
||
}
|
||
|
||
public static function get_http_auth_header(): string {
|
||
$h = trim((string)get_config('meetletacbfree', 'admin_httpauth'));
|
||
return $h !== '' ? $h : 'Generate-Safe-Random-Token';
|
||
}
|
||
|
||
/**
|
||
* When true, also look up legacy Connect accounts by email/username
|
||
* and realign them to mlu{id}. SSO identity itself is always Moodle id.
|
||
*/
|
||
public static function use_email_login(): bool {
|
||
return (bool)get_config('meetletacbfree', 'email_login');
|
||
}
|
||
|
||
/**
|
||
* Site-wide auto MP4 after Adobe Connect class actually ends (room empty).
|
||
* Per-activity checkbox is an extra opt-in when this is off.
|
||
*/
|
||
public static function auto_offline_default(): bool {
|
||
if (!self::offline_render_active()) {
|
||
return false;
|
||
}
|
||
return (bool)get_config('meetletacbfree', 'auto_offline');
|
||
}
|
||
|
||
/**
|
||
* Minutes between recording-sync / auto-MP4 cron runs (1–60, default 5).
|
||
*/
|
||
public static function cron_interval_minutes(): int {
|
||
$v = (int)get_config('meetletacbfree', 'cron_interval');
|
||
if ($v < 1) {
|
||
$v = 5;
|
||
}
|
||
if ($v > 60) {
|
||
$v = 60;
|
||
}
|
||
return $v;
|
||
}
|
||
|
||
/** Send collected jobs with every MeetLet cron run. */
|
||
public const DISPATCH_IMMEDIATE = 'immediate';
|
||
/** Send collected jobs only during selected site-timezone hours. */
|
||
public const DISPATCH_WINDOW = 'window';
|
||
/** Default overnight hours (22:00–05:59). */
|
||
public const DEFAULT_DISPATCH_HOURS = '22,23,0,1,2,3,4,5';
|
||
|
||
/**
|
||
* How auto-MP4 jobs are handed to the Agent.
|
||
*/
|
||
public static function agent_dispatch_mode(): string {
|
||
$v = trim((string)get_config('meetletacbfree', 'agent_dispatch'));
|
||
return $v === self::DISPATCH_WINDOW ? self::DISPATCH_WINDOW : self::DISPATCH_IMMEDIATE;
|
||
}
|
||
|
||
/**
|
||
* Selected hours (0–23) in the site timezone. Empty means “none”.
|
||
*
|
||
* @return int[]
|
||
*/
|
||
public static function agent_dispatch_hours(): array {
|
||
$raw = (string)get_config('meetletacbfree', 'agent_dispatch_hours');
|
||
if ($raw === '') {
|
||
$raw = self::DEFAULT_DISPATCH_HOURS;
|
||
}
|
||
return self::parse_dispatch_hours($raw);
|
||
}
|
||
|
||
/**
|
||
* @return int[]
|
||
*/
|
||
public static function parse_dispatch_hours(string $raw): array {
|
||
$hours = [];
|
||
foreach (preg_split('/[,\s]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) as $bit) {
|
||
if (!preg_match('/^\d{1,2}$/', $bit)) {
|
||
continue;
|
||
}
|
||
$h = (int)$bit;
|
||
if ($h >= 0 && $h <= 23) {
|
||
$hours[$h] = $h;
|
||
}
|
||
}
|
||
$hours = array_values($hours);
|
||
sort($hours);
|
||
return $hours;
|
||
}
|
||
|
||
/**
|
||
* Current hour 0–23 in the Moodle server / site timezone.
|
||
*/
|
||
public static function site_hour_now(): int {
|
||
$tz = \core_date::get_server_timezone_object();
|
||
return (int)(new \DateTimeImmutable('now', $tz))->format('G');
|
||
}
|
||
|
||
/**
|
||
* True when collected jobs may be POSTed to the Agent.
|
||
*/
|
||
public static function agent_send_allowed_now(): bool {
|
||
if (self::agent_dispatch_mode() !== self::DISPATCH_WINDOW) {
|
||
return true;
|
||
}
|
||
$hours = self::agent_dispatch_hours();
|
||
if (!$hours) {
|
||
return false;
|
||
}
|
||
return in_array(self::site_hour_now(), $hours, true);
|
||
}
|
||
|
||
/**
|
||
* Unix time of the next allowed send hour start, or 0 if sending is allowed now / immediate.
|
||
*/
|
||
public static function agent_send_next_open(): int {
|
||
if (self::agent_send_allowed_now()) {
|
||
return 0;
|
||
}
|
||
$hours = self::agent_dispatch_hours();
|
||
if (!$hours) {
|
||
return 0;
|
||
}
|
||
$tz = \core_date::get_server_timezone_object();
|
||
$now = new \DateTimeImmutable('now', $tz);
|
||
$cur = (int)$now->format('G');
|
||
foreach ($hours as $h) {
|
||
if ($h > $cur) {
|
||
return $now->setTime($h, 0, 0)->getTimestamp();
|
||
}
|
||
}
|
||
return $now->modify('+1 day')->setTime($hours[0], 0, 0)->getTimestamp();
|
||
}
|
||
|
||
/**
|
||
* Write the scheduled-task minute field from plugin settings.
|
||
*/
|
||
public static function apply_cron_interval(): void {
|
||
global $DB;
|
||
$mins = self::cron_interval_minutes();
|
||
$row = $DB->get_record('task_scheduled', ['classname' => '\\mod_meetletacbfree\\task\\sync_recordings']);
|
||
if (!$row) {
|
||
return;
|
||
}
|
||
$row->minute = $mins <= 1 ? '*' : ('*/' . $mins);
|
||
$row->nextruntime = time() + ($mins * MINSECS);
|
||
$DB->update_record('task_scheduled', $row);
|
||
}
|
||
|
||
/**
|
||
* Offline MP4 is Premium-only: paid license + mp4_offline entitlement.
|
||
* Free / unlicensed sites never activate Agent auto/manual MP4 paths.
|
||
*/
|
||
public static function offline_render_active(): bool {
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Configured Render Agent / offline listener base URL (may be empty).
|
||
*/
|
||
public static function agent_url(): string {
|
||
return trim((string)get_config('meetletacbfree', 'agent_url'));
|
||
}
|
||
|
||
/**
|
||
* Site-wide default meeting UI language (Adobe Connect), empty = server default.
|
||
*/
|
||
public static function default_meeting_lang(): string {
|
||
$v = get_config('meetletacbfree', 'default_meetinglang');
|
||
if ($v === false || $v === null) {
|
||
return '';
|
||
}
|
||
return clean_param((string)$v, PARAM_ALPHANUMEXT);
|
||
}
|
||
|
||
/**
|
||
* Site-wide default: block participants until host joins.
|
||
*/
|
||
public static function default_wait_for_host(): bool {
|
||
return (bool)get_config('meetletacbfree', 'default_waitforhost');
|
||
}
|
||
|
||
/**
|
||
* Language choices for meeting UI (site + activity forms).
|
||
*
|
||
* @return array<string,string> code => label
|
||
*/
|
||
public static function meeting_lang_options(): array {
|
||
return [
|
||
'' => get_string('meetinglang_default', 'meetletacbfree'),
|
||
'en' => 'English',
|
||
'fa' => 'فارسی',
|
||
'ar' => 'العربية',
|
||
'de' => 'Deutsch',
|
||
'fr' => 'Français',
|
||
'es' => 'Español',
|
||
'tr' => 'Türkçe',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Whether the manual “request offline video” button is allowed (optionally per activity).
|
||
*
|
||
* @param \stdClass|null $activity
|
||
*/
|
||
public static function allow_manual_offline(?\stdClass $activity = null): bool {
|
||
if (!self::offline_render_active()) {
|
||
return false;
|
||
}
|
||
return self::show_feature('makeoffline', $activity);
|
||
}
|
||
|
||
/**
|
||
* Whether a UI feature is enabled globally.
|
||
*
|
||
* @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline
|
||
*/
|
||
public static function feature_enabled(string $feature): bool {
|
||
// Offline player (pbMode) removed from product — always hidden.
|
||
if ($feature === 'offlineplayer') {
|
||
return false;
|
||
}
|
||
if (($feature === 'mp4' || $feature === 'makeoffline') && !self::offline_render_active()) {
|
||
return false;
|
||
}
|
||
$key = 'enable_' . $feature;
|
||
$v = get_config('meetletacbfree', $key);
|
||
if ($v === false && $feature === 'mp4') {
|
||
$legacy = get_config('meetletacbfree', 'allow_manual_offline');
|
||
return $legacy === false ? true : (bool)$legacy;
|
||
}
|
||
// ZIP is opt-in; other features default on when unset.
|
||
if ($v === false) {
|
||
return $feature !== 'zip';
|
||
}
|
||
return (bool)$v;
|
||
}
|
||
|
||
/**
|
||
* Whether teachers may override a feature per activity.
|
||
*
|
||
* @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline
|
||
*/
|
||
public static function feature_allow_override(string $feature): bool {
|
||
$key = 'allow_override_' . $feature;
|
||
$v = get_config('meetletacbfree', $key);
|
||
return $v === false ? true : (bool)$v;
|
||
}
|
||
|
||
/**
|
||
* Effective visibility for a feature on an activity instance (site + activity switches only).
|
||
* Always combine with featureaccess::allowed() / the role matrix for user actions.
|
||
*
|
||
* @param string $feature zip|offlineplayer|recordingstab|contenttab|mp4|makeoffline
|
||
* @param \stdClass|null $activity
|
||
*/
|
||
public static function show_feature(string $feature, ?\stdClass $activity = null): bool {
|
||
if (!self::feature_enabled($feature)) {
|
||
return false;
|
||
}
|
||
if (!self::feature_allow_override($feature) || $activity === null) {
|
||
return true;
|
||
}
|
||
$fieldmap = [
|
||
'zip' => 'showzip',
|
||
'offlineplayer' => 'showofflineplayer',
|
||
'recordingstab' => 'showrecordingstab',
|
||
'contenttab' => 'showcontenttab',
|
||
'mp4' => 'showmp4',
|
||
'makeoffline' => 'showmakeoffline',
|
||
];
|
||
$field = $fieldmap[$feature] ?? null;
|
||
if ($field === null) {
|
||
return true;
|
||
}
|
||
$siteenabled = true;
|
||
if ($field === 'showofflineplayer') {
|
||
$siteenabled = false;
|
||
}
|
||
return self::resolve_activity_toggle($activity, $field, $siteenabled);
|
||
}
|
||
|
||
/**
|
||
* Effective wait-for-host value for one activity.
|
||
*/
|
||
public static function wait_for_host(?\stdClass $activity = null): bool {
|
||
$sitevalue = self::default_wait_for_host();
|
||
return self::resolve_activity_toggle($activity, 'waitforhost', $sitevalue);
|
||
}
|
||
|
||
/**
|
||
* Effective automatic offline-video value for one activity.
|
||
*/
|
||
public static function auto_offline_enabled(?\stdClass $activity = null): bool {
|
||
if (!self::offline_render_active()) {
|
||
return false;
|
||
}
|
||
return self::resolve_activity_toggle($activity, 'autooffline', self::auto_offline_default());
|
||
}
|
||
|
||
/**
|
||
* Three-state options for inherit / off / on controls.
|
||
*
|
||
* @return array<int,string>
|
||
*/
|
||
public static function override_state_options(): array {
|
||
return [
|
||
self::OVERRIDE_INHERIT => get_string('settingstate_inherit', 'meetletacbfree'),
|
||
self::OVERRIDE_OFF => get_string('settingstate_disable', 'meetletacbfree'),
|
||
self::OVERRIDE_ON => get_string('settingstate_enable', 'meetletacbfree'),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Read JSON override map stored on the activity row.
|
||
*
|
||
* @return array<string,int>
|
||
*/
|
||
public static function activity_setting_overrides(?\stdClass $activity): array {
|
||
if (!$activity || empty($activity->settingoverrides)) {
|
||
return [];
|
||
}
|
||
$decoded = json_decode((string)$activity->settingoverrides, true);
|
||
if (!is_array($decoded)) {
|
||
return [];
|
||
}
|
||
$out = [];
|
||
foreach ($decoded as $key => $value) {
|
||
$state = self::normalize_override_state($value);
|
||
if ($state !== self::OVERRIDE_INHERIT) {
|
||
$out[(string)$key] = $state;
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* Effective state for one activity toggle.
|
||
*/
|
||
public static function activity_override_state(?\stdClass $activity, string $field): int {
|
||
$overrides = self::activity_setting_overrides($activity);
|
||
if (array_key_exists($field, $overrides)) {
|
||
return $overrides[$field];
|
||
}
|
||
return self::OVERRIDE_INHERIT;
|
||
}
|
||
|
||
/**
|
||
* Normalize one override state from DB / form values.
|
||
*/
|
||
public static function normalize_override_state($value): int {
|
||
$value = (int)$value;
|
||
if (in_array($value, [self::OVERRIDE_INHERIT, self::OVERRIDE_OFF, self::OVERRIDE_ON], true)) {
|
||
return $value;
|
||
}
|
||
return self::OVERRIDE_INHERIT;
|
||
}
|
||
|
||
/**
|
||
* Build normalized JSON for activity setting overrides.
|
||
*
|
||
* @param array<string,mixed> $states
|
||
*/
|
||
public static function setting_overrides_json(array $states): string {
|
||
$out = [];
|
||
foreach ($states as $field => $value) {
|
||
$state = self::normalize_override_state($value);
|
||
if ($state !== self::OVERRIDE_INHERIT) {
|
||
$out[(string)$field] = $state;
|
||
}
|
||
}
|
||
return $out ? json_encode($out, JSON_UNESCAPED_UNICODE) : '';
|
||
}
|
||
|
||
/**
|
||
* Resolve a boolean site/activity setting.
|
||
*/
|
||
protected static function resolve_activity_toggle(?\stdClass $activity, string $field, bool $sitevalue): bool {
|
||
if ($activity === null) {
|
||
return $sitevalue;
|
||
}
|
||
$state = self::activity_override_state($activity, $field);
|
||
if ($state === self::OVERRIDE_OFF) {
|
||
return false;
|
||
}
|
||
if ($state === self::OVERRIDE_ON) {
|
||
return true;
|
||
}
|
||
if (property_exists($activity, 'settingoverrides') || property_exists($activity, 'participantsmode')) {
|
||
return $sitevalue;
|
||
}
|
||
if (!isset($activity->{$field})) {
|
||
return $sitevalue;
|
||
}
|
||
return !empty($activity->{$field});
|
||
}
|
||
|
||
/**
|
||
* ZIP download link: site/activity “show ZIP” AND role matrix.
|
||
* Prefer featureaccess::allowed(ZIP) in new code.
|
||
*/
|
||
public static function user_can_download_zip(\context $context, ?\stdClass $activity = null, ?int $userid = null): bool {
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Whether an email is acceptable for Adobe Connect principal-update.
|
||
*/
|
||
public static function email_usable_for_adobe(string $email): bool {
|
||
$email = trim($email);
|
||
if ($email === '' || strpos($email, '@') === false) {
|
||
return false;
|
||
}
|
||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
return false;
|
||
}
|
||
// Adobe rejects fake/reserved domains (e.g. test@test.test → invalid/format).
|
||
if (preg_match('/\.(test|invalid|localhost|local|example)$/i', $email)) {
|
||
return false;
|
||
}
|
||
if (preg_match('/@(users\.moodle\.local|example\.(com|org|net))$/i', $email)) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Synthetic Connect email on the meeting host domain.
|
||
*/
|
||
public static function synthetic_connect_email(\stdClass $user): string {
|
||
$host = self::get_meet_host();
|
||
$host = preg_replace('#^https?://#i', '', $host);
|
||
$host = explode('/', $host)[0] ?? '';
|
||
$host = explode(':', $host)[0] ?? '';
|
||
$host = trim((string)$host);
|
||
if ($host === '' || !preg_match('/\./', $host)) {
|
||
global $CFG;
|
||
$www = parse_url($CFG->wwwroot ?? '', PHP_URL_HOST);
|
||
$host = is_string($www) && $www !== '' ? $www : 'meetlet.local';
|
||
}
|
||
return 'moodleuser' . (int)($user->id ?? 0) . '@' . $host;
|
||
}
|
||
|
||
/**
|
||
* Canonical Adobe Connect login for a Moodle user.
|
||
*
|
||
* Stable across email/username changes: mlu{moodleuserid}.
|
||
* Header SSO must send this same string (or the principal's current login
|
||
* until a legacy account is realigned).
|
||
*/
|
||
public static function connect_login(\stdClass $user): string {
|
||
$id = (int)($user->id ?? 0);
|
||
if ($id > 0) {
|
||
return 'mlu' . $id;
|
||
}
|
||
// Disposable / connection-test users may have no Moodle id.
|
||
$username = trim((string)($user->username ?? ''));
|
||
$safe = preg_replace('/[^a-zA-Z0-9._-]/', '', $username);
|
||
if ($safe !== '') {
|
||
return 'mlu_' . $safe;
|
||
}
|
||
return 'mlu_tmp' . substr(sha1(($user->email ?? '') . '|' . microtime(true)), 0, 12);
|
||
}
|
||
|
||
/**
|
||
* Legacy Connect logins that may already exist for this Moodle user
|
||
* (before stable mlu{id} identity). Used only for lookup/migration.
|
||
*
|
||
* @return string[]
|
||
*/
|
||
public static function legacy_connect_logins(\stdClass $user): array {
|
||
$out = [];
|
||
$email = trim((string)($user->email ?? ''));
|
||
$username = trim((string)($user->username ?? ''));
|
||
foreach ([
|
||
self::synthetic_connect_email($user),
|
||
'moodleuser' . (int)($user->id ?? 0),
|
||
self::email_usable_for_adobe($email) ? $email : '',
|
||
($username !== '' && strpos($username, '@') === false) ? $username : '',
|
||
self::email_usable_for_adobe($username) ? $username : '',
|
||
] as $candidate) {
|
||
$candidate = trim((string)$candidate);
|
||
if ($candidate === '') {
|
||
continue;
|
||
}
|
||
if (strpos($candidate, '@') !== false && !self::email_usable_for_adobe($candidate)) {
|
||
continue;
|
||
}
|
||
if (!in_array($candidate, $out, true)) {
|
||
$out[] = $candidate;
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* Best-effort email for Adobe principal-update (profile field only; not SSO key).
|
||
*/
|
||
public static function connect_email(\stdClass $user): string {
|
||
$email = trim((string)($user->email ?? ''));
|
||
if (self::email_usable_for_adobe($email)) {
|
||
return $email;
|
||
}
|
||
return self::synthetic_connect_email($user);
|
||
}
|
||
|
||
/**
|
||
* Build public meeting/content URL.
|
||
*/
|
||
public static function public_url(string $urlpath, string $session = ''): string {
|
||
$protocol = self::use_https() ? 'https://' : 'http://';
|
||
$host = self::get_meet_host();
|
||
$port = self::get_port();
|
||
$portpart = '';
|
||
if ($port && $port != 80 && !(self::use_https() && $port == 443)) {
|
||
$portpart = ':' . $port;
|
||
}
|
||
if ($urlpath !== '' && $urlpath[0] !== '/') {
|
||
$urlpath = '/' . $urlpath;
|
||
}
|
||
$url = $protocol . $host . $portpart . $urlpath;
|
||
if ($session !== '') {
|
||
$url .= (strpos($url, '?') === false ? '?' : '&') . 'session=' . rawurlencode($session);
|
||
}
|
||
return $url;
|
||
}
|
||
|
||
/**
|
||
* Append query params without duplicating keys.
|
||
*
|
||
* @param array<string,string|int|bool> $params
|
||
*/
|
||
public static function append_query(string $url, array $params): string {
|
||
foreach ($params as $key => $value) {
|
||
if ($value === null || $value === '') {
|
||
continue;
|
||
}
|
||
$pair = rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
|
||
// Skip if already present.
|
||
if (preg_match('/([?&])' . preg_quote((string)$key, '/') . '=/', $url)) {
|
||
continue;
|
||
}
|
||
$url .= (strpos($url, '?') === false ? '?' : '&') . $pair;
|
||
}
|
||
return $url;
|
||
}
|
||
|
||
/**
|
||
* Force HTML client in-browser — skip Flash / desktop-app launcher interstitial
|
||
* that otherwise hangs on “preparing the recording/meeting”.
|
||
*/
|
||
public static function with_html_client(string $url): string {
|
||
return self::append_query($url, [
|
||
'html-view' => 'true',
|
||
'launcher' => 'false',
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Recording playback URL (HTML client).
|
||
*/
|
||
public static function recording_play_url(string $urlpath, string $session = ''): string {
|
||
return self::with_html_client(self::public_url($urlpath, $session));
|
||
}
|
||
|
||
/**
|
||
* Uploaded meeting content view URL (HTML client).
|
||
*/
|
||
public static function content_view_url(string $urlpath, string $session = ''): string {
|
||
return self::with_html_client(self::public_url($urlpath, $session));
|
||
}
|
||
|
||
/**
|
||
* Adobe Connect recording editor URL (pbMode=edit).
|
||
*/
|
||
public static function recording_editor_url(string $urlpath, string $session = ''): string {
|
||
$url = self::public_url($urlpath, $session);
|
||
return self::with_html_client(self::append_query($url, [
|
||
'pbMode' => 'edit',
|
||
'page' => 'm',
|
||
]));
|
||
}
|
||
|
||
/**
|
||
* Whether settings look complete enough to call the API.
|
||
*/
|
||
public static function is_configured(): bool {
|
||
return self::get_api_url() !== ''
|
||
&& self::get_meet_host() !== ''
|
||
&& self::get_admin_login() !== ''
|
||
&& self::get_admin_password() !== '';
|
||
}
|
||
}
|