mirror of
https://github.com/langgenius/dify-plugin-sdks.git
synced 2026-07-22 18:35:29 -04:00
11954ee36e
- Enhanced the GitHub provider to handle both form-encoded and JSON payloads from webhooks. - Updated error handling to provide clearer messages for payload parsing issues. - Ensured compatibility with various content types in incoming requests. This change improves the robustness of the GitHub trigger integration by accommodating different data formats.
291 lines
12 KiB
Python
291 lines
12 KiB
Python
import hashlib
|
|
import hmac
|
|
import secrets
|
|
import time
|
|
import urllib.parse
|
|
from collections.abc import Mapping
|
|
from typing import Any
|
|
import uuid
|
|
|
|
import requests
|
|
from werkzeug import Request, Response
|
|
|
|
from dify_plugin.entities import I18nObject, ParameterOption
|
|
from dify_plugin.entities.trigger import Subscription, TriggerDispatch, Unsubscription
|
|
from dify_plugin.errors.trigger import TriggerProviderCredentialValidationError, TriggerProviderOAuthError
|
|
from dify_plugin.errors.trigger import SubscriptionError, TriggerDispatchError, TriggerValidationError
|
|
from dify_plugin.interfaces.trigger import TriggerProvider
|
|
|
|
|
|
class GithubProvider(TriggerProvider):
|
|
_AUTH_URL = "https://github.com/login/oauth/authorize"
|
|
_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
|
_API_USER_URL = "https://api.github.com/user"
|
|
|
|
def _oauth_get_authorization_url(self, redirect_uri: str, system_credentials: Mapping[str, Any]) -> str:
|
|
"""
|
|
Generate the authorization URL for the Github OAuth.
|
|
"""
|
|
state = secrets.token_urlsafe(16)
|
|
params = {
|
|
"client_id": system_credentials["client_id"],
|
|
"redirect_uri": redirect_uri,
|
|
"scope": system_credentials.get("scope", "read:user"),
|
|
"state": state,
|
|
# Optionally: allow_signup, login, etc.
|
|
}
|
|
return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}"
|
|
|
|
def _oauth_get_credentials(self, system_credentials: Mapping[str, Any], request: Request) -> Mapping[str, Any]:
|
|
"""
|
|
Exchange code for access_token.
|
|
"""
|
|
code = request.args.get("code")
|
|
if not code:
|
|
raise TriggerProviderOAuthError("No code provided")
|
|
|
|
data = {
|
|
"client_id": system_credentials["client_id"],
|
|
"client_secret": system_credentials["client_secret"],
|
|
"code": code,
|
|
}
|
|
headers = {"Accept": "application/json"}
|
|
response = requests.post(self._TOKEN_URL, data=data, headers=headers, timeout=10)
|
|
response_json = response.json()
|
|
access_token = response_json.get("access_token")
|
|
if not access_token:
|
|
raise TriggerProviderOAuthError(f"Error in GitHub OAuth: {response_json}")
|
|
return {"access_tokens": access_token}
|
|
|
|
def _validate_credentials(self, credentials: dict) -> None:
|
|
try:
|
|
if "access_tokens" not in credentials or not credentials.get("access_tokens"):
|
|
raise TriggerProviderCredentialValidationError("GitHub API Access Token is required.")
|
|
headers = {
|
|
"Authorization": f"Bearer {credentials['access_tokens']}",
|
|
"Accept": "application/vnd.github+json",
|
|
}
|
|
response = requests.get(self._API_USER_URL, headers=headers, timeout=10)
|
|
if response.status_code != 200:
|
|
raise TriggerProviderCredentialValidationError(response.json().get("message"))
|
|
except Exception as e:
|
|
raise TriggerProviderCredentialValidationError(str(e)) from e
|
|
|
|
def _dispatch_event(self, subscription: Subscription, request: Request) -> TriggerDispatch:
|
|
"""
|
|
Dispatch GitHub webhook events - focusing on issue comment events
|
|
"""
|
|
# Verify webhook signature if secret is provided
|
|
webhook_secret = subscription.properties.get("webhook_secret")
|
|
if webhook_secret:
|
|
signature = request.headers.get("X-Hub-Signature-256")
|
|
if not signature:
|
|
raise TriggerValidationError("Missing webhook signature")
|
|
|
|
# Verify the signature
|
|
expected_signature = (
|
|
"sha256=" + hmac.new(webhook_secret.encode(), request.get_data(), hashlib.sha256).hexdigest()
|
|
)
|
|
|
|
if not hmac.compare_digest(signature, expected_signature):
|
|
raise TriggerValidationError("Invalid webhook signature")
|
|
|
|
event_type = request.headers.get("X-GitHub-Event")
|
|
if not event_type:
|
|
raise TriggerDispatchError("Missing GitHub event type header")
|
|
|
|
try:
|
|
# GitHub webhooks can send data as form-encoded or JSON
|
|
content_type = request.headers.get('Content-Type', '')
|
|
|
|
if 'application/x-www-form-urlencoded' in content_type:
|
|
# For form-encoded data, the payload is in the 'payload' field
|
|
import json
|
|
form_data = request.form.get('payload')
|
|
if not form_data:
|
|
raise TriggerDispatchError("Missing payload in form data")
|
|
payload = json.loads(form_data)
|
|
else:
|
|
# For JSON content type or when Content-Type is missing/other
|
|
payload = request.get_json(force=True)
|
|
|
|
if not payload:
|
|
raise TriggerDispatchError("Empty request body")
|
|
except Exception as e:
|
|
raise TriggerDispatchError(f"Failed to parse payload: {e}") from e
|
|
|
|
response = Response(response='{"status": "ok"}', status=200, mimetype="application/json")
|
|
|
|
# Create trigger event dispatch with GitHub event type
|
|
# Map GitHub events to our trigger events
|
|
if event_type == "issue_comment":
|
|
return TriggerDispatch(triggers=["issue_comment"], response=response)
|
|
elif event_type == "issues":
|
|
# Issues event can trigger multiple workflows based on action
|
|
action = payload.get("action")
|
|
if action == "opened":
|
|
# Dispatch both generic issues event and specific opened event
|
|
return TriggerDispatch(triggers=["issues", "issues.opened"], response=response)
|
|
elif action == "closed":
|
|
return TriggerDispatch(triggers=["issues", "issues.closed"], response=response)
|
|
else:
|
|
return TriggerDispatch(triggers=["issues"], response=response)
|
|
else:
|
|
# For other events, pass them through with prefix
|
|
return TriggerDispatch(triggers=[f"github.{event_type}"], response=response)
|
|
|
|
def _subscribe(self, endpoint: str, credentials: Mapping[str, Any], parameters: Mapping[str, Any]) -> Subscription:
|
|
"""
|
|
Create a GitHub webhook subscription for issue comment events
|
|
"""
|
|
# Extract parameters
|
|
webhook_id = uuid.uuid4().hex
|
|
webhook_secret = webhook_id
|
|
repository = parameters.get("repository") # format: "owner/repo"
|
|
events = parameters.get("events", ["issue_comment", "issues"])
|
|
|
|
if not repository:
|
|
raise ValueError("repository is required (format: owner/repo)")
|
|
|
|
# Parse repository owner and name
|
|
try:
|
|
owner, repo = repository.split("/")
|
|
except ValueError:
|
|
raise ValueError("repository must be in format 'owner/repo'") from None
|
|
|
|
# Create webhook using GitHub API
|
|
url = f"https://api.github.com/repos/{owner}/{repo}/hooks"
|
|
headers = {
|
|
"Authorization": f"Bearer {credentials.get('access_tokens')}",
|
|
"Accept": "application/vnd.github+json",
|
|
}
|
|
|
|
webhook_data = {
|
|
"name": "web",
|
|
"active": True,
|
|
"events": events,
|
|
"config": {"url": endpoint, "content_type": "json", "insecure_ssl": "0"},
|
|
}
|
|
|
|
# Add secret if provided
|
|
if webhook_secret:
|
|
webhook_data["config"]["secret"] = webhook_secret
|
|
|
|
try:
|
|
response = requests.post(url, json=webhook_data, headers=headers, timeout=10)
|
|
if response.status_code == 201:
|
|
webhook = response.json()
|
|
# Return subscription with webhook details
|
|
return Subscription(
|
|
expires_at=int(time.time()) + 30 * 24 * 60 * 60, # 30 days expiration
|
|
endpoint=endpoint,
|
|
properties={
|
|
"external_id": str(webhook["id"]),
|
|
"repository": repository,
|
|
"events": events,
|
|
"webhook_secret": webhook_secret,
|
|
"active": webhook["active"],
|
|
},
|
|
)
|
|
else:
|
|
response_data = response.json() if response.content else {}
|
|
error_msg = response_data.get("message", "Unknown error")
|
|
error_details = response_data.get("errors", [])
|
|
|
|
# Log detailed error information for debugging
|
|
print(f"GitHub webhook creation failed with status {response.status_code}")
|
|
print(f"Request URL: {url}")
|
|
print(f"Request data: {webhook_data}")
|
|
print(f"Response: {response_data}")
|
|
|
|
detailed_error = f"Failed to create GitHub webhook: {error_msg}"
|
|
if error_details:
|
|
detailed_error += f" Details: {error_details}"
|
|
|
|
raise SubscriptionError(
|
|
detailed_error,
|
|
error_code="WEBHOOK_CREATION_FAILED",
|
|
external_response=response_data,
|
|
)
|
|
except requests.RequestException as e:
|
|
raise SubscriptionError(f"Network error while creating webhook: {e}", error_code="NETWORK_ERROR") from e
|
|
|
|
def _unsubscribe(self, endpoint: str, subscription: Subscription, credentials: Mapping[str, Any]) -> Unsubscription:
|
|
"""
|
|
Remove a GitHub webhook subscription
|
|
"""
|
|
# Extract webhook details from properties
|
|
external_id = subscription.properties.get("external_id")
|
|
repository = subscription.properties.get("repository")
|
|
|
|
if not external_id or not repository:
|
|
return Unsubscription(
|
|
success=False, message="Missing webhook ID or repository information", error_code="MISSING_PROPERTIES"
|
|
)
|
|
|
|
# Parse repository
|
|
try:
|
|
owner, repo = repository.split("/")
|
|
except ValueError:
|
|
return Unsubscription(
|
|
success=False, message="Invalid repository format in properties", error_code="INVALID_REPOSITORY"
|
|
)
|
|
|
|
# Delete webhook using GitHub API
|
|
url = f"https://api.github.com/repos/{owner}/{repo}/hooks/{external_id}"
|
|
headers = {
|
|
"Authorization": f"Bearer {credentials.get('access_tokens')}",
|
|
"Accept": "application/vnd.github+json",
|
|
}
|
|
|
|
try:
|
|
response = requests.delete(url, headers=headers, timeout=10)
|
|
if response.status_code == 204:
|
|
return Unsubscription(
|
|
success=True, message=f"Successfully removed webhook {external_id} from {repository}"
|
|
)
|
|
elif response.status_code == 404:
|
|
return Unsubscription(
|
|
success=False,
|
|
message=f"Webhook {external_id} not found in repository {repository}",
|
|
error_code="WEBHOOK_NOT_FOUND",
|
|
)
|
|
else:
|
|
return Unsubscription(
|
|
success=False,
|
|
message=f"Failed to delete webhook: {response.json().get('message', 'Unknown error')}",
|
|
error_code="API_ERROR",
|
|
external_response=response.json(),
|
|
)
|
|
except requests.RequestException as e:
|
|
return Unsubscription(
|
|
success=False, message=f"Network error while deleting webhook: {e}", error_code="NETWORK_ERROR"
|
|
)
|
|
|
|
def _refresh(self, endpoint: str, subscription: Subscription, credentials: Mapping[str, Any]) -> Subscription:
|
|
"""
|
|
Refresh a GitHub webhook subscription (extend expiration)
|
|
GitHub webhooks don't expire, so we just extend our internal expiration
|
|
"""
|
|
# Simply return the subscription with extended expiration
|
|
# GitHub webhooks don't have built-in expiration
|
|
return Subscription(
|
|
expires_at=int(time.time()) + 30 * 24 * 60 * 60, # Extend by 30 days
|
|
endpoint=endpoint, # Keep the same endpoint
|
|
properties=subscription.properties, # Keep the same properties
|
|
)
|
|
|
|
def _fetch_parameter_options(self, parameter: str) -> list[ParameterOption]:
|
|
return [
|
|
ParameterOption(
|
|
value="iamjoel",
|
|
label=I18nObject(en_US="Joel"),
|
|
icon="https://avatars.githubusercontent.com/u/2120155?s=40&v=4",
|
|
),
|
|
ParameterOption(
|
|
value="yeuoly",
|
|
label=I18nObject(en_US="Yeuoly"),
|
|
icon="https://avatars.githubusercontent.com/u/45712896?s=60&v=4",
|
|
),
|
|
]
|