Files
dify-plugin-sdks/python/examples/github_trigger/provider/github.py
T
Harry 4ba395f80a chore: update GitHub provider to support additional OAuth scopes and improve payload handling
- Bumped version in manifest.yaml to 0.0.5.
- Enhanced OAuth credential retrieval to include the 'admin:repo_hook' scope.
- Improved payload parsing logic for better handling of incoming requests.
- Adjusted default event in provider configuration for clarity.
2025-09-08 18:30:35 +08:00

298 lines
13 KiB
Python

import hashlib
import hmac
import secrets
import time
import urllib.parse
from collections.abc import Mapping
from typing import Any
import uuid
from dify_plugin.entities.oauth import TriggerOAuthCredentials
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,
# must contain webhook scope
"scope": system_credentials.get("scope", "read:user admin:repo_hook"),
"state": state,
}
return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}"
def _oauth_get_credentials(
self, redirect_uri: str, system_credentials: Mapping[str, Any], request: Request
) -> TriggerOAuthCredentials:
"""
Exchange code for access_token.
"""
code = request.args.get("code")
if not code:
raise TriggerProviderOAuthError("No code provided")
# Optionally: validate state here
data = {
"client_id": system_credentials["client_id"],
"client_secret": system_credentials["client_secret"],
"code": code,
"redirect_uri": redirect_uri,
}
headers = {"Accept": "application/json"}
response = requests.post(self._TOKEN_URL, data=data, headers=headers, timeout=10)
response_json = response.json()
access_tokens = response_json.get("access_token")
if not access_tokens:
raise TriggerProviderOAuthError(f"Error in GitHub OAuth: {response_json}")
return TriggerOAuthCredentials(credentials={"access_tokens": access_tokens}, expires_at=-1)
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",
),
]