machinaos 0.0.1

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 (288) hide show
  1. package/.env.template +71 -0
  2. package/LICENSE +21 -0
  3. package/README.md +87 -0
  4. package/bin/cli.js +159 -0
  5. package/client/.dockerignore +45 -0
  6. package/client/Dockerfile +68 -0
  7. package/client/eslint.config.js +29 -0
  8. package/client/index.html +13 -0
  9. package/client/nginx.conf +66 -0
  10. package/client/package.json +48 -0
  11. package/client/src/App.tsx +27 -0
  12. package/client/src/Dashboard.tsx +1173 -0
  13. package/client/src/ParameterPanel.tsx +301 -0
  14. package/client/src/components/AIAgentNode.tsx +321 -0
  15. package/client/src/components/APIKeyValidator.tsx +118 -0
  16. package/client/src/components/ClaudeChatModelNode.tsx +18 -0
  17. package/client/src/components/ConditionalEdge.tsx +189 -0
  18. package/client/src/components/CredentialsModal.tsx +306 -0
  19. package/client/src/components/EdgeConditionEditor.tsx +443 -0
  20. package/client/src/components/GeminiChatModelNode.tsx +18 -0
  21. package/client/src/components/GenericNode.tsx +357 -0
  22. package/client/src/components/LocationParameterPanel.tsx +154 -0
  23. package/client/src/components/ModelNode.tsx +286 -0
  24. package/client/src/components/OpenAIChatModelNode.tsx +18 -0
  25. package/client/src/components/OutputPanel.tsx +471 -0
  26. package/client/src/components/ParameterRenderer.tsx +1874 -0
  27. package/client/src/components/SkillEditorModal.tsx +417 -0
  28. package/client/src/components/SquareNode.tsx +797 -0
  29. package/client/src/components/StartNode.tsx +250 -0
  30. package/client/src/components/ToolkitNode.tsx +365 -0
  31. package/client/src/components/TriggerNode.tsx +463 -0
  32. package/client/src/components/auth/LoginPage.tsx +247 -0
  33. package/client/src/components/auth/ProtectedRoute.tsx +59 -0
  34. package/client/src/components/base/BaseChatModelNode.tsx +271 -0
  35. package/client/src/components/icons/AIProviderIcons.tsx +50 -0
  36. package/client/src/components/maps/GoogleMapsPicker.tsx +137 -0
  37. package/client/src/components/maps/MapsPreviewPanel.tsx +110 -0
  38. package/client/src/components/maps/index.ts +26 -0
  39. package/client/src/components/parameterPanel/InputSection.tsx +1094 -0
  40. package/client/src/components/parameterPanel/LocationPanelLayout.tsx +65 -0
  41. package/client/src/components/parameterPanel/MapsSection.tsx +92 -0
  42. package/client/src/components/parameterPanel/MiddleSection.tsx +571 -0
  43. package/client/src/components/parameterPanel/OutputSection.tsx +81 -0
  44. package/client/src/components/parameterPanel/ParameterPanelLayout.tsx +82 -0
  45. package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +436 -0
  46. package/client/src/components/parameterPanel/index.ts +42 -0
  47. package/client/src/components/shared/DataPanel.tsx +142 -0
  48. package/client/src/components/shared/JSONTreeRenderer.tsx +106 -0
  49. package/client/src/components/ui/AIResultModal.tsx +204 -0
  50. package/client/src/components/ui/AndroidSettingsPanel.tsx +401 -0
  51. package/client/src/components/ui/CodeEditor.tsx +81 -0
  52. package/client/src/components/ui/CollapsibleSection.tsx +88 -0
  53. package/client/src/components/ui/ComponentItem.tsx +154 -0
  54. package/client/src/components/ui/ComponentPalette.tsx +321 -0
  55. package/client/src/components/ui/ConsolePanel.tsx +1074 -0
  56. package/client/src/components/ui/ErrorBoundary.tsx +196 -0
  57. package/client/src/components/ui/InputNodesPanel.tsx +204 -0
  58. package/client/src/components/ui/MapSelector.tsx +314 -0
  59. package/client/src/components/ui/Modal.tsx +149 -0
  60. package/client/src/components/ui/NodeContextMenu.tsx +192 -0
  61. package/client/src/components/ui/NodeOutputPanel.tsx +1150 -0
  62. package/client/src/components/ui/OutputDisplayPanel.tsx +381 -0
  63. package/client/src/components/ui/SettingsPanel.tsx +243 -0
  64. package/client/src/components/ui/TopToolbar.tsx +736 -0
  65. package/client/src/components/ui/WhatsAppSettingsPanel.tsx +345 -0
  66. package/client/src/components/ui/WorkflowSidebar.tsx +294 -0
  67. package/client/src/config/antdTheme.ts +186 -0
  68. package/client/src/config/api.ts +54 -0
  69. package/client/src/contexts/AuthContext.tsx +221 -0
  70. package/client/src/contexts/ThemeContext.tsx +42 -0
  71. package/client/src/contexts/WebSocketContext.tsx +1971 -0
  72. package/client/src/factories/baseChatModelFactory.ts +256 -0
  73. package/client/src/hooks/useAndroidOperations.ts +164 -0
  74. package/client/src/hooks/useApiKeyValidation.ts +107 -0
  75. package/client/src/hooks/useApiKeys.ts +238 -0
  76. package/client/src/hooks/useAppTheme.ts +17 -0
  77. package/client/src/hooks/useComponentPalette.ts +51 -0
  78. package/client/src/hooks/useCopyPaste.ts +155 -0
  79. package/client/src/hooks/useDragAndDrop.ts +124 -0
  80. package/client/src/hooks/useDragVariable.ts +88 -0
  81. package/client/src/hooks/useExecution.ts +313 -0
  82. package/client/src/hooks/useParameterPanel.ts +176 -0
  83. package/client/src/hooks/useReactFlowNodes.ts +189 -0
  84. package/client/src/hooks/useToolSchema.ts +209 -0
  85. package/client/src/hooks/useWhatsApp.ts +196 -0
  86. package/client/src/hooks/useWorkflowManagement.ts +46 -0
  87. package/client/src/index.css +315 -0
  88. package/client/src/main.tsx +19 -0
  89. package/client/src/nodeDefinitions/aiAgentNodes.ts +336 -0
  90. package/client/src/nodeDefinitions/aiModelNodes.ts +340 -0
  91. package/client/src/nodeDefinitions/androidDeviceNodes.ts +140 -0
  92. package/client/src/nodeDefinitions/androidServiceNodes.ts +383 -0
  93. package/client/src/nodeDefinitions/chatNodes.ts +135 -0
  94. package/client/src/nodeDefinitions/codeNodes.ts +54 -0
  95. package/client/src/nodeDefinitions/documentNodes.ts +379 -0
  96. package/client/src/nodeDefinitions/index.ts +15 -0
  97. package/client/src/nodeDefinitions/locationNodes.ts +463 -0
  98. package/client/src/nodeDefinitions/schedulerNodes.ts +220 -0
  99. package/client/src/nodeDefinitions/skillNodes.ts +211 -0
  100. package/client/src/nodeDefinitions/toolNodes.ts +198 -0
  101. package/client/src/nodeDefinitions/utilityNodes.ts +284 -0
  102. package/client/src/nodeDefinitions/whatsappNodes.ts +865 -0
  103. package/client/src/nodeDefinitions/workflowNodes.ts +41 -0
  104. package/client/src/nodeDefinitions.ts +104 -0
  105. package/client/src/schemas/workflowSchema.ts +264 -0
  106. package/client/src/services/dynamicParameterService.ts +96 -0
  107. package/client/src/services/execution/aiAgentExecutionService.ts +35 -0
  108. package/client/src/services/executionService.ts +232 -0
  109. package/client/src/services/workflowApi.ts +91 -0
  110. package/client/src/store/useAppStore.ts +582 -0
  111. package/client/src/styles/theme.ts +508 -0
  112. package/client/src/styles/zIndex.ts +17 -0
  113. package/client/src/types/ComponentTypes.ts +39 -0
  114. package/client/src/types/EdgeCondition.ts +231 -0
  115. package/client/src/types/INodeProperties.ts +288 -0
  116. package/client/src/types/NodeTypes.ts +28 -0
  117. package/client/src/utils/formatters.ts +33 -0
  118. package/client/src/utils/googleMapsLoader.ts +140 -0
  119. package/client/src/utils/locationUtils.ts +85 -0
  120. package/client/src/utils/nodeUtils.ts +31 -0
  121. package/client/src/utils/workflow.ts +30 -0
  122. package/client/src/utils/workflowExport.ts +120 -0
  123. package/client/src/vite-env.d.ts +12 -0
  124. package/client/tailwind.config.js +60 -0
  125. package/client/tsconfig.json +25 -0
  126. package/client/tsconfig.node.json +11 -0
  127. package/client/vite.config.js +35 -0
  128. package/docker-compose.prod.yml +107 -0
  129. package/docker-compose.yml +104 -0
  130. package/docs-MachinaOs/README.md +85 -0
  131. package/docs-MachinaOs/deployment/docker.mdx +228 -0
  132. package/docs-MachinaOs/deployment/production.mdx +345 -0
  133. package/docs-MachinaOs/docs.json +75 -0
  134. package/docs-MachinaOs/faq.mdx +309 -0
  135. package/docs-MachinaOs/favicon.svg +5 -0
  136. package/docs-MachinaOs/installation.mdx +160 -0
  137. package/docs-MachinaOs/introduction.mdx +114 -0
  138. package/docs-MachinaOs/logo/dark.svg +6 -0
  139. package/docs-MachinaOs/logo/light.svg +6 -0
  140. package/docs-MachinaOs/nodes/ai-agent.mdx +216 -0
  141. package/docs-MachinaOs/nodes/ai-models.mdx +240 -0
  142. package/docs-MachinaOs/nodes/android.mdx +411 -0
  143. package/docs-MachinaOs/nodes/overview.mdx +181 -0
  144. package/docs-MachinaOs/nodes/schedulers.mdx +316 -0
  145. package/docs-MachinaOs/nodes/webhooks.mdx +330 -0
  146. package/docs-MachinaOs/nodes/whatsapp.mdx +305 -0
  147. package/docs-MachinaOs/quickstart.mdx +119 -0
  148. package/docs-MachinaOs/tutorials/ai-agent-workflow.mdx +177 -0
  149. package/docs-MachinaOs/tutorials/android-automation.mdx +242 -0
  150. package/docs-MachinaOs/tutorials/first-workflow.mdx +134 -0
  151. package/docs-MachinaOs/tutorials/whatsapp-automation.mdx +185 -0
  152. package/nul +0 -0
  153. package/package.json +70 -0
  154. package/scripts/build.js +158 -0
  155. package/scripts/check-ports.ps1 +33 -0
  156. package/scripts/clean.js +40 -0
  157. package/scripts/docker.js +93 -0
  158. package/scripts/kill-port.ps1 +154 -0
  159. package/scripts/start.js +210 -0
  160. package/scripts/stop.js +325 -0
  161. package/server/.dockerignore +44 -0
  162. package/server/Dockerfile +45 -0
  163. package/server/constants.py +249 -0
  164. package/server/core/__init__.py +1 -0
  165. package/server/core/cache.py +461 -0
  166. package/server/core/config.py +128 -0
  167. package/server/core/container.py +99 -0
  168. package/server/core/database.py +1211 -0
  169. package/server/core/logging.py +314 -0
  170. package/server/main.py +289 -0
  171. package/server/middleware/__init__.py +5 -0
  172. package/server/middleware/auth.py +89 -0
  173. package/server/models/__init__.py +1 -0
  174. package/server/models/auth.py +52 -0
  175. package/server/models/cache.py +24 -0
  176. package/server/models/database.py +211 -0
  177. package/server/models/nodes.py +455 -0
  178. package/server/package.json +9 -0
  179. package/server/pyproject.toml +72 -0
  180. package/server/requirements.txt +83 -0
  181. package/server/routers/__init__.py +1 -0
  182. package/server/routers/android.py +294 -0
  183. package/server/routers/auth.py +203 -0
  184. package/server/routers/database.py +151 -0
  185. package/server/routers/maps.py +142 -0
  186. package/server/routers/nodejs_compat.py +289 -0
  187. package/server/routers/webhook.py +90 -0
  188. package/server/routers/websocket.py +2127 -0
  189. package/server/routers/whatsapp.py +761 -0
  190. package/server/routers/workflow.py +200 -0
  191. package/server/services/__init__.py +1 -0
  192. package/server/services/ai.py +2415 -0
  193. package/server/services/android/__init__.py +27 -0
  194. package/server/services/android/broadcaster.py +114 -0
  195. package/server/services/android/client.py +608 -0
  196. package/server/services/android/manager.py +78 -0
  197. package/server/services/android/protocol.py +165 -0
  198. package/server/services/android_service.py +588 -0
  199. package/server/services/auth.py +131 -0
  200. package/server/services/chat_client.py +160 -0
  201. package/server/services/deployment/__init__.py +12 -0
  202. package/server/services/deployment/manager.py +706 -0
  203. package/server/services/deployment/state.py +47 -0
  204. package/server/services/deployment/triggers.py +275 -0
  205. package/server/services/event_waiter.py +785 -0
  206. package/server/services/execution/__init__.py +77 -0
  207. package/server/services/execution/cache.py +769 -0
  208. package/server/services/execution/conditions.py +373 -0
  209. package/server/services/execution/dlq.py +132 -0
  210. package/server/services/execution/executor.py +1351 -0
  211. package/server/services/execution/models.py +531 -0
  212. package/server/services/execution/recovery.py +235 -0
  213. package/server/services/handlers/__init__.py +126 -0
  214. package/server/services/handlers/ai.py +355 -0
  215. package/server/services/handlers/android.py +260 -0
  216. package/server/services/handlers/code.py +278 -0
  217. package/server/services/handlers/document.py +598 -0
  218. package/server/services/handlers/http.py +193 -0
  219. package/server/services/handlers/polyglot.py +105 -0
  220. package/server/services/handlers/tools.py +845 -0
  221. package/server/services/handlers/triggers.py +107 -0
  222. package/server/services/handlers/utility.py +822 -0
  223. package/server/services/handlers/whatsapp.py +476 -0
  224. package/server/services/maps.py +289 -0
  225. package/server/services/memory_store.py +103 -0
  226. package/server/services/node_executor.py +375 -0
  227. package/server/services/parameter_resolver.py +218 -0
  228. package/server/services/polyglot_client.py +169 -0
  229. package/server/services/scheduler.py +155 -0
  230. package/server/services/skill_loader.py +417 -0
  231. package/server/services/status_broadcaster.py +826 -0
  232. package/server/services/temporal/__init__.py +23 -0
  233. package/server/services/temporal/activities.py +344 -0
  234. package/server/services/temporal/client.py +76 -0
  235. package/server/services/temporal/executor.py +147 -0
  236. package/server/services/temporal/worker.py +251 -0
  237. package/server/services/temporal/workflow.py +355 -0
  238. package/server/services/temporal/ws_client.py +236 -0
  239. package/server/services/text.py +111 -0
  240. package/server/services/user_auth.py +172 -0
  241. package/server/services/websocket_client.py +29 -0
  242. package/server/services/workflow.py +597 -0
  243. package/server/skills/android-skill/SKILL.md +82 -0
  244. package/server/skills/assistant-personality/SKILL.md +45 -0
  245. package/server/skills/code-skill/SKILL.md +140 -0
  246. package/server/skills/http-skill/SKILL.md +161 -0
  247. package/server/skills/maps-skill/SKILL.md +170 -0
  248. package/server/skills/memory-skill/SKILL.md +154 -0
  249. package/server/skills/scheduler-skill/SKILL.md +84 -0
  250. package/server/skills/whatsapp-skill/SKILL.md +283 -0
  251. package/server/uv.lock +2916 -0
  252. package/server/whatsapp-rpc/.dockerignore +30 -0
  253. package/server/whatsapp-rpc/Dockerfile +44 -0
  254. package/server/whatsapp-rpc/Dockerfile.web +17 -0
  255. package/server/whatsapp-rpc/README.md +139 -0
  256. package/server/whatsapp-rpc/cli.js +95 -0
  257. package/server/whatsapp-rpc/configs/config.yaml +7 -0
  258. package/server/whatsapp-rpc/docker-compose.yml +35 -0
  259. package/server/whatsapp-rpc/docs/API.md +410 -0
  260. package/server/whatsapp-rpc/go.mod +67 -0
  261. package/server/whatsapp-rpc/go.sum +203 -0
  262. package/server/whatsapp-rpc/package.json +30 -0
  263. package/server/whatsapp-rpc/schema.json +1294 -0
  264. package/server/whatsapp-rpc/scripts/clean.cjs +66 -0
  265. package/server/whatsapp-rpc/scripts/cli.js +162 -0
  266. package/server/whatsapp-rpc/src/go/cmd/server/main.go +91 -0
  267. package/server/whatsapp-rpc/src/go/config/config.go +49 -0
  268. package/server/whatsapp-rpc/src/go/rpc/rpc.go +446 -0
  269. package/server/whatsapp-rpc/src/go/rpc/server.go +112 -0
  270. package/server/whatsapp-rpc/src/go/whatsapp/history.go +166 -0
  271. package/server/whatsapp-rpc/src/go/whatsapp/messages.go +390 -0
  272. package/server/whatsapp-rpc/src/go/whatsapp/service.go +2130 -0
  273. package/server/whatsapp-rpc/src/go/whatsapp/types.go +261 -0
  274. package/server/whatsapp-rpc/src/python/pyproject.toml +15 -0
  275. package/server/whatsapp-rpc/src/python/whatsapp_rpc/__init__.py +4 -0
  276. package/server/whatsapp-rpc/src/python/whatsapp_rpc/client.py +427 -0
  277. package/server/whatsapp-rpc/web/app.py +609 -0
  278. package/server/whatsapp-rpc/web/requirements.txt +6 -0
  279. package/server/whatsapp-rpc/web/rpc_client.py +427 -0
  280. package/server/whatsapp-rpc/web/static/openapi.yaml +59 -0
  281. package/server/whatsapp-rpc/web/templates/base.html +150 -0
  282. package/server/whatsapp-rpc/web/templates/contacts.html +240 -0
  283. package/server/whatsapp-rpc/web/templates/dashboard.html +320 -0
  284. package/server/whatsapp-rpc/web/templates/groups.html +328 -0
  285. package/server/whatsapp-rpc/web/templates/messages.html +465 -0
  286. package/server/whatsapp-rpc/web/templates/messaging.html +681 -0
  287. package/server/whatsapp-rpc/web/templates/send.html +259 -0
  288. package/server/whatsapp-rpc/web/templates/settings.html +459 -0
@@ -0,0 +1,785 @@
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"