mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-04-29 12:10:24 +00:00
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>
This commit is contained in:
parent
c8f6f7e63c
commit
4fb2e5db9a
200 changed files with 24538 additions and 2126 deletions
446
server/app/component/service/trigger/app_handler_service.py
Normal file
446
server/app/component/service/trigger/app_handler_service.py
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
# ========= 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue