diff --git a/extension/Classes/Api/websocket.php b/extension/Classes/Api/websocket.php index bbd7695531b1c17e4e45838adaec81d7345e01a3..4d26a32371b7b052193b6dac8499e2dfcb1c19e4 100644 --- a/extension/Classes/Api/websocket.php +++ b/extension/Classes/Api/websocket.php @@ -11,6 +11,9 @@ namespace IMATHUZH\Qfq\Api; require_once(__DIR__ . '/../../vendor/autoload.php'); use IMATHUZH\Qfq\Core\Form\Chat; +use IMATHUZH\Qfq\Core\Helper\Logger; +use IMATHUZH\Qfq\Core\Helper\Path; +use IMATHUZH\Qfq\Core\Store\Config; use Ratchet\Server\IoServer; use Ratchet\Http\HttpServer; use Ratchet\WebSocket\WsServer; @@ -25,8 +28,17 @@ use Ratchet\WebSocket\WsServer; define('QFQ_API', 'Api call'); $answer = array(); + +$logFile = Path::absoluteWebsocketLogFile(); +$timestamp = date("Y-m-d H:i:s"); + // Define a port number -define('PORT', 8090); +$configuredPort = Config::get(SYSTEM_WEBSOCKET_PORT) ?? ''; +define('PORT', $configuredPort); + +if (empty($configuredPort)) { + Logger::logMessage($timestamp . " - No port defined. Websocket server can not be started. Set the port in QFQ config and restart docker.", $logFile); +} try { $chat = new Chat(); @@ -40,6 +52,7 @@ try { PORT ); + Logger::logMessage($timestamp . " - Port: " . $configuredPort . ". Websocket server started!", $logFile); $server->run(); } catch (\Throwable $e) { diff --git a/extension/Classes/Controller/QfqController.php b/extension/Classes/Controller/QfqController.php index 6cf5822e70d256f3f1defd9275bbe8f14575e9d2..abc3b3bd87b16af2d89fee3489870d1f920095e3 100644 --- a/extension/Classes/Controller/QfqController.php +++ b/extension/Classes/Controller/QfqController.php @@ -7,7 +7,10 @@ namespace IMATHUZH\Qfq\Controller; require_once(__DIR__ . '/../../vendor/autoload.php'); +use IMATHUZH\Qfq\Core\Helper\Logger; +use IMATHUZH\Qfq\Core\Helper\Path; use IMATHUZH\Qfq\Core\QuickFormQuery; +use IMATHUZH\Qfq\Core\Store\Store; use IMATHUZH\Qfq\Core\Typo3\T3Handler; use TYPO3\CMS\Core\Http\HtmlResponse; @@ -46,6 +49,8 @@ class QfqController extends \TYPO3\CMS\Extbase\Mvc\Controller\ActionController { $html = $qfq->process(); $flagOk = true; + $this->websocketStart(); + } catch (\UserFormException $e) { $html = $e->formatMessage(); @@ -98,4 +103,32 @@ class QfqController extends \TYPO3\CMS\Extbase\Mvc\Controller\ActionController { return $content; } + private function websocketStart() { + // Websocket preparations for non docker environment + $scriptName = Path::urlApi('websocket.php'); + $command = 'nohup php ' . $scriptName . ' &'; + $logFile = Path::absoluteWebsocketLogFile(); + $websocketKillCmd = 'pkill -f ' . $scriptName; + $timestamp = date("Y-m-d H:i:s"); + + $websocketPort = Store::getVar(SYSTEM_WEBSOCKET_PORT, STORE_SYSTEM); + + // Use pgrep to check if the WebSocket server is already running + $running = shell_exec("pgrep -f '$scriptName'"); + + // In no docker environment the websocket server start/shut down can be handled automatically. + if (!file_exists('/.dockerenv')) { + if(!empty($websocketPort) && !$running) { + // Execute command and redirect its output to the log file + shell_exec($command . ' >> ' . $logFile . ' 2>&1'); + + } elseif (empty($websocketPort) && $running) { + shell_exec($websocketKillCmd . ' >> ' . $logFile . ' 2>&1'); + + $message = "WebSocket server shut down."; + Logger::logMessage($timestamp . ' - ' . $message, $logFile); + } + } + } + } \ No newline at end of file diff --git a/extension/Classes/Core/AbstractBuildForm.php b/extension/Classes/Core/AbstractBuildForm.php index abb08ba0fb066108590ce0e165e42a8c4078ce79..1e4843695de2f1391b34f5272cbdafb6c6778175 100644 --- a/extension/Classes/Core/AbstractBuildForm.php +++ b/extension/Classes/Core/AbstractBuildForm.php @@ -3118,6 +3118,7 @@ abstract class AbstractBuildForm { */ public function buildChat(array $formElement, $htmlFormElementName, $value, array &$json, $mode = FORM_LOAD) { $chatConfig = Support::createChatConfig($formElement, $this->store); + $chatConfigJson = json_encode($chatConfig, JSON_UNESCAPED_SLASHES); $baseUrl = $this->store::getVar(SYSTEM_BASE_URL, STORE_SYSTEM . STORE_EMPTY); // Part 1: Build fieldset for chat @@ -3140,6 +3141,7 @@ abstract class AbstractBuildForm { // Part 2: Build chat bubbles content $chatWindowAttribute = Support::doAttribute('name', $htmlFormElementName . '-chat'); $chatWindowAttribute .= Support::doAttribute(ATTRIBUTE_DATA_REFERENCE, $formElement[FE_DATA_REFERENCE] . '-chat'); + $chatWindowAttribute .= Support::doAttribute('data-chat-config', $chatConfigJson); $chatHead = '<div class="qfq-chat-window" '. $chatWindowAttribute . '><span class="fas fa-search chat-search-activate"></span><div class="chat-search"><input type="text" class="chat-search-input qfq-skip-dirty" placeholder="Search..." ' . $chatConfig['disabledFlag'] . '><button class="chat-search-btn qfq-skip-dirty" ' . $chatConfig['disabledFlag'] . '>Search</button><span class="chat-search-info"></span></div><div class="chat-messages"><div class="fas fa-arrow-circle-up chat-top-symbol"></div>'; $chatTail = '</div></div>'; @@ -3238,8 +3240,10 @@ abstract class AbstractBuildForm { $inputHtml = "$inputHtml $inputAttribute>$textarea"; $wrapSetupClass = $this->wrap[WRAP_SETUP_ELEMENT][WRAP_SETUP_CLASS] ?? ''; - // value is not needed for chat element. reset it to empty - $value = ''; + + // Return last message id as value + $columnId = array_column($result, $dbColumnNames['id']); + $value = max($columnId); $json = $this->getFormElementForJson($htmlFormElementName, $value, $formElement, $wrapSetupClass); $json['chat'] = $chatJson; diff --git a/extension/Classes/Core/Constants.php b/extension/Classes/Core/Constants.php index 21505e04c2e9fbdbc0b687bd48c363e34035951c..4ac4ec52f103d2bf0e538bb00db025e0afb855c0 100644 --- a/extension/Classes/Core/Constants.php +++ b/extension/Classes/Core/Constants.php @@ -793,6 +793,7 @@ const CSS_REQUIRED_LEFT = 'required-left'; const SYSTEM_QFQ_PROJECT_PATH = 'qfqProjectPath'; const SYSTEM_DO_NOT_LOG_COLUMN = 'doNotLogColumn'; const SYSTEM_PROTECTED_FOLDER_CHECK = 'protectedFolderCheck'; +const SYSTEM_WEBSOCKET_PORT = 'websocketPort'; const EXTRA_ENABLE_SWITCH = 'enableSwitch'; const EXTRA_COLUMN_NAME_AlIAS_SLUG = 'columnNameAliasSlug'; diff --git a/extension/Classes/Core/Form/Chat.php b/extension/Classes/Core/Form/Chat.php index 71a06f5de46dbd56bc0e0bbe28a7309fed4c4815..5d04da1d08dc5825d44099f5c90461723fcfa532 100644 --- a/extension/Classes/Core/Form/Chat.php +++ b/extension/Classes/Core/Form/Chat.php @@ -9,6 +9,8 @@ namespace IMATHUZH\Qfq\Core\Form; +use IMATHUZH\Qfq\Core\Helper\Logger; +use IMATHUZH\Qfq\Core\Helper\Path; use Ratchet\MessageComponentInterface; use Ratchet\ConnectionInterface; @@ -19,34 +21,101 @@ use Ratchet\ConnectionInterface; */ class Chat implements MessageComponentInterface { protected $clients; + protected $logFile; + protected $clientInfo; + protected $clientConnections; public function __construct() { $this->clients = new \SplObjectStorage; + $this->logFile = Path::absoluteWebsocketLogFile(); + $this->clientInfo = []; } public function onOpen(ConnectionInterface $conn) { $this->clients->attach($conn); - echo "New connection! ({$conn->resourceId})\n"; + $this->clientConnections[$conn->resourceId] = $conn; + $timestamp = date("Y-m-d H:i:s"); + Logger::logMessage($timestamp . " - " . "New connection! ({$conn->resourceId})", $this->logFile); } public function onMessage(ConnectionInterface $from, $msg) { - foreach ($this->clients as $client) { - if ($from !== $client) { - $client->send($msg); + + $data = json_decode($msg); + // Catch heartbeat connection keeping + if ($data && $data->type == 'heartbeat') { + return; + } + + if ($data && $data->type == 'config') { + // Handle the configuration data + foreach ($data->data as $key => $value) { + $this->clientInfo[$from->resourceId][$key] = $value; } + + $this->updateReceiverIds($from); + + $timestamp = date("Y-m-d H:i:s"); + Logger::logMessage($timestamp . " - " . "Connection configured! ({$from->resourceId}) Config: " . $msg, $this->logFile); + $from->send('Current ID: '. $from->resourceId . ', Receiver IDs: ' . json_encode($this->clientInfo[$from->resourceId]['receiverIds'])); + return; } - if ($msg === 'heartbeat') { - $from->send('pong'); + + // Refresh receiverIds if there exist a new client connection + $this->updateReceiverIds($from); + + if (!empty($this->clientInfo[$from->resourceId])) { + // send message to client with given receiverId + foreach ($this->clientInfo[$from->resourceId]['receiverIds'] as $receiverId) { + if (isset($this->clientConnections[$receiverId])) { + $this->clientConnections[$receiverId]->send('Sent from: '. $from->resourceId); + $messages = $this->getMessages($data, $from, $this->clientInfo[$receiverId]); + $this->clientConnections[$receiverId]->send(json_encode($messages, JSON_UNESCAPED_SLASHES)); + } + } } } public function onClose(ConnectionInterface $conn) { $this->clients->detach($conn); - echo "Connection {$conn->resourceId} has disconnected\n"; + unset($this->clientConnections[$conn->resourceId]); + $timestamp = date("Y-m-d H:i:s"); + Logger::logMessage($timestamp . " - " . "Connection {$conn->resourceId} has disconnected", $this->logFile); } public function onError(ConnectionInterface $conn, \Exception $e) { - echo "An error has occurred: {$e->getMessage()}\n"; + $timestamp = date("Y-m-d H:i:s"); + Logger::logMessage($timestamp . " - " . "An error has occurred: {$e->getMessage()}", $this->logFile); $conn->close(); } + + private function updateReceiverIds($from) { + if (!isset($this->clientInfo[$from->resourceId]['receiverIds'])) { + $this->clientInfo[$from->resourceId]['receiverIds'] = array(); + } + + foreach ($this->clients as $client) { + if ($from !== $client && !in_array($client->resourceId, $this->clientInfo[$from->resourceId]['receiverIds']) && $this->clientInfo[$from->resourceId]['grIdGroupList'] === $this->clientInfo[$client->resourceId]['grIdGroupList']) { + $this->clientInfo[$from->resourceId]['receiverIds'][] = $client->resourceId; + } + } + } + + private function getMessages($data, $from, $client) { + $chatJson = array(); + $chatJson[$data->messageId]['bubbleClass'] = 'chat-left-bubble'; + $chatJson[$data->messageId]['username'] = $this->clientInfo[$from->resourceId]['username']; + + if ($this->clientInfo[$from->resourceId]['pIdCreator'] === $client['pIdCreator']) { + $chatJson[$data->messageId]['bubbleClass'] = 'chat-right-bubble'; + $chatJson[$data->messageId]['username'] = ''; + } + + // current date + $timestamp = time(); + $chatJson[$data->messageId]['title'] = date('d.m.Y H:i', $timestamp); + $chatJson[$data->messageId]['message'] = $data->value; + $chatJson[$data->messageId]['chatTime'] = date('H:i', $timestamp); + + return $chatJson; + } } \ No newline at end of file diff --git a/extension/Classes/Core/Helper/Path.php b/extension/Classes/Core/Helper/Path.php index 4c1acc4ec29da3da4e01da3c2c3763d35320a6a7..a071467419d53adbf62bbdffa4ba0b8e77b830c6 100644 --- a/extension/Classes/Core/Helper/Path.php +++ b/extension/Classes/Core/Helper/Path.php @@ -78,9 +78,11 @@ class Path { private static $overloadAbsoluteQfqLogFile = null; private static $overloadAbsoluteMailLogFile = null; private static $overloadAbsoluteSqlLogFile = null; + private static $overloadAbsoluteWebsocketLogFile = null; private const LOG_TO_QFQ_LOG_FILE_DEFAULT = 'qfq.log'; // Don't use directly, use absoluteQfqLogFile() private const LOG_TO_MAIL_LOG_FILE_DEFAULT = 'mail.log'; // Don't use directly, use absoluteMailLogFile() private const LOG_TO_SQL_LOG_FILE_DEFAULT = 'sql.log'; // Don't use directly, use absoluteSqlLogFile() + private const LOG_TO_WEBSOCKET_LOG_FILE_DEFAULT = 'websocket.log'; private const PROJECT_TO_LOG_DEFAULT = 'log'; // Don't use directly, use absoluteLog() private const APP_TO_LOG_IN_PROTECTED = 'fileadmin/protected/log'; // Don't use directly, use absoluteLog() @@ -164,6 +166,17 @@ class Path { return self::$overloadAbsoluteMailLogFile; } + /** + * @return string + * @throws \UserFormException + */ + public static function absoluteWebsocketLogFile(): string { + if (is_null(self::$overloadAbsoluteWebsocketLogFile)) { + return self::absoluteLog(self::LOG_TO_WEBSOCKET_LOG_FILE_DEFAULT); + } + return self::$overloadAbsoluteWebsocketLogFile; + } + /** * @param array $pathPartsToAppend * @return string diff --git a/extension/ext_conf_template.txt b/extension/ext_conf_template.txt index 677b072fd45c842303994dbe66a4cdc2ea782135..2958e50696b58ecb287a18fb476f8886f0d6e905 100644 --- a/extension/ext_conf_template.txt +++ b/extension/ext_conf_template.txt @@ -19,6 +19,9 @@ editInlineReportDarkTheme = 0 # cat=config/config; type=string; label=Report as File Auto Export:Default is 'no'. If set to 'yes': When a QFQ tt-content record is rendered which does not contain the "file=" keyword, then its body is exported to a file in the qfq-project directory and the tt-content body is replaced by "file=<path_to_file>". reportAsFileAutoExport = +# cat=config/config; type=string; label=Websocket server port:Default is empty and no active websocket. If set: Websocket server is running and ready for chat implementation. +websocketPort = + # cat=graphics/config; type=string; label=Command 'inkscape':Default is 'inkscape'. Will be used to convert SVG to images (png). An empty string disables `inkscape`. If it is not available, `convert` will be used instead. cmdInkscape = inkscape diff --git a/javascript/src/Helper/qfqChat.js b/javascript/src/Helper/qfqChat.js index f9a7599f0d7015de14e6757b6f891efcbc7125a4..d0d8b3859753359d3a3076ed5c2f69f9897fe48a 100644 --- a/javascript/src/Helper/qfqChat.js +++ b/javascript/src/Helper/qfqChat.js @@ -3,7 +3,7 @@ */ /* global console */ -/* global CodeMirror */ +/* global qfqChat */ /* global $ */ /** @@ -22,10 +22,10 @@ QfqNS.Helper = QfqNS.Helper || {}; (function (n) { 'use strict'; - let qfqChat = function () { + let qfqChat = function (form) { var chatWindowsElements = document.getElementsByClassName("qfq-chat-window"); for (var i = 0; i < chatWindowsElements.length; i++) { - new ChatWindow(chatWindowsElements[i]); + new ChatWindow(chatWindowsElements[i], form); } } @@ -35,17 +35,22 @@ QfqNS.Helper = QfqNS.Helper || {}; */ class ChatWindow { - constructor(chatWindowElement) { + constructor(chatWindowElement, form) { this.chatWindow = chatWindowElement; + this.form = form; + this.elementName = this.chatWindow.parentNode.name; this.chatSearch = this.chatWindow.querySelector(".chat-search"); this.searchInput = this.chatWindow.querySelector(".chat-search-input"); this.searchBtn = this.chatWindow.querySelector(".chat-search-btn"); this.chatMessages = this.chatWindow.querySelector(".chat-messages"); this.topBtn = this.chatWindow.querySelector(".chat-top-symbol"); this.activateSearchBtn = this.chatWindow.querySelector(".chat-search-activate"); + this.chatInput = this.chatWindow.nextElementSibling; this.currentSearchIndex = 0; this.searchResults = []; this.lastSearchTerm = ''; + this.connection = ''; + this.chatRefresh = false; this.init(); } @@ -86,10 +91,73 @@ QfqNS.Helper = QfqNS.Helper || {}; } }); + document.addEventListener("visibilitychange", function() { + if (this.chatRefresh) { + this.chatMessages.scrollTop = this.chatMessages.scrollHeight; + this.chatRefresh = false; + } + }.bind(this)); + + this.form.on('form.submit.successful', (obj) => { + let elementName = this.elementName; + let newMessageId = qfqChat.getValue(obj.data, elementName); + + let messageData = {value: this.chatInput.value, messageId: newMessageId}; + this.connection.send(JSON.stringify(messageData)); + console.log('send data: ' + JSON.stringify(messageData)); + }); + this.chatMessages.addEventListener('scroll', () => this.checkOverflow()); this.checkOverflow(); this.scrollToBottom(); + + // Build up websocket connection + this.connection = new WebSocket('ws://webwork20:46301/ws'); + this.connection.onopen = function(e) { + console.log("Connection established!"); + let chatJsonConfigString = this.chatWindow.getAttribute('data-chat-config'); + let chatJsonConfig = JSON.parse(chatJsonConfigString); + + let chatConfig = { + type: "config", + data: chatJsonConfig + }; + + let keepConnection = {type: "heartbeat"}; + + this.connection.send(JSON.stringify(chatConfig)); + // Send heartbeat message every 30 seconds + setInterval(function() { + this.connection.send(JSON.stringify(keepConnection)); + }.bind(this), 30000); + }.bind(this); + + this.connection.onmessage = function(e) { + try { + // Try to parse the data as JSON + let decodedData = JSON.parse(e.data); + let entriesArray = Object.entries(decodedData); + // If the parsing succeeds and it's an array with more than one element + if (entriesArray.length > 0) { + let element = this.chatWindow; + qfqChat.refreshChat(element, decodedData); + + // Set flag if users tab is not active. + if (document.visibilityState !== 'visible') { + this.chatRefresh = true; + } + } + } catch (error) {} + }.bind(this); + + this.connection.onerror = function(e) { + console.error("Connection error!", e); + }; + + this.connection.onclose = function(e) { + console.log("Connection closed!", e); + }; } scrollToBottom() { @@ -270,6 +338,18 @@ QfqNS.Helper = QfqNS.Helper || {}; return chatContainerElement; }; + qfqChat.getValue = function (obj, elementName) { + let returnValue = null; + + obj["form-update"].forEach(item => { + if(item["form-element"] === elementName) { + returnValue = item.value; + } + }); + + return returnValue; + }; + n.qfqChat = qfqChat; })(QfqNS.Helper); diff --git a/javascript/src/QfqForm.js b/javascript/src/QfqForm.js index 250897c9ac0016446cb7b9860da9c4399d134306..7edd0f904468e404154c8d9e2524b5d520d33b09 100644 --- a/javascript/src/QfqForm.js +++ b/javascript/src/QfqForm.js @@ -153,7 +153,7 @@ var QfqNS = QfqNS || {}; //n.Helper.jqxEditor(); n.Helper.tinyMce(); n.Helper.codemirror(); - n.Helper.qfqChat(); + n.Helper.qfqChat(this.form); this.form.on('form.submit.before', n.Helper.tinyMce.prepareSave); this.form.on('form.validation.before', n.Helper.tinyMce.prepareSave);