machinaos 0.0.1 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (422) hide show
  1. package/.env.template +71 -71
  2. package/LICENSE +21 -21
  3. package/README.md +163 -87
  4. package/bin/cli.js +62 -106
  5. package/client/.dockerignore +45 -45
  6. package/client/Dockerfile +68 -68
  7. package/client/dist/assets/index-DFSC53FP.css +1 -0
  8. package/client/dist/assets/index-fJ-1gTf5.js +613 -0
  9. package/client/dist/index.html +14 -0
  10. package/client/eslint.config.js +34 -16
  11. package/client/nginx.conf +66 -66
  12. package/client/package.json +61 -48
  13. package/client/src/App.tsx +27 -27
  14. package/client/src/Dashboard.tsx +1200 -1172
  15. package/client/src/ParameterPanel.tsx +302 -300
  16. package/client/src/components/AIAgentNode.tsx +315 -321
  17. package/client/src/components/APIKeyValidator.tsx +117 -117
  18. package/client/src/components/ClaudeChatModelNode.tsx +17 -17
  19. package/client/src/components/CredentialsModal.tsx +1200 -306
  20. package/client/src/components/GeminiChatModelNode.tsx +17 -17
  21. package/client/src/components/GenericNode.tsx +356 -356
  22. package/client/src/components/LocationParameterPanel.tsx +153 -153
  23. package/client/src/components/ModelNode.tsx +285 -285
  24. package/client/src/components/OpenAIChatModelNode.tsx +17 -17
  25. package/client/src/components/OutputPanel.tsx +470 -470
  26. package/client/src/components/ParameterRenderer.tsx +1873 -1873
  27. package/client/src/components/SkillEditorModal.tsx +3 -3
  28. package/client/src/components/SquareNode.tsx +812 -796
  29. package/client/src/components/ToolkitNode.tsx +365 -365
  30. package/client/src/components/auth/LoginPage.tsx +247 -247
  31. package/client/src/components/auth/ProtectedRoute.tsx +59 -59
  32. package/client/src/components/base/BaseChatModelNode.tsx +270 -270
  33. package/client/src/components/icons/AIProviderIcons.tsx +50 -50
  34. package/client/src/components/maps/GoogleMapsPicker.tsx +136 -136
  35. package/client/src/components/maps/MapsPreviewPanel.tsx +109 -109
  36. package/client/src/components/maps/index.ts +25 -25
  37. package/client/src/components/parameterPanel/InputSection.tsx +1094 -1094
  38. package/client/src/components/parameterPanel/LocationPanelLayout.tsx +64 -64
  39. package/client/src/components/parameterPanel/MapsSection.tsx +91 -91
  40. package/client/src/components/parameterPanel/MiddleSection.tsx +867 -571
  41. package/client/src/components/parameterPanel/OutputSection.tsx +80 -80
  42. package/client/src/components/parameterPanel/ParameterPanelLayout.tsx +81 -81
  43. package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +436 -436
  44. package/client/src/components/parameterPanel/index.ts +41 -41
  45. package/client/src/components/shared/DataPanel.tsx +142 -142
  46. package/client/src/components/shared/JSONTreeRenderer.tsx +105 -105
  47. package/client/src/components/ui/AIResultModal.tsx +203 -203
  48. package/client/src/components/ui/ApiKeyInput.tsx +93 -0
  49. package/client/src/components/ui/CodeEditor.tsx +81 -81
  50. package/client/src/components/ui/CollapsibleSection.tsx +87 -87
  51. package/client/src/components/ui/ComponentItem.tsx +153 -153
  52. package/client/src/components/ui/ComponentPalette.tsx +320 -320
  53. package/client/src/components/ui/ConsolePanel.tsx +151 -43
  54. package/client/src/components/ui/ErrorBoundary.tsx +195 -195
  55. package/client/src/components/ui/InputNodesPanel.tsx +203 -203
  56. package/client/src/components/ui/MapSelector.tsx +313 -313
  57. package/client/src/components/ui/Modal.tsx +151 -148
  58. package/client/src/components/ui/NodeOutputPanel.tsx +1150 -1150
  59. package/client/src/components/ui/OutputDisplayPanel.tsx +381 -381
  60. package/client/src/components/ui/QRCodeDisplay.tsx +182 -0
  61. package/client/src/components/ui/TopToolbar.tsx +736 -736
  62. package/client/src/components/ui/WorkflowSidebar.tsx +293 -293
  63. package/client/src/config/antdTheme.ts +186 -186
  64. package/client/src/contexts/AuthContext.tsx +221 -221
  65. package/client/src/contexts/ThemeContext.tsx +42 -42
  66. package/client/src/contexts/WebSocketContext.tsx +2144 -1971
  67. package/client/src/factories/baseChatModelFactory.ts +255 -255
  68. package/client/src/hooks/useAndroidOperations.ts +118 -164
  69. package/client/src/hooks/useApiKeyValidation.ts +106 -106
  70. package/client/src/hooks/useApiKeys.ts +238 -238
  71. package/client/src/hooks/useAppTheme.ts +17 -17
  72. package/client/src/hooks/useComponentPalette.ts +50 -50
  73. package/client/src/hooks/useDragAndDrop.ts +123 -123
  74. package/client/src/hooks/useDragVariable.ts +88 -88
  75. package/client/src/hooks/useExecution.ts +319 -313
  76. package/client/src/hooks/useParameterPanel.ts +176 -176
  77. package/client/src/hooks/useReactFlowNodes.ts +188 -188
  78. package/client/src/hooks/useToolSchema.ts +209 -209
  79. package/client/src/hooks/useWhatsApp.ts +196 -196
  80. package/client/src/hooks/useWorkflowManagement.ts +45 -45
  81. package/client/src/index.css +314 -314
  82. package/client/src/nodeDefinitions/aiAgentNodes.ts +335 -335
  83. package/client/src/nodeDefinitions/aiModelNodes.ts +340 -340
  84. package/client/src/nodeDefinitions/androidServiceNodes.ts +383 -383
  85. package/client/src/nodeDefinitions/chatNodes.ts +135 -135
  86. package/client/src/nodeDefinitions/codeNodes.ts +54 -54
  87. package/client/src/nodeDefinitions/index.ts +14 -14
  88. package/client/src/nodeDefinitions/locationNodes.ts +462 -462
  89. package/client/src/nodeDefinitions/schedulerNodes.ts +220 -220
  90. package/client/src/nodeDefinitions/skillNodes.ts +17 -5
  91. package/client/src/nodeDefinitions/utilityNodes.ts +284 -284
  92. package/client/src/nodeDefinitions/whatsappNodes.ts +821 -865
  93. package/client/src/nodeDefinitions.ts +101 -103
  94. package/client/src/services/dynamicParameterService.ts +95 -95
  95. package/client/src/services/execution/aiAgentExecutionService.ts +34 -34
  96. package/client/src/services/executionService.ts +227 -231
  97. package/client/src/services/workflowApi.ts +91 -91
  98. package/client/src/store/useAppStore.ts +578 -581
  99. package/client/src/styles/theme.ts +513 -508
  100. package/client/src/styles/zIndex.ts +16 -16
  101. package/client/src/types/ComponentTypes.ts +38 -38
  102. package/client/src/types/INodeProperties.ts +287 -287
  103. package/client/src/types/NodeTypes.ts +27 -27
  104. package/client/src/utils/formatters.ts +32 -32
  105. package/client/src/utils/googleMapsLoader.ts +139 -139
  106. package/client/src/utils/locationUtils.ts +84 -84
  107. package/client/src/utils/nodeUtils.ts +30 -30
  108. package/client/src/utils/workflow.ts +29 -29
  109. package/client/src/vite-env.d.ts +12 -12
  110. package/client/tailwind.config.js +59 -59
  111. package/client/tsconfig.json +25 -25
  112. package/client/vite.config.js +35 -35
  113. package/install.ps1 +308 -0
  114. package/install.sh +343 -0
  115. package/package.json +81 -70
  116. package/scripts/build.js +174 -51
  117. package/scripts/clean.js +40 -40
  118. package/scripts/start.js +234 -210
  119. package/scripts/stop.js +301 -325
  120. package/server/.dockerignore +44 -44
  121. package/server/Dockerfile +45 -45
  122. package/server/constants.py +244 -249
  123. package/server/core/cache.py +460 -460
  124. package/server/core/config.py +127 -127
  125. package/server/core/container.py +98 -98
  126. package/server/core/database.py +1296 -1210
  127. package/server/core/logging.py +313 -313
  128. package/server/main.py +288 -288
  129. package/server/middleware/__init__.py +5 -5
  130. package/server/middleware/auth.py +89 -89
  131. package/server/models/auth.py +52 -52
  132. package/server/models/cache.py +24 -24
  133. package/server/models/database.py +235 -210
  134. package/server/models/nodes.py +435 -455
  135. package/server/pyproject.toml +75 -72
  136. package/server/requirements.txt +83 -83
  137. package/server/routers/android.py +294 -294
  138. package/server/routers/auth.py +203 -203
  139. package/server/routers/database.py +150 -150
  140. package/server/routers/maps.py +141 -141
  141. package/server/routers/nodejs_compat.py +288 -288
  142. package/server/routers/webhook.py +90 -90
  143. package/server/routers/websocket.py +2239 -2127
  144. package/server/routers/whatsapp.py +761 -761
  145. package/server/routers/workflow.py +199 -199
  146. package/server/services/ai.py +2444 -2414
  147. package/server/services/android_service.py +588 -588
  148. package/server/services/auth.py +130 -130
  149. package/server/services/chat_client.py +160 -160
  150. package/server/services/deployment/manager.py +706 -706
  151. package/server/services/event_waiter.py +675 -785
  152. package/server/services/execution/executor.py +1351 -1351
  153. package/server/services/execution/models.py +1 -1
  154. package/server/services/handlers/__init__.py +122 -126
  155. package/server/services/handlers/ai.py +390 -355
  156. package/server/services/handlers/android.py +69 -260
  157. package/server/services/handlers/code.py +278 -278
  158. package/server/services/handlers/http.py +193 -193
  159. package/server/services/handlers/tools.py +146 -32
  160. package/server/services/handlers/triggers.py +107 -107
  161. package/server/services/handlers/utility.py +822 -822
  162. package/server/services/handlers/whatsapp.py +423 -476
  163. package/server/services/maps.py +288 -288
  164. package/server/services/memory_store.py +103 -103
  165. package/server/services/node_executor.py +372 -375
  166. package/server/services/scheduler.py +155 -155
  167. package/server/services/skill_loader.py +1 -1
  168. package/server/services/status_broadcaster.py +834 -826
  169. package/server/services/temporal/__init__.py +23 -23
  170. package/server/services/temporal/activities.py +344 -344
  171. package/server/services/temporal/client.py +76 -76
  172. package/server/services/temporal/executor.py +147 -147
  173. package/server/services/temporal/worker.py +251 -251
  174. package/server/services/temporal/workflow.py +355 -355
  175. package/server/services/temporal/ws_client.py +236 -236
  176. package/server/services/text.py +110 -110
  177. package/server/services/user_auth.py +172 -172
  178. package/server/services/websocket_client.py +29 -29
  179. package/server/services/workflow.py +597 -597
  180. package/server/skills/android-skill/SKILL.md +4 -4
  181. package/server/skills/code-skill/SKILL.md +123 -89
  182. package/server/skills/maps-skill/SKILL.md +3 -3
  183. package/server/skills/memory-skill/SKILL.md +1 -1
  184. package/server/skills/web-search-skill/SKILL.md +154 -0
  185. package/server/skills/whatsapp-skill/SKILL.md +3 -3
  186. package/server/uv.lock +461 -100
  187. package/server/whatsapp-rpc/.dockerignore +30 -30
  188. package/server/whatsapp-rpc/Dockerfile +44 -44
  189. package/server/whatsapp-rpc/Dockerfile.web +17 -17
  190. package/server/whatsapp-rpc/README.md +139 -139
  191. package/server/whatsapp-rpc/bin/whatsapp-rpc-server +0 -0
  192. package/server/whatsapp-rpc/cli.js +95 -95
  193. package/server/whatsapp-rpc/configs/config.yaml +6 -6
  194. package/server/whatsapp-rpc/docker-compose.yml +35 -35
  195. package/server/whatsapp-rpc/docs/API.md +410 -410
  196. package/server/whatsapp-rpc/node_modules/.package-lock.json +259 -0
  197. package/server/whatsapp-rpc/node_modules/chalk/license +9 -0
  198. package/server/whatsapp-rpc/node_modules/chalk/package.json +83 -0
  199. package/server/whatsapp-rpc/node_modules/chalk/readme.md +297 -0
  200. package/server/whatsapp-rpc/node_modules/chalk/source/index.d.ts +325 -0
  201. package/server/whatsapp-rpc/node_modules/chalk/source/index.js +225 -0
  202. package/server/whatsapp-rpc/node_modules/chalk/source/utilities.js +33 -0
  203. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
  204. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
  205. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
  206. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
  207. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
  208. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
  209. package/server/whatsapp-rpc/node_modules/commander/LICENSE +22 -0
  210. package/server/whatsapp-rpc/node_modules/commander/Readme.md +1148 -0
  211. package/server/whatsapp-rpc/node_modules/commander/esm.mjs +16 -0
  212. package/server/whatsapp-rpc/node_modules/commander/index.js +26 -0
  213. package/server/whatsapp-rpc/node_modules/commander/lib/argument.js +145 -0
  214. package/server/whatsapp-rpc/node_modules/commander/lib/command.js +2179 -0
  215. package/server/whatsapp-rpc/node_modules/commander/lib/error.js +43 -0
  216. package/server/whatsapp-rpc/node_modules/commander/lib/help.js +462 -0
  217. package/server/whatsapp-rpc/node_modules/commander/lib/option.js +329 -0
  218. package/server/whatsapp-rpc/node_modules/commander/lib/suggestSimilar.js +100 -0
  219. package/server/whatsapp-rpc/node_modules/commander/package-support.json +16 -0
  220. package/server/whatsapp-rpc/node_modules/commander/package.json +80 -0
  221. package/server/whatsapp-rpc/node_modules/commander/typings/esm.d.mts +3 -0
  222. package/server/whatsapp-rpc/node_modules/commander/typings/index.d.ts +884 -0
  223. package/server/whatsapp-rpc/node_modules/cross-spawn/LICENSE +21 -0
  224. package/server/whatsapp-rpc/node_modules/cross-spawn/README.md +89 -0
  225. package/server/whatsapp-rpc/node_modules/cross-spawn/index.js +39 -0
  226. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/enoent.js +59 -0
  227. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/parse.js +91 -0
  228. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/util/escape.js +47 -0
  229. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/util/readShebang.js +23 -0
  230. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/util/resolveCommand.js +52 -0
  231. package/server/whatsapp-rpc/node_modules/cross-spawn/package.json +73 -0
  232. package/server/whatsapp-rpc/node_modules/execa/index.d.ts +955 -0
  233. package/server/whatsapp-rpc/node_modules/execa/index.js +309 -0
  234. package/server/whatsapp-rpc/node_modules/execa/lib/command.js +119 -0
  235. package/server/whatsapp-rpc/node_modules/execa/lib/error.js +87 -0
  236. package/server/whatsapp-rpc/node_modules/execa/lib/kill.js +102 -0
  237. package/server/whatsapp-rpc/node_modules/execa/lib/pipe.js +42 -0
  238. package/server/whatsapp-rpc/node_modules/execa/lib/promise.js +36 -0
  239. package/server/whatsapp-rpc/node_modules/execa/lib/stdio.js +49 -0
  240. package/server/whatsapp-rpc/node_modules/execa/lib/stream.js +133 -0
  241. package/server/whatsapp-rpc/node_modules/execa/lib/verbose.js +19 -0
  242. package/server/whatsapp-rpc/node_modules/execa/license +9 -0
  243. package/server/whatsapp-rpc/node_modules/execa/package.json +90 -0
  244. package/server/whatsapp-rpc/node_modules/execa/readme.md +822 -0
  245. package/server/whatsapp-rpc/node_modules/get-stream/license +9 -0
  246. package/server/whatsapp-rpc/node_modules/get-stream/package.json +53 -0
  247. package/server/whatsapp-rpc/node_modules/get-stream/readme.md +291 -0
  248. package/server/whatsapp-rpc/node_modules/get-stream/source/array-buffer.js +84 -0
  249. package/server/whatsapp-rpc/node_modules/get-stream/source/array.js +32 -0
  250. package/server/whatsapp-rpc/node_modules/get-stream/source/buffer.js +20 -0
  251. package/server/whatsapp-rpc/node_modules/get-stream/source/contents.js +101 -0
  252. package/server/whatsapp-rpc/node_modules/get-stream/source/index.d.ts +119 -0
  253. package/server/whatsapp-rpc/node_modules/get-stream/source/index.js +5 -0
  254. package/server/whatsapp-rpc/node_modules/get-stream/source/string.js +36 -0
  255. package/server/whatsapp-rpc/node_modules/get-stream/source/utils.js +11 -0
  256. package/server/whatsapp-rpc/node_modules/get-them-args/LICENSE +21 -0
  257. package/server/whatsapp-rpc/node_modules/get-them-args/README.md +95 -0
  258. package/server/whatsapp-rpc/node_modules/get-them-args/index.js +97 -0
  259. package/server/whatsapp-rpc/node_modules/get-them-args/package.json +36 -0
  260. package/server/whatsapp-rpc/node_modules/human-signals/LICENSE +201 -0
  261. package/server/whatsapp-rpc/node_modules/human-signals/README.md +168 -0
  262. package/server/whatsapp-rpc/node_modules/human-signals/build/src/core.js +273 -0
  263. package/server/whatsapp-rpc/node_modules/human-signals/build/src/main.d.ts +73 -0
  264. package/server/whatsapp-rpc/node_modules/human-signals/build/src/main.js +70 -0
  265. package/server/whatsapp-rpc/node_modules/human-signals/build/src/realtime.js +16 -0
  266. package/server/whatsapp-rpc/node_modules/human-signals/build/src/signals.js +34 -0
  267. package/server/whatsapp-rpc/node_modules/human-signals/package.json +61 -0
  268. package/server/whatsapp-rpc/node_modules/is-stream/index.d.ts +81 -0
  269. package/server/whatsapp-rpc/node_modules/is-stream/index.js +29 -0
  270. package/server/whatsapp-rpc/node_modules/is-stream/license +9 -0
  271. package/server/whatsapp-rpc/node_modules/is-stream/package.json +44 -0
  272. package/server/whatsapp-rpc/node_modules/is-stream/readme.md +60 -0
  273. package/server/whatsapp-rpc/node_modules/isexe/LICENSE +15 -0
  274. package/server/whatsapp-rpc/node_modules/isexe/README.md +51 -0
  275. package/server/whatsapp-rpc/node_modules/isexe/index.js +57 -0
  276. package/server/whatsapp-rpc/node_modules/isexe/mode.js +41 -0
  277. package/server/whatsapp-rpc/node_modules/isexe/package.json +31 -0
  278. package/server/whatsapp-rpc/node_modules/isexe/test/basic.js +221 -0
  279. package/server/whatsapp-rpc/node_modules/isexe/windows.js +42 -0
  280. package/server/whatsapp-rpc/node_modules/kill-port/.editorconfig +12 -0
  281. package/server/whatsapp-rpc/node_modules/kill-port/.gitattributes +1 -0
  282. package/server/whatsapp-rpc/node_modules/kill-port/LICENSE +21 -0
  283. package/server/whatsapp-rpc/node_modules/kill-port/README.md +140 -0
  284. package/server/whatsapp-rpc/node_modules/kill-port/cli.js +25 -0
  285. package/server/whatsapp-rpc/node_modules/kill-port/example.js +21 -0
  286. package/server/whatsapp-rpc/node_modules/kill-port/index.js +46 -0
  287. package/server/whatsapp-rpc/node_modules/kill-port/logo.png +0 -0
  288. package/server/whatsapp-rpc/node_modules/kill-port/package.json +41 -0
  289. package/server/whatsapp-rpc/node_modules/kill-port/pnpm-lock.yaml +4606 -0
  290. package/server/whatsapp-rpc/node_modules/kill-port/test.js +16 -0
  291. package/server/whatsapp-rpc/node_modules/merge-stream/LICENSE +21 -0
  292. package/server/whatsapp-rpc/node_modules/merge-stream/README.md +78 -0
  293. package/server/whatsapp-rpc/node_modules/merge-stream/index.js +41 -0
  294. package/server/whatsapp-rpc/node_modules/merge-stream/package.json +19 -0
  295. package/server/whatsapp-rpc/node_modules/mimic-fn/index.d.ts +52 -0
  296. package/server/whatsapp-rpc/node_modules/mimic-fn/index.js +71 -0
  297. package/server/whatsapp-rpc/node_modules/mimic-fn/license +9 -0
  298. package/server/whatsapp-rpc/node_modules/mimic-fn/package.json +45 -0
  299. package/server/whatsapp-rpc/node_modules/mimic-fn/readme.md +90 -0
  300. package/server/whatsapp-rpc/node_modules/npm-run-path/index.d.ts +90 -0
  301. package/server/whatsapp-rpc/node_modules/npm-run-path/index.js +52 -0
  302. package/server/whatsapp-rpc/node_modules/npm-run-path/license +9 -0
  303. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/index.d.ts +31 -0
  304. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/index.js +12 -0
  305. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/license +9 -0
  306. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/package.json +41 -0
  307. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/readme.md +57 -0
  308. package/server/whatsapp-rpc/node_modules/npm-run-path/package.json +49 -0
  309. package/server/whatsapp-rpc/node_modules/npm-run-path/readme.md +104 -0
  310. package/server/whatsapp-rpc/node_modules/onetime/index.d.ts +59 -0
  311. package/server/whatsapp-rpc/node_modules/onetime/index.js +41 -0
  312. package/server/whatsapp-rpc/node_modules/onetime/license +9 -0
  313. package/server/whatsapp-rpc/node_modules/onetime/package.json +45 -0
  314. package/server/whatsapp-rpc/node_modules/onetime/readme.md +94 -0
  315. package/server/whatsapp-rpc/node_modules/path-key/index.d.ts +40 -0
  316. package/server/whatsapp-rpc/node_modules/path-key/index.js +16 -0
  317. package/server/whatsapp-rpc/node_modules/path-key/license +9 -0
  318. package/server/whatsapp-rpc/node_modules/path-key/package.json +39 -0
  319. package/server/whatsapp-rpc/node_modules/path-key/readme.md +61 -0
  320. package/server/whatsapp-rpc/node_modules/shebang-command/index.js +19 -0
  321. package/server/whatsapp-rpc/node_modules/shebang-command/license +9 -0
  322. package/server/whatsapp-rpc/node_modules/shebang-command/package.json +34 -0
  323. package/server/whatsapp-rpc/node_modules/shebang-command/readme.md +34 -0
  324. package/server/whatsapp-rpc/node_modules/shebang-regex/index.d.ts +22 -0
  325. package/server/whatsapp-rpc/node_modules/shebang-regex/index.js +2 -0
  326. package/server/whatsapp-rpc/node_modules/shebang-regex/license +9 -0
  327. package/server/whatsapp-rpc/node_modules/shebang-regex/package.json +35 -0
  328. package/server/whatsapp-rpc/node_modules/shebang-regex/readme.md +33 -0
  329. package/server/whatsapp-rpc/node_modules/shell-exec/LICENSE +21 -0
  330. package/server/whatsapp-rpc/node_modules/shell-exec/README.md +60 -0
  331. package/server/whatsapp-rpc/node_modules/shell-exec/index.js +47 -0
  332. package/server/whatsapp-rpc/node_modules/shell-exec/package.json +29 -0
  333. package/server/whatsapp-rpc/node_modules/signal-exit/LICENSE.txt +16 -0
  334. package/server/whatsapp-rpc/node_modules/signal-exit/README.md +74 -0
  335. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/browser.d.ts +12 -0
  336. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/browser.d.ts.map +1 -0
  337. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/browser.js +10 -0
  338. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/browser.js.map +1 -0
  339. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/index.d.ts +48 -0
  340. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/index.d.ts.map +1 -0
  341. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/index.js +279 -0
  342. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/index.js.map +1 -0
  343. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/package.json +3 -0
  344. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/signals.d.ts +29 -0
  345. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/signals.d.ts.map +1 -0
  346. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/signals.js +42 -0
  347. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/signals.js.map +1 -0
  348. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/browser.d.ts +12 -0
  349. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/browser.d.ts.map +1 -0
  350. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/browser.js +4 -0
  351. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/browser.js.map +1 -0
  352. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/index.d.ts +48 -0
  353. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/index.d.ts.map +1 -0
  354. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/index.js +275 -0
  355. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/index.js.map +1 -0
  356. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/package.json +3 -0
  357. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/signals.d.ts +29 -0
  358. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/signals.d.ts.map +1 -0
  359. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/signals.js +39 -0
  360. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/signals.js.map +1 -0
  361. package/server/whatsapp-rpc/node_modules/signal-exit/package.json +106 -0
  362. package/server/whatsapp-rpc/node_modules/strip-final-newline/index.js +14 -0
  363. package/server/whatsapp-rpc/node_modules/strip-final-newline/license +9 -0
  364. package/server/whatsapp-rpc/node_modules/strip-final-newline/package.json +43 -0
  365. package/server/whatsapp-rpc/node_modules/strip-final-newline/readme.md +35 -0
  366. package/server/whatsapp-rpc/node_modules/which/CHANGELOG.md +166 -0
  367. package/server/whatsapp-rpc/node_modules/which/LICENSE +15 -0
  368. package/server/whatsapp-rpc/node_modules/which/README.md +54 -0
  369. package/server/whatsapp-rpc/node_modules/which/bin/node-which +52 -0
  370. package/server/whatsapp-rpc/node_modules/which/package.json +43 -0
  371. package/server/whatsapp-rpc/node_modules/which/which.js +125 -0
  372. package/server/whatsapp-rpc/package-lock.json +272 -0
  373. package/server/whatsapp-rpc/package.json +30 -30
  374. package/server/whatsapp-rpc/schema.json +1294 -1294
  375. package/server/whatsapp-rpc/scripts/clean.cjs +66 -66
  376. package/server/whatsapp-rpc/scripts/cli.js +162 -162
  377. package/server/whatsapp-rpc/src/go/whatsapp/history.go +166 -166
  378. package/server/whatsapp-rpc/src/python/pyproject.toml +15 -15
  379. package/server/whatsapp-rpc/src/python/whatsapp_rpc/__init__.py +4 -4
  380. package/server/whatsapp-rpc/src/python/whatsapp_rpc/client.py +427 -427
  381. package/server/whatsapp-rpc/web/app.py +609 -609
  382. package/server/whatsapp-rpc/web/requirements.txt +6 -6
  383. package/server/whatsapp-rpc/web/rpc_client.py +427 -427
  384. package/server/whatsapp-rpc/web/static/openapi.yaml +59 -59
  385. package/server/whatsapp-rpc/web/templates/base.html +149 -149
  386. package/server/whatsapp-rpc/web/templates/contacts.html +240 -240
  387. package/server/whatsapp-rpc/web/templates/dashboard.html +319 -319
  388. package/server/whatsapp-rpc/web/templates/groups.html +328 -328
  389. package/server/whatsapp-rpc/web/templates/messages.html +465 -465
  390. package/server/whatsapp-rpc/web/templates/messaging.html +680 -680
  391. package/server/whatsapp-rpc/web/templates/send.html +258 -258
  392. package/server/whatsapp-rpc/web/templates/settings.html +459 -459
  393. package/client/src/components/ui/AndroidSettingsPanel.tsx +0 -401
  394. package/client/src/components/ui/WhatsAppSettingsPanel.tsx +0 -345
  395. package/client/src/nodeDefinitions/androidDeviceNodes.ts +0 -140
  396. package/docker-compose.prod.yml +0 -107
  397. package/docker-compose.yml +0 -104
  398. package/docs-MachinaOs/README.md +0 -85
  399. package/docs-MachinaOs/deployment/docker.mdx +0 -228
  400. package/docs-MachinaOs/deployment/production.mdx +0 -345
  401. package/docs-MachinaOs/docs.json +0 -75
  402. package/docs-MachinaOs/faq.mdx +0 -309
  403. package/docs-MachinaOs/favicon.svg +0 -5
  404. package/docs-MachinaOs/installation.mdx +0 -160
  405. package/docs-MachinaOs/introduction.mdx +0 -114
  406. package/docs-MachinaOs/logo/dark.svg +0 -6
  407. package/docs-MachinaOs/logo/light.svg +0 -6
  408. package/docs-MachinaOs/nodes/ai-agent.mdx +0 -216
  409. package/docs-MachinaOs/nodes/ai-models.mdx +0 -240
  410. package/docs-MachinaOs/nodes/android.mdx +0 -411
  411. package/docs-MachinaOs/nodes/overview.mdx +0 -181
  412. package/docs-MachinaOs/nodes/schedulers.mdx +0 -316
  413. package/docs-MachinaOs/nodes/webhooks.mdx +0 -330
  414. package/docs-MachinaOs/nodes/whatsapp.mdx +0 -305
  415. package/docs-MachinaOs/quickstart.mdx +0 -119
  416. package/docs-MachinaOs/tutorials/ai-agent-workflow.mdx +0 -177
  417. package/docs-MachinaOs/tutorials/android-automation.mdx +0 -242
  418. package/docs-MachinaOs/tutorials/first-workflow.mdx +0 -134
  419. package/docs-MachinaOs/tutorials/whatsapp-automation.mdx +0 -185
  420. package/nul +0 -0
  421. package/scripts/check-ports.ps1 +0 -33
  422. package/scripts/kill-port.ps1 +0 -154
@@ -1,785 +1,675 @@
1
- """Event Waiter Service - Generic event waiting for trigger nodes.
2
-
3
- Supports any trigger type (WhatsApp, Email, Webhook, MQTT, etc.)
4
- Uses Redis Streams when available for persistence, falls back to asyncio.Future.
5
-
6
- Architecture:
7
- - Redis mode: Events stored in Redis Streams, waiters poll streams with blocking XREAD
8
- - Memory mode: Events dispatched to in-memory asyncio.Future waiters
9
- """
10
- import asyncio
11
- import json
12
- import uuid
13
- import time
14
- from dataclasses import dataclass, field
15
- from typing import Dict, Any, Optional, Callable, List, TYPE_CHECKING
16
-
17
- from core.logging import get_logger
18
-
19
- if TYPE_CHECKING:
20
- from core.cache import CacheService
21
-
22
- logger = get_logger(__name__)
23
-
24
-
25
- # =============================================================================
26
- # CACHE SERVICE REFERENCE
27
- # =============================================================================
28
-
29
- _cache_service: Optional["CacheService"] = None
30
- _main_loop: Optional[asyncio.AbstractEventLoop] = None
31
-
32
-
33
- def set_cache_service(cache: "CacheService") -> None:
34
- """Set the cache service for Redis Streams support.
35
-
36
- Called during application startup from main.py.
37
- """
38
- global _cache_service, _main_loop
39
- _cache_service = cache
40
- # Store reference to the main event loop for thread-safe dispatch
41
- try:
42
- _main_loop = asyncio.get_running_loop()
43
- except RuntimeError:
44
- _main_loop = None
45
- mode = "Redis Streams" if cache and cache.is_redis_available() else "asyncio.Future"
46
- logger.info(f"[EventWaiter] Initialized with {mode} backend")
47
-
48
-
49
- def get_cache_service() -> Optional["CacheService"]:
50
- """Get the cache service if available."""
51
- return _cache_service
52
-
53
-
54
- def is_redis_mode() -> bool:
55
- """Check if Redis Streams mode is active.
56
-
57
- Returns True only if Redis is connected AND supports Streams commands.
58
- This prevents runtime failures when Redis doesn't support XREADGROUP/XADD.
59
- """
60
- return _cache_service is not None and _cache_service.is_streams_available()
61
-
62
-
63
- # =============================================================================
64
- # LID TO PHONE RESOLUTION CACHE
65
- # =============================================================================
66
-
67
- # Cache: group_jid -> {lid -> phone}
68
- # TTL: 5 minutes (group membership can change)
69
- _lid_phone_cache: Dict[str, Dict[str, str]] = {}
70
- _lid_cache_timestamps: Dict[str, float] = {}
71
- LID_CACHE_TTL = 300 # 5 minutes
72
-
73
-
74
- async def resolve_lid_to_phone(group_jid: str, lid: str) -> Optional[str]:
75
- """Resolve a LID to phone number using cached group info.
76
-
77
- Args:
78
- group_jid: The group JID (e.g., '120363422738675920@g.us')
79
- lid: The LID to resolve (e.g., '201872623300767@lid' or just '201872623300767')
80
-
81
- Returns:
82
- Phone number if found, None otherwise
83
- """
84
- # Normalize LID (remove @lid suffix if present)
85
- lid_key = lid.split('@')[0] if '@' in lid else lid
86
-
87
- # Check cache validity
88
- if group_jid in _lid_phone_cache:
89
- cache_time = _lid_cache_timestamps.get(group_jid, 0)
90
- if time.time() - cache_time < LID_CACHE_TTL:
91
- phone = _lid_phone_cache[group_jid].get(lid_key)
92
- if phone:
93
- return phone
94
-
95
- # Cache miss or expired - fetch group info
96
- await refresh_group_lid_cache(group_jid)
97
-
98
- # Try again from cache
99
- if group_jid in _lid_phone_cache:
100
- phone = _lid_phone_cache[group_jid].get(lid_key)
101
- if phone:
102
- return phone
103
-
104
- logger.warning(f"[LIDResolver] Could not resolve LID {lid_key} in group {group_jid}")
105
- return None
106
-
107
-
108
- async def refresh_group_lid_cache(group_jid: str) -> bool:
109
- """Fetch group info and cache LID->phone mappings.
110
-
111
- Args:
112
- group_jid: The group JID to fetch info for
113
-
114
- Returns:
115
- True if successful, False otherwise
116
- """
117
- try:
118
- from routers.whatsapp import get_client
119
-
120
- client = await get_client()
121
- result = await client.call("group_info", {"group_id": group_jid})
122
-
123
- if not result or 'participants' not in result:
124
- logger.warning(f"[LIDResolver] No participants in group_info for {group_jid}")
125
- return False
126
-
127
- # Build LID->phone mapping
128
- lid_map: Dict[str, str] = {}
129
- for participant in result.get('participants', []):
130
- jid = participant.get('jid', '')
131
- phone = participant.get('phone', '')
132
-
133
- if jid and phone:
134
- # Extract LID key (number before @)
135
- lid_key = jid.split('@')[0] if '@' in jid else jid
136
- lid_map[lid_key] = phone
137
- logger.debug(f"[LIDResolver] Cached: {lid_key} -> {phone}")
138
-
139
- _lid_phone_cache[group_jid] = lid_map
140
- _lid_cache_timestamps[group_jid] = time.time()
141
-
142
- logger.debug(f"[LIDResolver] Cached {len(lid_map)} participants for group {group_jid}")
143
- return True
144
-
145
- except Exception as e:
146
- logger.error(f"[LIDResolver] Failed to fetch group info for {group_jid}: {e}")
147
- return False
148
-
149
-
150
- def get_cached_phone(group_jid: str, lid: str) -> Optional[str]:
151
- """Get phone from cache synchronously (for use in filter function).
152
-
153
- Args:
154
- group_jid: The group JID
155
- lid: The LID to look up
156
-
157
- Returns:
158
- Phone number if cached, None otherwise
159
- """
160
- lid_key = lid.split('@')[0] if '@' in lid else lid
161
-
162
- if group_jid in _lid_phone_cache:
163
- cache_time = _lid_cache_timestamps.get(group_jid, 0)
164
- if time.time() - cache_time < LID_CACHE_TTL:
165
- return _lid_phone_cache[group_jid].get(lid_key)
166
-
167
- return None
168
-
169
-
170
- # =============================================================================
171
- # TRIGGER CONFIGURATION REGISTRY
172
- # =============================================================================
173
-
174
- @dataclass
175
- class TriggerConfig:
176
- """Configuration for a trigger node type."""
177
- node_type: str
178
- event_type: str # Event to wait for (e.g., 'whatsapp_message_received')
179
- display_name: str
180
-
181
-
182
- # Registry of supported trigger types (event-based triggers only)
183
- # Note: cronScheduler is NOT an event-based trigger - it uses APScheduler directly
184
- TRIGGER_REGISTRY: Dict[str, TriggerConfig] = {
185
- 'start': TriggerConfig(
186
- node_type='start',
187
- event_type='deploy_triggered',
188
- display_name='Deploy Start'
189
- ),
190
- 'whatsappReceive': TriggerConfig(
191
- node_type='whatsappReceive',
192
- event_type='whatsapp_message_received',
193
- display_name='WhatsApp Message'
194
- ),
195
- 'webhookTrigger': TriggerConfig(
196
- node_type='webhookTrigger',
197
- event_type='webhook_received',
198
- display_name='Webhook Request'
199
- ),
200
- 'chatTrigger': TriggerConfig(
201
- node_type='chatTrigger',
202
- event_type='chat_message_received',
203
- display_name='Chat Message'
204
- ),
205
- # Future triggers - just add to registry:
206
- # 'emailTrigger': TriggerConfig('emailTrigger', 'email_received', 'Email'),
207
- # 'mqttTrigger': TriggerConfig('mqttTrigger', 'mqtt_message', 'MQTT Message'),
208
- # 'telegramTrigger': TriggerConfig('telegramTrigger', 'telegram_message', 'Telegram'),
209
- }
210
-
211
-
212
- def is_trigger_node(node_type: str) -> bool:
213
- """Check if a node type is a trigger node (workflow starting point).
214
-
215
- Uses constants.WORKFLOW_TRIGGER_TYPES for comprehensive trigger detection.
216
- This includes all trigger types: start, cronScheduler, and event-based triggers.
217
- """
218
- from constants import WORKFLOW_TRIGGER_TYPES
219
- return node_type in WORKFLOW_TRIGGER_TYPES
220
-
221
-
222
- def is_event_trigger_node(node_type: str) -> bool:
223
- """Check if a node type is an event-based trigger (waits for events).
224
-
225
- Event-based triggers are registered in TRIGGER_REGISTRY and wait for
226
- external events to fire. This excludes 'start' and 'cronScheduler' which
227
- have their own execution mechanisms.
228
- """
229
- return node_type in TRIGGER_REGISTRY
230
-
231
-
232
- def get_trigger_config(node_type: str) -> Optional[TriggerConfig]:
233
- """Get trigger configuration for a node type."""
234
- return TRIGGER_REGISTRY.get(node_type)
235
-
236
-
237
- # =============================================================================
238
- # FILTER BUILDERS - One per trigger type
239
- # =============================================================================
240
-
241
- def build_whatsapp_filter(params: Dict) -> Callable[[Dict], bool]:
242
- """Build filter function for WhatsApp messages.
243
-
244
- Based on schema.json event.message_received fields:
245
- - message_type: text, image, video, audio, document, sticker, location, contact, contacts
246
- - sender: Sender JID (e.g., 1234567890@s.whatsapp.net for DMs, or LID like 123@lid for groups)
247
- - chat_id: Chat JID (same as sender for DMs, group JID for groups)
248
- - is_from_me: boolean - true if sent by connected account
249
- - is_group: boolean - true if message is in a group chat
250
- - is_forwarded: boolean - true if message is forwarded
251
- - text: text content (for text messages)
252
- - group_info: { group_jid, sender_jid, sender_name } - present for group messages
253
- - sender_jid may be LID format, use LID cache to resolve to real phone
254
- """
255
- msg_type = params.get('messageTypeFilter', 'all')
256
- sender_filter = params.get('filter', 'all')
257
- contact_phone = params.get('contactPhone', '')
258
- group_id = params.get('group_id') or params.get('groupId', '')
259
- sender_number = params.get('senderNumber', '') # Optional sender filter within group
260
- keywords = [k.strip().lower() for k in params.get('keywords', '').split(',') if k.strip()]
261
- ignore_own = params.get('ignoreOwnMessages', True)
262
- forwarded_filter = params.get('forwardedFilter', 'all') # 'all', 'only_forwarded', 'ignore_forwarded'
263
-
264
- logger.debug(f"[WhatsAppFilter] Built: type={msg_type}, filter={sender_filter}, group_id='{group_id}', forwarded={forwarded_filter}")
265
-
266
- def matches(m: Dict) -> bool:
267
- msg_chat_id = m.get('chat_id', '')
268
- msg_sender = m.get('sender', '')
269
- group_info = m.get('group_info', {})
270
- is_group = m.get('is_group', False)
271
-
272
- # For group messages, try to resolve LID to phone using cache
273
- sender_jid = group_info.get('sender_jid', '') if is_group else msg_sender
274
- sender_phone = ''
275
-
276
- if is_group and sender_jid:
277
- # Check if sender_jid is a LID (ends with @lid)
278
- if '@lid' in sender_jid:
279
- # Try to get resolved phone from cache
280
- cached_phone = get_cached_phone(msg_chat_id, sender_jid)
281
- if cached_phone:
282
- sender_phone = cached_phone
283
- else:
284
- # LID not in cache, extract number part as fallback
285
- sender_phone = sender_jid.split('@')[0] if '@' in sender_jid else sender_jid
286
- else:
287
- # Not a LID, extract phone from JID
288
- sender_phone = sender_jid.split('@')[0] if '@' in sender_jid else sender_jid
289
- else:
290
- # DM - extract phone from sender
291
- sender_phone = msg_sender.split('@')[0] if '@' in msg_sender else msg_sender
292
-
293
- # Message type filter (schema field: message_type)
294
- if msg_type != 'all' and m.get('message_type') != msg_type:
295
- return False
296
-
297
- # Sender filter - for contact filter, use actual phone number
298
- if sender_filter == 'any_contact':
299
- # Only accept non-group messages (individual/contact messages)
300
- if is_group:
301
- return False
302
-
303
- if sender_filter == 'contact':
304
- if contact_phone not in sender_phone:
305
- return False
306
-
307
- if sender_filter == 'group':
308
- # For group filter, check if message is from that group
309
- if not is_group:
310
- return False
311
- if msg_chat_id != group_id:
312
- return False
313
- # Optional: filter by specific sender within group using resolved phone number
314
- if sender_number:
315
- if sender_number not in sender_phone:
316
- return False
317
-
318
- if sender_filter == 'keywords':
319
- text = (m.get('text') or '').lower()
320
- if not any(kw in text for kw in keywords):
321
- return False
322
-
323
- # Ignore own messages (schema field: is_from_me)
324
- if ignore_own and m.get('is_from_me'):
325
- return False
326
-
327
- # Forwarded message filter (schema field: is_forwarded)
328
- is_forwarded = m.get('is_forwarded', False)
329
- logger.debug(f"[WhatsAppFilter] Forwarded check: filter={forwarded_filter}, is_forwarded={is_forwarded}, raw_value={m.get('is_forwarded')}")
330
- if forwarded_filter == 'only_forwarded' and not is_forwarded:
331
- logger.debug(f"[WhatsAppFilter] Rejected: only_forwarded but message is not forwarded")
332
- return False
333
- if forwarded_filter == 'ignore_forwarded' and is_forwarded:
334
- logger.debug(f"[WhatsAppFilter] Rejected: ignore_forwarded but message is forwarded")
335
- return False
336
-
337
- logger.debug(f"[WhatsAppFilter] Matched message from {sender_phone}")
338
- return True
339
-
340
- return matches
341
-
342
-
343
- def build_webhook_filter(params: Dict) -> Callable[[Dict], bool]:
344
- """Build filter function for webhook requests.
345
-
346
- Filters by webhook path to ensure the event is for the correct trigger node.
347
-
348
- Args:
349
- params: Node parameters with 'path' field
350
-
351
- Returns:
352
- Filter function that checks if event path matches
353
- """
354
- webhook_path = params.get('path', '')
355
-
356
- def matches(data: Dict) -> bool:
357
- event_path = data.get('path', '')
358
- if webhook_path and event_path != webhook_path:
359
- return False
360
- return True
361
-
362
- return matches
363
-
364
-
365
- def build_chat_filter(params: Dict) -> Callable[[Dict], bool]:
366
- """Build filter function for chat messages from console input.
367
-
368
- Args:
369
- params: Node parameters with 'sessionId' field
370
-
371
- Returns:
372
- Filter function that checks if event session_id matches
373
- """
374
- session_id = params.get('sessionId', 'default')
375
-
376
- def matches(data: Dict) -> bool:
377
- event_session = data.get('session_id', 'default')
378
- if session_id != 'default' and event_session != session_id:
379
- return False
380
- return True
381
-
382
- return matches
383
-
384
-
385
- # Registry of filter builders per trigger type
386
- FILTER_BUILDERS: Dict[str, Callable[[Dict], Callable[[Dict], bool]]] = {
387
- 'whatsappReceive': build_whatsapp_filter,
388
- 'webhookTrigger': build_webhook_filter,
389
- 'chatTrigger': build_chat_filter,
390
- }
391
-
392
-
393
- def build_filter(node_type: str, params: Dict) -> Callable[[Dict], bool]:
394
- """Build a filter function for the given trigger type and parameters."""
395
- builder = FILTER_BUILDERS.get(node_type)
396
- if builder:
397
- return builder(params)
398
- # Default: accept all events
399
- return lambda x: True
400
-
401
-
402
- # =============================================================================
403
- # WAITER DATA STRUCTURES
404
- # =============================================================================
405
-
406
- @dataclass
407
- class Waiter:
408
- """Single event waiter.
409
-
410
- In memory mode: uses asyncio.Future
411
- In Redis mode: uses stream polling with stored metadata
412
- """
413
- id: str = field(default_factory=lambda: str(uuid.uuid4()))
414
- node_id: str = ""
415
- node_type: str = ""
416
- event_type: str = ""
417
- params: Dict = field(default_factory=dict) # Store params for Redis mode filter rebuild
418
- filter_fn: Callable[[Dict], bool] = field(default_factory=lambda: lambda x: True)
419
- future: Optional[asyncio.Future] = None # Only used in memory mode
420
- cancelled: bool = False
421
- created_at: float = field(default_factory=time.time)
422
-
423
-
424
- # Module-level waiter storage (used in both modes for tracking)
425
- _waiters: Dict[str, Waiter] = {}
426
-
427
- # Redis stream names
428
- EVENTS_STREAM_PREFIX = "events:"
429
- WAITERS_KEY_PREFIX = "waiters:"
430
- # NOTE: Each waiter uses its own consumer group to ensure ALL waiters receive ALL messages.
431
- # Redis consumer groups deliver each message to only ONE consumer in the group.
432
- # For trigger nodes, we want broadcast semantics where every waiter evaluates every event.
433
- CONSUMER_GROUP_PREFIX = "waiter_group_" # Each waiter gets: waiter_group_{waiter_id}
434
-
435
-
436
- def _get_stream_name(event_type: str) -> str:
437
- """Get Redis stream name for event type."""
438
- return f"{EVENTS_STREAM_PREFIX}{event_type}"
439
-
440
-
441
- # =============================================================================
442
- # WAITER REGISTRATION
443
- # =============================================================================
444
-
445
- async def register(node_type: str, node_id: str, params: Dict) -> Waiter:
446
- """Register a waiter for a trigger node.
447
-
448
- Args:
449
- node_type: Type of trigger node (e.g., 'whatsappReceive')
450
- node_id: ID of the node waiting
451
- params: Node parameters for building filter
452
-
453
- Returns:
454
- Waiter object to await
455
- """
456
- config = get_trigger_config(node_type)
457
- if not config:
458
- raise ValueError(f"Unknown trigger type: {node_type}")
459
-
460
- # Note: LID cache for group sender resolution is populated lazily on first message
461
- # We don't pre-fetch here to avoid blocking deployment with sequential RPC calls
462
- if node_type == 'whatsappReceive':
463
- filter_type = params.get('filter', 'all')
464
- group_id = params.get('group_id') or params.get('groupId', '')
465
- sender_number = params.get('senderNumber', '')
466
-
467
- if filter_type == 'group' and group_id and sender_number:
468
- logger.debug(f"[EventWaiter] Group filter with sender: {group_id}, sender: {sender_number}")
469
-
470
- # Create waiter
471
- waiter = Waiter(
472
- node_id=node_id,
473
- node_type=node_type,
474
- event_type=config.event_type,
475
- params=params,
476
- filter_fn=build_filter(node_type, params),
477
- )
478
-
479
- if is_redis_mode():
480
- # Redis mode: store waiter metadata in Redis
481
- cache = get_cache_service()
482
- waiter_key = f"{WAITERS_KEY_PREFIX}{waiter.id}"
483
-
484
- # Each waiter gets its own consumer group for broadcast semantics
485
- # This ensures ALL waiters receive ALL messages (not load-balanced)
486
- consumer_group = f"{CONSUMER_GROUP_PREFIX}{waiter.id}"
487
-
488
- waiter_data = {
489
- "id": waiter.id,
490
- "node_id": node_id,
491
- "node_type": node_type,
492
- "event_type": config.event_type,
493
- "params": json.dumps(params),
494
- "created_at": waiter.created_at,
495
- "consumer_group": consumer_group, # Store for cleanup
496
- }
497
- await cache.set(waiter_key, waiter_data, ttl=86400) # 24 hour TTL
498
-
499
- # Create unique consumer group for this waiter
500
- # start_id='$' means only new messages from this point forward
501
- stream_name = _get_stream_name(config.event_type)
502
- await cache.stream_create_group(stream_name, consumer_group, start_id='$')
503
-
504
- logger.debug(f"[EventWaiter] Registered {node_type} waiter {waiter.id} (Redis)")
505
- else:
506
- # Memory mode: create asyncio.Future
507
- try:
508
- loop = asyncio.get_running_loop()
509
- waiter.future = loop.create_future()
510
- except RuntimeError:
511
- waiter.future = asyncio.get_event_loop().create_future()
512
-
513
- logger.debug(f"[EventWaiter] Registered {node_type} waiter {waiter.id}")
514
-
515
- _waiters[waiter.id] = waiter
516
- return waiter
517
-
518
-
519
- async def wait_for_event(waiter: Waiter, timeout: Optional[float] = None) -> Dict:
520
- """Wait for an event matching the waiter's filter.
521
-
522
- Args:
523
- waiter: The registered waiter
524
- timeout: Optional timeout in seconds (None = wait forever)
525
-
526
- Returns:
527
- Event data when matched
528
-
529
- Raises:
530
- asyncio.CancelledError: If waiter was cancelled
531
- asyncio.TimeoutError: If timeout exceeded
532
- """
533
- if is_redis_mode():
534
- return await _wait_redis(waiter, timeout)
535
- else:
536
- return await _wait_memory(waiter, timeout)
537
-
538
-
539
- async def _wait_memory(waiter: Waiter, timeout: Optional[float]) -> Dict:
540
- """Wait using asyncio.Future (memory mode)."""
541
- if waiter.future is None:
542
- raise RuntimeError("Waiter has no Future (memory mode not initialized)")
543
-
544
- try:
545
- if timeout:
546
- return await asyncio.wait_for(waiter.future, timeout)
547
- else:
548
- return await waiter.future
549
- except asyncio.CancelledError:
550
- _cleanup_waiter(waiter.id)
551
- raise
552
-
553
-
554
- async def _wait_redis(waiter: Waiter, timeout: Optional[float]) -> Dict:
555
- """Wait using Redis Streams polling.
556
-
557
- Polls the event stream with blocking XREAD, checking each message against the filter.
558
- Each waiter has its own consumer group for broadcast semantics.
559
- """
560
- cache = get_cache_service()
561
- stream_name = _get_stream_name(waiter.event_type)
562
-
563
- # Use waiter-specific consumer group for broadcast (all waiters see all messages)
564
- consumer_group = f"{CONSUMER_GROUP_PREFIX}{waiter.id}"
565
- consumer_name = f"consumer_{waiter.id}"
566
-
567
- # Start reading from now (new messages only)
568
- last_id = '$'
569
- block_ms = 5000 # 5 second blocks to allow cancellation checks
570
-
571
- start_time = time.time()
572
-
573
- while not waiter.cancelled:
574
- # Check timeout
575
- if timeout and (time.time() - start_time) > timeout:
576
- raise asyncio.TimeoutError(f"Waiter {waiter.id} timed out after {timeout}s")
577
-
578
- # Read from stream with blocking using waiter's own consumer group
579
- try:
580
- result = await cache.stream_read_group(
581
- consumer_group, # Each waiter has its own group
582
- consumer_name,
583
- {stream_name: '>'}, # '>' = new messages for this consumer
584
- count=10,
585
- block=block_ms
586
- )
587
-
588
- if not result:
589
- # No messages, continue polling
590
- continue
591
-
592
- # Process messages
593
- for stream_data in result:
594
- stream, messages = stream_data
595
- for msg_id, fields in messages:
596
- # Deserialize event data
597
- event_data = {}
598
- for k, v in fields.items():
599
- try:
600
- event_data[k] = json.loads(v)
601
- except (json.JSONDecodeError, TypeError):
602
- event_data[k] = v
603
-
604
- # Check filter
605
- if waiter.filter_fn(event_data):
606
- # Match found - acknowledge and return
607
- await cache.stream_ack(stream_name, consumer_group, msg_id)
608
- _cleanup_waiter(waiter.id)
609
- logger.info(f"[EventWaiter] Waiter {waiter.id} matched event {msg_id}")
610
- return event_data
611
- else:
612
- # No match - acknowledge but continue waiting
613
- await cache.stream_ack(stream_name, consumer_group, msg_id)
614
-
615
- except asyncio.CancelledError:
616
- _cleanup_waiter(waiter.id)
617
- raise
618
-
619
- # Waiter was cancelled via cancel() flag
620
- _cleanup_waiter(waiter.id)
621
- raise asyncio.CancelledError(f"Waiter {waiter.id} cancelled")
622
-
623
-
624
- def _cleanup_waiter(waiter_id: str) -> None:
625
- """Remove waiter from storage."""
626
- _waiters.pop(waiter_id, None)
627
-
628
- # Also remove from Redis if in Redis mode
629
- if is_redis_mode():
630
- cache = get_cache_service()
631
- waiter_key = f"{WAITERS_KEY_PREFIX}{waiter_id}"
632
- asyncio.create_task(cache.delete(waiter_key))
633
-
634
-
635
- # =============================================================================
636
- # EVENT DISPATCH
637
- # =============================================================================
638
-
639
- async def dispatch_async(event_type: str, data: Dict) -> int:
640
- """Dispatch event asynchronously (for Redis mode).
641
-
642
- Args:
643
- event_type: Type of event (e.g., 'whatsapp_message_received')
644
- data: Event data
645
-
646
- Returns:
647
- 1 if event was added to stream, 0 otherwise
648
- """
649
- logger.debug(f"[EventWaiter] dispatch_async: event_type='{event_type}'")
650
-
651
- if is_redis_mode():
652
- cache = get_cache_service()
653
- stream_name = _get_stream_name(event_type)
654
- msg_id = await cache.stream_add(stream_name, data)
655
- if msg_id:
656
- logger.debug(f"[EventWaiter] Added event to stream {stream_name}: {msg_id}")
657
- return 1
658
- return 0
659
- else:
660
- # Fall back to sync dispatch for memory mode
661
- return dispatch(event_type, data)
662
-
663
-
664
- def dispatch(event_type: str, data: Dict) -> int:
665
- """Dispatch event to matching waiters (synchronous, memory mode).
666
-
667
- Thread-safe: Can be called from APScheduler threads or async context.
668
-
669
- Args:
670
- event_type: Type of event (e.g., 'whatsapp_message_received')
671
- data: Event data
672
-
673
- Returns:
674
- Number of waiters resolved
675
- """
676
- if is_redis_mode():
677
- # In Redis mode, use async dispatch
678
- # Handle both async context and thread context (e.g., APScheduler callbacks)
679
- try:
680
- # Try to get the current running loop
681
- loop = asyncio.get_running_loop()
682
- # We're in an async context - schedule task normally
683
- asyncio.create_task(dispatch_async(event_type, data))
684
- except RuntimeError:
685
- # No running loop - we're in a thread (e.g., APScheduler callback)
686
- # Use the stored main loop with thread-safe dispatch
687
- if _main_loop is not None and _main_loop.is_running():
688
- asyncio.run_coroutine_threadsafe(dispatch_async(event_type, data), _main_loop)
689
- else:
690
- logger.warning(f"[EventWaiter] No event loop available for dispatch of {event_type}")
691
- return 0 # Actual resolution happens in _wait_redis
692
-
693
- resolved = 0
694
- to_remove = []
695
-
696
- for wid, w in _waiters.items():
697
- if w.event_type == event_type and w.future and not w.future.done():
698
- try:
699
- if w.filter_fn(data):
700
- w.future.set_result(data)
701
- to_remove.append(wid)
702
- resolved += 1
703
- logger.debug(f"[EventWaiter] Resolved {w.node_type} waiter {wid}")
704
- except Exception as e:
705
- logger.error(f"[EventWaiter] Filter error for waiter {wid}: {e}")
706
-
707
- for wid in to_remove:
708
- _waiters.pop(wid, None)
709
-
710
- return resolved
711
-
712
-
713
- # =============================================================================
714
- # WAITER CANCELLATION
715
- # =============================================================================
716
-
717
- def cancel(waiter_id: str) -> bool:
718
- """Cancel a waiter by ID."""
719
- if w := _waiters.pop(waiter_id, None):
720
- w.cancelled = True
721
-
722
- if w.future and not w.future.done():
723
- w.future.cancel()
724
-
725
- # Also remove from Redis if in Redis mode
726
- if is_redis_mode():
727
- cache = get_cache_service()
728
- waiter_key = f"{WAITERS_KEY_PREFIX}{waiter_id}"
729
- asyncio.create_task(cache.delete(waiter_key))
730
-
731
- logger.debug(f"[EventWaiter] Cancelled waiter {waiter_id}")
732
- return True
733
-
734
- return False
735
-
736
-
737
- def cancel_for_node(node_id: str) -> int:
738
- """Cancel all waiters for a node."""
739
- to_cancel = [wid for wid, w in _waiters.items() if w.node_id == node_id]
740
- for wid in to_cancel:
741
- cancel(wid)
742
- return len(to_cancel)
743
-
744
-
745
- # =============================================================================
746
- # UTILITY FUNCTIONS
747
- # =============================================================================
748
-
749
- def get_active_waiters() -> List[Dict[str, Any]]:
750
- """Get info about active waiters (for debugging/UI)."""
751
- return [
752
- {
753
- "id": w.id,
754
- "node_id": w.node_id,
755
- "node_type": w.node_type,
756
- "event_type": w.event_type,
757
- "done": w.future.done() if w.future else False,
758
- "cancelled": w.cancelled,
759
- "age_seconds": time.time() - w.created_at,
760
- "mode": "redis" if is_redis_mode() else "memory",
761
- }
762
- for w in _waiters.values()
763
- ]
764
-
765
-
766
- def clear_all() -> int:
767
- """Clear all waiters (for testing/cleanup)."""
768
- count = len(_waiters)
769
- for w in _waiters.values():
770
- w.cancelled = True
771
- if w.future and not w.future.done():
772
- w.future.cancel()
773
- _waiters.clear()
774
-
775
- # Clear Redis waiter keys if in Redis mode
776
- if is_redis_mode():
777
- cache = get_cache_service()
778
- asyncio.create_task(cache.clear_pattern(f"{WAITERS_KEY_PREFIX}*"))
779
-
780
- return count
781
-
782
-
783
- def get_backend_mode() -> str:
784
- """Get current backend mode for debugging."""
785
- return "redis" if is_redis_mode() else "memory"
1
+ """Event Waiter Service - Generic event waiting for trigger nodes.
2
+
3
+ Supports any trigger type (WhatsApp, Email, Webhook, MQTT, etc.)
4
+ Uses Redis Streams when available for persistence, falls back to asyncio.Future.
5
+
6
+ Architecture:
7
+ - Redis mode: Events stored in Redis Streams, waiters poll streams with blocking XREAD
8
+ - Memory mode: Events dispatched to in-memory asyncio.Future waiters
9
+ """
10
+ import asyncio
11
+ import json
12
+ import uuid
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from typing import Dict, Any, Optional, Callable, List, TYPE_CHECKING
16
+
17
+ from core.logging import get_logger
18
+
19
+ if TYPE_CHECKING:
20
+ from core.cache import CacheService
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ # =============================================================================
26
+ # CACHE SERVICE REFERENCE
27
+ # =============================================================================
28
+
29
+ _cache_service: Optional["CacheService"] = None
30
+ _main_loop: Optional[asyncio.AbstractEventLoop] = None
31
+
32
+
33
+ def set_cache_service(cache: "CacheService") -> None:
34
+ """Set the cache service for Redis Streams support.
35
+
36
+ Called during application startup from main.py.
37
+ """
38
+ global _cache_service, _main_loop
39
+ _cache_service = cache
40
+ # Store reference to the main event loop for thread-safe dispatch
41
+ try:
42
+ _main_loop = asyncio.get_running_loop()
43
+ except RuntimeError:
44
+ _main_loop = None
45
+ mode = "Redis Streams" if cache and cache.is_redis_available() else "asyncio.Future"
46
+ logger.info(f"[EventWaiter] Initialized with {mode} backend")
47
+
48
+
49
+ def get_cache_service() -> Optional["CacheService"]:
50
+ """Get the cache service if available."""
51
+ return _cache_service
52
+
53
+
54
+ def is_redis_mode() -> bool:
55
+ """Check if Redis Streams mode is active.
56
+
57
+ Returns True only if Redis is connected AND supports Streams commands.
58
+ This prevents runtime failures when Redis doesn't support XREADGROUP/XADD.
59
+ """
60
+ return _cache_service is not None and _cache_service.is_streams_available()
61
+
62
+
63
+ # =============================================================================
64
+ # NOTE: LID TO PHONE RESOLUTION
65
+ # =============================================================================
66
+ # LID (Linked ID) resolution is now handled by the Go WhatsApp RPC (service.go).
67
+ # The Go RPC resolves LIDs to phone numbers before sending the message_received event.
68
+ # The sender_phone field in the event data contains the already-resolved phone number.
69
+ # No Python-side LID cache is needed anymore.
70
+ # =============================================================================
71
+
72
+
73
+ # =============================================================================
74
+ # TRIGGER CONFIGURATION REGISTRY
75
+ # =============================================================================
76
+
77
+ @dataclass
78
+ class TriggerConfig:
79
+ """Configuration for a trigger node type."""
80
+ node_type: str
81
+ event_type: str # Event to wait for (e.g., 'whatsapp_message_received')
82
+ display_name: str
83
+
84
+
85
+ # Registry of supported trigger types (event-based triggers only)
86
+ # Note: cronScheduler is NOT an event-based trigger - it uses APScheduler directly
87
+ TRIGGER_REGISTRY: Dict[str, TriggerConfig] = {
88
+ 'start': TriggerConfig(
89
+ node_type='start',
90
+ event_type='deploy_triggered',
91
+ display_name='Deploy Start'
92
+ ),
93
+ 'whatsappReceive': TriggerConfig(
94
+ node_type='whatsappReceive',
95
+ event_type='whatsapp_message_received',
96
+ display_name='WhatsApp Message'
97
+ ),
98
+ 'webhookTrigger': TriggerConfig(
99
+ node_type='webhookTrigger',
100
+ event_type='webhook_received',
101
+ display_name='Webhook Request'
102
+ ),
103
+ 'chatTrigger': TriggerConfig(
104
+ node_type='chatTrigger',
105
+ event_type='chat_message_received',
106
+ display_name='Chat Message'
107
+ ),
108
+ # Future triggers - just add to registry:
109
+ # 'emailTrigger': TriggerConfig('emailTrigger', 'email_received', 'Email'),
110
+ # 'mqttTrigger': TriggerConfig('mqttTrigger', 'mqtt_message', 'MQTT Message'),
111
+ # 'telegramTrigger': TriggerConfig('telegramTrigger', 'telegram_message', 'Telegram'),
112
+ }
113
+
114
+
115
+ def is_trigger_node(node_type: str) -> bool:
116
+ """Check if a node type is a trigger node (workflow starting point).
117
+
118
+ Uses constants.WORKFLOW_TRIGGER_TYPES for comprehensive trigger detection.
119
+ This includes all trigger types: start, cronScheduler, and event-based triggers.
120
+ """
121
+ from constants import WORKFLOW_TRIGGER_TYPES
122
+ return node_type in WORKFLOW_TRIGGER_TYPES
123
+
124
+
125
+ def is_event_trigger_node(node_type: str) -> bool:
126
+ """Check if a node type is an event-based trigger (waits for events).
127
+
128
+ Event-based triggers are registered in TRIGGER_REGISTRY and wait for
129
+ external events to fire. This excludes 'start' and 'cronScheduler' which
130
+ have their own execution mechanisms.
131
+ """
132
+ return node_type in TRIGGER_REGISTRY
133
+
134
+
135
+ def get_trigger_config(node_type: str) -> Optional[TriggerConfig]:
136
+ """Get trigger configuration for a node type."""
137
+ return TRIGGER_REGISTRY.get(node_type)
138
+
139
+
140
+ # =============================================================================
141
+ # FILTER BUILDERS - One per trigger type
142
+ # =============================================================================
143
+
144
+ def build_whatsapp_filter(params: Dict) -> Callable[[Dict], bool]:
145
+ """Build filter function for WhatsApp messages.
146
+
147
+ Based on Go RPC handleIncomingMessage() event fields (service.go):
148
+ - message_id: string - unique message ID
149
+ - sender: string - Sender JID (may be LID for groups)
150
+ - sender_phone: string - RESOLVED phone number (LID already resolved by Go RPC!)
151
+ - chat_id: string - Chat JID (same as sender for DMs, group JID for groups)
152
+ - timestamp: time - message timestamp
153
+ - is_from_me: boolean - true if sent by connected account
154
+ - is_group: boolean - true if message is in a group chat
155
+ - message_type: string - text, image, video, audio, document, sticker, location, contact, contacts
156
+ - text: string - text content (for text messages)
157
+ - is_forwarded: boolean - true if message is forwarded
158
+ - forwarding_score: int - forwarding count
159
+ - group_info: object - present for group messages:
160
+ - group_jid: string
161
+ - sender_jid: string
162
+ - sender_phone: string - RESOLVED phone number
163
+ - sender_name: string - push name if available
164
+
165
+ Note: The Go RPC already resolves LIDs to phone numbers before sending the event.
166
+ The sender_phone field contains the resolved phone number - no manual LID resolution needed!
167
+ """
168
+ msg_type = params.get('messageTypeFilter', 'all')
169
+ sender_filter = params.get('filter', 'all')
170
+ contact_phone = params.get('contactPhone', '')
171
+ group_id = params.get('group_id') or params.get('groupId', '')
172
+ sender_number = params.get('senderNumber', '') # Optional sender filter within group
173
+ keywords = [k.strip().lower() for k in params.get('keywords', '').split(',') if k.strip()]
174
+ ignore_own = params.get('ignoreOwnMessages', True)
175
+ forwarded_filter = params.get('forwardedFilter', 'all') # 'all', 'only_forwarded', 'ignore_forwarded'
176
+
177
+ logger.debug(f"[WhatsAppFilter] Built: type={msg_type}, filter={sender_filter}, group_id='{group_id}', forwarded={forwarded_filter}")
178
+
179
+ def matches(m: Dict) -> bool:
180
+ msg_chat_id = m.get('chat_id', '')
181
+ is_group = m.get('is_group', False)
182
+ group_info = m.get('group_info', {})
183
+
184
+ # Use sender_phone directly - Go RPC already resolves LIDs to phone numbers!
185
+ # For group messages, prefer group_info.sender_phone, fall back to root sender_phone
186
+ if is_group:
187
+ sender_phone = group_info.get('sender_phone', '') or m.get('sender_phone', '')
188
+ else:
189
+ sender_phone = m.get('sender_phone', '')
190
+
191
+ # Fallback: extract phone from sender JID if sender_phone not available
192
+ if not sender_phone:
193
+ sender = m.get('sender', '')
194
+ sender_phone = sender.split('@')[0] if '@' in sender else sender
195
+
196
+ # Message type filter (schema field: message_type)
197
+ if msg_type != 'all' and m.get('message_type') != msg_type:
198
+ return False
199
+
200
+ # Sender filter - for contact filter, use actual phone number
201
+ if sender_filter == 'any_contact':
202
+ # Only accept non-group messages (individual/contact messages)
203
+ if is_group:
204
+ return False
205
+
206
+ if sender_filter == 'contact':
207
+ if contact_phone not in sender_phone:
208
+ return False
209
+
210
+ if sender_filter == 'group':
211
+ # For group filter, check if message is from that group
212
+ if not is_group:
213
+ return False
214
+ if msg_chat_id != group_id:
215
+ return False
216
+ # Optional: filter by specific sender within group using resolved phone number
217
+ if sender_number:
218
+ if sender_number not in sender_phone:
219
+ return False
220
+
221
+ if sender_filter == 'keywords':
222
+ text = (m.get('text') or '').lower()
223
+ if not any(kw in text for kw in keywords):
224
+ return False
225
+
226
+ # Ignore own messages (schema field: is_from_me)
227
+ if ignore_own and m.get('is_from_me'):
228
+ return False
229
+
230
+ # Forwarded message filter (schema field: is_forwarded)
231
+ is_forwarded = m.get('is_forwarded', False)
232
+ if forwarded_filter == 'only_forwarded' and not is_forwarded:
233
+ return False
234
+ if forwarded_filter == 'ignore_forwarded' and is_forwarded:
235
+ return False
236
+
237
+ logger.debug(f"[WhatsAppFilter] Matched message from {sender_phone}")
238
+ return True
239
+
240
+ return matches
241
+
242
+
243
+ def build_webhook_filter(params: Dict) -> Callable[[Dict], bool]:
244
+ """Build filter function for webhook requests.
245
+
246
+ Filters by webhook path to ensure the event is for the correct trigger node.
247
+
248
+ Args:
249
+ params: Node parameters with 'path' field
250
+
251
+ Returns:
252
+ Filter function that checks if event path matches
253
+ """
254
+ webhook_path = params.get('path', '')
255
+
256
+ def matches(data: Dict) -> bool:
257
+ event_path = data.get('path', '')
258
+ if webhook_path and event_path != webhook_path:
259
+ return False
260
+ return True
261
+
262
+ return matches
263
+
264
+
265
+ def build_chat_filter(params: Dict) -> Callable[[Dict], bool]:
266
+ """Build filter function for chat messages from console input.
267
+
268
+ Args:
269
+ params: Node parameters with 'sessionId' field
270
+
271
+ Returns:
272
+ Filter function that checks if event session_id matches
273
+ """
274
+ session_id = params.get('sessionId', 'default')
275
+
276
+ def matches(data: Dict) -> bool:
277
+ event_session = data.get('session_id', 'default')
278
+ if session_id != 'default' and event_session != session_id:
279
+ return False
280
+ return True
281
+
282
+ return matches
283
+
284
+
285
+ # Registry of filter builders per trigger type
286
+ FILTER_BUILDERS: Dict[str, Callable[[Dict], Callable[[Dict], bool]]] = {
287
+ 'whatsappReceive': build_whatsapp_filter,
288
+ 'webhookTrigger': build_webhook_filter,
289
+ 'chatTrigger': build_chat_filter,
290
+ }
291
+
292
+
293
+ def build_filter(node_type: str, params: Dict) -> Callable[[Dict], bool]:
294
+ """Build a filter function for the given trigger type and parameters."""
295
+ builder = FILTER_BUILDERS.get(node_type)
296
+ if builder:
297
+ return builder(params)
298
+ # Default: accept all events
299
+ return lambda x: True
300
+
301
+
302
+ # =============================================================================
303
+ # WAITER DATA STRUCTURES
304
+ # =============================================================================
305
+
306
+ @dataclass
307
+ class Waiter:
308
+ """Single event waiter.
309
+
310
+ In memory mode: uses asyncio.Future
311
+ In Redis mode: uses stream polling with stored metadata
312
+ """
313
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
314
+ node_id: str = ""
315
+ node_type: str = ""
316
+ event_type: str = ""
317
+ params: Dict = field(default_factory=dict) # Store params for Redis mode filter rebuild
318
+ filter_fn: Callable[[Dict], bool] = field(default_factory=lambda: lambda x: True)
319
+ future: Optional[asyncio.Future] = None # Only used in memory mode
320
+ cancelled: bool = False
321
+ created_at: float = field(default_factory=time.time)
322
+
323
+
324
+ # Module-level waiter storage (used in both modes for tracking)
325
+ _waiters: Dict[str, Waiter] = {}
326
+
327
+ # Redis stream names
328
+ EVENTS_STREAM_PREFIX = "events:"
329
+ WAITERS_KEY_PREFIX = "waiters:"
330
+ # NOTE: Each waiter uses its own consumer group to ensure ALL waiters receive ALL messages.
331
+ # Redis consumer groups deliver each message to only ONE consumer in the group.
332
+ # For trigger nodes, we want broadcast semantics where every waiter evaluates every event.
333
+ CONSUMER_GROUP_PREFIX = "waiter_group_" # Each waiter gets: waiter_group_{waiter_id}
334
+
335
+
336
+ def _get_stream_name(event_type: str) -> str:
337
+ """Get Redis stream name for event type."""
338
+ return f"{EVENTS_STREAM_PREFIX}{event_type}"
339
+
340
+
341
+ # =============================================================================
342
+ # WAITER REGISTRATION
343
+ # =============================================================================
344
+
345
+ async def register(node_type: str, node_id: str, params: Dict) -> Waiter:
346
+ """Register a waiter for a trigger node.
347
+
348
+ Args:
349
+ node_type: Type of trigger node (e.g., 'whatsappReceive')
350
+ node_id: ID of the node waiting
351
+ params: Node parameters for building filter
352
+
353
+ Returns:
354
+ Waiter object to await
355
+ """
356
+ config = get_trigger_config(node_type)
357
+ if not config:
358
+ raise ValueError(f"Unknown trigger type: {node_type}")
359
+
360
+ # Create waiter
361
+ waiter = Waiter(
362
+ node_id=node_id,
363
+ node_type=node_type,
364
+ event_type=config.event_type,
365
+ params=params,
366
+ filter_fn=build_filter(node_type, params),
367
+ )
368
+
369
+ if is_redis_mode():
370
+ # Redis mode: store waiter metadata in Redis
371
+ cache = get_cache_service()
372
+ waiter_key = f"{WAITERS_KEY_PREFIX}{waiter.id}"
373
+
374
+ # Each waiter gets its own consumer group for broadcast semantics
375
+ # This ensures ALL waiters receive ALL messages (not load-balanced)
376
+ consumer_group = f"{CONSUMER_GROUP_PREFIX}{waiter.id}"
377
+
378
+ waiter_data = {
379
+ "id": waiter.id,
380
+ "node_id": node_id,
381
+ "node_type": node_type,
382
+ "event_type": config.event_type,
383
+ "params": json.dumps(params),
384
+ "created_at": waiter.created_at,
385
+ "consumer_group": consumer_group, # Store for cleanup
386
+ }
387
+ await cache.set(waiter_key, waiter_data, ttl=86400) # 24 hour TTL
388
+
389
+ # Create unique consumer group for this waiter
390
+ # start_id='$' means only new messages from this point forward
391
+ stream_name = _get_stream_name(config.event_type)
392
+ await cache.stream_create_group(stream_name, consumer_group, start_id='$')
393
+
394
+ logger.debug(f"[EventWaiter] Registered {node_type} waiter {waiter.id} (Redis)")
395
+ else:
396
+ # Memory mode: create asyncio.Future
397
+ try:
398
+ loop = asyncio.get_running_loop()
399
+ waiter.future = loop.create_future()
400
+ except RuntimeError:
401
+ waiter.future = asyncio.get_event_loop().create_future()
402
+
403
+ logger.debug(f"[EventWaiter] Registered {node_type} waiter {waiter.id}")
404
+
405
+ _waiters[waiter.id] = waiter
406
+ return waiter
407
+
408
+
409
+ async def wait_for_event(waiter: Waiter, timeout: Optional[float] = None) -> Dict:
410
+ """Wait for an event matching the waiter's filter.
411
+
412
+ Args:
413
+ waiter: The registered waiter
414
+ timeout: Optional timeout in seconds (None = wait forever)
415
+
416
+ Returns:
417
+ Event data when matched
418
+
419
+ Raises:
420
+ asyncio.CancelledError: If waiter was cancelled
421
+ asyncio.TimeoutError: If timeout exceeded
422
+ """
423
+ if is_redis_mode():
424
+ return await _wait_redis(waiter, timeout)
425
+ else:
426
+ return await _wait_memory(waiter, timeout)
427
+
428
+
429
+ async def _wait_memory(waiter: Waiter, timeout: Optional[float]) -> Dict:
430
+ """Wait using asyncio.Future (memory mode)."""
431
+ if waiter.future is None:
432
+ raise RuntimeError("Waiter has no Future (memory mode not initialized)")
433
+
434
+ try:
435
+ if timeout:
436
+ return await asyncio.wait_for(waiter.future, timeout)
437
+ else:
438
+ return await waiter.future
439
+ except asyncio.CancelledError:
440
+ _cleanup_waiter(waiter.id)
441
+ raise
442
+
443
+
444
+ async def _wait_redis(waiter: Waiter, timeout: Optional[float]) -> Dict:
445
+ """Wait using Redis Streams polling.
446
+
447
+ Polls the event stream with blocking XREAD, checking each message against the filter.
448
+ Each waiter has its own consumer group for broadcast semantics.
449
+ """
450
+ cache = get_cache_service()
451
+ stream_name = _get_stream_name(waiter.event_type)
452
+
453
+ # Use waiter-specific consumer group for broadcast (all waiters see all messages)
454
+ consumer_group = f"{CONSUMER_GROUP_PREFIX}{waiter.id}"
455
+ consumer_name = f"consumer_{waiter.id}"
456
+
457
+ # Start reading from now (new messages only)
458
+ last_id = '$'
459
+ block_ms = 5000 # 5 second blocks to allow cancellation checks
460
+
461
+ start_time = time.time()
462
+
463
+ while not waiter.cancelled:
464
+ # Check timeout
465
+ if timeout and (time.time() - start_time) > timeout:
466
+ raise asyncio.TimeoutError(f"Waiter {waiter.id} timed out after {timeout}s")
467
+
468
+ # Read from stream with blocking using waiter's own consumer group
469
+ try:
470
+ result = await cache.stream_read_group(
471
+ consumer_group, # Each waiter has its own group
472
+ consumer_name,
473
+ {stream_name: '>'}, # '>' = new messages for this consumer
474
+ count=10,
475
+ block=block_ms
476
+ )
477
+
478
+ if not result:
479
+ # No messages, continue polling
480
+ continue
481
+
482
+ # Process messages
483
+ for stream_data in result:
484
+ stream, messages = stream_data
485
+ for msg_id, fields in messages:
486
+ # Deserialize event data
487
+ event_data = {}
488
+ for k, v in fields.items():
489
+ try:
490
+ event_data[k] = json.loads(v)
491
+ except (json.JSONDecodeError, TypeError):
492
+ event_data[k] = v
493
+
494
+ # Check filter
495
+ if waiter.filter_fn(event_data):
496
+ # Match found - acknowledge and return
497
+ await cache.stream_ack(stream_name, consumer_group, msg_id)
498
+ _cleanup_waiter(waiter.id)
499
+ logger.info(f"[EventWaiter] Waiter {waiter.id} matched event {msg_id}")
500
+ return event_data
501
+ else:
502
+ # No match - acknowledge but continue waiting
503
+ await cache.stream_ack(stream_name, consumer_group, msg_id)
504
+
505
+ except asyncio.CancelledError:
506
+ _cleanup_waiter(waiter.id)
507
+ raise
508
+
509
+ # Waiter was cancelled via cancel() flag
510
+ _cleanup_waiter(waiter.id)
511
+ raise asyncio.CancelledError(f"Waiter {waiter.id} cancelled")
512
+
513
+
514
+ def _cleanup_waiter(waiter_id: str) -> None:
515
+ """Remove waiter from storage."""
516
+ _waiters.pop(waiter_id, None)
517
+
518
+ # Also remove from Redis if in Redis mode
519
+ if is_redis_mode():
520
+ cache = get_cache_service()
521
+ waiter_key = f"{WAITERS_KEY_PREFIX}{waiter_id}"
522
+ asyncio.create_task(cache.delete(waiter_key))
523
+
524
+
525
+ # =============================================================================
526
+ # EVENT DISPATCH
527
+ # =============================================================================
528
+
529
+ async def dispatch_async(event_type: str, data: Dict) -> int:
530
+ """Dispatch event asynchronously (for Redis mode).
531
+
532
+ Args:
533
+ event_type: Type of event (e.g., 'whatsapp_message_received')
534
+ data: Event data
535
+
536
+ Returns:
537
+ 1 if event was added to stream, 0 otherwise
538
+ """
539
+ logger.debug(f"[EventWaiter] dispatch_async: event_type='{event_type}'")
540
+
541
+ if is_redis_mode():
542
+ cache = get_cache_service()
543
+ stream_name = _get_stream_name(event_type)
544
+ msg_id = await cache.stream_add(stream_name, data)
545
+ if msg_id:
546
+ logger.debug(f"[EventWaiter] Added event to stream {stream_name}: {msg_id}")
547
+ return 1
548
+ return 0
549
+ else:
550
+ # Fall back to sync dispatch for memory mode
551
+ return dispatch(event_type, data)
552
+
553
+
554
+ def dispatch(event_type: str, data: Dict) -> int:
555
+ """Dispatch event to matching waiters (synchronous, memory mode).
556
+
557
+ Thread-safe: Can be called from APScheduler threads or async context.
558
+
559
+ Args:
560
+ event_type: Type of event (e.g., 'whatsapp_message_received')
561
+ data: Event data
562
+
563
+ Returns:
564
+ Number of waiters resolved
565
+ """
566
+ if is_redis_mode():
567
+ # In Redis mode, use async dispatch
568
+ # Handle both async context and thread context (e.g., APScheduler callbacks)
569
+ try:
570
+ # Try to get the current running loop
571
+ loop = asyncio.get_running_loop()
572
+ # We're in an async context - schedule task normally
573
+ asyncio.create_task(dispatch_async(event_type, data))
574
+ except RuntimeError:
575
+ # No running loop - we're in a thread (e.g., APScheduler callback)
576
+ # Use the stored main loop with thread-safe dispatch
577
+ if _main_loop is not None and _main_loop.is_running():
578
+ asyncio.run_coroutine_threadsafe(dispatch_async(event_type, data), _main_loop)
579
+ else:
580
+ logger.warning(f"[EventWaiter] No event loop available for dispatch of {event_type}")
581
+ return 0 # Actual resolution happens in _wait_redis
582
+
583
+ resolved = 0
584
+ to_remove = []
585
+
586
+ for wid, w in _waiters.items():
587
+ if w.event_type == event_type and w.future and not w.future.done():
588
+ try:
589
+ if w.filter_fn(data):
590
+ w.future.set_result(data)
591
+ to_remove.append(wid)
592
+ resolved += 1
593
+ logger.debug(f"[EventWaiter] Resolved {w.node_type} waiter {wid}")
594
+ except Exception as e:
595
+ logger.error(f"[EventWaiter] Filter error for waiter {wid}: {e}")
596
+
597
+ for wid in to_remove:
598
+ _waiters.pop(wid, None)
599
+
600
+ return resolved
601
+
602
+
603
+ # =============================================================================
604
+ # WAITER CANCELLATION
605
+ # =============================================================================
606
+
607
+ def cancel(waiter_id: str) -> bool:
608
+ """Cancel a waiter by ID."""
609
+ if w := _waiters.pop(waiter_id, None):
610
+ w.cancelled = True
611
+
612
+ if w.future and not w.future.done():
613
+ w.future.cancel()
614
+
615
+ # Also remove from Redis if in Redis mode
616
+ if is_redis_mode():
617
+ cache = get_cache_service()
618
+ waiter_key = f"{WAITERS_KEY_PREFIX}{waiter_id}"
619
+ asyncio.create_task(cache.delete(waiter_key))
620
+
621
+ logger.debug(f"[EventWaiter] Cancelled waiter {waiter_id}")
622
+ return True
623
+
624
+ return False
625
+
626
+
627
+ def cancel_for_node(node_id: str) -> int:
628
+ """Cancel all waiters for a node."""
629
+ to_cancel = [wid for wid, w in _waiters.items() if w.node_id == node_id]
630
+ for wid in to_cancel:
631
+ cancel(wid)
632
+ return len(to_cancel)
633
+
634
+
635
+ # =============================================================================
636
+ # UTILITY FUNCTIONS
637
+ # =============================================================================
638
+
639
+ def get_active_waiters() -> List[Dict[str, Any]]:
640
+ """Get info about active waiters (for debugging/UI)."""
641
+ return [
642
+ {
643
+ "id": w.id,
644
+ "node_id": w.node_id,
645
+ "node_type": w.node_type,
646
+ "event_type": w.event_type,
647
+ "done": w.future.done() if w.future else False,
648
+ "cancelled": w.cancelled,
649
+ "age_seconds": time.time() - w.created_at,
650
+ "mode": "redis" if is_redis_mode() else "memory",
651
+ }
652
+ for w in _waiters.values()
653
+ ]
654
+
655
+
656
+ def clear_all() -> int:
657
+ """Clear all waiters (for testing/cleanup)."""
658
+ count = len(_waiters)
659
+ for w in _waiters.values():
660
+ w.cancelled = True
661
+ if w.future and not w.future.done():
662
+ w.future.cancel()
663
+ _waiters.clear()
664
+
665
+ # Clear Redis waiter keys if in Redis mode
666
+ if is_redis_mode():
667
+ cache = get_cache_service()
668
+ asyncio.create_task(cache.clear_pattern(f"{WAITERS_KEY_PREFIX}*"))
669
+
670
+ return count
671
+
672
+
673
+ def get_backend_mode() -> str:
674
+ """Get current backend mode for debugging."""
675
+ return "redis" if is_redis_mode() else "memory"