mirror of
https://github.com/langgenius/dify-plugin-sdks.git
synced 2026-07-22 02:15:22 -04:00
521 lines
22 KiB
Python
521 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import secrets
|
|
import time
|
|
import urllib.parse
|
|
import uuid
|
|
from collections.abc import Mapping, Sequence
|
|
from typing import Any
|
|
|
|
import requests
|
|
from werkzeug import Request, Response
|
|
|
|
from dify_plugin.entities.oauth import OAuthCredentials, TriggerOAuthCredentials
|
|
from dify_plugin.entities.provider_config import CredentialType
|
|
from dify_plugin.entities.trigger import EventDispatch, Subscription, UnsubscribeResult
|
|
from dify_plugin.errors.trigger import (
|
|
SubscriptionError,
|
|
TriggerDispatchError,
|
|
TriggerProviderCredentialValidationError,
|
|
TriggerProviderOAuthError,
|
|
TriggerValidationError,
|
|
UnsubscribeError,
|
|
)
|
|
from dify_plugin.interfaces.trigger import Trigger, TriggerSubscriptionConstructor
|
|
|
|
|
|
class GoogleDriveTrigger(Trigger):
|
|
"""Validate Google Drive webhook headers and dispatch change events."""
|
|
|
|
_EVENT_NAME = "drive_change_detected"
|
|
_STORAGE_KEY = "google-drive-trigger:last-page-token"
|
|
_CHANGES_ENDPOINT = "https://www.googleapis.com/drive/v3/changes"
|
|
|
|
def _dispatch_event(self, subscription: Subscription, request: Request) -> EventDispatch:
|
|
if request.method not in {"POST", "GET"}:
|
|
return EventDispatch(events=[], response=self._ok_response())
|
|
|
|
headers = {
|
|
key: request.headers.get(key)
|
|
for key in (
|
|
"X-Goog-Channel-ID",
|
|
"X-Goog-Channel-Token",
|
|
"X-Goog-Message-Number",
|
|
"X-Goog-Resource-ID",
|
|
"X-Goog-Resource-State",
|
|
"X-Goog-Resource-URI",
|
|
)
|
|
}
|
|
|
|
channel_id = headers.get("X-Goog-Channel-ID")
|
|
resource_id = headers.get("X-Goog-Resource-ID")
|
|
resource_state = (headers.get("X-Goog-Resource-State") or "").lower()
|
|
channel_token = headers.get("X-Goog-Channel-Token")
|
|
|
|
expected_channel = subscription.properties.get("channel_id")
|
|
expected_resource = subscription.properties.get("resource_id")
|
|
expected_token = subscription.properties.get("channel_token")
|
|
|
|
if expected_channel and channel_id and expected_channel != channel_id:
|
|
raise TriggerValidationError("Mismatched channel ID in Google Drive notification")
|
|
|
|
if expected_resource and resource_id and expected_resource != resource_id:
|
|
raise TriggerValidationError("Mismatched resource ID in Google Drive notification")
|
|
|
|
if expected_token and channel_token and expected_token != channel_token:
|
|
raise TriggerValidationError("Invalid channel token for Google Drive notification")
|
|
|
|
body = request.get_json(silent=True)
|
|
if body and not isinstance(body, Mapping):
|
|
raise TriggerDispatchError("Invalid JSON payload for Google Drive webhook")
|
|
|
|
request.environ["google_drive.trigger.headers"] = {k: v for k, v in headers.items() if v is not None}
|
|
if isinstance(body, Mapping):
|
|
request.environ["google_drive.trigger.body"] = body
|
|
|
|
# Google sends an initial sync notification that does not represent changes
|
|
if resource_state == "sync":
|
|
return EventDispatch(events=[], response=self._ok_response())
|
|
|
|
event_name = subscription.properties.get("event_name", self._EVENT_NAME)
|
|
|
|
if not self.runtime.credentials:
|
|
raise TriggerDispatchError("Missing Google Drive OAuth access token in runtime credentials")
|
|
|
|
if not subscription.parameters:
|
|
raise TriggerDispatchError("Missing Google Drive parameters in subscription")
|
|
|
|
# fetch changes
|
|
changes = self._fetch_changes(
|
|
access_token=self.runtime.credentials.get("access_token") or "",
|
|
spaces=subscription.properties.get("spaces") or [],
|
|
include_removed=subscription.parameters.get("include_removed", False),
|
|
restrict_to_my_drive=subscription.parameters.get("restrict_to_my_drive", False),
|
|
include_items_from_all_drives=subscription.parameters.get("include_items_from_all_drives", False),
|
|
supports_all_drives=subscription.parameters.get("supports_all_drives", False),
|
|
subscription=subscription,
|
|
)
|
|
|
|
return EventDispatch(events=[event_name], response=self._ok_response(), payload={"changes": changes})
|
|
|
|
def _fetch_changes(
|
|
self,
|
|
access_token: str,
|
|
spaces: Sequence[str],
|
|
include_removed: bool,
|
|
restrict_to_my_drive: bool,
|
|
include_items_from_all_drives: bool,
|
|
supports_all_drives: bool,
|
|
subscription: Subscription,
|
|
):
|
|
"""
|
|
Fetch changes from Google Drive.
|
|
|
|
This method will fetch changes from Google Drive until the nextPageToken is not found.
|
|
It will also set the max_page_token to the storage.
|
|
|
|
Args:
|
|
access_token: The access token for Google Drive.
|
|
spaces: The spaces to fetch changes from.
|
|
include_removed: Whether to include removed items.
|
|
restrict_to_my_drive: Whether to restrict to my drive.
|
|
include_items_from_all_drives: Whether to include items from all drives.
|
|
supports_all_drives: Whether to support all drives.
|
|
subscription: The subscription to fetch changes from.
|
|
"""
|
|
headers = {"Authorization": f"Bearer {access_token}"}
|
|
changes: list[dict[str, Any]] = []
|
|
|
|
while True:
|
|
# (FIXME) it may lead some duplicates if frequency is too high
|
|
page_token = self._get_max_page_token(subscription)
|
|
params: dict[str, Any] = {
|
|
"pageToken": page_token,
|
|
"spaces": ",".join(spaces),
|
|
"includeRemoved": str(include_removed).lower(),
|
|
"restrictToMyDrive": str(restrict_to_my_drive).lower(),
|
|
"includeItemsFromAllDrives": str(include_items_from_all_drives).lower(),
|
|
"supportsAllDrives": str(supports_all_drives).lower(),
|
|
"fields": "changes(changeType,removed,fileId,"
|
|
"file(name,id,mimeType,owners,parents,driveId,teamDriveId,trashed,ownedByMe,"
|
|
"modifiedTime,createdTime,webViewLink,iconLink,lastModifyingUser,capabilities)),"
|
|
"newStartPageToken,nextPageToken",
|
|
}
|
|
|
|
try:
|
|
response = requests.get(self._CHANGES_ENDPOINT, headers=headers, params=params, timeout=10)
|
|
except requests.RequestException as exc:
|
|
raise ValueError(f"Failed to fetch Google Drive changes: {exc}") from exc
|
|
|
|
payload = response.json() if response.content else {}
|
|
if response.status_code != 200:
|
|
raise ValueError(f"Google Drive changes API error: {payload}")
|
|
|
|
next_page_token = payload.get("nextPageToken")
|
|
new_start_page_token = payload.get("newStartPageToken")
|
|
|
|
current_page_token = self._get_max_page_token(subscription)
|
|
if current_page_token == next_page_token:
|
|
# duplicate changes detected, skip the rest of the changes
|
|
break
|
|
|
|
raw_changes = payload.get("changes") or []
|
|
if isinstance(raw_changes, Sequence):
|
|
for change in raw_changes:
|
|
if isinstance(change, Mapping):
|
|
changes.append(dict(change))
|
|
|
|
if next_page_token:
|
|
self._set_max_page_token(next_page_token)
|
|
|
|
if new_start_page_token:
|
|
self._set_max_page_token(new_start_page_token)
|
|
break
|
|
|
|
return changes
|
|
|
|
def _set_max_page_token(self, token: str) -> None:
|
|
"""
|
|
Set the maximum page token to the storage.
|
|
|
|
If the storage is not found, do nothing.
|
|
"""
|
|
try:
|
|
self.runtime.session.storage.set(self._STORAGE_KEY, token.encode())
|
|
except Exception:
|
|
pass
|
|
|
|
def _get_max_page_token(self, subscription: Subscription) -> str:
|
|
"""
|
|
Get the maximum page token from the storage.
|
|
|
|
If the storage is not found, return 0.
|
|
"""
|
|
try:
|
|
return self.runtime.session.storage.get(self._STORAGE_KEY).decode() or "0"
|
|
except Exception:
|
|
return subscription.properties.get("start_page_token") or "0"
|
|
|
|
@staticmethod
|
|
def _ok_response() -> Response:
|
|
return Response(response=json.dumps({"status": "ok"}), mimetype="application/json", status=200)
|
|
|
|
|
|
class GoogleDriveSubscriptionConstructor(TriggerSubscriptionConstructor):
|
|
"""Manage Google Drive change notification channels."""
|
|
|
|
_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
_ABOUT_URL = "https://www.googleapis.com/drive/v3/about"
|
|
_START_PAGE_TOKEN_URL = "https://www.googleapis.com/drive/v3/changes/startPageToken"
|
|
_WATCH_URL = "https://www.googleapis.com/drive/v3/changes/watch"
|
|
_STOP_URL = "https://www.googleapis.com/drive/v3/channels/stop"
|
|
_DEFAULT_SCOPE = (
|
|
"https://www.googleapis.com/auth/drive.metadata.readonly https://www.googleapis.com/auth/drive.appdata"
|
|
)
|
|
|
|
def _validate_api_key(self, credentials: Mapping[str, Any]) -> None:
|
|
raise TriggerProviderCredentialValidationError("Google Drive trigger only supports OAuth credentials.")
|
|
|
|
def _oauth_get_authorization_url(self, redirect_uri: str, system_credentials: Mapping[str, Any]) -> str:
|
|
client_id = system_credentials.get("client_id")
|
|
if not client_id:
|
|
raise TriggerProviderOAuthError("Client ID is required to start Google Drive OAuth flow")
|
|
|
|
scope = system_credentials.get("scope", self._DEFAULT_SCOPE)
|
|
params = {
|
|
"response_type": "code",
|
|
"client_id": client_id,
|
|
"redirect_uri": redirect_uri,
|
|
"scope": scope,
|
|
"access_type": "offline",
|
|
"prompt": "consent",
|
|
"include_granted_scopes": "true",
|
|
}
|
|
return f"{self._AUTH_URL}?{urllib.parse.urlencode(params, quote_via=urllib.parse.quote)}"
|
|
|
|
def _oauth_get_credentials(
|
|
self, redirect_uri: str, system_credentials: Mapping[str, Any], request: Request
|
|
) -> TriggerOAuthCredentials:
|
|
code = request.args.get("code")
|
|
if not code:
|
|
raise TriggerProviderOAuthError("Missing authorization code in callback request")
|
|
|
|
client_id = system_credentials.get("client_id")
|
|
client_secret = system_credentials.get("client_secret")
|
|
if not client_id or not client_secret:
|
|
raise TriggerProviderOAuthError("Client ID and Client Secret are required")
|
|
|
|
data = {
|
|
"code": code,
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
"redirect_uri": redirect_uri,
|
|
"grant_type": "authorization_code",
|
|
}
|
|
|
|
try:
|
|
response = requests.post(self._TOKEN_URL, data=data, timeout=10)
|
|
except requests.RequestException as exc:
|
|
raise TriggerProviderOAuthError(f"Failed to exchange authorization code: {exc}") from exc
|
|
|
|
payload = response.json() if response.content else {}
|
|
if response.status_code != 200:
|
|
raise TriggerProviderOAuthError(
|
|
f"Failed to obtain Google Drive OAuth tokens: {payload.get('error_description') or payload}"
|
|
)
|
|
|
|
access_token = payload.get("access_token")
|
|
refresh_token = payload.get("refresh_token")
|
|
expires_in = payload.get("expires_in")
|
|
if not access_token or not refresh_token:
|
|
raise TriggerProviderOAuthError("OAuth response does not contain access_token or refresh_token")
|
|
|
|
expires_at = int(time.time()) + int(expires_in) if expires_in else -1
|
|
profile = self._fetch_about(access_token)
|
|
|
|
credentials = {
|
|
"access_token": access_token,
|
|
"refresh_token": refresh_token,
|
|
"scope": payload.get("scope", self._DEFAULT_SCOPE),
|
|
"token_type": payload.get("token_type", "Bearer"),
|
|
"user": json.dumps(profile.get("user", {})),
|
|
}
|
|
|
|
return TriggerOAuthCredentials(credentials=credentials, expires_at=expires_at)
|
|
|
|
def _oauth_refresh_credentials(
|
|
self, redirect_uri: str, system_credentials: Mapping[str, Any], credentials: Mapping[str, Any]
|
|
) -> OAuthCredentials:
|
|
refresh_token = credentials.get("refresh_token")
|
|
if not refresh_token:
|
|
raise TriggerProviderOAuthError("Refresh token is required to renew Google Drive access token")
|
|
|
|
client_id = system_credentials.get("client_id")
|
|
client_secret = system_credentials.get("client_secret")
|
|
if not client_id or not client_secret:
|
|
raise TriggerProviderOAuthError("Client ID and Client Secret are required to refresh Google Drive tokens")
|
|
|
|
data = {
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
"refresh_token": refresh_token,
|
|
"grant_type": "refresh_token",
|
|
}
|
|
|
|
try:
|
|
response = requests.post(self._TOKEN_URL, data=data, timeout=10)
|
|
except requests.RequestException as exc:
|
|
raise TriggerProviderOAuthError(f"Failed to refresh Google Drive OAuth token: {exc}") from exc
|
|
|
|
payload = response.json() if response.content else {}
|
|
if response.status_code != 200:
|
|
raise TriggerProviderOAuthError(
|
|
f"Unable to refresh Google Drive OAuth token: {payload.get('error_description') or payload}"
|
|
)
|
|
|
|
access_token = payload.get("access_token")
|
|
expires_in = payload.get("expires_in")
|
|
if not access_token:
|
|
raise TriggerProviderOAuthError("Refresh response does not contain access_token")
|
|
|
|
expires_at = int(time.time()) + int(expires_in) if expires_in else -1
|
|
refreshed_credentials = {
|
|
**credentials,
|
|
"access_token": access_token,
|
|
"scope": payload.get("scope", credentials.get("scope", self._DEFAULT_SCOPE)),
|
|
"token_type": payload.get("token_type", credentials.get("token_type", "Bearer")),
|
|
}
|
|
|
|
return OAuthCredentials(credentials=refreshed_credentials, expires_at=expires_at)
|
|
|
|
def _create_subscription(
|
|
self,
|
|
endpoint: str,
|
|
parameters: Mapping[str, Any],
|
|
credentials: Mapping[str, Any],
|
|
credential_type: CredentialType,
|
|
) -> Subscription:
|
|
if credential_type != CredentialType.OAUTH:
|
|
raise SubscriptionError("Google Drive trigger requires OAuth credentials", error_code="OAUTH_REQUIRED")
|
|
|
|
access_token = credentials.get("access_token")
|
|
if not access_token:
|
|
raise SubscriptionError("Missing Google Drive OAuth access token", error_code="MISSING_ACCESS_TOKEN")
|
|
|
|
spaces = self._normalize_spaces(parameters.get("spaces"))
|
|
if not spaces:
|
|
raise SubscriptionError("At least one Drive space must be selected", error_code="SPACES_REQUIRED")
|
|
|
|
start_page_token = self._fetch_start_page_token(access_token, spaces)
|
|
|
|
# set the start_page_token to the storage
|
|
self.runtime.session.storage.set(GoogleDriveTrigger._STORAGE_KEY, str(start_page_token).encode("utf-8"))
|
|
|
|
channel_id = str(uuid.uuid4())
|
|
channel_token = secrets.token_urlsafe(24)
|
|
|
|
watch_body: dict[str, Any] = {
|
|
"id": channel_id,
|
|
"type": "web_hook",
|
|
"address": endpoint,
|
|
"token": channel_token,
|
|
"payload": True,
|
|
"expiration": int(time.time() * 1000) + (24 * 60 * 60 - 1800) * 1000,
|
|
# add a 1800 seconds buffer to the expiration time to avoid race conditions
|
|
}
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
params = {
|
|
"pageToken": start_page_token,
|
|
"spaces": ",".join(spaces),
|
|
"supportsAllDrives": "true",
|
|
}
|
|
|
|
try:
|
|
response = requests.post(self._WATCH_URL, headers=headers, params=params, json=watch_body, timeout=10)
|
|
except requests.RequestException as exc:
|
|
raise SubscriptionError(
|
|
f"Network error while creating Google Drive watch: {exc}", error_code="NETWORK_ERROR"
|
|
) from exc
|
|
|
|
payload = response.json() if response.content else {}
|
|
if response.status_code != 200:
|
|
raise SubscriptionError(
|
|
f"Failed to create Google Drive watch: {payload.get('error', payload)}",
|
|
error_code="WATCH_CREATION_FAILED",
|
|
external_response=payload,
|
|
)
|
|
|
|
resource_id = payload.get("resourceId")
|
|
expiration = payload.get("expiration")
|
|
expires_at = int(int(expiration) / 1000) if expiration else -1
|
|
|
|
properties: dict[str, Any] = {
|
|
"channel_id": channel_id,
|
|
"resource_id": resource_id,
|
|
"channel_token": channel_token,
|
|
"watch_expiration": expiration,
|
|
"start_page_token": start_page_token,
|
|
"spaces": spaces,
|
|
"event_name": GoogleDriveTrigger._EVENT_NAME,
|
|
"user": json.loads(credentials.get("user", "{}")),
|
|
}
|
|
|
|
return Subscription(
|
|
expires_at=expires_at,
|
|
endpoint=endpoint,
|
|
parameters=parameters,
|
|
properties=properties,
|
|
)
|
|
|
|
def _delete_subscription(
|
|
self, subscription: Subscription, credentials: Mapping[str, Any], credential_type: CredentialType
|
|
) -> UnsubscribeResult:
|
|
if credential_type != CredentialType.OAUTH:
|
|
raise UnsubscribeError(
|
|
"Google Drive trigger requires OAuth credentials to unsubscribe", error_code="OAUTH_REQUIRED"
|
|
)
|
|
|
|
access_token = credentials.get("access_token")
|
|
if not access_token:
|
|
raise UnsubscribeError("Missing Google Drive OAuth access token", error_code="MISSING_ACCESS_TOKEN")
|
|
|
|
channel_id = subscription.properties.get("channel_id")
|
|
resource_id = subscription.properties.get("resource_id")
|
|
if not channel_id or not resource_id:
|
|
raise UnsubscribeError("Subscription does not contain channel metadata", error_code="MISSING_CHANNEL")
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
body = {"id": channel_id, "resourceId": resource_id}
|
|
|
|
try:
|
|
response = requests.post(self._STOP_URL, headers=headers, json=body, timeout=10)
|
|
except requests.RequestException as exc:
|
|
raise UnsubscribeError(
|
|
f"Network error while stopping Google Drive watch: {exc}", error_code="NETWORK_ERROR"
|
|
) from exc
|
|
|
|
if response.status_code in {200, 204}:
|
|
return UnsubscribeResult(success=True, message="Google Drive watch channel stopped successfully")
|
|
|
|
payload = response.json() if response.content else {}
|
|
raise UnsubscribeError(
|
|
f"Failed to stop Google Drive watch: {payload.get('error', payload)}",
|
|
error_code="WATCH_STOP_FAILED",
|
|
external_response=payload,
|
|
)
|
|
|
|
def _refresh_subscription(
|
|
self, subscription: Subscription, credentials: Mapping[str, Any], credential_type: CredentialType
|
|
) -> Subscription:
|
|
parameters = dict(subscription.parameters or {})
|
|
return self._create_subscription(
|
|
endpoint=subscription.endpoint,
|
|
parameters=parameters,
|
|
credentials=credentials,
|
|
credential_type=credential_type,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _fetch_about(self, access_token: str) -> Mapping[str, Any]:
|
|
headers = {"Authorization": f"Bearer {access_token}"}
|
|
params = {"fields": "user"}
|
|
try:
|
|
response = requests.get(self._ABOUT_URL, headers=headers, params=params, timeout=10)
|
|
except requests.RequestException as exc:
|
|
raise TriggerProviderOAuthError(f"Failed to fetch Google Drive profile: {exc}") from exc
|
|
|
|
payload = response.json() if response.content else {}
|
|
if response.status_code != 200:
|
|
raise TriggerProviderOAuthError(f"Unable to fetch Google Drive profile: {payload.get('error', payload)}")
|
|
return payload
|
|
|
|
def _fetch_start_page_token(self, access_token: str, spaces: Sequence[str]) -> str:
|
|
headers = {"Authorization": f"Bearer {access_token}"}
|
|
params = {"spaces": ",".join(spaces)}
|
|
try:
|
|
response = requests.get(self._START_PAGE_TOKEN_URL, headers=headers, params=params, timeout=10)
|
|
except requests.RequestException as exc:
|
|
raise SubscriptionError(
|
|
f"Network error while fetching startPageToken: {exc}", error_code="NETWORK_ERROR"
|
|
) from exc
|
|
|
|
payload = response.json() if response.content else {}
|
|
if response.status_code != 200:
|
|
raise SubscriptionError(
|
|
f"Failed to fetch startPageToken: {payload.get('error', payload)}",
|
|
error_code="START_TOKEN_FAILED",
|
|
external_response=payload,
|
|
)
|
|
|
|
token = payload.get("startPageToken")
|
|
if not token:
|
|
raise SubscriptionError("startPageToken not present in response", error_code="START_TOKEN_MISSING")
|
|
return str(token)
|
|
|
|
@staticmethod
|
|
def _normalize_spaces(raw: Any) -> list[str]:
|
|
if not raw:
|
|
return []
|
|
if isinstance(raw, str):
|
|
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
if isinstance(raw, Sequence):
|
|
normalized: list[str] = []
|
|
for item in raw:
|
|
if isinstance(item, str) and item.strip():
|
|
normalized.append(item.strip())
|
|
return normalized
|
|
raise SubscriptionError("spaces must be a string or list of strings", error_code="INVALID_SPACES")
|