Skip to content

Commit 70ba852

Browse files
hjlarryMairuis
andauthored
feat: cli tool support create trigger plugins (#485)
* feat: cli tool support create trigger plugins * fix(trigger): update placeholder comment in SubscriptionConstructor for webhook registration --------- Co-authored-by: Harry <xh001x@hotmail.com>
1 parent 06afbec commit 70ba852

8 files changed

Lines changed: 370 additions & 1 deletion

File tree

cmd/commandline/plugin/category.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,17 @@ const PLUGIN_GUIDE = `Before starting, here's some basic knowledge about Plugin
2121
` + BOLD + `- Tool` + RESET + `: ` + GREEN + `Tool Providers like Google Search, Stable Diffusion, etc. Used to perform specific tasks.` + RESET + `
2222
` + BOLD + `- Model` + RESET + `: ` + GREEN + `Model Providers like OpenAI, Anthropic, etc. Use their models to enhance AI capabilities.` + RESET + `
2323
` + BOLD + `- Endpoint` + RESET + `: ` + GREEN + `Similar to Service API in Dify and Ingress in Kubernetes. Extend HTTP services as endpoints with custom logic.` + RESET + `
24+
` + BOLD + `- Trigger` + RESET + `: ` + GREEN + `Event-driven providers that dispatch workflow executions via webhooks or polling.` + RESET + `
2425
` + BOLD + `- Agent Strategy` + RESET + `: ` + GREEN + `Implement your own agent strategies like Function Calling, ReAct, ToT, CoT, etc.` + RESET + `
2526
26-
Based on the ability you want to extend, Plugins are divided into five types: ` + BOLD + `Tool` + RESET + `, ` + BOLD + `Model` + RESET + `, ` + BOLD + `Extension` + RESET + `, ` + BOLD + `Agent Strategy` + RESET + `, and ` + BOLD + `Datasource` + RESET + `.
27+
Based on the ability you want to extend, Plugins are divided into six types: ` + BOLD + `Tool` + RESET + `, ` + BOLD + `Model` + RESET + `, ` + BOLD + `Extension` + RESET + `, ` + BOLD + `Agent Strategy` + RESET + `, ` + BOLD + `Datasource` + RESET + `, and ` + BOLD + `Trigger` + RESET + `.
2728
2829
` + BOLD + `- Tool` + RESET + `: ` + YELLOW + `A tool provider that can also implement endpoints. For example, building a Discord Bot requires both ` + BLUE + `Sending` + RESET + YELLOW + ` and ` + BLUE + `Receiving Messages` + RESET + YELLOW + `, so both ` + BOLD + `Tool` + RESET + YELLOW + ` and ` + BOLD + `Endpoint` + RESET + YELLOW + ` functionality.` + RESET + `
2930
` + BOLD + `- Model` + RESET + `: ` + YELLOW + `Strictly for model providers, no other extensions allowed.` + RESET + `
3031
` + BOLD + `- Extension` + RESET + `: ` + YELLOW + `For simple HTTP services that extend functionality.` + RESET + `
3132
` + BOLD + `- Agent Strategy` + RESET + `: ` + YELLOW + `Implement custom agent logic with a focused approach.` + RESET + `
3233
` + BOLD + `- Datasource` + RESET + `: ` + YELLOW + `Provide datasource for Dify RAG Pipeline.` + RESET + `
34+
` + BOLD + `- Trigger` + RESET + `: ` + YELLOW + `Build webhook or polling integrations that emit events to kick off workflows.` + RESET + `
3335
3436
We've provided templates to help you get started. Choose one of the options below:
3537
`
@@ -49,6 +51,7 @@ var categories = []string{
4951
"moderation",
5052
"extension",
5153
"datasource",
54+
"trigger",
5255
}
5356

5457
func newCategory() category {

cmd/commandline/plugin/init.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ func InitPluginWithFlags(
157157
"extension",
158158
"agent-strategy",
159159
"datasource",
160+
"trigger",
160161
}
161162
valid := false
162163
for _, cat := range validCategories {
@@ -448,6 +449,10 @@ func (m model) createPlugin() {
448449
manifest.Plugins.Datasources = []string{fmt.Sprintf("provider/%s.yaml", manifest.Name)}
449450
}
450451

452+
if categoryString == "trigger" {
453+
manifest.Plugins.Triggers = []string{fmt.Sprintf("provider/%s.yaml", manifest.Name)}
454+
}
455+
451456
manifest.Meta = plugin_entities.PluginMeta{
452457
Version: "0.0.1",
453458
Arch: []constants.Arch{

cmd/commandline/plugin/python.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,18 @@ var PYTHON_DATASOURCE_PROVIDER_MANIFEST_TEMPLATE []byte
103103
//go:embed templates/python/datasource_provider.py
104104
var PYTHON_DATASOURCE_PROVIDER_PY_TEMPLATE []byte
105105

106+
//go:embed templates/python/trigger_provider.yaml
107+
var PYTHON_TRIGGER_PROVIDER_TEMPLATE []byte
108+
109+
//go:embed templates/python/trigger_provider.py
110+
var PYTHON_TRIGGER_PROVIDER_PY_TEMPLATE []byte
111+
112+
//go:embed templates/python/trigger.yaml
113+
var PYTHON_TRIGGER_TEMPLATE []byte
114+
115+
//go:embed templates/python/trigger.py
116+
var PYTHON_TRIGGER_EVENT_PY_TEMPLATE []byte
117+
106118
//go:embed templates/python/GUIDE.md
107119
var PYTHON_GUIDE []byte
108120

@@ -249,5 +261,11 @@ func createPythonEnvironment(
249261
}
250262
}
251263

264+
if category == "trigger" {
265+
if err := createPythonTrigger(root, manifest); err != nil {
266+
return err
267+
}
268+
}
269+
252270
return nil
253271
}

cmd/commandline/plugin/python_categories.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,3 +292,43 @@ func createPythonDatasource(root string, manifest *plugin_entities.PluginDeclara
292292

293293
return nil
294294
}
295+
296+
func createPythonTrigger(root string, manifest *plugin_entities.PluginDeclaration) error {
297+
triggerProviderPyContent, err := renderTemplate(PYTHON_TRIGGER_PROVIDER_PY_TEMPLATE, manifest, []string{""})
298+
if err != nil {
299+
return err
300+
}
301+
triggerProviderPyPath := filepath.Join(root, "provider", fmt.Sprintf("%s.py", manifest.Name))
302+
if err := writeFile(triggerProviderPyPath, triggerProviderPyContent); err != nil {
303+
return err
304+
}
305+
306+
triggerProviderManifestContent, err := renderTemplate(PYTHON_TRIGGER_PROVIDER_TEMPLATE, manifest, []string{""})
307+
if err != nil {
308+
return err
309+
}
310+
triggerProviderManifestPath := filepath.Join(root, "provider", fmt.Sprintf("%s.yaml", manifest.Name))
311+
if err := writeFile(triggerProviderManifestPath, triggerProviderManifestContent); err != nil {
312+
return err
313+
}
314+
315+
triggerEventManifestContent, err := renderTemplate(PYTHON_TRIGGER_TEMPLATE, manifest, []string{""})
316+
if err != nil {
317+
return err
318+
}
319+
triggerEventManifestPath := filepath.Join(root, "events", fmt.Sprintf("%s_event.yaml", manifest.Name))
320+
if err := writeFile(triggerEventManifestPath, triggerEventManifestContent); err != nil {
321+
return err
322+
}
323+
324+
triggerEventPyContent, err := renderTemplate(PYTHON_TRIGGER_EVENT_PY_TEMPLATE, manifest, []string{""})
325+
if err != nil {
326+
return err
327+
}
328+
triggerEventPyPath := filepath.Join(root, "events", fmt.Sprintf("%s_event.py", manifest.Name))
329+
if err := writeFile(triggerEventPyPath, triggerEventPyContent); err != nil {
330+
return err
331+
}
332+
333+
return nil
334+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from collections.abc import Mapping
2+
from typing import Any
3+
4+
from werkzeug import Request
5+
6+
from dify_plugin.entities.trigger import Variables
7+
from dify_plugin.errors.trigger import EventIgnoreError
8+
from dify_plugin.interfaces.trigger import Event
9+
10+
11+
class {{ .PluginName | SnakeToCamel }}TriggerEvent(Event):
12+
"""
13+
Basic example trigger handler that emits the raw incoming payload.
14+
Replace the logic here with your integration specific processing.
15+
"""
16+
17+
def _on_event(self, request: Request, parameters: Mapping[str, Any], payload: Mapping[str, Any]) -> Variables:
18+
payload = request.get_json(silent=True) or {}
19+
20+
sample_filter = parameters.get("sample_filter")
21+
if sample_filter and sample_filter not in str(payload):
22+
raise EventIgnoreError()
23+
24+
return Variables(
25+
variables={
26+
"message": "Hello from {{ .PluginName | SnakeToCamel }}!",
27+
"raw_event": payload,
28+
}
29+
)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
identity:
2+
name: "{{ .PluginName }}_event"
3+
author: "{{ .Author }}"
4+
label:
5+
en_US: "{{ .PluginName | SnakeToCamel }} Event"
6+
zh_Hans: "{{ .PluginName }} 事件"
7+
pt_BR: "Evento {{ .PluginName | SnakeToCamel }}"
8+
ja_JP: "{{ .PluginName }} イベント"
9+
10+
description:
11+
en_US: "{{ .PluginDescription }}"
12+
zh_Hans: "{{ .PluginDescription }}"
13+
pt_BR: "{{ .PluginDescription }}"
14+
ja_JP: "{{ .PluginDescription }}"
15+
16+
parameters:
17+
- name: "sample_filter"
18+
type: "string"
19+
required: false
20+
label:
21+
en_US: "Sample Filter"
22+
zh_Hans: "示例过滤器"
23+
pt_BR: "Filtro de Exemplo"
24+
ja_JP: "サンプルフィルター"
25+
help:
26+
en_US: "Optionally filter events before executing the trigger."
27+
zh_Hans: "在执行触发器之前可选地过滤事件。"
28+
pt_BR: "Opcionalmente filtre eventos antes de executar o gatilho."
29+
ja_JP: "トリガーを実行する前にイベントをフィルタリングします (任意)。"
30+
31+
output_schema:
32+
type: object
33+
properties:
34+
message:
35+
type: string
36+
description: "Sample message returned to downstream workflow nodes."
37+
raw_event:
38+
type: object
39+
description: "Original payload returned by the third-party integration."
40+
41+
extra:
42+
python:
43+
source: events/{{ .PluginName }}_event.py
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import time
2+
from collections.abc import Mapping
3+
from typing import Any
4+
5+
from werkzeug import Request, Response
6+
7+
from dify_plugin.entities.oauth import TriggerOAuthCredentials
8+
from dify_plugin.entities.provider_config import CredentialType
9+
from dify_plugin.entities.trigger import EventDispatch, Subscription, UnsubscribeResult
10+
from dify_plugin.errors.trigger import (
11+
SubscriptionError,
12+
TriggerDispatchError,
13+
TriggerProviderCredentialValidationError,
14+
TriggerProviderOAuthError,
15+
UnsubscribeError,
16+
)
17+
from dify_plugin.interfaces.trigger import Trigger, TriggerSubscriptionConstructor
18+
19+
20+
class {{ .PluginName | SnakeToCamel }}Trigger(Trigger):
21+
"""
22+
Handle the webhook event dispatch.
23+
"""
24+
def _dispatch_event(self, subscription: Subscription, request: Request) -> EventDispatch:
25+
payload: Mapping[str, Any] = self._validate_payload(request)
26+
response = Response(response='{"status": "ok"}', status=200, mimetype="application/json")
27+
events: list[str] = self._dispatch_trigger_events(payload=payload)
28+
return EventDispatch(events=events, response=response)
29+
30+
def _dispatch_trigger_events(self, payload: Mapping[str, Any]) -> list[str]:
31+
"""Dispatch events based on webhook payload."""
32+
events = []
33+
# Get the event type from the payload
34+
event_type = payload.get("type", "")
35+
36+
if event_type.startswith("my-event-type"):
37+
events.append("{{ .PluginName }}_event")
38+
39+
return events
40+
41+
def _validate_payload(self, request: Request) -> Mapping[str, Any]:
42+
try:
43+
payload = request.get_json(force=True)
44+
if not payload:
45+
raise TriggerDispatchError("Empty request body")
46+
return payload
47+
except TriggerDispatchError:
48+
raise
49+
except Exception as exc:
50+
raise TriggerDispatchError(f"Failed to parse payload: {exc}") from exc
51+
52+
class {{ .PluginName | SnakeToCamel }}SubscriptionConstructor(TriggerSubscriptionConstructor):
53+
"""Manage {{ .PluginName }} trigger subscriptions."""
54+
55+
def _validate_api_key(self, credentials: dict[str, Any]) -> None:
56+
api_key = credentials.get("api_key")
57+
if not api_key:
58+
raise TriggerProviderCredentialValidationError("API key is required to validate credentials.")
59+
60+
def _create_subscription(
61+
self,
62+
endpoint: str,
63+
parameters: Mapping[str, Any],
64+
credentials: Mapping[str, Any],
65+
credential_type: CredentialType,
66+
) -> Subscription:
67+
68+
events: list[str] = parameters.get("events", [])
69+
70+
# Replace this placeholder with API calls to register a webhook
71+
return Subscription(
72+
expires_at=int(time.time()) + 7 * 24 * 60 * 60,
73+
endpoint=endpoint,
74+
properties={
75+
"external_id": "example-subscription",
76+
"events": events,
77+
},
78+
)
79+
80+
def _delete_subscription(
81+
self,
82+
subscription: Subscription,
83+
credentials: Mapping[str, Any],
84+
credential_type: CredentialType,
85+
) -> UnsubscribeResult:
86+
# Tear down any remote subscription that was created in `_subscribe`.
87+
return UnsubscribeResult(success=True, message="Subscription removed.")
88+
89+
def _refresh_subscription(
90+
self,
91+
subscription: Subscription,
92+
credentials: Mapping[str, Any],
93+
credential_type: CredentialType,
94+
) -> Subscription:
95+
# Extend the subscription lifetime or renew tokens with your upstream service.
96+
return Subscription(
97+
expires_at=int(time.time()) + 7 * 24 * 60 * 60,
98+
endpoint=subscription.endpoint,
99+
properties=subscription.properties,
100+
)

0 commit comments

Comments
 (0)