diff --git a/docs/configuration/tools/index.md b/docs/configuration/tools/index.md index 57e683e445..6d8fc1c96d 100644 --- a/docs/configuration/tools/index.md +++ b/docs/configuration/tools/index.md @@ -38,6 +38,7 @@ Built-in tools are included with docker-agent and require no external dependenci | `open_url` | Open a fixed URL in the user's default browser | [Open URL](../../tools/open-url/index.md) | | `transfer_task` | Delegate to sub-agents (auto-enabled) | [Transfer Task](../../tools/transfer-task/index.md) | | `background_agents` | Parallel sub-agent dispatch | [Background Agents](../../tools/background-agents/index.md) | +| `webhook` | Send outbound notifications (Slack, Discord, Telegram, IFTTT, Teams, …) | [Webhook](../../tools/webhook/index.md) | | `handoff` | Local conversation handoff to another agent in the same config (auto-enabled by `handoffs:`) | [Handoff](../../tools/handoff/index.md) | | `a2a` | A2A remote agent connection | [A2A](../../tools/a2a/index.md) | | `mcp_catalog` | Discover and activate remote MCP servers from the Docker MCP Catalog on demand | [MCP Catalog](../../tools/mcp-catalog/index.md) | diff --git a/docs/tools/webhook/index.md b/docs/tools/webhook/index.md new file mode 100644 index 0000000000..7157fd1b43 --- /dev/null +++ b/docs/tools/webhook/index.md @@ -0,0 +1,51 @@ +--- +title: "Webhook Tool" +description: "Send outbound notifications to Slack, Discord, Telegram, IFTTT, and more." +keywords: docker agent, ai agents, tools, toolsets, webhook, slack, discord, telegram, ifttt +linkTitle: "Webhook" +weight: 145 +canonical: https://docs.docker.com/ai/docker-agent/tools/webhook/ +--- + +_Send outbound notifications to Slack, Discord, Telegram, IFTTT, and more._ + +## Overview +The webhook toolset lets an agent POST a message to a webhook, shaping the JSON payload for the target service. Delivery is one-way — the tool reports the HTTP status, not a response body. It uses the SSRF-safe HTTP client (requests to non-public addresses are refused), and pairs naturally with the [`scheduler`](../scheduler/index.md) tool for alerting. + +## Configuration +```yaml +toolsets: + - type: webhook +``` +No configuration options. + +## `send_webhook` +| Parameter | Required | Description | +| --- | --- | --- | +| `url` | Yes | The webhook URL to POST to. | +| `message` | Yes | The message text. | +| `provider` | No | Payload format (default `generic`). | +| `value2`, `value3` | No | IFTTT extra fields (`provider=ifttt`). | +| `chat_id` | No | Telegram chat id (`provider=telegram`). | + +## Providers +| Provider | Payload | +| --- | --- | +| `slack`, `mattermost`, `rocketchat`, `googlechat`, `teams`, `generic` | `{"text": message}` | +| `discord` | `{"content": message}` | +| `ifttt` | `{"value1": message, "value2": …, "value3": …}` | +| `telegram` | `{"chat_id": …, "text": message}` | + +## Example +```yaml +agents: + root: + model: openai/gpt-5-mini + instruction: If a scheduled check fails, notify the team via send_webhook. + toolsets: + - type: webhook + - type: scheduler +``` + +> [!TIP] +> Combine with the [`scheduler`](../scheduler/index.md): "every 15 minutes, check the build; if it broke, `send_webhook` to Slack." diff --git a/pkg/teamloader/toolsets/catalog.go b/pkg/teamloader/toolsets/catalog.go index b6cc9770da..64177a55ea 100644 --- a/pkg/teamloader/toolsets/catalog.go +++ b/pkg/teamloader/toolsets/catalog.go @@ -32,6 +32,7 @@ var BuiltinToolsets = []BuiltinToolsetInfo{ builtinToolset("think", "think", "Step-by-step reasoning scratchpad for planning"), builtinToolset("todo", "todo", "Manage a task list for complex multi-step workflows"), builtinToolset("user_prompt", "user-prompt", "Ask the user questions and collect interactive input"), + builtinToolset("webhook", "webhook", "Send outbound notifications (Slack, Discord, Telegram, IFTTT, Teams, and more)"), } func builtinToolset(toolsetType, docsSlug, summary string) BuiltinToolsetInfo { diff --git a/pkg/teamloader/toolsets/toolsets.go b/pkg/teamloader/toolsets/toolsets.go index 79258250c9..5441ae81af 100644 --- a/pkg/teamloader/toolsets/toolsets.go +++ b/pkg/teamloader/toolsets/toolsets.go @@ -28,6 +28,7 @@ import ( "github.com/docker/docker-agent/pkg/tools/builtin/think" "github.com/docker/docker-agent/pkg/tools/builtin/todo" "github.com/docker/docker-agent/pkg/tools/builtin/userprompt" + "github.com/docker/docker-agent/pkg/tools/builtin/webhook" "github.com/docker/docker-agent/pkg/tools/mcp" ) @@ -113,5 +114,8 @@ func DefaultToolsetCreators() map[string]teamloader.ToolsetCreator { "rag": func(ctx context.Context, toolset latest.Toolset, parentDir string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { return rag.CreateToolSet(ctx, toolset, parentDir, runConfig) }, + "webhook": func(_ context.Context, _ latest.Toolset, _ string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { + return webhook.CreateToolSet(runConfig) + }, } } diff --git a/pkg/tools/builtin/webhook/webhook.go b/pkg/tools/builtin/webhook/webhook.go new file mode 100644 index 0000000000..2505dd8071 --- /dev/null +++ b/pkg/tools/builtin/webhook/webhook.go @@ -0,0 +1,166 @@ +package webhook + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/httpclient" + "github.com/docker/docker-agent/pkg/tools" +) + +const ( + ToolNameSendWebhook = "send_webhook" + + category = "webhook" + requestTimeout = 30 * time.Second + maxRespRead = 64 << 10 +) + +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +type ToolSet struct { + client httpDoer +} + +var ( + _ tools.ToolSet = (*ToolSet)(nil) + _ tools.Instructable = (*ToolSet)(nil) +) + +func New() *ToolSet { + return &ToolSet{client: httpclient.NewSafeClient(requestTimeout, false)} +} + +func CreateToolSet(_ *config.RuntimeConfig) (tools.ToolSet, error) { + return New(), nil +} + +var supportedProviders = []string{ + "slack", "discord", "ifttt", "telegram", + "mattermost", "rocketchat", "googlechat", "teams", "generic", +} + +type SendArgs struct { + URL string `json:"url" jsonschema:"The webhook URL to POST to"` + Message string `json:"message" jsonschema:"The message text to send"` + Provider string `json:"provider,omitempty" jsonschema:"Payload format: slack, discord, ifttt, telegram, mattermost, rocketchat, googlechat, teams, or generic (default generic)"` + Value2 string `json:"value2,omitempty" jsonschema:"IFTTT value2 field (provider=ifttt only)"` + Value3 string `json:"value3,omitempty" jsonschema:"IFTTT value3 field (provider=ifttt only)"` + ChatID string `json:"chat_id,omitempty" jsonschema:"Telegram chat id (provider=telegram only)"` +} + +func normalizeProvider(p string) string { + switch p = strings.ToLower(strings.TrimSpace(p)); p { + case "": + return "generic" + case "google_chat", "gchat": + return "googlechat" + case "msteams", "microsoftteams", "microsoft_teams": + return "teams" + case "rocket.chat", "rocket_chat": + return "rocketchat" + default: + return p + } +} + +func buildPayload(provider, message, value2, value3, chatID string) (string, []byte, error) { + var payload map[string]string + switch normalizeProvider(provider) { + case "generic", "slack", "mattermost", "rocketchat", "googlechat", "teams": + payload = map[string]string{"text": message} + case "discord": + payload = map[string]string{"content": message} + case "ifttt": + payload = map[string]string{"value1": message} + if value2 != "" { + payload["value2"] = value2 + } + if value3 != "" { + payload["value3"] = value3 + } + case "telegram": + if strings.TrimSpace(chatID) == "" { + return "", nil, fmt.Errorf("telegram requires chat_id") + } + payload = map[string]string{"chat_id": chatID, "text": message} + default: + return "", nil, fmt.Errorf("unknown provider %q (supported: %s)", provider, strings.Join(supportedProviders, ", ")) + } + body, err := json.Marshal(payload) + if err != nil { + return "", nil, err + } + return "application/json", body, nil +} + +func (t *ToolSet) send(ctx context.Context, args SendArgs) (*tools.ToolCallResult, error) { + if strings.TrimSpace(args.URL) == "" { + return tools.ResultError("Error: url is required."), nil + } + if strings.TrimSpace(args.Message) == "" { + return tools.ResultError("Error: message is required."), nil + } + if u, err := url.Parse(args.URL); err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return tools.ResultError("Error: url must be a valid http(s) URL."), nil + } + + contentType, body, err := buildPayload(args.Provider, args.Message, args.Value2, args.Value3, args.ChatID) + if err != nil { + return tools.ResultError("Error: " + err.Error()), nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, args.URL, bytes.NewReader(body)) + if err != nil { + return tools.ResultError("Error: " + err.Error()), nil + } + req.Header.Set("Content-Type", contentType) + + resp, err := t.client.Do(req) + if err != nil { + return tools.ResultError("Error: sending webhook: " + err.Error()), nil + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxRespRead)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return tools.ResultError(fmt.Sprintf("Webhook returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))), nil + } + return tools.ResultSuccess(fmt.Sprintf("Delivered to %s webhook (HTTP %d).", normalizeProvider(args.Provider), resp.StatusCode)), nil +} + +func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { + return []tools.Tool{ + { + Name: ToolNameSendWebhook, + Category: category, + Description: "Send a message to a webhook (Slack, Discord, IFTTT, or a generic URL). POSTs a provider-shaped JSON payload and reports delivery status.", + Parameters: tools.MustSchemaFor[SendArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.send), + Annotations: tools.ToolAnnotations{Title: "Send Webhook"}, + AddDescriptionParameter: true, + }, + }, nil +} + +func (t *ToolSet) Instructions() string { + return `## Webhook Tool + +Send an outbound notification with send_webhook(url, message, provider?): + +- provider shapes the payload — slack, discord, ifttt, telegram, mattermost, + rocketchat, googlechat, teams, or generic (default). Telegram needs chat_id. +- Delivery is one-way; the tool reports the HTTP status, not a response body. +- Requests to non-public addresses are refused.` +} diff --git a/pkg/tools/builtin/webhook/webhook_test.go b/pkg/tools/builtin/webhook/webhook_test.go new file mode 100644 index 0000000000..08ccd377ea --- /dev/null +++ b/pkg/tools/builtin/webhook/webhook_test.go @@ -0,0 +1,172 @@ +package webhook + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type fakeDoer struct { + gotReq *http.Request + gotBody []byte + status int + err error +} + +func (f *fakeDoer) Do(req *http.Request) (*http.Response, error) { + f.gotReq = req + if req.Body != nil { + f.gotBody, _ = io.ReadAll(req.Body) + } + if f.err != nil { + return nil, f.err + } + status := f.status + if status == 0 { + status = http.StatusOK + } + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader("ok")), + Header: make(http.Header), + }, nil +} + +func newTestToolSet(d httpDoer) *ToolSet { + ts := New() + ts.client = d + return ts +} + +func decode(t *testing.T, body []byte) map[string]string { + t.Helper() + var m map[string]string + require.NoError(t, json.Unmarshal(body, &m)) + return m +} + +func TestBuildPayload(t *testing.T) { + t.Parallel() + + tests := []struct { + provider string + wantKey string + }{ + {"slack", "text"}, + {"discord", "content"}, + {"mattermost", "text"}, + {"rocketchat", "text"}, + {"googlechat", "text"}, + {"teams", "text"}, + {"msteams", "text"}, + {"generic", "text"}, + {"", "text"}, + } + for _, tc := range tests { + ct, body, err := buildPayload(tc.provider, "hello", "", "", "") + require.NoError(t, err, tc.provider) + require.Equal(t, "application/json", ct) + require.Equal(t, "hello", decode(t, body)[tc.wantKey], tc.provider) + } +} + +func TestBuildPayloadIFTTT(t *testing.T) { + t.Parallel() + + _, body, err := buildPayload("ifttt", "msg", "two", "three", "") + require.NoError(t, err) + m := decode(t, body) + require.Equal(t, "msg", m["value1"]) + require.Equal(t, "two", m["value2"]) + require.Equal(t, "three", m["value3"]) +} + +func TestBuildPayloadTelegram(t *testing.T) { + t.Parallel() + + _, body, err := buildPayload("telegram", "hi", "", "", "12345") + require.NoError(t, err) + m := decode(t, body) + require.Equal(t, "12345", m["chat_id"]) + require.Equal(t, "hi", m["text"]) + + _, _, err = buildPayload("telegram", "hi", "", "", "") + require.Error(t, err) +} + +func TestBuildPayloadUnknownProvider(t *testing.T) { + t.Parallel() + + _, _, err := buildPayload("webex", "x", "", "", "") + require.Error(t, err) +} + +func TestSendSuccess(t *testing.T) { + t.Parallel() + + fd := &fakeDoer{status: 200} + ts := newTestToolSet(fd) + + res, err := ts.send(context.Background(), SendArgs{ + URL: "https://hooks.slack.com/services/xxx", Message: "deploy done", Provider: "slack", + }) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + + require.Equal(t, http.MethodPost, fd.gotReq.Method) + require.Equal(t, "application/json", fd.gotReq.Header.Get("Content-Type")) + require.Equal(t, "deploy done", decode(t, fd.gotBody)["text"]) +} + +func TestSendNon2xx(t *testing.T) { + t.Parallel() + + ts := newTestToolSet(&fakeDoer{status: 404}) + res, err := ts.send(context.Background(), SendArgs{URL: "https://example.com/hook", Message: "hi"}) + require.NoError(t, err) + require.True(t, res.IsError) + require.Contains(t, res.Output, "404") +} + +func TestSendNetworkError(t *testing.T) { + t.Parallel() + + ts := newTestToolSet(&fakeDoer{err: io.ErrUnexpectedEOF}) + res, err := ts.send(context.Background(), SendArgs{URL: "https://example.com/hook", Message: "hi"}) + require.NoError(t, err) + require.True(t, res.IsError) +} + +func TestSendValidation(t *testing.T) { + t.Parallel() + + ts := newTestToolSet(&fakeDoer{}) + + r1, _ := ts.send(context.Background(), SendArgs{Message: "hi"}) + require.True(t, r1.IsError) + + r2, _ := ts.send(context.Background(), SendArgs{URL: "https://x/y"}) + require.True(t, r2.IsError) + + r3, _ := ts.send(context.Background(), SendArgs{URL: "ftp://x/y", Message: "hi"}) + require.True(t, r3.IsError) + + r4, _ := ts.send(context.Background(), SendArgs{URL: "https://x/y", Message: "hi", Provider: "webex"}) + require.True(t, r4.IsError) +} + +func TestToolSetInterfaces(t *testing.T) { + t.Parallel() + + ts := New() + require.NotEmpty(t, ts.Instructions()) + toolz, err := ts.Tools(context.Background()) + require.NoError(t, err) + require.Len(t, toolz, 1) + require.Equal(t, ToolNameSendWebhook, toolz[0].Name) +}