mirror of
https://github.com/mudler/LocalAGI.git
synced 2026-07-24 11:15:22 -04:00
257 lines
9.3 KiB
Go
257 lines
9.3 KiB
Go
// Package actions contains action implementations used by LocalAGI.
|
|
// This file implements the "webhook" action which can send an HTTP request
|
|
// to an external service with a configurable method, content type, and payload.
|
|
package actions
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/mudler/LocalAGI/core/types"
|
|
"github.com/mudler/LocalAGI/pkg/config"
|
|
"github.com/sashabaranov/go-openai/jsonschema"
|
|
)
|
|
|
|
// NewWebhook constructs a WebhookAction using provided configuration values:
|
|
// - url: Destination endpoint for the HTTP request (required).
|
|
// - method: HTTP method to use (GET, POST, PUT, DELETE, ...). Defaults to POST.
|
|
// - contentType: Value for the Content-Type header (e.g., application/json).
|
|
// - payloadTemplate: Optional template for the request body; the runtime parameter
|
|
// "payload" (if provided) will replace the "{{payload}}" placeholder inside this template.
|
|
func NewWebhook(cfg map[string]string) *WebhookAction {
|
|
wa := &WebhookAction{
|
|
url: strings.TrimSpace(cfg["url"]),
|
|
method: strings.ToUpper(strings.TrimSpace(cfg["method"])),
|
|
contentType: strings.TrimSpace(cfg["contentType"]),
|
|
payloadTemplate: cfg["payloadTemplate"],
|
|
}
|
|
// Optional custom overrides
|
|
if cfg != nil {
|
|
wa.customName = cfg["custom_name"]
|
|
wa.customDescription = cfg["custom_description"]
|
|
wa.customPayloadDescription = cfg["custom_payload_description"]
|
|
}
|
|
if wa.method == "" {
|
|
wa.method = http.MethodPost
|
|
}
|
|
return wa
|
|
}
|
|
|
|
// WebhookAction holds the static configuration for the webhook.
|
|
// These values come from the action configuration (UI/agent config),
|
|
// while the runtime parameter only carries the dynamic payload.
|
|
// - url: Target endpoint for the request.
|
|
// - method: HTTP method to use. Defaults to POST if not provided.
|
|
// - contentType: Sets the Content-Type header when a body is sent.
|
|
// - payloadTemplate: Optional template used to build the request body; occurrences
|
|
// of "{{payload}}" get replaced with the runtime payload string.
|
|
// If no placeholder is present, the template is used as-is.
|
|
// For GET requests the body is omitted regardless of payload.
|
|
//
|
|
// Note: This action does not follow redirects!
|
|
type WebhookAction struct {
|
|
url string
|
|
method string
|
|
contentType string
|
|
payloadTemplate string
|
|
customName string
|
|
customDescription string
|
|
customPayloadDescription string
|
|
}
|
|
|
|
// Run executes the webhook call.
|
|
// It reads the runtime parameter "payload" (optional), merges it into the
|
|
// configured payloadTemplate (if any), constructs an HTTP request using the
|
|
// configured URL, method and content type, and then returns a summary with the
|
|
// response status and body (truncated to 4KiB for safety).
|
|
func (a *WebhookAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
|
// Runtime parameters: only payload
|
|
type input struct {
|
|
Payload string `json:"payload"`
|
|
}
|
|
var in input
|
|
if err := params.Unmarshal(&in); err != nil {
|
|
return types.ActionResult{}, err
|
|
}
|
|
|
|
// Validate essential configuration. The URL must be provided via the
|
|
// action configuration (not via runtime parameters).
|
|
if a.url == "" {
|
|
return types.ActionResult{}, fmt.Errorf("configuration.url is required")
|
|
}
|
|
|
|
method := a.method
|
|
|
|
// Build the request body based on template and payload:
|
|
// - If a payloadTemplate is provided, replace occurrences of "{{payload}}"
|
|
// with the runtime payload value.
|
|
// - If the template does not contain the placeholder but is provided, we use
|
|
// the template as-is (common for static JSON bodies prepared at config time).
|
|
// - If no template is configured, we send the runtime payload as-is.
|
|
// - For GET requests the body is omitted regardless of payload.
|
|
var payload string
|
|
if a.payloadTemplate != "" {
|
|
payload = strings.ReplaceAll(a.payloadTemplate, "{{payload}}", in.Payload)
|
|
if payload == a.payloadTemplate && in.Payload != "" {
|
|
// If no placeholder found, fallback to template or payload alone
|
|
payload = a.payloadTemplate
|
|
}
|
|
} else {
|
|
payload = in.Payload
|
|
}
|
|
|
|
var body io.Reader
|
|
if method != http.MethodGet && payload != "" {
|
|
body = bytes.NewBufferString(payload)
|
|
}
|
|
|
|
// Create the HTTP request bound to the provided context so that cancellation
|
|
// or timeouts from the caller propagate to the outbound call.
|
|
req, err := http.NewRequestWithContext(ctx, method, a.url, body)
|
|
if err != nil {
|
|
return types.ActionResult{}, err
|
|
}
|
|
|
|
// Set Content-Type header if configured. For GET requests this header is
|
|
// typically ignored by servers as there is no body.
|
|
if a.contentType != "" {
|
|
req.Header.Set("Content-Type", a.contentType)
|
|
}
|
|
|
|
// Use a new http.Client with default settings. Consider configuring timeouts
|
|
// at the caller level via the context, or wiring a custom client if needed.
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return types.ActionResult{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Read and safely truncate the response body to avoid flooding the agent's
|
|
// context with very large payloads. Errors on ReadAll are ignored here as
|
|
// we already have the status code.
|
|
respBytes, _ := io.ReadAll(resp.Body)
|
|
respBody := string(respBytes)
|
|
if len(respBody) > 4096 {
|
|
respBody = respBody[:4096] + "... (truncated)"
|
|
}
|
|
|
|
return types.ActionResult{
|
|
// Return the response body as the result.
|
|
// If the response body is empty, use the status text as the result (e.g. "OK" for status code 200).
|
|
Result: func() string {
|
|
if respBody == "" {
|
|
return http.StatusText(resp.StatusCode)
|
|
}
|
|
return respBody
|
|
}(),
|
|
// Include the response status code in the metadata.
|
|
Metadata: map[string]interface{}{
|
|
"statusCode": resp.StatusCode,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Definition returns the action schema exposed to the planner/runtime.
|
|
// Only the runtime parameter "payload" is accepted; all connection details
|
|
// are configured statically via the action configuration UI.
|
|
func (a *WebhookAction) Definition() types.ActionDefinition {
|
|
name := "webhook"
|
|
description := "Send an HTTP request to a configured URL/method/content-type. Accepts a runtime payload parameter optionally inserted into the configured payload template."
|
|
if a.customName != "" {
|
|
name = a.customName
|
|
}
|
|
if a.customDescription != "" {
|
|
description = a.customDescription
|
|
}
|
|
payloadDesc := "Payload/body to send with the request at runtime. If a payloadTemplate is configured, '{{payload}}' will be replaced by this value."
|
|
if a.customPayloadDescription != "" {
|
|
payloadDesc = a.customPayloadDescription
|
|
}
|
|
return types.ActionDefinition{
|
|
Name: types.ActionDefinitionName(name),
|
|
Description: description,
|
|
Properties: map[string]jsonschema.Definition{
|
|
"payload": {
|
|
Type: jsonschema.String,
|
|
Description: payloadDesc,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Plannable indicates the action can be suggested/used by planners without
|
|
// requiring hidden context; inputs are straightforward and safe.
|
|
func (a *WebhookAction) Plannable() bool { return true }
|
|
|
|
// WebhookConfigMeta returns the metadata for Webhook action configuration fields:
|
|
// - url: The endpoint to send requests to (required).
|
|
// - method: One of GET/POST/PUT/DELETE. Defaults to POST.
|
|
// - contentType: Common content types selectable from a dropdown.
|
|
// - payloadTemplate: Optional body template. At runtime, "{{payload}}" is
|
|
// replaced by the provided payload parameter. If missing, the template is used
|
|
// as-is; for GET, no body is sent regardless.
|
|
func WebhookConfigMeta() []config.Field {
|
|
return []config.Field{
|
|
{
|
|
Name: "custom_name",
|
|
Label: "Custom Name",
|
|
Type: config.FieldTypeText,
|
|
Required: false,
|
|
HelpText: "Custom name for the action (optional, defaults to 'webhook')",
|
|
},
|
|
{
|
|
Name: "custom_description",
|
|
Label: "Custom Description",
|
|
Type: config.FieldTypeText,
|
|
Required: false,
|
|
HelpText: "Custom description for the action (optional)",
|
|
},
|
|
{
|
|
Name: "custom_payload_description",
|
|
Label: "Custom Payload Description",
|
|
Type: config.FieldTypeText,
|
|
Required: false,
|
|
HelpText: "Override the payload parameter description shown in the UI/schema (optional).",
|
|
},
|
|
{
|
|
Name: "url",
|
|
Label: "URL",
|
|
Type: config.FieldTypeText,
|
|
Required: true,
|
|
HelpText: "Destination URL for the webhook",
|
|
},
|
|
{
|
|
Name: "method",
|
|
Label: "HTTP Method",
|
|
Type: config.FieldTypeSelect,
|
|
Options: []config.FieldOption{{Value: http.MethodGet, Label: "GET"}, {Value: http.MethodPost, Label: "POST"}, {Value: http.MethodPut, Label: "PUT"}, {Value: http.MethodDelete, Label: "DELETE"}},
|
|
DefaultValue: http.MethodPost,
|
|
Required: true,
|
|
HelpText: "HTTP method to use",
|
|
},
|
|
{
|
|
Name: "contentType",
|
|
Label: "Content Type",
|
|
Type: config.FieldTypeSelect,
|
|
Options: []config.FieldOption{
|
|
{Value: "application/json", Label: "application/json"},
|
|
{Value: "text/plain", Label: "text/plain"},
|
|
{Value: "application/x-www-form-urlencoded", Label: "application/x-www-form-urlencoded"},
|
|
},
|
|
Required: true,
|
|
HelpText: "Content-Type header to send",
|
|
},
|
|
{
|
|
Name: "payloadTemplate",
|
|
Label: "Payload Template",
|
|
Type: config.FieldTypeTextarea,
|
|
HelpText: "Optional template used to craft the request body. Use '{{payload}}' as placeholder for the runtime payload.",
|
|
},
|
|
}
|
|
}
|