eigent/server/app/component/service/trigger/app_handler_service.py
Ahmed Awelkair A 4fb2e5db9a
feat: schedule and webhook triggers (#823)
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>
2026-03-02 20:38:02 +08:00

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)