. /** * Audience-aware MeetLet errors (student vs teacher/admin) with help codes. * * @package mod_meetletacbfree * @copyright 2026 MeetLet * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_meetletacbfree\local; use moodle_exception; defined('MOODLE_INTERNAL') || die(); /** * Structured plugin error with a public help code (ML-…) and staff-only detail. */ class usererror extends moodle_exception { /** @var string Internal catalog id, e.g. sso_01 */ public string $helpid = 'err_01'; /** @var array Staff fact rows (login, adobe_code, …) */ public array $facts = []; /** @var string Optional Adobe XML / raw snippet */ public string $xml = ''; /** * @param string $helpid Catalog id (sso_01, api_01, …) * @param string $errorcode Moodle lang string id * @param array $facts * @param string $xml */ public function __construct(string $helpid, string $errorcode, array $facts = [], string $xml = '') { $this->helpid = $helpid !== '' ? $helpid : 'err_01'; $this->facts = $facts; $this->xml = trim($xml); $debug = $this->facts; if ($this->xml !== '') { $debug['xml'] = $this->xml; } parent::__construct($errorcode, 'meetletacbfree', '', null, json_encode($debug, JSON_UNESCAPED_UNICODE)); } /** * Teachers, managers, and site admins see technical detail; students do not. */ public static function is_staff(\context $context, ?int $userid = null): bool { return has_any_capability([ 'moodle/site:config', 'moodle/course:update', 'moodle/course:manageactivities', 'mod/meetletacbfree:addinstance', 'mod/meetletacbfree:host', 'mod/meetletacbfree:presenter', ], $context, $userid); } /** * Help-code catalog shown in the server-setup guide. * * @return array */ public static function catalog(): array { return [ 'sso_01' => [ 'label' => 'ML-SSO-01', 'titlekey' => 'helpcode_sso_01_title', 'fixkey' => 'helpcode_sso_01_fix', ], 'api_01' => [ 'label' => 'ML-API-01', 'titlekey' => 'helpcode_api_01_title', 'fixkey' => 'helpcode_api_01_fix', ], 'api_02' => [ 'label' => 'ML-API-02', 'titlekey' => 'helpcode_api_02_title', 'fixkey' => 'helpcode_api_02_fix', ], 'usr_01' => [ 'label' => 'ML-USR-01', 'titlekey' => 'helpcode_usr_01_title', 'fixkey' => 'helpcode_usr_01_fix', ], 'mtg_01' => [ 'label' => 'ML-MTG-01', 'titlekey' => 'helpcode_mtg_01_title', 'fixkey' => 'helpcode_mtg_01_fix', ], 'cfg_01' => [ 'label' => 'ML-CFG-01', 'titlekey' => 'helpcode_cfg_01_title', 'fixkey' => 'helpcode_cfg_01_fix', ], 'err_01' => [ 'label' => 'ML-ERR-01', 'titlekey' => 'helpcode_err_01_title', 'fixkey' => 'helpcode_err_01_fix', ], ]; } /** * Public label for a catalog id (ML-SSO-01). */ public static function label(string $helpid): string { $cat = self::catalog(); return $cat[$helpid]['label'] ?? 'ML-ERR-01'; } /** * Ajax payload for join / recording launch failures. * * @return array{code:string,message:string,detailhtml:string,staff:bool} */ public static function payload(\Throwable $e, \context $context): array { if (self::is_gate_exception($e)) { return [ 'code' => '', 'message' => $e->getMessage(), 'detailhtml' => '', 'staff' => false, ]; } $parsed = self::from_throwable($e); $label = self::label($parsed['helpid']); $staff = self::is_staff($context); if (!$staff) { return [ 'code' => $label, 'message' => get_string('error_user_unavailable', 'meetletacbfree', $label), 'detailhtml' => '', 'staff' => false, ]; } return [ 'code' => $label, 'message' => self::staff_title($parsed['helpid'], $e), 'detailhtml' => self::render_staff_html($parsed), 'staff' => true, ]; } /** * WS success envelope. * * @return array{url:string,ok:bool,helpcode:string,message:string,detailhtml:string} */ public static function ws_ok(string $url): array { return [ 'url' => $url, 'ok' => true, 'helpcode' => '', 'message' => '', 'detailhtml' => '', ]; } /** * WS failure envelope (HTTP 200 so the UI can format HTML). * * @return array{url:string,ok:bool,helpcode:string,message:string,detailhtml:string} */ public static function ws_fail(\Throwable $e, \context $context): array { $p = self::payload($e, $context); return [ 'url' => '', 'ok' => false, 'helpcode' => $p['code'], 'message' => $p['message'], 'detailhtml' => $p['detailhtml'], ]; } /** * @return array{url: \core_external\external_value, ok: \core_external\external_value, helpcode: \core_external\external_value, message: \core_external\external_value, detailhtml: \core_external\external_value} */ public static function ws_url_returns(): array { return [ 'url' => new \core_external\external_value(PARAM_RAW, 'URL with session when ok'), 'ok' => new \core_external\external_value(PARAM_BOOL, 'False when the session could not be opened'), 'helpcode' => new \core_external\external_value(PARAM_TEXT, 'Public help code, e.g. ML-SSO-01'), 'message' => new \core_external\external_value(PARAM_RAW, 'Audience-appropriate short message'), 'detailhtml' => new \core_external\external_value(PARAM_RAW, 'Staff-only HTML; empty for students'), ]; } /** * Pretty-print an Adobe XML snippet for a code box. */ public static function format_xml(string $raw): string { $raw = trim($raw); if ($raw === '') { return ''; } $start = strpos($raw, '/i', $chunk, $m)) { $chunk = $m[0]; } else { $chunk = substr($chunk, 0, 1200); } $dom = new \DOMDocument(); $dom->preserveWhiteSpace = false; $dom->formatOutput = true; if (@$dom->loadXML($chunk)) { $out = $dom->saveXML($dom->documentElement); return $out !== false ? trim($out) : trim($chunk); } return trim($chunk); } /** * @return array{helpid:string,facts:array,xml:string} */ public static function from_throwable(\Throwable $e): array { if ($e instanceof self) { return [ 'helpid' => $e->helpid, 'facts' => $e->facts, 'xml' => $e->xml, ]; } $code = $e instanceof moodle_exception ? (string)$e->errorcode : ''; $helpid = self::helpid_from_moodle($code); $facts = []; $xml = ''; if ($e instanceof moodle_exception && is_scalar($e->a) && (string)$e->a !== '') { $legacy = self::parse_legacy_detail((string)$e->a); $facts = $legacy['facts']; $xml = $legacy['xml']; } return ['helpid' => $helpid, 'facts' => $facts, 'xml' => $xml]; } protected static function is_gate_exception(\Throwable $e): bool { if (!$e instanceof moodle_exception) { return false; } return in_array($e->errorcode, [ 'meetingended', 'meetingnotstarted', 'waitingforhost', 'notparticipant', 'usergrouprequired', 'joinblocked_waitinghost', ], true); } protected static function helpid_from_moodle(string $errorcode): string { $map = [ 'errorhttpauth' => 'sso_01', 'erroremptyxml' => 'api_01', 'errorlogin' => 'api_02', 'errorcreateuser' => 'usr_01', 'errorcreatemeeting' => 'mtg_01', 'nomeeting' => 'mtg_01', 'errorexistingsco' => 'mtg_01', 'notconfigured' => 'cfg_01', ]; return $map[$errorcode] ?? 'err_01'; } /** * @return array{facts:array,xml:string} */ protected static function parse_legacy_detail(string $detail): array { $facts = []; $xml = ''; if (preg_match('/login=([^\s·]+)/u', $detail, $m)) { $facts['login'] = $m[1]; } if (preg_match('/\bcode=([^\s·]+)/u', $detail, $m)) { $facts['adobe_code'] = $m[1]; } if (preg_match('/subcode=([^\s·]+)/u', $detail, $m)) { $facts['adobe_subcode'] = $m[1]; } $bodypos = stripos($detail, 'body='); if ($bodypos !== false) { $xml = self::format_xml(substr($detail, $bodypos + 5)); } return ['facts' => $facts, 'xml' => $xml]; } protected static function staff_title(string $helpid, \Throwable $e): string { $cat = self::catalog(); $key = $cat[$helpid]['titlekey'] ?? ''; if ($key !== '' && get_string_manager()->string_exists($key, 'meetletacbfree')) { return get_string($key, 'meetletacbfree'); } if ($e instanceof moodle_exception) { return $e->getMessage(); } return get_string('error', 'meetletacbfree'); } /** * @param array{helpid:string,facts:array,xml:string} $parsed */ protected static function render_staff_html(array $parsed): string { $helpid = $parsed['helpid']; $label = self::label($helpid); $cat = self::catalog(); $titlekey = $cat[$helpid]['titlekey'] ?? ''; $fixkey = $cat[$helpid]['fixkey'] ?? ''; $title = ($titlekey !== '' && get_string_manager()->string_exists($titlekey, 'meetletacbfree')) ? get_string($titlekey, 'meetletacbfree') : get_string('error', 'meetletacbfree'); $codebox = \html_writer::div( \html_writer::span(get_string('error_helpcode_label', 'meetletacbfree'), 'meetlet-err__codelabel') . \html_writer::tag('code', s($label), ['data-copy-code' => $label]), 'meetlet-err__codebox' ); $rows = ''; $factorder = ['login', 'adobe_code', 'adobe_subcode', 'egress_ip']; $facts = $parsed['facts']; foreach ($factorder as $key) { if (empty($facts[$key])) { continue; } $dtkey = 'errorfact_' . $key; $dt = get_string_manager()->string_exists($dtkey, 'meetletacbfree') ? get_string($dtkey, 'meetletacbfree') : $key; $rows .= \html_writer::tag( 'div', \html_writer::tag('dt', s($dt)) . \html_writer::tag('dd', \html_writer::tag('code', s((string)$facts[$key]))), ['class' => 'meetlet-err__fact'] ); } foreach ($facts as $key => $val) { if (in_array($key, $factorder, true) || (string)$val === '') { continue; } $rows .= \html_writer::tag( 'div', \html_writer::tag('dt', s((string)$key)) . \html_writer::tag('dd', \html_writer::tag('code', s((string)$val))), ['class' => 'meetlet-err__fact'] ); } $factshtml = $rows !== '' ? \html_writer::tag('dl', $rows, ['class' => 'meetlet-err__facts']) : ''; $fix = ''; if ($fixkey !== '' && get_string_manager()->string_exists($fixkey, 'meetletacbfree')) { $fix = \html_writer::div(get_string($fixkey, 'meetletacbfree'), 'meetlet-err__fix'); } $xmlhtml = ''; if ($parsed['xml'] !== '') { $xmlhtml = \html_writer::tag('p', get_string('error_adobe_response', 'meetletacbfree'), ['class' => 'meetlet-err__prelabel']) . \html_writer::tag('pre', s($parsed['xml']), ['class' => 'meetlet-err__xml']); } $guide = \html_writer::tag('p', get_string('error_see_serverguide', 'meetletacbfree'), ['class' => 'meetlet-err__guide']); return \html_writer::div( \html_writer::tag('p', s($title), ['class' => 'meetlet-err__lead']) . $codebox . $factshtml . $fix . $xmlhtml . $guide, 'meetlet-err meetlet-err--staff' ); } }