engine.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. import browser from 'webextension-polyfill';
  2. import { nanoid } from 'nanoid';
  3. import { tasks } from '@/utils/shared';
  4. import {
  5. clearCache,
  6. toCamelCase,
  7. sleep,
  8. parseJSON,
  9. isObject,
  10. objectHasKey,
  11. } from '@/utils/helper';
  12. import referenceData from '@/utils/reference-data';
  13. import { convertData, waitTabLoaded, getBlockConnection } from './helper';
  14. import executeContentScript from './execute-content-script';
  15. class WorkflowEngine {
  16. constructor(
  17. workflow,
  18. { states, logger, blocksHandler, parentWorkflow, options }
  19. ) {
  20. this.id = nanoid();
  21. this.states = states;
  22. this.logger = logger;
  23. this.workflow = workflow;
  24. this.blocksHandler = blocksHandler;
  25. this.parentWorkflow = parentWorkflow;
  26. this.saveLog = workflow.settings?.saveLog ?? true;
  27. this.loopList = {};
  28. this.repeatedTasks = {};
  29. this.windowId = null;
  30. this.triggerBlock = null;
  31. this.currentBlock = null;
  32. this.childWorkflowId = null;
  33. this.isDestroyed = false;
  34. this.isUsingProxy = false;
  35. this.blocks = {};
  36. this.history = [];
  37. this.columnsId = {};
  38. this.historyCtxData = {};
  39. this.eventListeners = {};
  40. this.preloadScripts = [];
  41. this.columns = { column: { index: 0, name: 'column', type: 'any' } };
  42. let variables = {};
  43. let { globalData } = workflow;
  44. if (options && options?.data) {
  45. globalData = options.data.globalData;
  46. variables = isObject(options.data.variables)
  47. ? options?.data.variables
  48. : {};
  49. options.data = { globalData, variables };
  50. }
  51. this.options = options;
  52. this.activeTab = {
  53. url: '',
  54. frameId: 0,
  55. frames: {},
  56. groupId: null,
  57. id: options?.tabId,
  58. };
  59. this.referenceData = {
  60. variables,
  61. table: [],
  62. loopData: {},
  63. workflow: {},
  64. googleSheets: {},
  65. globalData: parseJSON(globalData, globalData),
  66. };
  67. this.onDebugEvent = ({ tabId }, method, params) => {
  68. if (tabId !== this.activeTab.id) return;
  69. (this.eventListeners[method] || []).forEach((listener) => {
  70. listener(params);
  71. });
  72. };
  73. this.onWorkflowStopped = (id) => {
  74. if (this.id !== id || this.isDestroyed) return;
  75. this.stop();
  76. };
  77. }
  78. reset() {
  79. this.loopList = {};
  80. this.repeatedTasks = {};
  81. this.windowId = null;
  82. this.currentBlock = null;
  83. this.childWorkflowId = null;
  84. this.isDestroyed = false;
  85. this.isUsingProxy = false;
  86. this.history = [];
  87. this.preloadScripts = [];
  88. this.columns = { column: { index: 0, name: 'column', type: 'any' } };
  89. this.activeTab = {
  90. url: '',
  91. frameId: 0,
  92. frames: {},
  93. groupId: null,
  94. id: this.options?.tabId,
  95. };
  96. this.referenceData = {
  97. table: [],
  98. loopData: {},
  99. workflow: {},
  100. googleSheets: {},
  101. variables: this.options.variables,
  102. globalData: this.referenceData.globalData,
  103. };
  104. }
  105. init() {
  106. if (this.workflow.isDisabled) return;
  107. if (!this.states) {
  108. console.error(`"${this.workflow.name}" workflow doesn't have states`);
  109. this.destroy('error');
  110. return;
  111. }
  112. const flow = this.workflow.drawflow;
  113. const parsedFlow = typeof flow === 'string' ? parseJSON(flow, {}) : flow;
  114. const blocks = parsedFlow?.drawflow?.Home.data;
  115. if (!blocks) {
  116. console.error(`${this.workflow.name} doesn't have blocks`);
  117. return;
  118. }
  119. const triggerBlock = Object.values(blocks).find(
  120. ({ name }) => name === 'trigger'
  121. );
  122. if (!triggerBlock) {
  123. console.error(`${this.workflow.name} doesn't have a trigger block`);
  124. return;
  125. }
  126. const workflowTable = this.workflow.table || this.workflow.dataColumns;
  127. const columns = Array.isArray(workflowTable)
  128. ? workflowTable
  129. : Object.values(workflowTable);
  130. columns.forEach(({ name, type, id }) => {
  131. const columnId = id || name;
  132. this.columnsId[name] = columnId;
  133. this.columns[columnId] = { index: 0, name, type };
  134. });
  135. if (this.workflow.settings.debugMode) {
  136. chrome.debugger.onEvent.addListener(this.onDebugEvent);
  137. }
  138. if (this.workflow.settings.reuseLastState) {
  139. const lastStateKey = `state:${this.workflow.id}`;
  140. browser.storage.local.get(lastStateKey).then((value) => {
  141. const lastState = value[lastStateKey];
  142. if (!lastState) return;
  143. Object.assign(this.columns, lastState.columns);
  144. Object.assign(this.referenceData, lastState.referenceData);
  145. });
  146. }
  147. this.blocks = blocks;
  148. this.startedTimestamp = Date.now();
  149. this.workflow.table = columns;
  150. this.currentBlock = triggerBlock;
  151. this.states.on('stop', this.onWorkflowStopped);
  152. this.states
  153. .add(this.id, {
  154. state: this.state,
  155. workflowId: this.workflow.id,
  156. parentState: this.parentWorkflow,
  157. })
  158. .then(() => {
  159. this.executeBlock(this.currentBlock);
  160. });
  161. }
  162. resume({ id, state }) {
  163. this.id = id;
  164. Object.keys(state).forEach((key) => {
  165. this[key] = state[key];
  166. });
  167. this.init(state.currentBlock);
  168. }
  169. addLogHistory(detail) {
  170. if (
  171. !this.saveLog &&
  172. (this.history.length >= 1001 || detail.name === 'blocks-group') &&
  173. detail.type !== 'error'
  174. )
  175. return;
  176. const historyId = nanoid();
  177. detail.id = historyId;
  178. if (
  179. detail.replacedValue ||
  180. (tasks[detail.name]?.refDataKeys && this.saveLog)
  181. ) {
  182. const { activeTabUrl, variables, loopData, prevBlockData } = JSON.parse(
  183. JSON.stringify(this.referenceData)
  184. );
  185. this.historyCtxData[historyId] = {
  186. referenceData: {
  187. loopData,
  188. variables,
  189. activeTabUrl,
  190. prevBlockData,
  191. },
  192. replacedValue: detail.replacedValue,
  193. };
  194. delete detail.replacedValue;
  195. }
  196. this.history.push(detail);
  197. }
  198. addDataToColumn(key, value) {
  199. if (Array.isArray(key)) {
  200. key.forEach((item) => {
  201. if (!isObject(item)) return;
  202. Object.entries(item).forEach(([itemKey, itemValue]) => {
  203. this.addDataToColumn(itemKey, itemValue);
  204. });
  205. });
  206. return;
  207. }
  208. const columnId =
  209. (this.columns[key] ? key : this.columnsId[key]) || 'column';
  210. const currentColumn = this.columns[columnId];
  211. const columnName = currentColumn.name || 'column';
  212. const convertedValue = convertData(value, currentColumn.type);
  213. if (objectHasKey(this.referenceData.table, currentColumn.index)) {
  214. this.referenceData.table[currentColumn.index][columnName] =
  215. convertedValue;
  216. } else {
  217. this.referenceData.table.push({ [columnName]: convertedValue });
  218. }
  219. currentColumn.index += 1;
  220. }
  221. setVariable(name, value) {
  222. this.referenceData.variables[name] = value;
  223. }
  224. async stop() {
  225. try {
  226. if (this.childWorkflowId) {
  227. await this.states.stop(this.childWorkflowId);
  228. }
  229. await this.destroy('stopped');
  230. } catch (error) {
  231. console.error(error);
  232. }
  233. }
  234. async executeQueue() {
  235. const { workflowQueue } = await browser.storage.local.get('workflowQueue');
  236. const queueIndex = (workflowQueue || []).indexOf(this.workflow.id);
  237. if (!workflowQueue || queueIndex === -1) return;
  238. const engine = new WorkflowEngine(this.workflow, {
  239. logger: this.logger,
  240. states: this.states,
  241. blocksHandler: this.blocksHandler,
  242. });
  243. engine.init();
  244. workflowQueue.splice(queueIndex, 1);
  245. await browser.storage.local.set({ workflowQueue });
  246. }
  247. async destroy(status, message) {
  248. try {
  249. if (this.isDestroyed) return;
  250. if (this.isUsingProxy) chrome.proxy.settings.clear({});
  251. if (this.workflow.settings.debugMode) {
  252. chrome.debugger.onEvent.removeListener(this.onDebugEvent);
  253. if (this.activeTab.id) {
  254. await sleep(1000);
  255. chrome.debugger.detach({ tabId: this.activeTab.id });
  256. }
  257. }
  258. const endedTimestamp = Date.now();
  259. this.executeQueue();
  260. if (!this.workflow.isTesting) {
  261. const { name, id } = this.workflow;
  262. let { logsCtxData } = await browser.storage.local.get('logsCtxData');
  263. if (!logsCtxData) logsCtxData = {};
  264. logsCtxData[this.id] = this.historyCtxData;
  265. await browser.storage.local.set({ logsCtxData });
  266. await this.logger.add({
  267. name,
  268. status,
  269. message,
  270. id: this.id,
  271. workflowId: id,
  272. history: this.saveLog ? this.history : [],
  273. endedAt: endedTimestamp,
  274. parentLog: this.parentWorkflow,
  275. startedAt: this.startedTimestamp,
  276. data: {
  277. table: this.referenceData.table,
  278. variables: this.referenceData.variables,
  279. },
  280. });
  281. }
  282. this.states.off('stop', this.onWorkflowStopped);
  283. await this.states.delete(this.id);
  284. this.dispatchEvent('destroyed', {
  285. status,
  286. message,
  287. id: this.id,
  288. currentBlock: this.currentBlock,
  289. });
  290. if (this.workflow.settings.reuseLastState) {
  291. const workflowState = {
  292. [`state:${this.workflow.id}`]: {
  293. columns: this.columns,
  294. referenceData: {
  295. table: this.referenceData.table,
  296. variables: this.referenceData.variables,
  297. },
  298. },
  299. };
  300. browser.storage.local.set(workflowState);
  301. } else if (status === 'success') {
  302. clearCache(this.workflow);
  303. }
  304. this.isDestroyed = true;
  305. this.eventListeners = {};
  306. } catch (error) {
  307. console.error(error);
  308. }
  309. }
  310. async executeBlock(block, prevBlockData, isRetry) {
  311. const currentState = await this.states.get(this.id);
  312. if (!currentState || currentState.isDestroyed) {
  313. if (this.isDestroyed) return;
  314. await this.destroy('stopped');
  315. return;
  316. }
  317. this.currentBlock = block;
  318. this.referenceData.prevBlockData = prevBlockData;
  319. this.referenceData.activeTabUrl = this.activeTab.url || '';
  320. if (!isRetry) {
  321. await this.states.update(this.id, { state: this.state });
  322. this.dispatchEvent('update', { state: this.state });
  323. }
  324. const startExecuteTime = Date.now();
  325. const blockHandler = this.blocksHandler[toCamelCase(block.name)];
  326. const handler =
  327. !blockHandler && tasks[block.name].category === 'interaction'
  328. ? this.blocksHandler.interactionBlock
  329. : blockHandler;
  330. if (!handler) {
  331. console.error(`"${block.name}" block doesn't have a handler`);
  332. this.destroy('stopped');
  333. return;
  334. }
  335. const replacedBlock = referenceData({
  336. block,
  337. data: this.referenceData,
  338. refKeys:
  339. isRetry || block.data.disableBlock
  340. ? null
  341. : tasks[block.name].refDataKeys,
  342. });
  343. const blockDelay = this.workflow.settings?.blockDelay || 0;
  344. const addBlockLog = (status, obj = {}) => {
  345. this.addLogHistory({
  346. type: status,
  347. name: block.name,
  348. description: block.data.description,
  349. replacedValue: replacedBlock.replacedValue,
  350. duration: Math.round(Date.now() - startExecuteTime),
  351. ...obj,
  352. });
  353. };
  354. try {
  355. let result;
  356. if (block.data.disableBlock) {
  357. result = {
  358. data: '',
  359. nextBlockId: getBlockConnection(block),
  360. };
  361. } else {
  362. result = await handler.call(this, replacedBlock, {
  363. prevBlockData,
  364. refData: this.referenceData,
  365. });
  366. addBlockLog(result.status || 'success', {
  367. logId: result.logId,
  368. });
  369. }
  370. if (result.replacedValue)
  371. replacedBlock.replacedValue = result.replacedValue;
  372. if (result.nextBlockId) {
  373. setTimeout(() => {
  374. this.executeBlock(this.blocks[result.nextBlockId], result.data);
  375. }, blockDelay);
  376. } else {
  377. this.addLogHistory({
  378. type: 'finish',
  379. name: 'finish',
  380. });
  381. this.dispatchEvent('finish');
  382. this.destroy('success');
  383. }
  384. } catch (error) {
  385. const { onError: blockOnError } = replacedBlock.data;
  386. if (blockOnError && blockOnError.enable) {
  387. if (blockOnError.retry && blockOnError.retryTimes) {
  388. await sleep(blockOnError.retryInterval * 1000);
  389. blockOnError.retryTimes -= 1;
  390. await this.executeBlock(replacedBlock, prevBlockData, true);
  391. return;
  392. }
  393. const nextBlockId = getBlockConnection(
  394. block,
  395. blockOnError.toDo === 'continue' ? 1 : 2
  396. );
  397. if (blockOnError.toDo !== 'error' && nextBlockId) {
  398. this.executeBlock(this.blocks[nextBlockId], '');
  399. return;
  400. }
  401. }
  402. addBlockLog('error', {
  403. message: error.message,
  404. ...(error.data || {}),
  405. });
  406. const { onError } = this.workflow.settings;
  407. if (onError === 'keep-running' && error.nextBlockId) {
  408. setTimeout(() => {
  409. this.executeBlock(this.blocks[error.nextBlockId], error.data || '');
  410. }, blockDelay);
  411. } else if (onError === 'restart-workflow' && !this.parentWorkflow) {
  412. const restartKey = `restart-count:${this.id}`;
  413. const restartCount = +localStorage.getItem(restartKey) || 0;
  414. const maxRestart = this.workflow.settings.restartTimes ?? 3;
  415. if (restartCount >= maxRestart) {
  416. localStorage.removeItem(restartKey);
  417. this.destroy();
  418. return;
  419. }
  420. this.reset();
  421. const triggerBlock = Object.values(this.blocks).find(
  422. ({ name }) => name === 'trigger'
  423. );
  424. this.executeBlock(triggerBlock);
  425. localStorage.setItem(restartKey, restartCount + 1);
  426. } else {
  427. this.destroy('error', error.message);
  428. }
  429. console.error(`${block.name}:`, error);
  430. }
  431. }
  432. dispatchEvent(name, params) {
  433. const listeners = this.eventListeners[name];
  434. if (!listeners) return;
  435. listeners.forEach((callback) => {
  436. callback(params);
  437. });
  438. }
  439. on(name, listener) {
  440. (this.eventListeners[name] = this.eventListeners[name] || []).push(
  441. listener
  442. );
  443. }
  444. get state() {
  445. const keys = [
  446. 'history',
  447. 'columns',
  448. 'activeTab',
  449. 'isUsingProxy',
  450. 'currentBlock',
  451. 'referenceData',
  452. 'childWorkflowId',
  453. 'startedTimestamp',
  454. ];
  455. const state = {
  456. name: this.workflow.name,
  457. icon: this.workflow.icon,
  458. };
  459. keys.forEach((key) => {
  460. state[key] = this[key];
  461. });
  462. return state;
  463. }
  464. async _sendMessageToTab(payload, options = {}) {
  465. try {
  466. if (!this.activeTab.id) {
  467. const error = new Error('no-tab');
  468. error.workflowId = this.id;
  469. throw error;
  470. }
  471. await waitTabLoaded(this.activeTab.id);
  472. await executeContentScript(
  473. this.activeTab.id,
  474. this.activeTab.frameId || 0
  475. );
  476. const { executedBlockOnWeb, debugMode } = this.workflow.settings;
  477. const messagePayload = {
  478. isBlock: true,
  479. debugMode,
  480. executedBlockOnWeb,
  481. activeTabId: this.activeTab.id,
  482. frameSelector: this.frameSelector,
  483. ...payload,
  484. };
  485. const data = await browser.tabs.sendMessage(
  486. this.activeTab.id,
  487. messagePayload,
  488. { frameId: this.activeTab.frameId, ...options }
  489. );
  490. return data;
  491. } catch (error) {
  492. if (error.message?.startsWith('Could not establish connection')) {
  493. error.message = 'Could not establish connection to the active tab';
  494. } else if (error.message?.startsWith('No tab')) {
  495. error.message = 'active-tab-removed';
  496. }
  497. throw error;
  498. }
  499. }
  500. }
  501. export default WorkflowEngine;