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.
- package/.env.template +71 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/bin/cli.js +159 -0
- package/client/.dockerignore +45 -0
- package/client/Dockerfile +68 -0
- package/client/eslint.config.js +29 -0
- package/client/index.html +13 -0
- package/client/nginx.conf +66 -0
- package/client/package.json +48 -0
- package/client/src/App.tsx +27 -0
- package/client/src/Dashboard.tsx +1173 -0
- package/client/src/ParameterPanel.tsx +301 -0
- package/client/src/components/AIAgentNode.tsx +321 -0
- package/client/src/components/APIKeyValidator.tsx +118 -0
- package/client/src/components/ClaudeChatModelNode.tsx +18 -0
- package/client/src/components/ConditionalEdge.tsx +189 -0
- package/client/src/components/CredentialsModal.tsx +306 -0
- package/client/src/components/EdgeConditionEditor.tsx +443 -0
- package/client/src/components/GeminiChatModelNode.tsx +18 -0
- package/client/src/components/GenericNode.tsx +357 -0
- package/client/src/components/LocationParameterPanel.tsx +154 -0
- package/client/src/components/ModelNode.tsx +286 -0
- package/client/src/components/OpenAIChatModelNode.tsx +18 -0
- package/client/src/components/OutputPanel.tsx +471 -0
- package/client/src/components/ParameterRenderer.tsx +1874 -0
- package/client/src/components/SkillEditorModal.tsx +417 -0
- package/client/src/components/SquareNode.tsx +797 -0
- package/client/src/components/StartNode.tsx +250 -0
- package/client/src/components/ToolkitNode.tsx +365 -0
- package/client/src/components/TriggerNode.tsx +463 -0
- package/client/src/components/auth/LoginPage.tsx +247 -0
- package/client/src/components/auth/ProtectedRoute.tsx +59 -0
- package/client/src/components/base/BaseChatModelNode.tsx +271 -0
- package/client/src/components/icons/AIProviderIcons.tsx +50 -0
- package/client/src/components/maps/GoogleMapsPicker.tsx +137 -0
- package/client/src/components/maps/MapsPreviewPanel.tsx +110 -0
- package/client/src/components/maps/index.ts +26 -0
- package/client/src/components/parameterPanel/InputSection.tsx +1094 -0
- package/client/src/components/parameterPanel/LocationPanelLayout.tsx +65 -0
- package/client/src/components/parameterPanel/MapsSection.tsx +92 -0
- package/client/src/components/parameterPanel/MiddleSection.tsx +571 -0
- package/client/src/components/parameterPanel/OutputSection.tsx +81 -0
- package/client/src/components/parameterPanel/ParameterPanelLayout.tsx +82 -0
- package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +436 -0
- package/client/src/components/parameterPanel/index.ts +42 -0
- package/client/src/components/shared/DataPanel.tsx +142 -0
- package/client/src/components/shared/JSONTreeRenderer.tsx +106 -0
- package/client/src/components/ui/AIResultModal.tsx +204 -0
- package/client/src/components/ui/AndroidSettingsPanel.tsx +401 -0
- package/client/src/components/ui/CodeEditor.tsx +81 -0
- package/client/src/components/ui/CollapsibleSection.tsx +88 -0
- package/client/src/components/ui/ComponentItem.tsx +154 -0
- package/client/src/components/ui/ComponentPalette.tsx +321 -0
- package/client/src/components/ui/ConsolePanel.tsx +1074 -0
- package/client/src/components/ui/ErrorBoundary.tsx +196 -0
- package/client/src/components/ui/InputNodesPanel.tsx +204 -0
- package/client/src/components/ui/MapSelector.tsx +314 -0
- package/client/src/components/ui/Modal.tsx +149 -0
- package/client/src/components/ui/NodeContextMenu.tsx +192 -0
- package/client/src/components/ui/NodeOutputPanel.tsx +1150 -0
- package/client/src/components/ui/OutputDisplayPanel.tsx +381 -0
- package/client/src/components/ui/SettingsPanel.tsx +243 -0
- package/client/src/components/ui/TopToolbar.tsx +736 -0
- package/client/src/components/ui/WhatsAppSettingsPanel.tsx +345 -0
- package/client/src/components/ui/WorkflowSidebar.tsx +294 -0
- package/client/src/config/antdTheme.ts +186 -0
- package/client/src/config/api.ts +54 -0
- package/client/src/contexts/AuthContext.tsx +221 -0
- package/client/src/contexts/ThemeContext.tsx +42 -0
- package/client/src/contexts/WebSocketContext.tsx +1971 -0
- package/client/src/factories/baseChatModelFactory.ts +256 -0
- package/client/src/hooks/useAndroidOperations.ts +164 -0
- package/client/src/hooks/useApiKeyValidation.ts +107 -0
- package/client/src/hooks/useApiKeys.ts +238 -0
- package/client/src/hooks/useAppTheme.ts +17 -0
- package/client/src/hooks/useComponentPalette.ts +51 -0
- package/client/src/hooks/useCopyPaste.ts +155 -0
- package/client/src/hooks/useDragAndDrop.ts +124 -0
- package/client/src/hooks/useDragVariable.ts +88 -0
- package/client/src/hooks/useExecution.ts +313 -0
- package/client/src/hooks/useParameterPanel.ts +176 -0
- package/client/src/hooks/useReactFlowNodes.ts +189 -0
- package/client/src/hooks/useToolSchema.ts +209 -0
- package/client/src/hooks/useWhatsApp.ts +196 -0
- package/client/src/hooks/useWorkflowManagement.ts +46 -0
- package/client/src/index.css +315 -0
- package/client/src/main.tsx +19 -0
- package/client/src/nodeDefinitions/aiAgentNodes.ts +336 -0
- package/client/src/nodeDefinitions/aiModelNodes.ts +340 -0
- package/client/src/nodeDefinitions/androidDeviceNodes.ts +140 -0
- package/client/src/nodeDefinitions/androidServiceNodes.ts +383 -0
- package/client/src/nodeDefinitions/chatNodes.ts +135 -0
- package/client/src/nodeDefinitions/codeNodes.ts +54 -0
- package/client/src/nodeDefinitions/documentNodes.ts +379 -0
- package/client/src/nodeDefinitions/index.ts +15 -0
- package/client/src/nodeDefinitions/locationNodes.ts +463 -0
- package/client/src/nodeDefinitions/schedulerNodes.ts +220 -0
- package/client/src/nodeDefinitions/skillNodes.ts +211 -0
- package/client/src/nodeDefinitions/toolNodes.ts +198 -0
- package/client/src/nodeDefinitions/utilityNodes.ts +284 -0
- package/client/src/nodeDefinitions/whatsappNodes.ts +865 -0
- package/client/src/nodeDefinitions/workflowNodes.ts +41 -0
- package/client/src/nodeDefinitions.ts +104 -0
- package/client/src/schemas/workflowSchema.ts +264 -0
- package/client/src/services/dynamicParameterService.ts +96 -0
- package/client/src/services/execution/aiAgentExecutionService.ts +35 -0
- package/client/src/services/executionService.ts +232 -0
- package/client/src/services/workflowApi.ts +91 -0
- package/client/src/store/useAppStore.ts +582 -0
- package/client/src/styles/theme.ts +508 -0
- package/client/src/styles/zIndex.ts +17 -0
- package/client/src/types/ComponentTypes.ts +39 -0
- package/client/src/types/EdgeCondition.ts +231 -0
- package/client/src/types/INodeProperties.ts +288 -0
- package/client/src/types/NodeTypes.ts +28 -0
- package/client/src/utils/formatters.ts +33 -0
- package/client/src/utils/googleMapsLoader.ts +140 -0
- package/client/src/utils/locationUtils.ts +85 -0
- package/client/src/utils/nodeUtils.ts +31 -0
- package/client/src/utils/workflow.ts +30 -0
- package/client/src/utils/workflowExport.ts +120 -0
- package/client/src/vite-env.d.ts +12 -0
- package/client/tailwind.config.js +60 -0
- package/client/tsconfig.json +25 -0
- package/client/tsconfig.node.json +11 -0
- package/client/vite.config.js +35 -0
- package/docker-compose.prod.yml +107 -0
- package/docker-compose.yml +104 -0
- package/docs-MachinaOs/README.md +85 -0
- package/docs-MachinaOs/deployment/docker.mdx +228 -0
- package/docs-MachinaOs/deployment/production.mdx +345 -0
- package/docs-MachinaOs/docs.json +75 -0
- package/docs-MachinaOs/faq.mdx +309 -0
- package/docs-MachinaOs/favicon.svg +5 -0
- package/docs-MachinaOs/installation.mdx +160 -0
- package/docs-MachinaOs/introduction.mdx +114 -0
- package/docs-MachinaOs/logo/dark.svg +6 -0
- package/docs-MachinaOs/logo/light.svg +6 -0
- package/docs-MachinaOs/nodes/ai-agent.mdx +216 -0
- package/docs-MachinaOs/nodes/ai-models.mdx +240 -0
- package/docs-MachinaOs/nodes/android.mdx +411 -0
- package/docs-MachinaOs/nodes/overview.mdx +181 -0
- package/docs-MachinaOs/nodes/schedulers.mdx +316 -0
- package/docs-MachinaOs/nodes/webhooks.mdx +330 -0
- package/docs-MachinaOs/nodes/whatsapp.mdx +305 -0
- package/docs-MachinaOs/quickstart.mdx +119 -0
- package/docs-MachinaOs/tutorials/ai-agent-workflow.mdx +177 -0
- package/docs-MachinaOs/tutorials/android-automation.mdx +242 -0
- package/docs-MachinaOs/tutorials/first-workflow.mdx +134 -0
- package/docs-MachinaOs/tutorials/whatsapp-automation.mdx +185 -0
- package/nul +0 -0
- package/package.json +70 -0
- package/scripts/build.js +158 -0
- package/scripts/check-ports.ps1 +33 -0
- package/scripts/clean.js +40 -0
- package/scripts/docker.js +93 -0
- package/scripts/kill-port.ps1 +154 -0
- package/scripts/start.js +210 -0
- package/scripts/stop.js +325 -0
- package/server/.dockerignore +44 -0
- package/server/Dockerfile +45 -0
- package/server/constants.py +249 -0
- package/server/core/__init__.py +1 -0
- package/server/core/cache.py +461 -0
- package/server/core/config.py +128 -0
- package/server/core/container.py +99 -0
- package/server/core/database.py +1211 -0
- package/server/core/logging.py +314 -0
- package/server/main.py +289 -0
- package/server/middleware/__init__.py +5 -0
- package/server/middleware/auth.py +89 -0
- package/server/models/__init__.py +1 -0
- package/server/models/auth.py +52 -0
- package/server/models/cache.py +24 -0
- package/server/models/database.py +211 -0
- package/server/models/nodes.py +455 -0
- package/server/package.json +9 -0
- package/server/pyproject.toml +72 -0
- package/server/requirements.txt +83 -0
- package/server/routers/__init__.py +1 -0
- package/server/routers/android.py +294 -0
- package/server/routers/auth.py +203 -0
- package/server/routers/database.py +151 -0
- package/server/routers/maps.py +142 -0
- package/server/routers/nodejs_compat.py +289 -0
- package/server/routers/webhook.py +90 -0
- package/server/routers/websocket.py +2127 -0
- package/server/routers/whatsapp.py +761 -0
- package/server/routers/workflow.py +200 -0
- package/server/services/__init__.py +1 -0
- package/server/services/ai.py +2415 -0
- package/server/services/android/__init__.py +27 -0
- package/server/services/android/broadcaster.py +114 -0
- package/server/services/android/client.py +608 -0
- package/server/services/android/manager.py +78 -0
- package/server/services/android/protocol.py +165 -0
- package/server/services/android_service.py +588 -0
- package/server/services/auth.py +131 -0
- package/server/services/chat_client.py +160 -0
- package/server/services/deployment/__init__.py +12 -0
- package/server/services/deployment/manager.py +706 -0
- package/server/services/deployment/state.py +47 -0
- package/server/services/deployment/triggers.py +275 -0
- package/server/services/event_waiter.py +785 -0
- package/server/services/execution/__init__.py +77 -0
- package/server/services/execution/cache.py +769 -0
- package/server/services/execution/conditions.py +373 -0
- package/server/services/execution/dlq.py +132 -0
- package/server/services/execution/executor.py +1351 -0
- package/server/services/execution/models.py +531 -0
- package/server/services/execution/recovery.py +235 -0
- package/server/services/handlers/__init__.py +126 -0
- package/server/services/handlers/ai.py +355 -0
- package/server/services/handlers/android.py +260 -0
- package/server/services/handlers/code.py +278 -0
- package/server/services/handlers/document.py +598 -0
- package/server/services/handlers/http.py +193 -0
- package/server/services/handlers/polyglot.py +105 -0
- package/server/services/handlers/tools.py +845 -0
- package/server/services/handlers/triggers.py +107 -0
- package/server/services/handlers/utility.py +822 -0
- package/server/services/handlers/whatsapp.py +476 -0
- package/server/services/maps.py +289 -0
- package/server/services/memory_store.py +103 -0
- package/server/services/node_executor.py +375 -0
- package/server/services/parameter_resolver.py +218 -0
- package/server/services/polyglot_client.py +169 -0
- package/server/services/scheduler.py +155 -0
- package/server/services/skill_loader.py +417 -0
- package/server/services/status_broadcaster.py +826 -0
- package/server/services/temporal/__init__.py +23 -0
- package/server/services/temporal/activities.py +344 -0
- package/server/services/temporal/client.py +76 -0
- package/server/services/temporal/executor.py +147 -0
- package/server/services/temporal/worker.py +251 -0
- package/server/services/temporal/workflow.py +355 -0
- package/server/services/temporal/ws_client.py +236 -0
- package/server/services/text.py +111 -0
- package/server/services/user_auth.py +172 -0
- package/server/services/websocket_client.py +29 -0
- package/server/services/workflow.py +597 -0
- package/server/skills/android-skill/SKILL.md +82 -0
- package/server/skills/assistant-personality/SKILL.md +45 -0
- package/server/skills/code-skill/SKILL.md +140 -0
- package/server/skills/http-skill/SKILL.md +161 -0
- package/server/skills/maps-skill/SKILL.md +170 -0
- package/server/skills/memory-skill/SKILL.md +154 -0
- package/server/skills/scheduler-skill/SKILL.md +84 -0
- package/server/skills/whatsapp-skill/SKILL.md +283 -0
- package/server/uv.lock +2916 -0
- package/server/whatsapp-rpc/.dockerignore +30 -0
- package/server/whatsapp-rpc/Dockerfile +44 -0
- package/server/whatsapp-rpc/Dockerfile.web +17 -0
- package/server/whatsapp-rpc/README.md +139 -0
- package/server/whatsapp-rpc/cli.js +95 -0
- package/server/whatsapp-rpc/configs/config.yaml +7 -0
- package/server/whatsapp-rpc/docker-compose.yml +35 -0
- package/server/whatsapp-rpc/docs/API.md +410 -0
- package/server/whatsapp-rpc/go.mod +67 -0
- package/server/whatsapp-rpc/go.sum +203 -0
- package/server/whatsapp-rpc/package.json +30 -0
- package/server/whatsapp-rpc/schema.json +1294 -0
- package/server/whatsapp-rpc/scripts/clean.cjs +66 -0
- package/server/whatsapp-rpc/scripts/cli.js +162 -0
- package/server/whatsapp-rpc/src/go/cmd/server/main.go +91 -0
- package/server/whatsapp-rpc/src/go/config/config.go +49 -0
- package/server/whatsapp-rpc/src/go/rpc/rpc.go +446 -0
- package/server/whatsapp-rpc/src/go/rpc/server.go +112 -0
- package/server/whatsapp-rpc/src/go/whatsapp/history.go +166 -0
- package/server/whatsapp-rpc/src/go/whatsapp/messages.go +390 -0
- package/server/whatsapp-rpc/src/go/whatsapp/service.go +2130 -0
- package/server/whatsapp-rpc/src/go/whatsapp/types.go +261 -0
- package/server/whatsapp-rpc/src/python/pyproject.toml +15 -0
- package/server/whatsapp-rpc/src/python/whatsapp_rpc/__init__.py +4 -0
- package/server/whatsapp-rpc/src/python/whatsapp_rpc/client.py +427 -0
- package/server/whatsapp-rpc/web/app.py +609 -0
- package/server/whatsapp-rpc/web/requirements.txt +6 -0
- package/server/whatsapp-rpc/web/rpc_client.py +427 -0
- package/server/whatsapp-rpc/web/static/openapi.yaml +59 -0
- package/server/whatsapp-rpc/web/templates/base.html +150 -0
- package/server/whatsapp-rpc/web/templates/contacts.html +240 -0
- package/server/whatsapp-rpc/web/templates/dashboard.html +320 -0
- package/server/whatsapp-rpc/web/templates/groups.html +328 -0
- package/server/whatsapp-rpc/web/templates/messages.html +465 -0
- package/server/whatsapp-rpc/web/templates/messaging.html +681 -0
- package/server/whatsapp-rpc/web/templates/send.html +259 -0
- 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"
|