mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-04-28 11:40:25 +00:00
Co-authored-by: Douglas <douglas.ym.lai@gmail.com> Co-authored-by: a7m-1st <ahmed.jimi.awelkair500@gmail.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Tong Chen <web_chentong@163.com>
446 lines
15 KiB
Python
446 lines
15 KiB
Python
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
|
|
"""
|
|
Trigger App Handler Service
|
|
|
|
Modular service for handling app-specific webhook authentication,
|
|
filtering, and payload normalization based on trigger_type.
|
|
"""
|
|
|
|
import re
|
|
from typing import Optional
|
|
from dataclasses import dataclass
|
|
from fastapi import Request
|
|
from sqlmodel import Session, select, and_
|
|
import logging
|
|
|
|
from app.model.trigger.trigger import Trigger
|
|
from app.model.config.config import Config
|
|
from app.model.trigger.app_configs import SlackTriggerConfig, WebhookTriggerConfig, ScheduleTriggerConfig
|
|
from app.type.trigger_types import TriggerType, ExecutionType, TriggerStatus
|
|
from app.type.config_group import ConfigGroup
|
|
|
|
|
|
@dataclass
|
|
class AppHandlerResult:
|
|
"""Result from app handler operations."""
|
|
success: bool
|
|
data: Optional[dict] = None
|
|
reason: Optional[str] = None
|
|
|
|
|
|
class BaseAppHandler:
|
|
"""Base class for app-specific handlers."""
|
|
|
|
trigger_type: TriggerType
|
|
execution_type: ExecutionType = ExecutionType.webhook
|
|
config_group: Optional[str] = None
|
|
|
|
async def get_credentials(self, session: Session, user_id: str) -> dict:
|
|
"""Get user credentials from config table."""
|
|
if not self.config_group:
|
|
return {}
|
|
|
|
configs = session.exec(
|
|
select(Config).where(
|
|
and_(
|
|
Config.user_id == int(user_id),
|
|
Config.config_group == self.config_group
|
|
)
|
|
)
|
|
).all()
|
|
return {config.config_name: config.config_value for config in configs}
|
|
|
|
async def authenticate(
|
|
self,
|
|
request: Request,
|
|
body: bytes,
|
|
trigger: Trigger,
|
|
session: Session
|
|
) -> AppHandlerResult:
|
|
"""
|
|
Authenticate the incoming webhook request.
|
|
Returns (success, challenge_response or None)
|
|
"""
|
|
return AppHandlerResult(success=True)
|
|
|
|
async def filter_event(
|
|
self,
|
|
payload: dict,
|
|
trigger: Trigger
|
|
) -> AppHandlerResult:
|
|
"""
|
|
Filter events based on trigger configuration.
|
|
Returns (should_process, reason)
|
|
"""
|
|
return AppHandlerResult(success=True, reason="ok")
|
|
|
|
def normalize_payload(
|
|
self,
|
|
payload: dict,
|
|
trigger: Trigger,
|
|
request_meta: dict = None
|
|
) -> dict:
|
|
"""Normalize the payload for execution input."""
|
|
return payload
|
|
|
|
|
|
class SlackAppHandler(BaseAppHandler):
|
|
"""Handler for Slack triggers."""
|
|
|
|
trigger_type = TriggerType.slack_trigger
|
|
execution_type = ExecutionType.slack
|
|
config_group = ConfigGroup.SLACK.value
|
|
|
|
async def authenticate(
|
|
self,
|
|
request: Request,
|
|
body: bytes,
|
|
trigger: Trigger,
|
|
session: Session
|
|
) -> AppHandlerResult:
|
|
"""Handle Slack authentication and URL verification."""
|
|
from camel.auth.slack_auth import SlackAuth
|
|
|
|
credentials = await self.get_credentials(session, trigger.user_id)
|
|
|
|
slack_auth = SlackAuth(
|
|
signing_secret=credentials.get("SLACK_SIGNING_SECRET"),
|
|
bot_token=credentials.get("SLACK_BOT_TOKEN"),
|
|
api_token=credentials.get("SLACK_API_TOKEN"),
|
|
)
|
|
|
|
# Check for URL verification challenge
|
|
challenge_response = slack_auth.get_verification_response(request, body)
|
|
if challenge_response:
|
|
# Return the challenge response (already in correct format: {"challenge": "..."})
|
|
logger.info(f"Slack URL verification - challenge_response: {challenge_response}")
|
|
return AppHandlerResult(success=True, data=challenge_response)
|
|
|
|
# Verify webhook signature
|
|
if not slack_auth.verify_webhook_request(request, body):
|
|
logger.warning("Invalid Slack webhook signature", extra={
|
|
"trigger_id": trigger.id
|
|
})
|
|
return AppHandlerResult(success=False, reason="invalid_signature")
|
|
|
|
return AppHandlerResult(success=True)
|
|
|
|
async def filter_event(
|
|
self,
|
|
payload: dict,
|
|
trigger: Trigger
|
|
) -> AppHandlerResult:
|
|
"""Filter Slack events based on trigger config."""
|
|
# Prefer 'config' field
|
|
config_data = trigger.config or {}
|
|
config = SlackTriggerConfig(**config_data)
|
|
event = payload.get("event", {})
|
|
event_type = event.get("type", "")
|
|
|
|
# Check event type
|
|
if not config.should_trigger(event_type):
|
|
return AppHandlerResult(success=False, reason="event_type_not_configured")
|
|
|
|
# Check channel filter (if channel_id is set, only trigger for that channel)
|
|
if config.channel_id:
|
|
if event.get("channel") != config.channel_id:
|
|
return AppHandlerResult(success=False, reason="channel_not_matched")
|
|
|
|
# Check bot message filter
|
|
if config.ignore_bot_messages:
|
|
if event.get("bot_id") or event.get("subtype") == "bot_message":
|
|
return AppHandlerResult(success=False, reason="bot_message_ignored")
|
|
|
|
# Check user filter
|
|
if config.ignore_users and event.get("user") in config.ignore_users:
|
|
return AppHandlerResult(success=False, reason="user_filtered")
|
|
|
|
# Check message filter regex
|
|
if config.message_filter and event.get("text"):
|
|
if not re.search(config.message_filter, event.get("text", ""), re.IGNORECASE):
|
|
return AppHandlerResult(success=False, reason="message_filter_not_matched")
|
|
|
|
return AppHandlerResult(success=True, reason="ok")
|
|
|
|
def normalize_payload(
|
|
self,
|
|
payload: dict,
|
|
trigger: Trigger,
|
|
request_meta: dict = None
|
|
) -> dict:
|
|
"""Normalize Slack event payload."""
|
|
logger.info("Normalizing payload", extra={"payload": payload})
|
|
# Prefer 'config' field
|
|
config_data = trigger.config or {}
|
|
config = SlackTriggerConfig(**config_data)
|
|
event = payload.get("event", {})
|
|
|
|
normalized = {
|
|
"event_type": event.get("type"),
|
|
"event_ts": event.get("event_ts"),
|
|
"team_id": payload.get("team_id"),
|
|
"user_id": event.get("user"),
|
|
"channel_id": event.get("channel"),
|
|
"text": event.get("text"),
|
|
"message_ts": event.get("ts"),
|
|
"thread_ts": event.get("thread_ts"),
|
|
"reaction": event.get("reaction"),
|
|
"files": event.get("files"),
|
|
"event_id": payload.get("event_id") or payload.get("id")
|
|
}
|
|
|
|
# if config.include_raw_payload:
|
|
# normalized["raw_payload"] = payload
|
|
|
|
return normalized
|
|
|
|
|
|
class DefaultWebhookHandler(BaseAppHandler):
|
|
"""Default handler for generic webhooks with config-based filtering."""
|
|
|
|
trigger_type = TriggerType.webhook
|
|
execution_type = ExecutionType.webhook
|
|
|
|
async def filter_event(
|
|
self,
|
|
payload: dict,
|
|
trigger: Trigger,
|
|
headers: dict = None,
|
|
body_raw: str = None
|
|
) -> AppHandlerResult:
|
|
"""Filter webhook events based on trigger config."""
|
|
config_data = trigger.config or {}
|
|
config = WebhookTriggerConfig(**config_data)
|
|
|
|
# Get text content for message_filter (check body for text field or stringify)
|
|
text = None
|
|
if isinstance(payload, dict):
|
|
text = payload.get("text") or payload.get("message") or payload.get("content")
|
|
if text is None and body_raw:
|
|
text = body_raw
|
|
|
|
# Use the config's should_trigger method
|
|
should_trigger, reason = config.should_trigger(
|
|
body=body_raw or "",
|
|
headers=headers or {},
|
|
text=text
|
|
)
|
|
|
|
if not should_trigger:
|
|
return AppHandlerResult(success=False, reason=reason)
|
|
|
|
return AppHandlerResult(success=True, reason="ok")
|
|
|
|
def normalize_payload(
|
|
self,
|
|
payload: dict,
|
|
trigger: Trigger,
|
|
request_meta: dict = None
|
|
) -> dict:
|
|
"""Normalize generic webhook payload with full request metadata."""
|
|
config_data = trigger.config or {}
|
|
config = WebhookTriggerConfig(**config_data)
|
|
|
|
result = {"body": payload}
|
|
|
|
if request_meta:
|
|
# Include headers if configured
|
|
if config.include_headers and "headers" in request_meta:
|
|
result["headers"] = request_meta["headers"]
|
|
|
|
# Include query params if configured
|
|
if config.include_query_params and "query_params" in request_meta:
|
|
result["query_params"] = request_meta["query_params"]
|
|
|
|
# Include request metadata if configured
|
|
if config.include_request_metadata:
|
|
if "method" in request_meta:
|
|
result["method"] = request_meta["method"]
|
|
if "url" in request_meta:
|
|
result["url"] = request_meta["url"]
|
|
if "client_ip" in request_meta:
|
|
result["client_ip"] = request_meta["client_ip"]
|
|
|
|
return result
|
|
|
|
|
|
class ScheduleAppHandler(BaseAppHandler):
|
|
"""
|
|
Handler for scheduled triggers.
|
|
|
|
Manages schedule-specific logic including:
|
|
- Expiration checking (expirationDate for recurring schedules)
|
|
- Date validation for one-time executions (date field)
|
|
"""
|
|
|
|
trigger_type = TriggerType.schedule
|
|
execution_type = ExecutionType.scheduled
|
|
|
|
async def filter_event(
|
|
self,
|
|
payload: dict,
|
|
trigger: Trigger
|
|
) -> AppHandlerResult:
|
|
"""
|
|
Filter scheduled events based on trigger config.
|
|
|
|
Checks:
|
|
- If one-time (date set) and date has passed
|
|
- If recurring with expirationDate and it has passed
|
|
"""
|
|
config_data = trigger.config or {}
|
|
|
|
try:
|
|
config = ScheduleTriggerConfig(**config_data)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Invalid schedule config",
|
|
extra={"trigger_id": trigger.id, "error": str(e)}
|
|
)
|
|
# Allow execution if config is missing/invalid (backwards compatibility)
|
|
return AppHandlerResult(success=True, reason="ok")
|
|
|
|
# Check if schedule should execute
|
|
should_execute, reason = config.should_execute()
|
|
|
|
if not should_execute:
|
|
return AppHandlerResult(success=False, reason=reason)
|
|
|
|
return AppHandlerResult(success=True, reason="ok")
|
|
|
|
def normalize_payload(
|
|
self,
|
|
payload: dict,
|
|
trigger: Trigger,
|
|
request_meta: dict = None
|
|
) -> dict:
|
|
"""Normalize scheduled trigger payload."""
|
|
config_data = trigger.config or {}
|
|
|
|
normalized = {
|
|
"scheduled_at": payload.get("scheduled_at"),
|
|
"trigger_id": trigger.id,
|
|
"trigger_name": trigger.name,
|
|
"is_single_execution": trigger.is_single_execution,
|
|
}
|
|
|
|
# Include config details if present
|
|
if config_data:
|
|
if config_data.get("date"):
|
|
normalized["date"] = config_data.get("date")
|
|
if config_data.get("expirationDate"):
|
|
normalized["expirationDate"] = config_data.get("expirationDate")
|
|
|
|
return normalized
|
|
|
|
def check_and_handle_expiration(
|
|
self,
|
|
trigger: Trigger,
|
|
session: Session
|
|
) -> bool:
|
|
"""
|
|
Check if a schedule has expired and handle accordingly.
|
|
|
|
Args:
|
|
trigger: The trigger to check
|
|
session: Database session for updates
|
|
|
|
Returns:
|
|
True if trigger is expired and was deactivated, False otherwise
|
|
"""
|
|
config_data = trigger.config or {}
|
|
|
|
try:
|
|
config = ScheduleTriggerConfig(**config_data)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Invalid schedule config during expiration check",
|
|
extra={"trigger_id": trigger.id, "error": str(e)}
|
|
)
|
|
return False
|
|
|
|
if config.is_expired():
|
|
# Deactivate the trigger
|
|
trigger.status = TriggerStatus.completed
|
|
session.add(trigger)
|
|
session.commit()
|
|
|
|
logger.info(
|
|
"Schedule trigger expired and deactivated",
|
|
extra={
|
|
"trigger_id": trigger.id,
|
|
"trigger_name": trigger.name,
|
|
"expiration_date": config.expirationDate or config.date
|
|
}
|
|
)
|
|
|
|
return True
|
|
|
|
return False
|
|
|
|
def validate_schedule_for_execution(
|
|
self,
|
|
trigger: Trigger
|
|
) -> tuple[bool, str]:
|
|
"""
|
|
Validate that a scheduled trigger is valid for execution.
|
|
|
|
Args:
|
|
trigger: The trigger to validate
|
|
|
|
Returns:
|
|
Tuple of (is_valid, reason)
|
|
"""
|
|
config_data = trigger.config or {}
|
|
|
|
try:
|
|
config = ScheduleTriggerConfig(**config_data)
|
|
except Exception as e:
|
|
return False, f"invalid_config: {str(e)}"
|
|
|
|
# Check expiration
|
|
if config.is_expired():
|
|
return False, "schedule_expired"
|
|
|
|
return True, "ok"
|
|
|
|
|
|
# Registry of handlers by trigger_type
|
|
_HANDLERS: dict[TriggerType, BaseAppHandler] = {
|
|
TriggerType.slack_trigger: SlackAppHandler(),
|
|
TriggerType.webhook: DefaultWebhookHandler(),
|
|
TriggerType.schedule: ScheduleAppHandler(),
|
|
}
|
|
|
|
|
|
def get_app_handler(trigger_type: TriggerType) -> Optional[BaseAppHandler]:
|
|
"""Get the handler for a trigger type."""
|
|
return _HANDLERS.get(trigger_type)
|
|
|
|
|
|
def register_app_handler(trigger_type: TriggerType, handler: BaseAppHandler):
|
|
"""Register a new app handler."""
|
|
_HANDLERS[trigger_type] = handler
|
|
|
|
|
|
def get_supported_trigger_types() -> list[TriggerType]:
|
|
"""Get list of trigger types with webhook support."""
|
|
return list(_HANDLERS.keys())
|
|
|
|
|
|
def get_schedule_handler() -> ScheduleAppHandler:
|
|
"""Get the schedule handler instance."""
|
|
return _HANDLERS.get(TriggerType.schedule)
|