otsdaq-utilities  3.10.00
ChatSupervisor.cc
1 #include "otsdaq-utilities/Chat/ChatSupervisor.h"
2 #include "otsdaq/CgiDataUtilities/CgiDataUtilities.h"
3 #include "otsdaq/GatewaySupervisor/GatewaySupervisor.h"
4 #include "otsdaq/Macros/CoutMacros.h"
5 #include "otsdaq/Macros/StringMacros.h"
6 #include "otsdaq/MessageFacility/MessageFacility.h"
7 #include "otsdaq/XmlUtilities/HttpXmlDocument.h"
8 
9 #include <xdaq/NamespaceURI.h>
10 
11 #include <fcntl.h>
12 #include <signal.h>
13 #include <sys/file.h>
14 #include <sys/wait.h>
15 #include <unistd.h>
16 
17 #include <fstream>
18 #include <iostream>
19 #include <sstream>
20 
21 using namespace ots;
22 
23 #undef __MF_SUBJECT__
24 #define __MF_SUBJECT__ "Chat"
25 
26 XDAQ_INSTANTIATOR_IMPL(ChatSupervisor)
27 
28 //==============================================================================
29 ChatSupervisor::ChatSupervisor(xdaq::ApplicationStub* stub) : CoreSupervisorBase(stub)
30 {
31  INIT_MF("." /*directory used is USER_DATA/LOG/.*/);
32 
33  ChatLastUpdateIndex = 1; // skip 0
34  slackDaemonPid_ = -1;
35 
36  // run ots_setup_slack.sh to enable OTS_EN_SLACK environment variable
37  enableSlackChat = (std::getenv("OTS_EN_SLACK") != nullptr &&
38  std::string(std::getenv("OTS_EN_SLACK")) == "1");
39  if(enableSlackChat)
40  {
41  const char* env = std::getenv("OTSDAQ_UTILITIES_DIR");
42  chatSupervisorToolsPath_ = env ? env : "";
43 
44  if(chatSupervisorToolsPath_.empty())
45  enableSlackChat = false;
46  if(chatSupervisorToolsPath_.back() != '/')
47  chatSupervisorToolsPath_ += '/';
48  chatSupervisorToolsPath_ += "tools/";
49 
50  const char* userData = std::getenv("USER_DATA");
51  if(userData)
52  slackInboxPath_ = std::string(userData) + "/ChatSlackInbox.txt";
53  else
54  slackInboxPath_ =
55  "/tmp/ots_slack_inbox_uid" + std::to_string(getuid()) + ".txt";
56 
57  // Require Slack configuration before enabling daemon/send-to-Slack behavior.
58  if(std::getenv("SLACK_BOT_TOKEN") == nullptr ||
59  std::getenv("SLACK_CHANNEL") == nullptr ||
60  std::getenv("SLACK_CHANNEL_ID") == nullptr)
61  enableSlackChat = false;
62 
63  __COUT__ << "ChatSupervisor: Slack chat "
64  << (enableSlackChat ? "enabled" : "disabled") << __E__;
65  __COUT__ << "ChatSupervisor path: " << chatSupervisorToolsPath_ << __E__;
66 
67  if(enableSlackChat)
68  startSlackDaemon();
69  }
70 }
71 
72 //==============================================================================
73 ChatSupervisor::~ChatSupervisor(void) { destroy(); }
74 
75 //==============================================================================
76 void ChatSupervisor::destroy(void) { stopSlackDaemon(); }
77 
78 //==============================================================================
79 void ChatSupervisor::defaultPage(xgi::Input* /* cgiIn */, xgi::Output* out)
80 {
81  out->getHTTPResponseHeader().addHeader("Access-Control-Allow-Origin", "*");
82  out->getHTTPResponseHeader().addHeader("Pragma", "no-cache");
83 
84  *out << "<!DOCTYPE HTML><html lang='en'><frameset col='100%' row='100%'><frame "
85  "src='/WebPath/html/Chat.html?urn="
86  << this->getApplicationDescriptor()->getLocalId() << "'></frameset></html>";
87 } //end defaultPage()
88 
89 //==============================================================================
93 {
94  CorePropertySupervisorBase::setSupervisorProperty(
95  CorePropertySupervisorBase::SUPERVISOR_PROPERTIES.AutomatedRequestTypes,
96  "RefreshChat");
97 }
98 
99 //==============================================================================
103 void ChatSupervisor::request(const std::string& requestType,
104  cgicc::Cgicc& cgiIn,
105  HttpXmlDocument& xmlOut,
106  const WebUsers::RequestUserInfo& /*userInfo*/)
107 {
108  __COUTVS__(40, requestType);
109 
110  // Commands:
111  // RefreshChat
112  // RefreshUsers
113  // SendChat
114 
115  cleanupExpiredChats();
116 
117  if(requestType == "RefreshChat")
118  {
119  receiveFromSlack();
120 
121  std::string lastUpdateIndexString =
122  CgiDataUtilities::postData(cgiIn, "lastUpdateIndex");
123  std::string user = CgiDataUtilities::postData(cgiIn, "user");
124  uint64_t lastUpdateIndex;
125  sscanf(lastUpdateIndexString.c_str(), "%lu", &lastUpdateIndex);
126 
127  insertChatRefresh(&xmlOut, lastUpdateIndex, user);
128  }
129  else if(requestType == "RefreshUsers")
130  {
131  insertActiveUsers(&xmlOut);
132  }
133  else if(requestType == "SendChat")
134  {
135  std::string chat = CgiDataUtilities::postData(cgiIn, "chat");
136  std::string user = CgiDataUtilities::postData(cgiIn, "user");
137  std::string type = CgiDataUtilities::postData(cgiIn, "type");
138  std::string notify = CgiDataUtilities::postData(cgiIn, "notify");
139 
140  escapeChat(chat);
141 
142  newChat(chat, user, type);
143 
144  if(notify == "1" || notify == "true")
145  GatewaySupervisor::addSystemMessage("*", "[USER] " + chat);
146  }
147  else if(requestType == "PageUser")
148  {
149  std::string topage = CgiDataUtilities::postData(cgiIn, "topage");
150  unsigned int topageId = CgiDataUtilities::postDataAsInt(cgiIn, "topageId");
151  std::string user = CgiDataUtilities::postData(cgiIn, "user");
152 
153  __COUT__ << "Paging = " << topage.substr(0, 10)
154  << "... from user = " << user.substr(0, 10) << std::endl;
155 
156  __COUTV__(topageId);
157 
158  theRemoteWebUsers_.sendSystemMessage(topage,
159  user + " is paging you to come chat.");
160  }
161  else
162  {
163  __SUP_SS__ << "requestType Request, " << requestType
164  << ", not recognized by the Chat Editor Supervisor (was it intended "
165  "for another Supervisor?)."
166  << __E__;
167  __SUP_SS_THROW__;
168  }
169 
170 } // end request()
171 
172 //==============================================================================
176 void ChatSupervisor::escapeChat(std::string& /*chat*/)
177 {
178  // char reserved[] = {'"','\'','&','<','>'};
179  // std::string replace[] = {"&#34;","&#39;","&#38;","&#60;","&#62;"};
180  // for(uint64_t i=0;i<chat.size();++i)
181  // for(uint64_t j=0;j<chat.size();++j)
182  // if(chat[i] ==
183 } // end escapeChat()
184 
185 //==============================================================================
187 void ChatSupervisor::insertActiveUsers(HttpXmlDocument* xmlOut)
188 {
189  xmlOut->addTextElementToData("active_users", theRemoteWebUsers_.getActiveUserList());
190 } // end insertActiveUsers()
191 
192 //==============================================================================
199 void ChatSupervisor::insertChatRefresh(HttpXmlDocument* xmlOut,
200  uint64_t lastUpdateIndex,
201  const std::string& user)
202 {
203  newUser(user);
204 
205  if(!isLastUpdateIndexStale(lastUpdateIndex))
206  return; // if lastUpdateIndex is current, return nothing
207 
208  // return new update index, full chat user list, and new chats!
209 
210  char tempStr[50];
211  sprintf(tempStr, "%lu", ChatLastUpdateIndex);
212  xmlOut->addTextElementToData("last_update_index", tempStr);
213 
214  // get all users
215  xmlOut->addTextElementToData("chat_users", "");
216  for(uint64_t i = 0; i < ChatUsers_.size(); ++i)
217  xmlOut->addTextElementToParent("chat_user", ChatUsers_[i], "chat_users");
218 
219  //if lastUpdateIndex == 0, first request, so give give full history!
220 
221  // get all accounts
222  xmlOut->addTextElementToData("chat_history", "");
223  for(uint64_t i = 0; i < ChatHistoryEntry_.size(); ++i) // output oldest to new
224  {
225  __COUTT__ << "Chat[" << i << "]: " << ChatHistoryIndex_[i] << " vs "
226  << lastUpdateIndex << __E__;
227  if(isChatOld(ChatHistoryIndex_[i], lastUpdateIndex))
228  continue;
229 
230  xmlOut->addTextElementToParent(
231  "chat_entry", ChatHistoryEntry_[i], "chat_history");
232  xmlOut->addTextElementToParent(
233  "chat_author", ChatHistoryAuthor_[i], "chat_history");
234  xmlOut->addTextElementToParent("chat_type", ChatHistoryType_[i], "chat_history");
235  sprintf(tempStr, "%lu", ChatHistoryTime_[i]);
236  xmlOut->addTextElementToParent("chat_time", tempStr, "chat_history");
237  }
238 } // end insertChatRefresh()
239 
240 //==============================================================================
243 void ChatSupervisor::newUser(const std::string& user)
244 {
245  for(uint64_t i = 0; i < ChatUsers_.size(); ++i)
246  if(ChatUsers_[i] == user)
247  {
248  ChatUsersTime_[i] = time(0); // update time
249  return; // do not add new if found
250  }
251 
252  __COUT__ << "New user: " << user << std::endl;
253  // add and increment
254  ChatUsers_.push_back(user);
255  ChatUsersTime_.push_back(time(0));
256  newChat(user + " joined the chat.",
257  "ots"); // add status message to chat, increment update
258 } // end newUser()
259 
260 //==============================================================================
263 void ChatSupervisor::newChat(const std::string& chat,
264  const std::string& user,
265  const std::string& type,
266  bool fromSlack)
267 {
268  ChatHistoryEntry_.push_back(chat);
269  ChatHistoryAuthor_.push_back(user);
270  ChatHistoryType_.push_back(type);
271  ChatHistoryTime_.push_back(time(0));
272  ChatHistoryIndex_.push_back(incrementAndGetLastUpdate());
273  if(enableSlackChat && !fromSlack)
274  sendToSlack(user, chat);
275 }
276 
277 //==============================================================================
280 bool ChatSupervisor::isChatOld(uint64_t chatIndex, uint64_t last)
281 {
282  return (last - chatIndex < (uint64_t(1) << 62));
283 }
284 
285 //==============================================================================
287 bool ChatSupervisor::isLastUpdateIndexStale(uint64_t last)
288 {
289  return ChatLastUpdateIndex != last;
290 }
291 
292 //==============================================================================
294 uint64_t ChatSupervisor::incrementAndGetLastUpdate()
295 {
296  if(!++ChatLastUpdateIndex)
297  ++ChatLastUpdateIndex; // skip 0
298  return ChatLastUpdateIndex;
299 }
300 
301 //==============================================================================
304 void ChatSupervisor::cleanupExpiredChats()
305 {
306  for(uint64_t i = 0; i < ChatHistoryEntry_.size(); ++i)
307  if(i >= CHAT_HISTORY_MAX_ENTRIES ||
308  ChatHistoryTime_[i] + CHAT_HISTORY_EXPIRATION_TIME < time(0)) // expired
309  {
310  removeChatHistoryEntry(i);
311  --i; // rewind loop
312  }
313  else
314  break; // chronological order, so first encountered that is still valid exit
315  // loop
316 
317  for(uint64_t i = 0; i < ChatUsers_.size(); ++i)
318  if(ChatUsersTime_[i] + CHAT_HISTORY_EXPIRATION_TIME < time(0)) // expired
319  {
320  removeChatUserEntry(i);
321  --i; // rewind loop
322  }
323  else
324  break; // chronological order, so first encountered that is still valid exit
325  // loop
326 } // end cleanupExpiredChats()
327 
328 //==============================================================================
330 void ChatSupervisor::removeChatHistoryEntry(uint64_t i)
331 {
332  ChatHistoryEntry_.erase(ChatHistoryEntry_.begin() + i);
333  ChatHistoryAuthor_.erase(ChatHistoryAuthor_.begin() + i);
334  ChatHistoryType_.erase(ChatHistoryType_.begin() + i);
335  ChatHistoryTime_.erase(ChatHistoryTime_.begin() + i);
336  ChatHistoryIndex_.erase(ChatHistoryIndex_.begin() + i);
337 } // end removeChatHistoryEntry()
338 
339 //==============================================================================
341 void ChatSupervisor::removeChatUserEntry(uint64_t i)
342 {
343  newChat(ChatUsers_[i] + " left the chat.",
344  "ots"); // add status message to chat, increment update
345  ChatUsers_.erase(ChatUsers_.begin() + i);
346  ChatUsersTime_.erase(ChatUsersTime_.begin() + i);
347 } // end removeChatUserEntry()
348 
349 //==============================================================================
351 void ChatSupervisor::sendToSlack(const std::string& user, const std::string& message)
352 {
353  // URL-encode message and user so that UTF-8 emoji bytes, quotes, and any
354  // other shell-special characters survive the shell command line intact.
355  // SendSlackChat.py calls urllib.parse.unquote() on both args to decode.
356  std::string command = "python3 " + chatSupervisorToolsPath_ + "SendSlackChat.py " +
357  "--message " + StringMacros::encodeURIComponent(message) +
358  " --user " + StringMacros::encodeURIComponent(user);
359  __COUT__ << "Executing command: " << command << __E__;
360 
361  try
362  {
363  auto result = StringMacros::exec(command.c_str());
364 
365  if(!result.empty() && result.find("Error:") != std::string::npos)
366  __COUT__ << "Error from SendSlackChat.py: " << result << __E__;
367  else if(!result.empty())
368  __COUT__ << "Response from SendSlackChat.py: " << result << __E__;
369  }
370  catch(const std::exception& e)
371  {
372  __COUT__ << "Exception while executing command: " << e.what() << __E__;
373  }
374 } // end sendToSlack()
375 
376 //==============================================================================
379 void ChatSupervisor::startSlackDaemon()
380 {
381  if(slackDaemonPid_ > 0)
382  return;
383 
384  std::string script = chatSupervisorToolsPath_ + "ReceiveSlackChat.py";
385  const char* intervalEnv = std::getenv("OTS_SLACK_POLL_INTERVAL");
386  std::string interval = intervalEnv ? intervalEnv : "30";
387  __COUT__ << "Starting Slack receive daemon: " << script << " -> " << slackInboxPath_
388  << " (interval=" << interval << "s)" << __E__;
389 
390  pid_t pid = fork();
391  if(pid < 0)
392  {
393  __COUT__ << "Failed to fork Slack receive daemon" << __E__;
394  return;
395  }
396  if(pid == 0)
397  {
398  execlp("python3",
399  "python3",
400  script.c_str(),
401  "--output",
402  slackInboxPath_.c_str(),
403  "--interval",
404  interval.c_str(),
405  (char*)nullptr);
406  _exit(1);
407  }
408 
409  slackDaemonPid_ = pid;
410  __COUT__ << "Slack receive daemon started with PID " << slackDaemonPid_ << __E__;
411 } // end startSlackDaemon()
412 
413 //==============================================================================
416 void ChatSupervisor::stopSlackDaemon()
417 {
418  if(slackDaemonPid_ <= 0)
419  return;
420 
421  int status = 0;
422  pid_t probe_pid = waitpid(slackDaemonPid_, &status, WNOHANG);
423  if(probe_pid < 0)
424  {
425  __COUT__ << "Failed to probe Slack receive daemon PID " << slackDaemonPid_
426  << "; leaving daemon state and inbox files intact" << __E__;
427  return;
428  }
429  bool can_manage = probe_pid != slackDaemonPid_;
430 
431  if(can_manage)
432  {
433  __COUT__ << "Stopping Slack receive daemon PID " << slackDaemonPid_ << __E__;
434  kill(slackDaemonPid_, SIGTERM);
435  }
436  bool exited = !can_manage;
437  for(int i = 0; i < 50 && !exited; ++i) // ~5s total
438  {
439  pid_t r = waitpid(slackDaemonPid_, &status, WNOHANG);
440  if(r == slackDaemonPid_)
441  {
442  exited = true;
443  break;
444  }
445  if(r < 0)
446  {
447  exited = true; // already reaped / not our child
448  break;
449  }
450  usleep(100000);
451  }
452  if(!exited)
453  {
454  __COUT__ << "Slack receive daemon did not exit after SIGTERM; sending SIGKILL"
455  << __E__;
456  kill(slackDaemonPid_, SIGKILL);
457  waitpid(slackDaemonPid_, &status, 0);
458  }
459  else
460  {
461  (void)waitpid(
462  slackDaemonPid_, &status, WNOHANG); // ensure reaped if it exited quickly
463  }
464 
465  slackDaemonPid_ = -1;
466 
467  // Clean up the inbox file
468  unlink(slackInboxPath_.c_str());
469  unlink((slackInboxPath_ + ".lock").c_str());
470 } // end stopSlackDaemon()
471 
472 //==============================================================================
476 void ChatSupervisor::receiveFromSlack()
477 {
478  if(!enableSlackChat || slackInboxPath_.empty())
479  return;
480 
481  std::string lockPath = slackInboxPath_ + ".lock";
482  int lockFd = open(lockPath.c_str(), O_WRONLY | O_CREAT, 0644);
483  if(lockFd < 0)
484  {
485  __COUT__ << "receiveFromSlack: cannot open lock file " << lockPath << __E__;
486  return;
487  }
488 
489  if(flock(lockFd, LOCK_EX | LOCK_NB) != 0)
490  {
491  close(lockFd);
492  return; // daemon is writing, try next cycle
493  }
494 
495  std::vector<std::string> lines;
496  {
497  std::ifstream infile(slackInboxPath_);
498  if(infile.is_open())
499  {
500  std::string line;
501  while(std::getline(infile, line))
502  if(!line.empty())
503  lines.push_back(line);
504  }
505  }
506 
507  // Truncate the file after reading
508  if(!lines.empty())
509  {
510  __COUT__ << "receiveFromSlack: read " << lines.size() << " line(s) from "
511  << slackInboxPath_ << __E__;
512  std::ofstream clearFile(slackInboxPath_, std::ios::trunc);
513  }
514 
515  flock(lockFd, LOCK_UN);
516  close(lockFd);
517 
518  for(const auto& line : lines)
519  {
520  size_t firstTab = line.find('\t');
521  if(firstTab == std::string::npos)
522  {
523  __COUT__ << "receiveFromSlack: skipping malformed line (no tab): " << line
524  << __E__;
525  continue;
526  }
527 
528  std::string tag = line.substr(0, firstTab);
529  if(tag != "MSG")
530  {
531  __COUT__ << "receiveFromSlack: skipping unknown tag '" << tag << "'" << __E__;
532  continue;
533  }
534 
535  size_t secondTab = line.find('\t', firstTab + 1);
536  if(secondTab == std::string::npos)
537  {
538  __COUT__ << "receiveFromSlack: skipping malformed MSG line: " << line
539  << __E__;
540  continue;
541  }
542 
543  std::string user = line.substr(firstTab + 1, secondTab - firstTab - 1);
544  std::string message = line.substr(secondTab + 1);
545 
546  auto replaceAll =
547  [](std::string& s, const std::string& from, const std::string& to) {
548  size_t pos = 0;
549  while((pos = s.find(from, pos)) != std::string::npos)
550  {
551  s.replace(pos, from.size(), to);
552  pos += to.size();
553  }
554  };
555 
556  // Keep OTS chat percent-encoding expected by WebGUI/html/Chat.html convertForClient().
557  replaceAll(user, "&", "%26");
558  replaceAll(user, "<", "%3C");
559  replaceAll(user, ">", "%3E");
560  replaceAll(user, "\"", "%22");
561  replaceAll(user, "'", "%27");
562 
563  // Reverse ReceiveSlackChat.py escaping: "\\\\" -> "\\" and "\\n" -> newline marker.
564  std::string decoded;
565  decoded.reserve(message.size());
566  for(size_t i = 0; i < message.size(); ++i)
567  {
568  if(message[i] == '\\' && i + 1 < message.size())
569  {
570  if(message[i + 1] == '\\')
571  {
572  decoded.push_back('\\');
573  ++i;
574  continue;
575  }
576  if(message[i + 1] == 'n')
577  {
578  decoded += "%0A%0D";
579  ++i;
580  continue;
581  }
582  }
583  decoded.push_back(message[i]);
584  }
585  message.swap(decoded);
586 
587  replaceAll(message, "&", "%26");
588  replaceAll(message, "<", "%3C");
589  replaceAll(message, ">", "%3E");
590  replaceAll(message, "\"", "%22");
591  replaceAll(message, "'", "%27");
592  replaceAll(message, " ", "%20%20");
593 
594  __COUT__ << "receiveFromSlack: injecting message from user '" << user << "' ("
595  << message.size() << " bytes)" << __E__;
596  newChat(message, "[slack] " + user, "" /*type*/, /*fromSlack=*/true);
597  }
598 } // end receiveFromSlack()
static std::string postData(cgicc::Cgicc &cgi, const std::string &needle)
virtual void request(const std::string &requestType, cgicc::Cgicc &cgiIn, HttpXmlDocument &xmlOut, const WebUsers::RequestUserInfo &userInfo) override
end forceSupervisorPropertyValues()
virtual void forceSupervisorPropertyValues(void) override
override to force supervisor property values (and ignore user settings)
xercesc::DOMElement * addTextElementToParent(const std::string &childName, const std::string &childText, xercesc::DOMElement *parent)
void INIT_MF(const char *name)
static std::string exec(const char *cmd)