$value) { if (!is_string($value)) { continue; } $decoded = json_decode($value); # Only an object or array replaces the string. A scalar result would retype a value the # sender declared as a string — "1000152915" would reach user code as an int, and # "true"/"null" as a bool/null, which the is_numeric() guard here used to let through. if (json_last_error() === JSON_ERROR_NONE && (is_object($decoded) || is_array($decoded))) { self::$in->$key = $decoded; } } # Handle metadata provided by AI Agents in ticket processing if (isset(self::$in->_metadata)) { # Bot conversation = voicebot/chatbot (aiSupportLevel "bot"), not phone channel per se. # Phone with human assist (real-time assistance) has aiSupportLevel "human". self::$isBotConversation = self::$in->_metadata?->aiSupportLevel === "bot"; # Strip the metadata if (!$includeMetadata) { unset(self::$in->_metadata); } } # Return the input data return self::$in; } /** * Check if we are in a bot conversation * Bot conversation = Chatbot or Voicebot * Human conversation = Email, or Chat/Voice when assisting the customer service representative * * Needed to quickly branch between AI Agent behavior, * Example: Allowing unplausible meter readings when a customer service representative is assisting the customer, but not when the customer is self-service. */ public static function isBotConversation(): bool { return self::$isBotConversation; } } /** * Allowed values for IntentInfo::$type. */ class IntentType { public const SUCCESS = 'success'; public const NEUTRAL = 'neutral'; public const WARNING = 'warning'; public const DANGER = 'danger'; } /** * Creates a new intent info (=message box visible to the agent in the Enneo UI). */ class IntentInfo { /** * @param string $type The type of the intent. Must be one of 'success', 'neutral', 'warning', 'danger'. * @param string $message The message to be shown to the agent, e.g. "Reading is plausible". * @param string|null $extraInfo Optional supplemental information about the intent, e.g. "Expected reading was 421 kWh. Plausbible because difference to 317 kWh is below threshold of 200 kWh" * @param string|null $code Optional internal code associated with the info. Not used by enneo. Defaults to None. */ public function __construct( public string $type, public string $message, public ?string $extraInfo = null, public ?string $code = null, ) { } } /** * Creates a new intent option (=button visible to the agent in the Enneo UI). */ class IntentOption { /** * @param string $type The type of the intent. Must be unique for each option. Usually accompanied by a matching output case * @param string $name The name of the option, e.g. "Enter into system". * @param string $icon The icon to be shown next to the option. Defaults to 'check'. Options: check, cancel * @param bool $recommended True for default action. An Intent can only have one default action. Defaults to False. * @param int $order Sorting index order. Frontend sorts in ascending order * @param string|null $handler Which microservice should handle this action, Options: cortex, mind or fe. ONLY USE IF YOU KNOW WHAT YOU ARE DOING. Defaults to None. */ public function __construct( public string $type, public string $name, public string $icon = 'check', public bool $recommended = false, public int $order = 0, public string|null $handler = '', ) { } } /** * Represents a field in form, displayed in user UI */ class FormField { public function __construct( public string $id, public string $type, public string $valueRef, public string $label, public bool $readonly = false, public bool $hidden = false, public array $options = [], ) { } } /** * Represents a form (as a set of form fields), displayed in user UI */ class Form { public array $fields = []; public static function fromInput(stdClass $in): Form { $result = new Form(); foreach ($in->_metadata->inputParameters as $param) { $field = new FormField( id: $param->key, type: match ($param->type) { 'date', 'datetime' => 'date', 'int' => 'integer', 'float' => 'number', 'bool' => 'checkbox', 'enum' => 'select', default => 'text', }, valueRef: 'data.' . $param->key, label: $param->name, readonly: $param->visibility === 'readonly', hidden: $param->visibility === 'hidden', options: [] ); $optionsCounter = 1; foreach ($param->options as $option) { $field->options[] = [ 'label' => $option->label, 'value' => $option->value, 'id' => 'option_' . $optionsCounter++, ]; } $result->fields[] = $field; } return $result; } public function getFormField(string $id): ?FormField { foreach ($this->fields as $field) { if ($field->id === $id) { return $field; } } return null; } } /** * Creates a new interaction object. * This is an enneo-specific object that defines the form shown to the agent in the Enneo UI. * The standard AI function returns an json-encoded Interaction object to STDOUT */ class Interaction implements \JsonSerializable { /** * @param object $data The input data for the AI function, i.e. form values for the input variables. It is recommended to pre-fill this with the input data coming from load_input_data(). * @param IntentOption[] $options A list of intent options (=buttons visible to the agent in the Enneo UI). Defaults to []. * @param IntentInfo[] $infos A list of intent infos (=message boxes visible to the agent in the Enneo UI). Defaults to []. * @param object|null|true $form Optional dictionary to override the forms to be shown to the agent. Usually not needed. Defaults to None. If set to true, then the form will be generated from the input data. */ public function __construct( public object $data, public array $options = [], public array $infos = [], public object|null|true $form = null, ) { if ($form === true) { $this->form = Form::fromInput($this->data); } } // When sending the interaction object to STDOUT, we can remove the metadata as it is not needed public function jsonSerialize(): mixed { $data = clone $this->data; if (property_exists($data, '_metadata')) { unset($data->_metadata); } return [ 'data' => $data, 'options' => $this->options, 'infos' => $this->infos, 'form' => $this->form, ]; } } /** * These are wrappers for the Enneo API endpoints. * You can use them to conveniently access the Enneo API. * * $authorizeAs selects the identity of every call: 'user' (default) reuses the credentials of the * user who triggered the script, 'serviceWorker' uses the sandbox's own machine account. Besides * permissions this decides whom Enneo books the resulting work on — a write made as 'serviceWorker' * produces a worklog entry for the machine account, which reporting counts as fully automated (L5). * Only use 'serviceWorker' for calls that genuinely need elevated permissions (e.g. writing * technical ticket fields such as rawData); keep user-triggered actions on 'user'. */ class ApiEnneo { /** * Get a contract by its ID. * See https://main.enneo.dev/api/mind/docs for documentation. * * @param int|string $contractId The contract ID * @return object The contract as object * @throws Exception */ public static function getContract(int|string $contractId): object { return self::get('/api/mind/contract/' . $contractId . '?includeRawData=true'); } /** * Get a ticket by its ID. * See https://main.enneo.dev/api/mind/docs for documentation. * * @param int $ticketId The ticket ID * @return object The ticket as object */ public static function getTicket(int $ticketId): object { return self::get('/api/mind/ticket/' . $ticketId, authorizeAs: 'serviceWorker'); } /** * Do an API GET call to an Enneo API endpoint * See https://main.enneo.dev/api/mind/docs for documentation. * * @param string $endpoint The endpoint, e.g. /api/mind/contract/123456 * @param array $params The HTTP GET parameters, e.g. ['contractId' => 123456]. Can also be passed through the URL, e.g. /api/mind/ticket/123?includeIntents=true * @param string $authorizeAs If 'user', any API requests from the script inherit the permissions of the user who started the script. If 'serviceWorker', the script runs with the permissions of the service worker. Defaults to 'user'. Also decides whom the resulting work is booked on in time tracking and reporting — see the class docblock. * * @return object|array The response body as object. All Enneo APIs return an object as response. * @throws Exception */ public static function get(string $endpoint, array $params = [], string $authorizeAs = 'user'): object|array { $headers = self::buildAuthorizationHeader($authorizeAs); $url = self::getEnneoUrl($endpoint); if ($params) { // Query parameters, not a body. Before this they were accepted and silently dropped, so // a caller passing a filter received unfiltered data and no error at all. $url .= (str_contains($endpoint, '?') ? '&' : '?').http_build_query($params); } return Api::call("GET", $url, $headers); } /** * Do an API POST call to an Enneo API endpoint * See https://main.enneo.dev/api/mind/docs for documentation. * * @param string $endpoint The endpoint, e.g. /api/mind/ticket * @param object|array $body The HTTP body payload as object or array. Will be JSON-encoded before sending. * @param string $authorizeAs If 'user', any API requests from the script inherit the permissions of the user who started the script. If 'serviceWorker', the script runs with the permissions of the service worker. Defaults to 'user'. Also decides whom the resulting work is booked on in time tracking and reporting — see the class docblock. * * @return object|array The response body as object * @throws Exception */ public static function post(string $endpoint, object|array $body, string $authorizeAs = 'user'): object|array { $headers = self::buildAuthorizationHeader($authorizeAs); return Api::call("POST", self::getEnneoUrl($endpoint), $headers, $body); } /** * Do an API PATCH call to an Enneo API endpoint * See https://main.enneo.dev/api/mind/docs for documentation. * * @param string $endpoint The endpoint, e.g. /api/mind/ticket * @param object|array $body The HTTP body payload as object or array. Will be JSON-encoded before sending. * @param string $authorizeAs If 'user', any API requests from the script inherit the permissions of the user who started the script. If 'serviceWorker', the script runs with the permissions of the service worker. Defaults to 'user'. Also decides whom the resulting work is booked on in time tracking and reporting — see the class docblock. * * @return object|array The response body as object * @throws Exception */ public static function patch(string $endpoint, object|array $body, string $authorizeAs = 'user'): object|array { $headers = self::buildAuthorizationHeader($authorizeAs); return Api::call("PATCH", self::getEnneoUrl($endpoint), $headers, $body); } /** * Do an API PUT call to an Enneo API endpoint * See https://main.enneo.dev/api/mind/docs for documentation. * * @param string $endpoint The endpoint, e.g. /api/mind/ticket * @param object|array $body The HTTP body payload as object or array. Will be JSON-encoded before sending. * @param string $authorizeAs If 'user', any API requests from the script inherit the permissions of the user who started the script. If 'serviceWorker', the script runs with the permissions of the service worker. Defaults to 'user'. Also decides whom the resulting work is booked on in time tracking and reporting — see the class docblock. * * @return object|array The response body as object * @throws Exception */ public static function put(string $endpoint, object|array $body, string $authorizeAs = 'user'): object|array { $headers = self::buildAuthorizationHeader($authorizeAs); return Api::call("PUT", self::getEnneoUrl($endpoint), $headers, $body); } /** * Do an API DELETE call to an Enneo API endpoint * See https://main.enneo.dev/api/mind/docs for documentation. * * @param string $endpoint The endpoint, e.g. /api/mind/ticket * @param string $authorizeAs If 'user', any API requests from the script inherit the permissions of the user who started the script. If 'serviceWorker', the script runs with the permissions of the service worker. Defaults to 'user'. Also decides whom the resulting work is booked on in time tracking and reporting — see the class docblock. * * @return object|array The response body as object * @throws Exception */ public static function delete(string $endpoint, string $authorizeAs = 'user'): object|array { $headers = self::buildAuthorizationHeader($authorizeAs); return Api::call("DELETE", self::getEnneoUrl($endpoint), $headers); } /** * Execute a helper function. * See https://main.enneo.dev/api/mind/docs for documentation. * @param string $name The name of the helper function, e.g. 'getContract' * @param object|array $parameters The parameters to pass to the user defined function * @param string $authorizeAs If 'user', any API requests from the script inherit the permissions of the user who started the script. If 'serviceWorker', the script runs with the permissions of the service worker. Defaults to 'user'. Also decides whom the resulting work is booked on in time tracking and reporting — see the class docblock. * * @return object|array * @throws Exception */ public static function executeUdf(string $name, object|array $parameters, string $authorizeAs = 'user'): object|array { if (!$authorizeAs) { $authorizeAs = 'serviceWorker'; } return ApiEnneo::post("/api/mind/executor/execute/$name", ["parameters" => $parameters], $authorizeAs); } /** * Resolve a secret value by key from the executorSecrets setting. * Returns null if the key is not configured. * * @param string $key The secret key name, e.g. "MY_API_TOKEN" * @return string|null The secret value or null when not found * @throws Exception */ public static function getSecret(string $key): ?string { $secrets = Setting::get('executorSecrets'); if (!is_array($secrets)) { return null; } foreach ($secrets as $secret) { $secretKey = is_array($secret) ? ($secret['key'] ?? null) : ($secret->key ?? null); $secretValue = is_array($secret) ? ($secret['value'] ?? null) : ($secret->value ?? null); if ($secretKey === $key) { return $secretValue; } } return null; } /** * Extract a file from an Enneo storage endpoint. * * @param string $path The path to the file in storage * @return string The file content as raw bytes * @throws Exception */ public static function getFileFromStorage(string $path): string { $endpoint = "/api/mind/storage/$path"; $headers = self::buildAuthorizationHeader('user'); return Api::call_raw("GET", self::getEnneoUrl($endpoint), $headers); } /** * Internal helper function to build the authorization header. */ private static function buildAuthorizationHeader(string $authorizeAs): array { if ($authorizeAs == 'serviceWorker' || !($_ENV['ENNEO_USER_AUTH_HEADER'] ?? false)) { return ['Authorization: Bearer ' . $_ENV['ENNEO_SESSION_TOKEN']]; } elseif ($authorizeAs == 'user') { return [$_ENV['ENNEO_USER_AUTH_HEADER']]; } else { throw new Exception("Unknown authorization type $authorizeAs. Use 'user' or 'serviceWorker'.", 400); } } /** * In the sandbox ENNEO_API_URL points straight at Mind's port on the internal host, so calls to * another service have to be re-pointed. For a local run it is the public domain and the reverse * proxy routes by path, which makes the replace a no-op — and is also why an unrecognised prefix * is passed through rather than rejected. */ private static function getEnneoUrl(string $endpoint): string { $enneoUrl = $_ENV['ENNEO_API_URL'] ?? ''; if (str_starts_with($endpoint, '/api/cortex')) { $enneoUrl = str_replace(':8005', ':8006', $enneoUrl); } elseif (str_starts_with($endpoint, '/api/auth')) { $enneoUrl = str_replace(':8005', ':8002', $enneoUrl); } elseif (str_starts_with($endpoint, '/api/ioProxy')) { $enneoUrl = str_replace(':8005', ':8007', $enneoUrl); } return $enneoUrl . $endpoint; } } class Api { // ------------------------------------- // --- Shared REST service endpoints --- // ------------------------------------- /** * HTTP call to REST-endpoint that returns the raw response * * @param string $method HTTP method: GET, POST, PUT, DELETE or PATCH * @param string $url URL to call, e.g. https://my-api.com/api/v1/endpoint * @param array $headers Array of HTTP headers, e.g. ['Content-Type: application/json'] * @param array|object|string|bool $params Body of the HTTP request, e.g. ['param1' => 'value1', 'param2' => 'value2'] * @param ?int $timeout Per-call request timeout in seconds. Overrides the global $SDK_API_TIMEOUT when set. Defaults to null (use global default). * @return string The raw response body * @throws Exception If the call was not successful, an exception is thrown */ public static function call_raw(string $method, string $url, array $headers = [], array|object|string|false $params = false, ?int $timeout = null): string { $curl = curl_init($url); // Log::info( "API $method call to $url about to start"); if ($method == 'GET') { // No curl parameters necessary } elseif ($method == 'POST') { curl_setopt($curl, CURLOPT_POST, 1); } elseif ($method == 'PUT' || $method == 'DELETE' || $method == 'PATCH') { curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method); } else { throw new Exception("Invalid Curl request method $method"); } $payload = null; if ($params && $params !== 'false' && $params !== '0') { if (is_object($params) || is_array($params)) { $payload = json_encode($params); } else { $payload = $params; } curl_setopt($curl, CURLOPT_POSTFIELDS, $payload); } // Per-call timeout overrides the global SDK default when provided curl_setopt($curl, CURLOPT_TIMEOUT, $timeout ?? $GLOBALS['SDK_API_TIMEOUT']); curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); curl_setopt($curl, CURLOPT_HEADER, 1); $headers[] = 'Content-Type: application/json'; $headers[] = 'User-Agent: enneo/1.0.0'; curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false); // Needed to avoid buggy powercloud SSL certificate curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); // Needed to avoid buggy HTTP2 implementation in Freshdesk $url = curl_getinfo($curl, CURLINFO_EFFECTIVE_URL); // Execute call $curl_response_with_header = curl_exec($curl); if ($curl_response_with_header === false) { throw new \Exception("API Call to " . parse_url($url)['host'] . "... failed: " . curl_error($curl), 1); } $header_size = curl_getinfo($curl, CURLINFO_HEADER_SIZE); $headers = substr($curl_response_with_header, 0, $header_size); $curl_response = substr($curl_response_with_header, $header_size); $curl_reponse_statuscode = curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl); // Extract headers $headers = explode("\n", $headers); foreach ($headers as $header) { if (str_contains($header, ':')) { list($key, $value) = explode(':', $header, 2); $headers[trim(strtolower($key))] = trim($value); if (preg_match_all('/charset[\:|=]\s{0,1}(\S*)/i', $value, $matches)) { $encoding = $matches[1][0]; } elseif (stristr($key, 'blacklisted')) { throw new \Exception(parse_url($url, PHP_URL_HOST) . ' responded that your IP is blacklisted'); } } } // Do we need to convert the response into UTF8, which is native for PHP? if (isset($encoding) && $encoding != 'utf-8' && (str_starts_with($curl_response, '{') || str_starts_with($curl_response, '['))) { // Convert into UTF8 used by PHP $curl_response = preg_replace_callback('/\\\\u([0-9a-fA-F]{4})/', function ($match) { return mb_convert_encoding(pack('H*', $match[1]), 'UTF-8', 'UCS-2BE'); }, $curl_response); } // Do we have a rate limit? if yes wait and retry if ($GLOBALS['SDK_RETRY_ON_RATE_LIMIT'] && $curl_reponse_statuscode == 429) { if ((int) ($headers['x-ratelimit-reset'] ?? 0)) { $delay = (int) $headers['x-ratelimit-reset'] - time(); } elseif ((int) ($headers['retry-after'] ?? 0)) { $delay = (int) $headers['retry-after'] + 1; } else { $delay = 60; } // Rate limit requests retry after $delay seconds. Sleeping..." sleep($delay); return self::call_raw($method, $url, $headers, $params, $timeout); } elseif ($curl_reponse_statuscode < 200 || $curl_reponse_statuscode >= 400) { if (str_starts_with($url, $_ENV['ENNEO_API_URL'])) { // For internal Enneo API calls, we show the original error message directly throw new Exception($curl_response); } else { throw new Exception("Api call to $url failed with code $curl_reponse_statuscode and response: $curl_response"); } } return $curl_response; } /** * HTTP call to REST-endpoint * * @param string $method HTTP method: GET, POST, PUT, DELETE or PATCH * @param string $url URL to call, e.g. https://my-api.com/api/v1/endpoint * @param array $headers Array of HTTP headers, e.g. ['Content-Type: application/json'] * @param array|object|string|bool $params Body of the HTTP request, e.g. ['param1' => 'value1', 'param2' => 'value2'] * @param ?int $timeout Per-call request timeout in seconds. Overrides the global $SDK_API_TIMEOUT when set. Defaults to null (use global default). * @return array|object The response body as object * @throws Exception If the call was not successful, an exception is thrown */ public static function call(string $method, string $url, array $headers = [], array|object|string|false $params = false, ?int $timeout = null): array|object { $curl_response = self::call_raw($method, $url, $headers, $params, $timeout); // Is the string a JSON? if (str_starts_with($curl_response, '{') || str_starts_with($curl_response, '[')) { // Parse data $res = json_decode($curl_response); if (is_null($res)) { throw new \Exception("Could not decode JSON String $curl_response returned from $url, Error Code " . json_last_error(), 1); } return $res; } else { // Special case when a REST API does not respond with a json-encoded response. User should have used call_raw() instead. return (object) ['response' => $curl_response]; } } } class Setting { /** * Helper class for getting settings from Enneo through the API. * * @param string $settingName The name of the setting, e.g. 'powercloudApiUrl' * @return mixed The setting value * @throws Exception */ public static function get(string $settingName): mixed { try { $response = ApiEnneo::get('/api/mind/settings/compact?showSecrets=true&filterByName=' . $settingName, authorizeAs: 'serviceWorker'); if (!isset($response->$settingName)) { return null; } return $response->$settingName; } catch (Exception $e) { throw new Exception("Error retrieving setting '$settingName': " . $e->getMessage(), 0, $e); } } /** * @param string $settingName * @param mixed $value * @return void * @throws Exception */ public static function set(string $settingName, mixed $value): void { $setting = new stdClass(); $setting->$settingName = $value; ApiEnneo::post('/api/mind/settings', $setting, authorizeAs: 'serviceWorker'); } } /** * Helper class for calling the Powercloud API. * Authorization is handled by this SDK. */ class ApiPowercloud { /** * @throws Exception */ public static function getCall(string $endpoint): object|array { $url = Setting::get('powercloudApiUrl') . $endpoint; $headers = ['Authorization: Basic ' . Setting::get('powercloudApiAuth')]; $result = Api::call("GET", $url, $headers); return $result; } /** * @throws Exception */ public static function postCall(string $endpoint, array|object $params): object|array { $url = Setting::get('powercloudApiUrl') . $endpoint; $headers = ['Authorization: Basic ' . Setting::get('powercloudApiAuth')]; $result = Api::call("POST", $url, $headers, $params); return $result; } /** * Extract the error message from a Powercloud API response. * * @param object|string $result The powercloud API response as object or string * @return string A human-readable error message */ public static function extractError(object|string $result): string { $message = 'Powercloud-Fehler: '; if (!is_object($result)) { $message .= $result; } elseif (sizeof($result->errors ?? []) > 0) { foreach ($result->errors as $error) { $message .= $error->messageLocalized . '; '; } } elseif (isset($result->response) && is_string($result->response)) { $message .= $result->response; } elseif (isset($result->messageLocalized)) { $message .= $result->messageLocalized; } else { $message .= print_r($result, 1); } return $message; } } /** * Show a message on an operator's screen, as a card in the top-right corner. * * The card goes straight to the operator's open tabs and is not stored: an operator who is offline, * or reloads the page, does not see it later, and it does not appear in the notification bell. * Sent as the sandbox's machine account — the only identity allowed to raise it. */ class Notification { const SEVERITIES = ['success', 'info', 'warning', 'error']; /** * Show a message to one operator. * * @param int $userId The operator to show it to * @param string $body The message * @param string $severity 'success', 'info', 'warning' or 'error' * @param string|null $actionUrl An in-app path, e.g. '/tickets/123', offered as a button * @param int $duration Seconds before the card closes itself; 0 keeps it until closed * @param string|null $key A stable name for the message; a repeat replaces the card instead of stacking * @param bool $showDontShowAgain Offer "Don't show again" for this key, remembered per browser. Ignored without a key. * @param bool $allowHtml Render body as HTML or Markdown — text formatting, lists, code and links; anything else is removed. * Never put ticket or customer data into HTML: it comes from outside and may carry markup. * False shows body as plain text, tags included. */ public static function send( int $userId, string $body, string $severity = 'info', ?string $actionUrl = null, int $duration = 0, ?string $key = null, bool $showDontShowAgain = false, bool $allowHtml = false, ): object|array { if (!in_array($severity, self::SEVERITIES, true)) { throw new Exception("Unknown severity $severity. Use one of " . implode(', ', self::SEVERITIES) . '.', 400); } if ($duration < 0) { throw new Exception('duration must be 0 or more seconds', 400); } return ApiEnneo::post('/api/ioProxy/executor/notification', [ 'userId' => $userId, 'body' => $body, 'severity' => $severity, 'actionUrl' => $actionUrl, 'duration' => $duration, 'key' => $key, 'showDontShowAgain' => $showDontShowAgain, 'allowHtml' => $allowHtml, ], 'serviceWorker'); } } class Helpers { /** * Format a date for display in german or english. * * @param string|DateTime $date The date to format * @param string $format The language code of the format. Either 'de' or 'en' * * @return string The date in a country-formatted style, e.g. 01.01.2021 or 2021-01-01 * * @throws Exception */ public static function formatDate(string|DateTime $date, string $format = 'de'): string { // $date must be formatted as mysql timestamp (yyyy-mm-dd) if (is_string($date)) { $d = DateTime::createFromFormat('Y-m-d', $date); if (!$d) { $d = DateTime::createFromFormat('Y-m-d H:i:s', $date); } if (!$d && $date) { return $date; } if (!$d) { if ($date === '') { throw new Exception('No date to format'); } else { throw new Exception("Date '$date' invalid"); } } } else { $d = $date; } if ($format == 'en') { return $d->format('Y-m-d'); } elseif ($format == 'de') { return $d->format('d.m.Y'); } else { throw new Exception("Unknown language $format"); } } /** * Parse a textual date into yyyy-mm-dd format. * * @param string $textualDate The textual date, e.g. 2021-01-01 or 01.01.2021 or 1.1.2021 or 1.1.21 * @return ?string The date in yyyy-mm-dd format or null if the date format is invalid */ public static function parseDateToYMD(string $str): ?string { $parsedDate = date_parse($str); // Check if the parsing was successful if ($parsedDate['error_count'] == 0 && $parsedDate['warning_count'] == 0 && $parsedDate['year'] > 0 && $parsedDate['month'] > 0 && $parsedDate['day'] > 0) { // Format and return the date as yyyy-mm-dd return sprintf('%04d-%02d-%02d', $parsedDate['year'], $parsedDate['month'], $parsedDate['day']); } else { $yearOfText = date('Y'); $str .= ' '; // Needed to capture dates and the end of the subject // Get format dd.mm.yyyy if (preg_match_all('/\s+(3[01]|[12][0-9]|0?[1-9])\.(1[012]|0?[1-9])\.((?:19|20)?(?:1|2)\d)[^\d]/', $str, $m)) { foreach ($m[0] as $key => $val) { $day = str_pad($m[1][$key], 2, '0', STR_PAD_LEFT); $month = str_pad($m[2][$key], 2, '0', STR_PAD_LEFT); $year = (strlen($m[3][$key]) == 2) ? '20' . $m[3][$key] : $m[3][$key]; $date = new DateTime($year . '-' . $month . '-' . $day); // $date = DateTime::createFromFormat('Y-m-d', $date) if ($date) { return $date->format('Y-m-d'); } } } // Get format dd.mm. and dd.mm if (preg_match_all('/\s+(3[01]|[12][0-9]|0?[1-9])\.(1[012]|0?[1-9])(?:\.|\s)[^\d]/', $str, $m)) { foreach ($m[0] as $key => $val) { $day = str_pad($m[1][$key], 2, '0', STR_PAD_LEFT); $month = str_pad($m[2][$key], 2, '0', STR_PAD_LEFT); $year = $yearOfText; $date = new DateTime($year . '-' . $month . '-' . $day); if ($date) { return $date->format('Y-m-d'); } } } return null; } } /** * Convert a string to a boolean value. * * @param mixed $val The value to convert to a boolean * @param bool|null $default The default value to return if the input value is null or an empty string * @return bool The converted boolean value */ public static function boolval(mixed $val, ?bool $default = null): bool { if ($val === 'false' || $val === '0') { return false; } elseif ($val === 'true' || $val === '1') { return true; } elseif (!is_null($default) && (is_null($val) || $val === '')) { return $default; } else { $boolval = (is_string($val) ? filter_var($val, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) : (bool) $val); } return (bool) $boolval; } /** * Group an IBAN into blocks of four characters. */ public static function formatIban(string $iban): string { return implode(' ', str_split(preg_replace('/\s+/', '', $iban), 4)); } /** * Check that the IBAN looks structurally valid. Does NOT verify the checksum. */ public static function validateIbanFormatting(string $iban): bool { return (bool) preg_match('/^[A-Z]{2}\d{2}\s?([0-9a-zA-Z]\s?){13,32}$/', $iban); } } /** * Helper class for accessing app metadata from environment variables. * These are set automatically by the Enneo platform when running an app. */ class AppInfo { public static function getId(): ?string { return $_ENV['ENNEO_APP_ID'] ?? null; } public static function getSlug(): ?string { return $_ENV['ENNEO_APP_SLUG'] ?? null; } public static function getVersion(): ?string { return $_ENV['ENNEO_APP_VERSION'] ?? null; } } /** * Persistent key-value storage for apps. * Values must be JSON-compliant (null, string, array, object, float, int). */ class AppStorage { private static function getAppId(): string { $appId = AppInfo::getId(); if (!$appId) { throw new Exception("ENNEO_APP_ID not set. AppStorage is only available when running as an app."); } return $appId; } /** * Get a value by key. * * @param string $key The storage key * @return mixed The stored value * @throws Exception */ public static function get(string $key): mixed { $appId = self::getAppId(); $result = ApiEnneo::get("/api/mind/app/{$appId}/data/{$key}", authorizeAs: 'serviceWorker'); return $result->value ?? null; } /** * Get a value with metadata (createdAt, modifiedAt, lastAccessAt). * * @param string $key The storage key * @return object Object with keys: key, value, createdAt, modifiedAt, lastAccessAt * @throws Exception */ public static function getMeta(string $key): object { $appId = self::getAppId(); return ApiEnneo::get("/api/mind/app/{$appId}/data/{$key}/meta", authorizeAs: 'serviceWorker'); } /** * Save a value. Creates a new entry if the key doesn't exist, updates if it does. * * @param string $key The storage key * @param mixed $value The value to store (must be JSON-compliant) * @return object|array Response with success status * @throws Exception */ public static function save(string $key, mixed $value): object|array { $appId = self::getAppId(); return ApiEnneo::put("/api/mind/app/{$appId}/data/{$key}", $value, authorizeAs: 'serviceWorker'); } /** * Delete a key from storage. * * @param string $key The storage key to delete * @return object|array Response * @throws Exception */ public static function delete(string $key): object|array { $appId = self::getAppId(); return ApiEnneo::delete("/api/mind/app/{$appId}/data/{$key}", authorizeAs: 'serviceWorker'); } /** * List all storage keys for this app. * * @return array List of objects with key, createdAt, modifiedAt, lastAccessAt * @throws Exception */ public static function listKeys(): array { $appId = self::getAppId(); $result = ApiEnneo::get("/api/mind/app/{$appId}/data", authorizeAs: 'serviceWorker'); return $result->keys ?? []; } }