BxDolAiActivity.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * Human-readable activity of agents: what an alert / scheduler / webhook agent
  7. * actually did (posted a comment, changed content, ran SQL...). One row per
  8. * write tool call, stored in `sys_agents_activity`, written by
  9. * BxDolAiActivityObserver and shown in Studio and in the App.
  10. *
  11. * @defgroup UnaCore UNA Core
  12. * @{
  13. */
  14. class BxDolAiActivity
  15. {
  16. const TABLE = 'sys_agents_activity';
  17. const SUMMARY_MAX = 300;
  18. const DETAILS_MAX = 20000;
  19. /**
  20. * tool name → action key. Tools not listed here are read-only and are not recorded.
  21. */
  22. const TOOL_ACTIONS = [
  23. 'comments_add' => 'comment_add',
  24. 'comments_update' => 'comment_update',
  25. 'comments_delete' => 'comment_delete',
  26. 'content_add' => 'content_add',
  27. 'content_update' => 'content_update',
  28. 'content_delete' => 'content_delete',
  29. 'mysql_write' => 'db_write',
  30. 'mysql_write_safe' => 'db_write',
  31. 'mysql_undo' => 'db_undo',
  32. 'email_send' => 'email',
  33. 'polyglot' => 'lang',
  34. 'agent_create' => 'agent',
  35. 'mockup_upsert' => 'mockup',
  36. ];
  37. public static function isTableReady(): bool
  38. {
  39. static $bReady = null;
  40. if ($bReady === null)
  41. $bReady = BxDolDb::getInstance()->isTableExists(self::TABLE);
  42. return $bReady;
  43. }
  44. /**
  45. * Record one tool call of an agent. $mixedResult is whatever the tool returned
  46. * (array or string); a "Tool ... failed:" string means the call threw.
  47. */
  48. public static function record(array $aAgent, array $aParams, string $sTool, array $aInputs, $mixedResult): int
  49. {
  50. if (!self::isTableReady())
  51. return 0;
  52. $sAction = self::TOOL_ACTIONS[$sTool] ?? '';
  53. if ($sAction === '')
  54. return 0;
  55. if ($sTool === 'agent_create' && !in_array(strtolower((string)($aInputs['action'] ?? '')), ['create', 'update'], true))
  56. return 0;
  57. if ($sTool === 'mockup_upsert' && !empty($aInputs['dry_run']))
  58. return 0;
  59. $aResult = is_array($mixedResult) ? $mixedResult : [];
  60. $sResultText = is_string($mixedResult) ? $mixedResult : '';
  61. if ($sResultText !== '' && ($aDecoded = json_decode($sResultText, true)) && is_array($aDecoded))
  62. $aResult = $aDecoded;
  63. $bOk = true;
  64. if (preg_match('/^Tool "[^"]*" failed:/', $sResultText))
  65. $bOk = false;
  66. elseif (isset($aResult['ok']) && !(int)$aResult['ok'])
  67. $bOk = false;
  68. elseif (isset($aResult['code']) && (int)$aResult['code'] >= 400)
  69. $bOk = false;
  70. elseif ($sTool === 'email_send' && $mixedResult === false)
  71. $bOk = false;
  72. $aRow = self::describeCall($sAction, $sTool, $aInputs, $aResult);
  73. $sThreadId = '';
  74. try {
  75. $sThreadId = (string)BxDolAiChat::threadId($aAgent, $aParams);
  76. } catch (Throwable $o) {
  77. }
  78. $aDetails = [
  79. 'inputs' => self::clipArray($aInputs),
  80. 'result' => $aResult ?: ($sResultText !== '' ? mb_substr($sResultText, 0, 2000) : null),
  81. ];
  82. $sDetails = json_encode($aDetails, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  83. if ($sDetails === false)
  84. $sDetails = '';
  85. if (strlen($sDetails) > self::DETAILS_MAX)
  86. $sDetails = substr($sDetails, 0, self::DETAILS_MAX);
  87. $oDb = BxDolDb::getInstance();
  88. $aSet = [
  89. 'agent_id' => (int)($aAgent['id'] ?? 0),
  90. 'profile_id' => (int)($aAgent['profile_id'] ?? 0),
  91. 'trigger' => (string)($aAgent['trigger'] ?? ''),
  92. 'thread_id' => $sThreadId,
  93. 'tool' => $sTool,
  94. 'action' => $sAction,
  95. 'unit' => (string)$aRow['unit'],
  96. 'object_id' => (int)$aRow['object_id'],
  97. 'object_url' => (string)$aRow['object_url'],
  98. 'object_title' => mb_substr((string)$aRow['object_title'], 0, 255),
  99. 'summary' => (string)$aRow['summary'],
  100. 'details' => $sDetails,
  101. 'ok' => $bOk ? 1 : 0,
  102. 'added' => time(),
  103. ];
  104. // the table may predate object_title
  105. foreach (array_keys($aSet) as $sField) {
  106. if (!$oDb->isFieldExists(self::TABLE, $sField))
  107. unset($aSet[$sField]);
  108. }
  109. $oDb->query("INSERT INTO `" . self::TABLE . "` SET " . $oDb->arrayToSQL($aSet));
  110. return (int)$oDb->lastId();
  111. }
  112. /**
  113. * unit / object_id / object_url / summary for one call — what the row is about.
  114. */
  115. protected static function describeCall(string $sAction, string $sTool, array $aInputs, array $aResult): array
  116. {
  117. $sUnit = '';
  118. $iObject = 0;
  119. $sUrl = '';
  120. $sTitle = '';
  121. $sSummary = '';
  122. $fText = function ($m, $iMax = self::SUMMARY_MAX) {
  123. $s = trim(strip_tags(html_entity_decode((string)$m, ENT_QUOTES, 'UTF-8')));
  124. $s = preg_replace('/\s+/u', ' ', $s);
  125. return mb_strlen($s) > $iMax ? mb_substr($s, 0, $iMax - 1) . '…' : $s;
  126. };
  127. switch ($sAction) {
  128. case 'comment_add':
  129. case 'comment_update':
  130. case 'comment_delete':
  131. $sUnit = (string)($aInputs['module'] ?? '');
  132. $iObject = (int)($aInputs['content_id'] ?? 0);
  133. $iCmt = (int)($aResult['id'] ?? $aInputs['comment_id'] ?? 0);
  134. $sSummary = $fText($aInputs['comment_text'] ?? '');
  135. $aTarget = self::commentTarget($sUnit, $iObject, $iCmt);
  136. $sTitle = $fText($aTarget['title'], 255);
  137. $sUrl = $aTarget['url'];
  138. break;
  139. case 'content_add':
  140. case 'content_update':
  141. case 'content_delete':
  142. $sUnit = (string)($aInputs['module'] ?? '');
  143. $iObject = (int)($aInputs['content_id'] ?? $aResult['id'] ?? $aResult['content_id'] ?? 0);
  144. $aData = is_array($aInputs['data'] ?? null) ? $aInputs['data'] : [];
  145. // deleted content has no title to fetch any more — keep what the tool was given
  146. $sTitle = $fText(self::contentTitle($sUnit, $iObject), 255);
  147. if ($sTitle === '')
  148. $sTitle = $fText($aData['title'] ?? $aData['name'] ?? '', 255);
  149. $sSummary = $fText($aData['text'] ?? $aData['description'] ?? '');
  150. if ($sAction !== 'content_delete')
  151. $sUrl = self::contentUrl($sUnit, $iObject);
  152. break;
  153. case 'db_write':
  154. $sQuery = trim((string)($aInputs['query'] ?? ''));
  155. if (preg_match('/^\s*(insert\s+into|update)\s+`?([a-z0-9_]+)`?/i', $sQuery, $m))
  156. $sUnit = $m[2];
  157. $iObject = (int)($aResult['id'] ?? $aResult['pk_value'] ?? $aResult['insert_id'] ?? 0);
  158. $sSummary = $fText($sQuery);
  159. // a row of a content module's data table is content: link it like content_update
  160. $aTarget = self::contentByTable($sUnit, $iObject);
  161. if ($aTarget) {
  162. $sTitle = $fText($aTarget['title'], 255);
  163. $sUrl = $aTarget['url'];
  164. }
  165. break;
  166. case 'db_undo':
  167. $sSummary = 'scope=' . (string)($aInputs['scope'] ?? 'last');
  168. if (!empty($aResult['undone']) && is_array($aResult['undone']))
  169. $sSummary .= ', ' . count($aResult['undone']) . ' rows';
  170. break;
  171. case 'email':
  172. $sUnit = (string)($aInputs['email'] ?? '');
  173. $sSummary = $fText($aInputs['subject'] ?? '');
  174. break;
  175. case 'lang':
  176. $sUnit = (string)($aInputs['key'] ?? '');
  177. $sSummary = $fText($aInputs['string'] ?? '');
  178. $sUrl = bx_absolute_url('studio/polyglot.php?page=keys');
  179. break;
  180. case 'agent':
  181. $sUnit = strtolower((string)($aInputs['action'] ?? 'create'));
  182. $iObject = (int)($aResult['id'] ?? $aInputs['agent_id'] ?? 0);
  183. $sSummary = $fText($aInputs['title'] ?? ($aResult['name'] ?? ''));
  184. $sUrl = !empty($aResult['studio']) ? bx_absolute_url((string)$aResult['studio']) : '';
  185. break;
  186. case 'mockup':
  187. $sUnit = 'block';
  188. $iObject = (int)($aInputs['block_id'] ?? 0);
  189. $sSummary = $fText($aResult['summary'] ?? '');
  190. $aBlock = self::pageBlock($iObject);
  191. $sTitle = $fText($aBlock['title'] ?? '', 255);
  192. $sUrl = (string)($aBlock['url'] ?? '');
  193. break;
  194. }
  195. return ['unit' => $sUnit, 'object_id' => $iObject, 'object_url' => $sUrl, 'object_title' => $sTitle, 'summary' => $sSummary];
  196. }
  197. /**
  198. * Rows for one agent, newest first, with the text a person reads.
  199. */
  200. public static function listForAgent(int $iAgentId, int $iStart = 0, int $iLimit = 50): array
  201. {
  202. if (!self::isTableReady() || $iAgentId <= 0)
  203. return [];
  204. $iLimit = max(1, min(200, $iLimit));
  205. $iStart = max(0, $iStart);
  206. $aRows = BxDolDb::getInstance()->getAll("SELECT * FROM `" . self::TABLE . "` WHERE `agent_id` = :a ORDER BY `id` DESC LIMIT {$iStart}, {$iLimit}", ['a' => $iAgentId]);
  207. $aOut = [];
  208. foreach ($aRows as $aRow)
  209. $aOut[] = self::formatRow($aRow);
  210. return $aOut;
  211. }
  212. /**
  213. * agent_id → ['count' => n, 'last' => ts] for the agents grid.
  214. */
  215. public static function statsByAgent(): array
  216. {
  217. if (!self::isTableReady())
  218. return [];
  219. $aRows = BxDolDb::getInstance()->getAll("SELECT `agent_id`, COUNT(*) AS `count`, MAX(`added`) AS `last` FROM `" . self::TABLE . "` GROUP BY `agent_id`");
  220. $aOut = [];
  221. foreach ($aRows as $aRow)
  222. $aOut[(int)$aRow['agent_id']] = ['count' => (int)$aRow['count'], 'last' => (int)$aRow['last']];
  223. return $aOut;
  224. }
  225. public static function deleteForAgent(int $iAgentId): void
  226. {
  227. if (!self::isTableReady() || $iAgentId <= 0)
  228. return;
  229. BxDolDb::getInstance()->query("DELETE FROM `" . self::TABLE . "` WHERE `agent_id` = :a", ['a' => $iAgentId]);
  230. }
  231. /**
  232. * @return array id, action, tool, ok, text (human sentence), summary, unit, object_id, url, added, added_formatted, thread_id
  233. */
  234. public static function formatRow(array $aRow): array
  235. {
  236. $iAdded = (int)($aRow['added'] ?? 0);
  237. $aRow = self::resolveTarget($aRow);
  238. return [
  239. 'id' => (int)($aRow['id'] ?? 0),
  240. 'action' => (string)($aRow['action'] ?? ''),
  241. 'tool' => (string)($aRow['tool'] ?? ''),
  242. 'ok' => (int)($aRow['ok'] ?? 1),
  243. 'text' => self::describe($aRow),
  244. 'summary' => (string)($aRow['summary'] ?? ''),
  245. 'unit' => (string)($aRow['unit'] ?? ''),
  246. 'object_id' => (int)($aRow['object_id'] ?? 0),
  247. 'url' => (string)($aRow['object_url'] ?? ''),
  248. 'title' => (string)($aRow['object_title'] ?? ''),
  249. 'thread_id' => (string)($aRow['thread_id'] ?? ''),
  250. 'added' => $iAdded,
  251. 'added_formatted' => $iAdded ? date('d.m.Y H:i', $iAdded) : '',
  252. ];
  253. }
  254. /**
  255. * "Posted a comment on Post #45", "Updated bx_events #3", ... Language keys
  256. * `_sys_agents_activity_<action>` take {unit}, {object}; the English text below is
  257. * the fallback while the keys are not compiled.
  258. */
  259. public static function describe(array $aRow): string
  260. {
  261. $sAction = (string)($aRow['action'] ?? '');
  262. $sUnit = (string)($aRow['unit'] ?? '');
  263. $iObject = (int)($aRow['object_id'] ?? 0);
  264. $bOk = (int)($aRow['ok'] ?? 1) === 1;
  265. $aFallback = [
  266. 'comment_add' => 'Posted a comment on {target}',
  267. 'comment_update' => 'Edited a comment on {target}',
  268. 'comment_delete' => 'Deleted a comment on {target}',
  269. 'content_add' => 'Created {target}',
  270. 'content_update' => 'Updated {target}',
  271. 'content_delete' => 'Deleted {target}',
  272. 'db_write' => 'Changed {target}',
  273. 'db_undo' => 'Rolled back changes',
  274. 'email' => 'Sent an email to {unit}',
  275. 'lang' => 'Changed language key {unit}',
  276. 'agent' => 'Agent {unit}: #{object}',
  277. 'mockup' => 'Updated page block #{object}',
  278. ];
  279. $sKey = '_sys_agents_activity_' . $sAction;
  280. $sTpl = _t($sKey);
  281. if ($sTpl === $sKey)
  282. $sTpl = $aFallback[$sAction] ?? ucfirst(str_replace('_', ' ', $sAction));
  283. $sUnitName = self::unitTitle($sUnit);
  284. $sObjectTitle = trim((string)($aRow['object_title'] ?? ''));
  285. // «Title» when it is known, otherwise "Unit #id"
  286. $sTarget = $sObjectTitle !== ''
  287. ? ($sUnitName !== '' ? $sUnitName . ': ' : '') . '«' . $sObjectTitle . '»'
  288. : trim($sUnitName . ($iObject ? ' #' . $iObject : ''));
  289. if ($sAction === 'db_write' && $sObjectTitle === '')
  290. $sTarget = 'table ' . $sUnit;
  291. // a language string compiled from the first version knows only {unit} #{object}: still show the title
  292. if ($sObjectTitle !== '' && strpos($sTpl, '{target}') === false && strpos($sTpl, '{title}') === false)
  293. $sTpl .= ': «{title}»';
  294. $sText = str_replace(['{target}', '{unit}', '{object}', '{title}'], [$sTarget, $sUnitName, (string)$iObject, $sObjectTitle], $sTpl);
  295. $sText = trim(str_replace(' #0', '', $sText));
  296. if (!$bOk) {
  297. $sFailed = _t('_sys_agents_activity_failed');
  298. if ($sFailed === '_sys_agents_activity_failed')
  299. $sFailed = 'failed';
  300. $sText .= ' (' . $sFailed . ')';
  301. }
  302. return $sText;
  303. }
  304. protected static function unitTitle(string $sUnit): string
  305. {
  306. if ($sUnit === '')
  307. return '';
  308. if (preg_match('/^bx_[a-z0-9_]+$/', $sUnit)) {
  309. $s = _t('_' . $sUnit);
  310. if ($s !== '_' . $sUnit)
  311. return $s;
  312. $aModule = BxDolModuleQuery::getInstance()->getModuleByName($sUnit);
  313. if ($aModule && !empty($aModule['title']))
  314. return $aModule['title'];
  315. }
  316. return $sUnit;
  317. }
  318. /**
  319. * Fill url / title for rows recorded before they were stored. Cheap when nothing is missing.
  320. */
  321. protected static function resolveTarget(array $aRow): array
  322. {
  323. $sAction = (string)($aRow['action'] ?? '');
  324. $sUnit = (string)($aRow['unit'] ?? '');
  325. $iObject = (int)($aRow['object_id'] ?? 0);
  326. $bContent = in_array($sAction, ['comment_add', 'comment_update', 'comment_delete', 'content_add', 'content_update'], true);
  327. if (!$bContent || $sUnit === '' || $iObject <= 0)
  328. return $aRow;
  329. $bComment = strpos($sAction, 'comment_') === 0;
  330. if (empty($aRow['object_title'])) {
  331. $sTitle = $bComment ? self::commentTarget($sUnit, $iObject, 0)['title'] : self::contentTitle($sUnit, $iObject);
  332. $aRow['object_title'] = mb_substr(trim(strip_tags($sTitle)), 0, 255);
  333. }
  334. if (empty($aRow['object_url']))
  335. $aRow['object_url'] = $bComment ? self::commentTarget($sUnit, $iObject, 0)['url'] : self::contentUrl($sUnit, $iObject);
  336. return $aRow;
  337. }
  338. /**
  339. * A row in a content module's entries table (bx_posts_data, bx_forum_discussions...)
  340. * is that module's content → [title, url], else null.
  341. */
  342. protected static function contentByTable(string $sTable, int $iId): ?array
  343. {
  344. if ($sTable === '' || $iId <= 0 || !preg_match('/^(bx_[a-z0-9]+)_/', $sTable, $m))
  345. return null;
  346. $sModule = $m[1];
  347. try {
  348. $oModule = BxDolModule::getInstance($sModule);
  349. if (!$oModule || empty($oModule->_oConfig->CNF['TABLE_ENTRIES']) || $oModule->_oConfig->CNF['TABLE_ENTRIES'] !== $sTable)
  350. return null;
  351. } catch (Throwable $o) {
  352. return null;
  353. }
  354. $sUrl = self::contentUrl($sModule, $iId);
  355. if ($sUrl === '')
  356. return null;
  357. return ['title' => self::contentTitle($sModule, $iId), 'url' => $sUrl];
  358. }
  359. /** Page block → [title, url of its page] for mockup rows. */
  360. protected static function pageBlock(int $iBlockId): array
  361. {
  362. if ($iBlockId <= 0)
  363. return [];
  364. try {
  365. $oDb = BxDolDb::getInstance();
  366. $aBlock = $oDb->getRow("SELECT b.`title`, b.`object`, p.`uri` FROM `sys_pages_blocks` b LEFT JOIN `sys_pages` p ON p.`object` = b.`object` WHERE b.`id` = :id LIMIT 1", ['id' => $iBlockId]);
  367. if (!$aBlock)
  368. return [];
  369. $sTitle = (string)$aBlock['title'];
  370. if ($sTitle !== '' && $sTitle[0] === '_')
  371. $sTitle = _t($sTitle);
  372. $sUrl = !empty($aBlock['uri']) ? bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $aBlock['uri'])) : '';
  373. return ['title' => $sTitle, 'url' => $sUrl];
  374. } catch (Throwable $o) {
  375. return [];
  376. }
  377. }
  378. protected static function contentUrl(string $sModule, int $iId): string
  379. {
  380. if ($sModule === '' || $iId <= 0)
  381. return '';
  382. try {
  383. if (!BxDolRequest::serviceExists($sModule, 'get_link'))
  384. return '';
  385. $s = BxDolService::call($sModule, 'get_link', [$iId]);
  386. return is_string($s) ? $s : '';
  387. } catch (Throwable $o) {
  388. return '';
  389. }
  390. }
  391. protected static function contentTitle(string $sModule, int $iId): string
  392. {
  393. if ($sModule === '' || $iId <= 0)
  394. return '';
  395. try {
  396. if (!BxDolRequest::serviceExists($sModule, 'get_title'))
  397. return '';
  398. $s = BxDolService::call($sModule, 'get_title', [$iId]);
  399. return is_string($s) ? $s : '';
  400. } catch (Throwable $o) {
  401. return '';
  402. }
  403. }
  404. /**
  405. * Title and link of the content a comment belongs to: the comments object knows
  406. * both (trigger table, base_url) whatever the module, and the comment itself when
  407. * the system supports comment permalinks. Falls back to the module's get_title / get_link.
  408. */
  409. protected static function commentTarget(string $sSystem, int $iContentId, int $iCmtId): array
  410. {
  411. $aOut = ['title' => '', 'url' => ''];
  412. if ($sSystem === '' || $iContentId <= 0)
  413. return $aOut;
  414. $sModule = $sSystem;
  415. try {
  416. $o = BxDolCmts::getObjectInstance($sSystem, $iContentId);
  417. if ($o) {
  418. $sModule = (string)($o->getModule() ?: $sSystem);
  419. $aOut['title'] = (string)$o->getObjectTitle($iContentId);
  420. if ($iCmtId > 0)
  421. $aOut['url'] = (string)$o->getViewUrl($iCmtId);
  422. if ($aOut['url'] === '') {
  423. $sBase = (string)$o->getBaseUrl();
  424. if ($sBase !== '' && strpos($sBase, '{') === false)
  425. $aOut['url'] = $sBase;
  426. }
  427. }
  428. } catch (Throwable $e) {
  429. }
  430. if ($aOut['title'] === '')
  431. $aOut['title'] = self::contentTitle($sModule, $iContentId);
  432. if ($aOut['url'] === '')
  433. $aOut['url'] = self::contentUrl($sModule, $iContentId);
  434. return $aOut;
  435. }
  436. protected static function clipArray(array $a, int $iMax = 2000): array
  437. {
  438. $aOut = [];
  439. foreach ($a as $k => $v) {
  440. if (is_string($v) && mb_strlen($v) > $iMax)
  441. $v = mb_substr($v, 0, $iMax) . '…';
  442. elseif (is_array($v))
  443. $v = self::clipArray($v, $iMax);
  444. $aOut[$k] = $v;
  445. }
  446. return $aOut;
  447. }
  448. }
  449. /** @} */